SendMessage()

This commit is contained in:
Mike Schwörer 2022-11-19 15:13:47 +01:00
parent fb37f94c0a
commit 85bfe79115
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
22 changed files with 1208 additions and 59 deletions

View File

@ -2,9 +2,10 @@ package handler
import ( import (
"blackforestbytes.com/simplecloudnotifier/api/apierr" "blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/models"
"blackforestbytes.com/simplecloudnotifier/common/ginresp" "blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/logic" "blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql" "database/sql"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
@ -14,6 +15,14 @@ import (
type APIHandler struct { type APIHandler struct {
app *logic.Application app *logic.Application
database *db.Database
}
func NewAPIHandler(app *logic.Application) APIHandler {
return APIHandler{
app: app,
database: app.Database,
}
} }
// CreateUser swaggerdoc // CreateUser swaggerdoc
@ -69,24 +78,24 @@ func (h APIHandler) CreateUser(g *gin.Context) ginresp.HTTPResponse {
sendKey := h.app.GenerateRandomAuthKey() sendKey := h.app.GenerateRandomAuthKey()
adminKey := h.app.GenerateRandomAuthKey() adminKey := h.app.GenerateRandomAuthKey()
err := h.app.Database.ClearFCMTokens(ctx, b.FCMToken) err := h.database.ClearFCMTokens(ctx, b.FCMToken)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
} }
if b.ProToken != nil { if b.ProToken != nil {
err := h.app.Database.ClearProTokens(ctx, *b.ProToken) err := h.database.ClearProTokens(ctx, *b.ProToken)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
} }
} }
userobj, err := h.app.Database.CreateUser(ctx, readKey, sendKey, adminKey, b.ProToken, b.Username) userobj, err := h.database.CreateUser(ctx, readKey, sendKey, adminKey, b.ProToken, b.Username)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to create user in db", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to create user in db", err)
} }
_, err = h.app.Database.CreateClient(ctx, userobj.UserID, clientType, b.FCMToken, b.AgentModel, b.AgentVersion) _, err = h.database.CreateClient(ctx, userobj.UserID, clientType, b.FCMToken, b.AgentModel, b.AgentVersion)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to create user in db", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to create user in db", err)
} }
@ -124,7 +133,7 @@ func (h APIHandler) GetUser(g *gin.Context) ginresp.HTTPResponse {
return *permResp return *permResp
} }
user, err := h.app.Database.GetUser(ctx, u.UserID) user, err := h.database.GetUser(ctx, u.UserID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return ginresp.InternAPIError(404, apierr.USER_NOT_FOUND, "User not found", err) return ginresp.InternAPIError(404, apierr.USER_NOT_FOUND, "User not found", err)
} }
@ -177,7 +186,7 @@ func (h APIHandler) UpdateUser(g *gin.Context) ginresp.HTTPResponse {
username = nil username = nil
} }
err := h.app.Database.UpdateUserUsername(ctx, u.UserID, b.Username) err := h.database.UpdateUserUsername(ctx, u.UserID, b.Username)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to update user", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to update user", err)
} }
@ -193,18 +202,18 @@ func (h APIHandler) UpdateUser(g *gin.Context) ginresp.HTTPResponse {
return ginresp.InternAPIError(400, apierr.INVALID_PRO_TOKEN, "Purchase token could not be verified", nil) return ginresp.InternAPIError(400, apierr.INVALID_PRO_TOKEN, "Purchase token could not be verified", nil)
} }
err = h.app.Database.ClearProTokens(ctx, *b.ProToken) err = h.database.ClearProTokens(ctx, *b.ProToken)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
} }
err = h.app.Database.UpdateUserProToken(ctx, u.UserID, b.ProToken) err = h.database.UpdateUserProToken(ctx, u.UserID, b.ProToken)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to update user", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to update user", err)
} }
} }
user, err := h.app.Database.GetUser(ctx, u.UserID) user, err := h.database.GetUser(ctx, u.UserID)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to query (updated) user", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to query (updated) user", err)
} }
@ -245,7 +254,7 @@ func (h APIHandler) ListClients(g *gin.Context) ginresp.HTTPResponse {
return *permResp return *permResp
} }
clients, err := h.app.Database.ListClients(ctx, u.UserID) clients, err := h.database.ListClients(ctx, u.UserID)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to query clients", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to query clients", err)
} }
@ -287,7 +296,7 @@ func (h APIHandler) GetClient(g *gin.Context) ginresp.HTTPResponse {
return *permResp return *permResp
} }
client, err := h.app.Database.GetClient(ctx, u.UserID, u.ClientID) client, err := h.database.GetClient(ctx, u.UserID, u.ClientID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return ginresp.InternAPIError(404, apierr.CLIENT_NOT_FOUND, "Client not found", err) return ginresp.InternAPIError(404, apierr.CLIENT_NOT_FOUND, "Client not found", err)
} }
@ -346,7 +355,7 @@ func (h APIHandler) AddClient(g *gin.Context) ginresp.HTTPResponse {
return *permResp return *permResp
} }
client, err := h.app.Database.CreateClient(ctx, u.UserID, clientType, b.FCMToken, b.AgentModel, b.AgentVersion) client, err := h.database.CreateClient(ctx, u.UserID, clientType, b.FCMToken, b.AgentModel, b.AgentVersion)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to create user in db", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to create user in db", err)
} }
@ -386,7 +395,7 @@ func (h APIHandler) DeleteClient(g *gin.Context) ginresp.HTTPResponse {
return *permResp return *permResp
} }
client, err := h.app.Database.GetClient(ctx, u.UserID, u.ClientID) client, err := h.database.GetClient(ctx, u.UserID, u.ClientID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return ginresp.InternAPIError(404, apierr.CLIENT_NOT_FOUND, "Client not found", err) return ginresp.InternAPIError(404, apierr.CLIENT_NOT_FOUND, "Client not found", err)
} }
@ -394,7 +403,7 @@ func (h APIHandler) DeleteClient(g *gin.Context) ginresp.HTTPResponse {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to query client", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to query client", err)
} }
err = h.app.Database.DeleteClient(ctx, u.ClientID) err = h.database.DeleteClient(ctx, u.ClientID)
if err != nil { if err != nil {
return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to delete client", err) return ginresp.InternAPIError(500, apierr.DATABASE_ERROR, "Failed to delete client", err)
} }
@ -446,8 +455,6 @@ func (h APIHandler) DeleteMessage(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented() return ginresp.NotImplemented()
} }
func NewAPIHandler(app *logic.Application) APIHandler { func (h APIHandler) SendMessage(g *gin.Context) ginresp.HTTPResponse {
return APIHandler{ return ginresp.NotImplemented()
app: app,
}
} }

View File

@ -1,9 +1,9 @@
package handler package handler
import ( import (
"blackforestbytes.com/simplecloudnotifier/api/models"
"blackforestbytes.com/simplecloudnotifier/common/ginresp" "blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/logic" "blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@ -1,21 +1,232 @@
package handler package handler
import ( import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/common/ginresp" "blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/logic" "blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/mathext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"net/http"
"strings"
"time"
) )
type MessageHandler struct { type MessageHandler struct {
app *logic.Application app *logic.Application
} database *db.Database
func (h MessageHandler) SendMessage(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
} }
func NewMessageHandler(app *logic.Application) MessageHandler { func NewMessageHandler(app *logic.Application) MessageHandler {
return MessageHandler{ return MessageHandler{
app: app, app: app,
database: app.Database,
}
}
// SendMessage swaggerdoc
//
// @Summary Send a new message
// @Description All parameter can be set via query-parameter or the json body. Only UserID, UserKey and Title are required
//
// @Param query_data query handler.SendMessage.query false " "
// @Param post_body body handler.SendMessage.body false " "
//
// @Success 200 {object} handler.SendMessage.response
// @Failure 400 {object} ginresp.apiError
// @Failure 401 {object} ginresp.apiError
// @Failure 403 {object} ginresp.apiError
// @Failure 404 {object} ginresp.apiError
// @Failure 500 {object} ginresp.apiError
//
// @Router / [POST]
// @Router /send [POST]
func (h MessageHandler) SendMessage(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID *int64 `form:"user_id"`
UserKey *string `form:"user_key"`
Channel *string `form:"channel"`
ChanKey *string `form:"chan_key"`
Title *string `form:"message_title"`
Content *string `form:"message_content"`
Priority *int `form:"priority"`
UserMessageID *string `form:"msg_id"`
SendTimestamp *float64 `form:"timestamp"`
}
type body struct {
UserID *int64 `json:"user_id"`
UserKey *string `json:"user_key"`
Channel *string `json:"channel"`
ChanKey *string `form:"chan_key"`
Title *string `json:"message_title"`
Content *string `json:"message_content"`
Priority *int `json:"priority"`
UserMessageID *string `json:"msg_id"`
SendTimestamp *float64 `json:"timestamp"`
}
type response struct {
Success bool `json:"success"`
ErrorID apierr.APIError `json:"error"`
ErrorHighlight int `json:"errhighlight"`
Message string `json:"message"`
SuppressSend bool `json:"suppress_send"`
Response string `json:"response"`
MessageCount int `json:"messagecount"`
Quota int `json:"quota"`
IsPro bool `json:"is_pro"`
QuotaMax int `json:"quota_max"`
SCNMessageID int64 `json:"scn_msg_id"`
}
var b body
var q query
ctx, errResp := h.app.StartRequest(g, nil, &q, &b)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
data := dataext.ObjectMerge(b, q)
if data.UserID == nil {
return ginresp.SendAPIError(400, apierr.MISSING_UID, 101, "Missing parameter [[user_id]]")
}
if data.UserKey == nil {
return ginresp.SendAPIError(400, apierr.MISSING_UID, 102, "Missing parameter [[user_token]]")
}
if data.Title == nil {
return ginresp.SendAPIError(400, apierr.MISSING_UID, 103, "Missing parameter [[title]]")
}
if data.SendTimestamp != nil && mathext.Abs(*data.SendTimestamp-float64(time.Now().Unix())) > (24*time.Hour).Seconds() {
return ginresp.SendAPIError(400, apierr.TIMESTAMP_OUT_OF_RANGE, -1, "The timestamp mus be within 24 hours of now()")
}
if data.Priority != nil && (*data.Priority != 0 && *data.Priority != 1 && *data.Priority != 2) {
return ginresp.SendAPIError(400, apierr.INVALID_PRIO, 105, "Invalid priority")
}
if len(strings.TrimSpace(*data.Title)) == 0 {
return ginresp.SendAPIError(400, apierr.NO_TITLE, 103, "No title specified")
}
if data.UserMessageID != nil && len(strings.TrimSpace(*data.UserMessageID)) > 64 {
return ginresp.SendAPIError(400, apierr.USR_MSG_ID_TOO_LONG, -1, "MessageID too long (64 characters)")
}
channelName := "main"
if data.Channel != nil {
channelName = strings.ToLower(strings.TrimSpace(*data.Channel))
}
user, err := h.database.GetUser(ctx, *data.UserID)
if err == sql.ErrNoRows {
return ginresp.SendAPIError(400, apierr.USER_NOT_FOUND, -1, "User not found")
}
if err != nil {
return ginresp.SendAPIError(500, apierr.DATABASE_ERROR, -1, "Failed to query user")
}
if len(strings.TrimSpace(*data.Title)) > 120 {
return ginresp.SendAPIError(400, apierr.TITLE_TOO_LONG, 103, "Title too long (120 characters)")
}
if data.Content != nil && len(strings.TrimSpace(*data.Content)) > user.MaxContentLength() {
return ginresp.SendAPIError(400, apierr.CONTENT_TOO_LONG, 104, fmt.Sprintf("Content too long (%d characters; max := %d characters)", len(strings.TrimSpace(*data.Content)), user.MaxContentLength()))
}
if data.UserMessageID != nil {
msg, err := h.database.GetMessageByUserMessageID(ctx, *data.UserMessageID)
if err != nil {
return ginresp.SendAPIError(500, apierr.DATABASE_ERROR, -1, "Failed to query existing message")
}
if msg != nil {
return ginresp.JSON(http.StatusOK, response{
Success: true,
ErrorID: apierr.NO_ERROR,
ErrorHighlight: 0,
Message: "Message already sent",
SuppressSend: true,
Response: "",
MessageCount: user.MessagesSent,
Quota: user.QuotaUsedToday(),
IsPro: user.IsPro,
QuotaMax: user.QuotaPerDay(),
SCNMessageID: msg.SCNMessageID,
})
}
}
if user.QuotaRemainingToday() <= 0 {
return ginresp.SendAPIError(403, apierr.QUOTA_REACHED, -1, fmt.Sprintf("Daily quota reached (%d)", user.QuotaPerDay()))
}
channel, err := h.app.GetOrCreateChannel(ctx, *data.UserID, channelName)
if err != nil {
return ginresp.SendAPIError(500, apierr.DATABASE_ERROR, -1, "Failed to query/create channel")
}
selfChanAdmin := *data.UserID == channel.OwnerUserID && *data.UserKey == user.AdminKey
selfChanSend := *data.UserID == channel.OwnerUserID && *data.UserKey == user.SendKey
forgChanSend := *data.UserID != channel.OwnerUserID && data.ChanKey != nil && *data.ChanKey == channel.SendKey
if !selfChanAdmin && !selfChanSend && !forgChanSend {
return ginresp.SendAPIError(401, apierr.USER_AUTH_FAILED, 102, fmt.Sprintf("Daily quota reached (%d)", user.QuotaPerDay()))
}
var sendTimestamp *time.Time = nil
if data.SendTimestamp != nil {
sendTimestamp = langext.Ptr(timeext.UnixFloatSeconds(*data.SendTimestamp))
}
priority := langext.Coalesce(data.Priority, 1)
msg, err := h.database.CreateMessage(ctx, *data.UserID, channel, sendTimestamp, *data.Title, data.Content, priority, data.UserMessageID)
if err != nil {
return ginresp.SendAPIError(500, apierr.DATABASE_ERROR, -1, "Failed to create message in db")
}
subscriptions, err := h.database.ListChannelSubscriptions(ctx, channel.ChannelID)
if err != nil {
return ginresp.SendAPIError(500, apierr.DATABASE_ERROR, -1, "Failed to query subscriptions")
}
for _, sub := range subscriptions {
clients, err := h.database.ListClients(ctx, sub.SubscriberUserID)
if err != nil {
return ginresp.SendAPIError(500, apierr.DATABASE_ERROR, -1, "Failed to query clients")
}
for _, client := range clients {
fcmDelivID, err := h.deliverMessage(ctx, client, msg)
if err != nil {
_, err = h.database.CreateRetryDelivery(ctx, client, msg)
if err != nil {
return ginresp.SendAPIError(500, apierr.DATABASE_ERROR, -1, "Failed to create delivery")
}
} else {
_, err = h.database.CreateSuccessDelivery(ctx, client, msg, *fcmDelivID)
if err != nil {
return ginresp.SendAPIError(500, apierr.DATABASE_ERROR, -1, "Failed to create delivery")
}
}
}
}
return ginresp.NotImplemented()
}
func (h MessageHandler) deliverMessage(ctx *logic.AppContext, client models.Client, msg models.Message) (*string, error) {
if client.FCMToken != nil {
fcmDelivID, err := h.app.Firebase.SendNotification(ctx, client, msg)
if err != nil {
return nil, err
}
return langext.Ptr(fcmDelivID), nil
} else {
return langext.Ptr(""), nil
} }
} }

View File

@ -117,7 +117,7 @@ func (r *Router) Init(e *gin.Engine) {
apiv2.GET("/messages/:mid", ginresp.Wrap(r.apiHandler.GetMessage)) apiv2.GET("/messages/:mid", ginresp.Wrap(r.apiHandler.GetMessage))
apiv2.DELETE("/messages/:mid", ginresp.Wrap(r.apiHandler.DeleteMessage)) apiv2.DELETE("/messages/:mid", ginresp.Wrap(r.apiHandler.DeleteMessage))
apiv2.POST("/messages", ginresp.Wrap(r.messageHandler.SendMessage)) apiv2.POST("/messages", ginresp.Wrap(r.apiHandler.SendMessage))
} }
// ================ Send API ================ // ================ Send API ================
@ -126,7 +126,7 @@ func (r *Router) Init(e *gin.Engine) {
{ {
sendAPI.POST("/", ginresp.Wrap(r.messageHandler.SendMessage)) sendAPI.POST("/", ginresp.Wrap(r.messageHandler.SendMessage))
sendAPI.POST("/send", ginresp.Wrap(r.messageHandler.SendMessage)) sendAPI.POST("/send", ginresp.Wrap(r.messageHandler.SendMessage))
sendAPI.POST("/send.php") sendAPI.POST("/send.php", ginresp.Wrap(r.messageHandler.SendMessage))
} }
if r.app.Config.ReturnRawErrors { if r.app.Config.ReturnRawErrors {

View File

@ -85,8 +85,8 @@ func InternAPIError(status int, errorid apierr.APIError, msg string, e error) HT
} }
} }
func SendAPIError(errorid apierr.APIError, highlight int, msg string) HTTPResponse { func SendAPIError(status int, errorid apierr.APIError, highlight int, msg string) HTTPResponse {
return &errHTTPResponse{statusCode: http.StatusInternalServerError, data: apiError{Success: false, Error: int(errorid), ErrorHighlight: highlight, Message: msg}} return &errHTTPResponse{statusCode: status, data: apiError{Success: false, Error: int(errorid), ErrorHighlight: highlight, Message: msg}}
} }
func NotImplemented() HTTPResponse { func NotImplemented() HTTPResponse {

View File

@ -1,7 +1,7 @@
package db package db
import ( import (
"blackforestbytes.com/simplecloudnotifier/api/models" "blackforestbytes.com/simplecloudnotifier/models"
"database/sql" "database/sql"
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
"time" "time"
@ -42,8 +42,8 @@ func (db *Database) CreateUser(ctx TxContext, readKey string, sendKey string, ad
TimestampLastRead: nil, TimestampLastRead: nil,
TimestampLastSent: nil, TimestampLastSent: nil,
MessagesSent: 0, MessagesSent: 0,
QuotaToday: 0, QuotaUsed: 0,
QuotaDay: nil, QuotaUsedDay: nil,
IsPro: protoken != nil, IsPro: protoken != nil,
ProToken: protoken, ProToken: protoken,
}, nil }, nil
@ -254,3 +254,261 @@ func (db *Database) DeleteClient(ctx TxContext, clientid int64) error {
return nil return nil
} }
func (db *Database) GetMessageByUserMessageID(ctx TxContext, usrMsgId string) (*models.Message, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, "SELECT * FROM messages WHERE usr_message_id = ? LIMIT 1", usrMsgId)
if err != nil {
return nil, err
}
msg, err := models.DecodeMessage(rows)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &msg, nil
}
func (db *Database) GetChannelByName(ctx TxContext, userid int64, chanName string) (*models.Channel, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, "SELECT * FROM channels WHERE owner_user_id = ? OR name = ? LIMIT 1", userid, chanName)
if err != nil {
return nil, err
}
channel, err := models.DecodeChannel(rows)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &channel, nil
}
func (db *Database) CreateChannel(ctx TxContext, userid int64, name string, subscribeKey string, sendKey string) (models.Channel, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Channel{}, err
}
now := time.Now().UTC()
res, err := tx.ExecContext(ctx, "INSERT INTO channels (owner_user_id, name, subscribe_key, send_key, timestamp_created) VALUES (?, ?, ?, ?, ?)",
userid,
name,
subscribeKey,
sendKey,
time2DB(now))
if err != nil {
return models.Channel{}, err
}
liid, err := res.LastInsertId()
if err != nil {
return models.Channel{}, err
}
return models.Channel{
ChannelID: liid,
OwnerUserID: userid,
Name: name,
SubscribeKey: subscribeKey,
SendKey: sendKey,
TimestampCreated: now,
TimestampLastRead: nil,
TimestampLastSent: nil,
MessagesSent: 0,
}, nil
}
func (db *Database) CreateSubscribtion(ctx TxContext, subscriberUID int64, ownerUID int64, chanName string, chanID int64, confirmed bool) (models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Subscription{}, err
}
now := time.Now().UTC()
res, err := tx.ExecContext(ctx, "INSERT INTO subscriptions (subscriber_user_id, channel_owner_user_id, channel_name, channel_id, timestamp_created, confirmed) VALUES (?, ?, ?, ?, ?, ?)",
subscriberUID,
ownerUID,
chanName,
chanID,
time2DB(now),
confirmed)
if err != nil {
return models.Subscription{}, err
}
liid, err := res.LastInsertId()
if err != nil {
return models.Subscription{}, err
}
return models.Subscription{
SubscriptionID: liid,
SubscriberUserID: subscriberUID,
ChannelOwnerUserID: ownerUID,
ChannelID: chanID,
ChannelName: chanName,
TimestampCreated: now,
Confirmed: confirmed,
}, nil
}
func (db *Database) CreateMessage(ctx TxContext, senderUserID int64, channel models.Channel, timestampSend *time.Time, title string, content *string, priority int, userMsgId *string) (models.Message, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Message{}, err
}
now := time.Now().UTC()
res, err := tx.ExecContext(ctx, "INSERT INTO messages (sender_user_id, owner_user_id, channel_name, channel_id, timestamp_real, timestamp_client, title, content, priority, usr_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
senderUserID,
channel.OwnerUserID,
channel.Name,
channel.ChannelID,
time2DB(now),
time2DBOpt(timestampSend),
title,
content,
priority,
userMsgId)
if err != nil {
return models.Message{}, err
}
liid, err := res.LastInsertId()
if err != nil {
return models.Message{}, err
}
return models.Message{
SCNMessageID: liid,
SenderUserID: senderUserID,
OwnerUserID: channel.OwnerUserID,
ChannelName: channel.Name,
ChannelID: channel.ChannelID,
TimestampReal: now,
TimestampClient: timestampSend,
Title: title,
Content: content,
Priority: priority,
UserMessageID: userMsgId,
}, nil
}
func (db *Database) ListChannelSubscriptions(ctx TxContext, channelID int64) ([]models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, "SELECT * FROM subscriptions WHERE channel_id = ?", channelID)
if err != nil {
return nil, err
}
data, err := models.DecodeSubscriptions(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) CreateRetryDelivery(ctx TxContext, client models.Client, msg models.Message) (models.Delivery, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Delivery{}, err
}
now := time.Now().UTC()
next := now.Add(5 * time.Second)
res, err := tx.ExecContext(ctx, "INSERT INTO deliveries (scn_message_id, receiver_user_id, receiver_client_id, timestamp_created, timestamp_finalized, status, fcm_message_id, next_delivery) VALUES (?, ?, ?, ?, ?, ?, ?)",
msg.SCNMessageID,
client.UserID,
client.ClientID,
time2DB(now),
nil,
models.DeliveryStatusRetry,
nil,
time2DB(next))
if err != nil {
return models.Delivery{}, err
}
liid, err := res.LastInsertId()
if err != nil {
return models.Delivery{}, err
}
return models.Delivery{
DeliveryID: liid,
SCNMessageID: msg.SCNMessageID,
ReceiverUserID: client.UserID,
ReceiverClientID: client.ClientID,
TimestampCreated: now,
TimestampFinalized: nil,
Status: models.DeliveryStatusRetry,
RetryCount: 0,
NextDelivery: langext.Ptr(next),
FCMMessageID: nil,
}, nil
}
func (db *Database) CreateSuccessDelivery(ctx TxContext, client models.Client, msg models.Message, fcmDelivID string) (models.Delivery, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Delivery{}, err
}
now := time.Now().UTC()
res, err := tx.ExecContext(ctx, "INSERT INTO deliveries (scn_message_id, receiver_user_id, receiver_client_id, timestamp_created, timestamp_finalized, status, fcm_message_id, next_delivery) VALUES (?, ?, ?, ?, ?, ?, ?)",
msg.SCNMessageID,
client.UserID,
client.ClientID,
time2DB(now),
time2DB(now),
models.DeliveryStatusSuccess,
fcmDelivID,
nil)
if err != nil {
return models.Delivery{}, err
}
liid, err := res.LastInsertId()
if err != nil {
return models.Delivery{}, err
}
return models.Delivery{
DeliveryID: liid,
SCNMessageID: msg.SCNMessageID,
ReceiverUserID: client.UserID,
ReceiverClientID: client.ClientID,
TimestampCreated: now,
TimestampFinalized: langext.Ptr(now),
Status: models.DeliveryStatusSuccess,
RetryCount: 0,
NextDelivery: nil,
FCMMessageID: langext.Ptr(fcmDelivID),
}, nil
}

View File

@ -14,8 +14,8 @@ CREATE TABLE users
messages_sent INTEGER NOT NULL DEFAULT '0', messages_sent INTEGER NOT NULL DEFAULT '0',
quota_today INTEGER NOT NULL DEFAULT '0', quota_used INTEGER NOT NULL DEFAULT '0',
quota_day TEXT NULL DEFAULT NULL, quota_used_day TEXT NULL DEFAULT NULL,
is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0, is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0,
pro_token TEXT NULL DEFAULT NULL pro_token TEXT NULL DEFAULT NULL
@ -66,8 +66,11 @@ CREATE TABLE subscriptions
subscriber_user_id INTEGER NOT NULL, subscriber_user_id INTEGER NOT NULL,
channel_owner_user_id INTEGER NOT NULL, channel_owner_user_id INTEGER NOT NULL,
channel_name TEXT NOT NULL, channel_name TEXT NOT NULL,
channel_id INTEGER NOT NULL,
confirmed INTEGER CHECK(confirmed IN (0, 1)) NOT NULL DEFAULT 0 timestamp_created INTEGER NOT NULL,
confirmed INTEGER CHECK(confirmed IN (0, 1)) NOT NULL
); );
CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_name); CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_name);
@ -76,8 +79,8 @@ CREATE TABLE messages
( (
scn_message_id INTEGER PRIMARY KEY AUTOINCREMENT, scn_message_id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_user_id INTEGER NOT NULL, sender_user_id INTEGER NOT NULL,
owner_user_id INTEGER NOT NULL,
channel_name TEXT NOT NULL, channel_name TEXT NOT NULL,
channel_id INTEGER NOT NULL, channel_id INTEGER NOT NULL,
timestamp_real INTEGER NOT NULL, timestamp_real INTEGER NOT NULL,
@ -88,8 +91,8 @@ CREATE TABLE messages
priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL, priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL,
usr_message_id TEXT NULL usr_message_id TEXT NULL
); );
CREATE INDEX "idx_messages_channel" ON messages (sender_user_id, channel_name); CREATE INDEX "idx_messages_channel" ON messages (owner_user_id, channel_name);
CREATE INDEX "idx_messages_idempotency" ON messages (sender_user_id, usr_message_id); CREATE INDEX "idx_messages_idempotency" ON messages (owner_user_id, usr_message_id);
CREATE TABLE deliveries CREATE TABLE deliveries

View File

@ -1,6 +1,9 @@
package db package db
import "time" import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
func bool2DB(b bool) int { func bool2DB(b bool) int {
if b { if b {
@ -13,3 +16,10 @@ func bool2DB(b bool) int {
func time2DB(t time.Time) int64 { func time2DB(t time.Time) int64 {
return t.UnixMilli() return t.UnixMilli()
} }
func time2DBOpt(t *time.Time) *int64 {
if t == nil {
return nil
}
return langext.Ptr(t.UnixMilli())
}

View File

@ -1,12 +1,15 @@
package firebase package firebase
import ( import (
"blackforestbytes.com/simplecloudnotifier/models"
"context" "context"
_ "embed" _ "embed"
fb "firebase.google.com/go" fb "firebase.google.com/go"
"firebase.google.com/go/messaging" "firebase.google.com/go/messaging"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"google.golang.org/api/option" "google.golang.org/api/option"
"strconv"
) )
//go:embed scnserviceaccountkey.json //go:embed scnserviceaccountkey.json
@ -43,26 +46,26 @@ type Notification struct {
Priority int Priority int
} }
func (fb App) SendNotification(ctx context.Context, notification Notification) (string, error) { func (fb App) SendNotification(ctx context.Context, client models.Client, msg models.Message) (string, error) {
n := messaging.Message{ n := messaging.Message{
Data: map[string]string{"scn_msg_id": notification.Id}, Data: map[string]string{"scn_msg_id": strconv.FormatInt(msg.SCNMessageID, 10)},
Notification: &messaging.Notification{ Notification: &messaging.Notification{
Title: notification.Title, Title: msg.Title,
Body: notification.Body, Body: langext.Coalesce(msg.Content, ""),
}, },
Android: nil, Android: nil,
APNS: nil, APNS: nil,
Webpush: nil, Webpush: nil,
FCMOptions: nil, FCMOptions: nil,
Token: notification.Token, Token: *client.FCMToken,
Topic: "", Topic: "",
Condition: "", Condition: "",
} }
if notification.Platform == "ios" { if client.Type == models.ClientTypeIOS {
n.APNS = nil n.APNS = nil
} }
if notification.Platform == "android" { if client.Type == models.ClientTypeAndroid {
n.Android = nil n.Android = nil
} }

View File

@ -9,7 +9,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.16 github.com/mattn/go-sqlite3 v1.14.16
github.com/rs/zerolog v1.28.0 github.com/rs/zerolog v1.28.0
github.com/swaggo/swag v1.8.7 github.com/swaggo/swag v1.8.7
gogs.mikescher.com/BlackForestBytes/goext v0.0.18 gogs.mikescher.com/BlackForestBytes/goext v0.0.20
google.golang.org/api v0.103.0 google.golang.org/api v0.103.0
) )

View File

@ -162,6 +162,12 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
gogs.mikescher.com/BlackForestBytes/goext v0.0.17 h1:jsfbvII7aa0SH9qY0fnXBdtNnQe1YY3DgXDThEwLICc= gogs.mikescher.com/BlackForestBytes/goext v0.0.17 h1:jsfbvII7aa0SH9qY0fnXBdtNnQe1YY3DgXDThEwLICc=
gogs.mikescher.com/BlackForestBytes/goext v0.0.17/go.mod h1:TMBOjo3FRFh/GiTT0z3nwLmgcFJB87oSF2VMs4XUCTQ= gogs.mikescher.com/BlackForestBytes/goext v0.0.17/go.mod h1:TMBOjo3FRFh/GiTT0z3nwLmgcFJB87oSF2VMs4XUCTQ=
gogs.mikescher.com/BlackForestBytes/goext v0.0.18 h1:fprrLoAPGdI4ObveHR1DjiP9WhlTJppWtjqMA6ZkyS8=
gogs.mikescher.com/BlackForestBytes/goext v0.0.18/go.mod h1:TMBOjo3FRFh/GiTT0z3nwLmgcFJB87oSF2VMs4XUCTQ=
gogs.mikescher.com/BlackForestBytes/goext v0.0.19 h1:IvCHlIHDviHQXntZFTNdV7qNq5yQnSEMxF8LA0Tf3IY=
gogs.mikescher.com/BlackForestBytes/goext v0.0.19/go.mod h1:TMBOjo3FRFh/GiTT0z3nwLmgcFJB87oSF2VMs4XUCTQ=
gogs.mikescher.com/BlackForestBytes/goext v0.0.20 h1:HxJ0iZ838TQnp/a+/DNajdZjZkV43OsK4VbHarOiHTs=
gogs.mikescher.com/BlackForestBytes/goext v0.0.20/go.mod h1:TMBOjo3FRFh/GiTT0z3nwLmgcFJB87oSF2VMs4XUCTQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=

View File

@ -6,6 +6,7 @@ import (
"blackforestbytes.com/simplecloudnotifier/common/ginresp" "blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/db" "blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/firebase" "blackforestbytes.com/simplecloudnotifier/firebase"
"blackforestbytes.com/simplecloudnotifier/models"
"context" "context"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -174,3 +175,31 @@ func (app *Application) getPermissions(ctx *AppContext, hdr string) (PermissionS
return NewEmptyPermissions(), nil return NewEmptyPermissions(), nil
} }
func (app *Application) GetOrCreateChannel(ctx *AppContext, userid int64, chanName string) (models.Channel, error) {
chanName = strings.ToLower(strings.TrimSpace(chanName))
existingChan, err := app.Database.GetChannelByName(ctx, userid, chanName)
if err != nil {
return models.Channel{}, err
}
if existingChan != nil {
return *existingChan, nil
}
subscribeKey := app.GenerateRandomAuthKey()
sendKey := app.GenerateRandomAuthKey()
newChan, err := app.Database.CreateChannel(ctx, userid, chanName, subscribeKey, sendKey)
if err != nil {
return models.Channel{}, err
}
_, err = app.Database.CreateSubscribtion(ctx, userid, userid, newChan.Name, newChan.ChannelID, true)
if err != nil {
return models.Channel{}, err
}
return newChan, nil
}

103
server/models/delivery.go Normal file
View File

@ -0,0 +1,103 @@
package models
import (
"database/sql"
"github.com/blockloop/scan"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
type DeliveryStatus string
const (
DeliveryStatusRetry DeliveryStatus = "RETRY"
DeliveryStatusSuccess DeliveryStatus = "SUCCESS"
DeliveryStatusFailed DeliveryStatus = "FAILED"
)
type Delivery struct {
DeliveryID int64
SCNMessageID int64
ReceiverUserID int64
ReceiverClientID int64
TimestampCreated time.Time
TimestampFinalized *time.Time
Status DeliveryStatus
RetryCount int
NextDelivery *time.Time
FCMMessageID *string
}
func (d Delivery) JSON() DeliveryJSON {
return DeliveryJSON{
DeliveryID: d.DeliveryID,
SCNMessageID: d.SCNMessageID,
ReceiverUserID: d.ReceiverUserID,
ReceiverClientID: d.ReceiverClientID,
TimestampCreated: d.TimestampCreated.Format(time.RFC3339Nano),
TimestampFinalized: timeOptFmt(d.TimestampFinalized, time.RFC3339Nano),
Status: d.Status,
RetryCount: d.RetryCount,
NextDelivery: timeOptFmt(d.NextDelivery, time.RFC3339Nano),
FCMMessageID: d.FCMMessageID,
}
}
type DeliveryJSON struct {
DeliveryID int64 `json:"delivery_id"`
SCNMessageID int64 `json:"scn_message_id"`
ReceiverUserID int64 `json:"receiver_user_id"`
ReceiverClientID int64 `json:"receiver_client_id"`
TimestampCreated string `json:"timestamp_created"`
TimestampFinalized *string `json:"tiestamp_finalized"`
Status DeliveryStatus `json:"status"`
RetryCount int `json:"retry_count"`
NextDelivery *string `json:"next_delivery"`
FCMMessageID *string `json:"fcm_message_id"`
}
type DeliveryDB struct {
DeliveryID int64 `db:"delivery_id"`
SCNMessageID int64 `db:"scn_message_id"`
ReceiverUserID int64 `db:"receiver_user_id"`
ReceiverClientID int64 `db:"receiver_client_id"`
TimestampCreated int64 `db:"timestamp_created"`
TimestampFinalized *int64 `db:"tiestamp_finalized"`
Status DeliveryStatus `db:"status"`
RetryCount int `db:"retry_count"`
NextDelivery *int64 `db:"next_delivery"`
FCMMessageID *string `db:"fcm_message_id"`
}
func (d DeliveryDB) Model() Delivery {
return Delivery{
DeliveryID: d.DeliveryID,
SCNMessageID: d.SCNMessageID,
ReceiverUserID: d.ReceiverUserID,
ReceiverClientID: d.ReceiverClientID,
TimestampCreated: time.UnixMilli(d.TimestampCreated),
TimestampFinalized: timeOptFromMilli(d.TimestampFinalized),
Status: d.Status,
RetryCount: d.RetryCount,
NextDelivery: timeOptFromMilli(d.NextDelivery),
FCMMessageID: d.FCMMessageID,
}
}
func DecodeDelivery(r *sql.Rows) (Delivery, error) {
var data DeliveryDB
err := scan.RowStrict(&data, r)
if err != nil {
return Delivery{}, err
}
return data.Model(), nil
}
func DecodeDeliveries(r *sql.Rows) ([]Delivery, error) {
var data []DeliveryDB
err := scan.RowsStrict(&data, r)
if err != nil {
return nil, err
}
return langext.ArrMap(data, func(v DeliveryDB) Delivery { return v.Model() }), nil
}

100
server/models/message.go Normal file
View File

@ -0,0 +1,100 @@
package models
import (
"database/sql"
"github.com/blockloop/scan"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
type Message struct {
SCNMessageID int64
SenderUserID int64
OwnerUserID int64
ChannelName string
ChannelID int64
TimestampReal time.Time
TimestampClient *time.Time
Title string
Content *string
Priority int
UserMessageID *string
}
func (m Message) JSON() MessageJSON {
return MessageJSON{
SCNMessageID: m.SCNMessageID,
SenderUserID: m.SenderUserID,
OwnerUserID: m.OwnerUserID,
ChannelName: m.ChannelName,
ChannelID: m.ChannelID,
Timestamp: langext.Coalesce(m.TimestampClient, m.TimestampReal).Format(time.RFC3339Nano),
Title: m.Title,
Content: m.Content,
Priority: m.Priority,
UserMessageID: m.UserMessageID,
Trimmed: false,
}
}
type MessageJSON struct {
SCNMessageID int64 `json:"scn_message_id"`
SenderUserID int64 `json:"sender_user_id"`
OwnerUserID int64 `json:"owner_user_id"`
ChannelName string `json:"channel_name"`
ChannelID int64 `json:"channel_id"`
Timestamp string `json:"timestamp"`
Title string `json:"title"`
Content *string `json:"body"`
Priority int `json:"priority"`
UserMessageID *string `json:"usr_message_id"`
Trimmed bool `json:"trimmed"`
}
type MessageDB struct {
SCNMessageID int64 `db:"scn_message_id"`
SenderUserID int64 `db:"sender_user_id"`
OwnerUserID int64 `db:"owner_user_id"`
ChannelName string `db:"channel_name"`
ChannelID int64 `db:"channel_id"`
TimestampReal int64 `db:"timestamp_real"`
TimestampClient *int64 `db:"timestamp_client"`
Title string `db:"title"`
Content *string `db:"content"`
Priority int `db:"priority"`
UserMessageID *string `db:"usr_message_id"`
}
func (m MessageDB) Model() Message {
return Message{
SCNMessageID: m.SCNMessageID,
SenderUserID: m.SenderUserID,
OwnerUserID: m.OwnerUserID,
ChannelName: m.ChannelName,
ChannelID: m.ChannelID,
TimestampReal: time.UnixMilli(m.TimestampReal),
TimestampClient: timeOptFromMilli(m.TimestampClient),
Title: m.Title,
Content: m.Content,
Priority: m.Priority,
UserMessageID: m.UserMessageID,
}
}
func DecodeMessage(r *sql.Rows) (Message, error) {
var data MessageDB
err := scan.RowStrict(&data, r)
if err != nil {
return Message{}, err
}
return data.Model(), nil
}
func DecodeMessages(r *sql.Rows) ([]Message, error) {
var data []MessageDB
err := scan.RowsStrict(&data, r)
if err != nil {
return nil, err
}
return langext.ArrMap(data, func(v MessageDB) Message { return v.Model() }), nil
}

View File

@ -0,0 +1,80 @@
package models
import (
"database/sql"
"github.com/blockloop/scan"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
type Subscription struct {
SubscriptionID int64
SubscriberUserID int64
ChannelOwnerUserID int64
ChannelID int64
ChannelName string
TimestampCreated time.Time
Confirmed bool
}
func (s Subscription) JSON() SubscriptionJSON {
return SubscriptionJSON{
SubscriptionID: s.SubscriptionID,
SubscriberUserID: s.SubscriberUserID,
ChannelOwnerUserID: s.ChannelOwnerUserID,
ChannelID: s.ChannelID,
ChannelName: s.ChannelName,
TimestampCreated: s.TimestampCreated.Format(time.RFC3339Nano),
Confirmed: s.Confirmed,
}
}
type SubscriptionJSON struct {
SubscriptionID int64 `json:"subscription_id"`
SubscriberUserID int64 `json:"subscriber_user_id"`
ChannelOwnerUserID int64 `json:"channel_owner_user_id"`
ChannelID int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
TimestampCreated string `json:"timestamp_created"`
Confirmed bool `json:"confirmed"`
}
type SubscriptionDB struct {
SubscriptionID int64 `db:"subscription_id"`
SubscriberUserID int64 `db:"subscriber_user_id"`
ChannelOwnerUserID int64 `db:"channel_owner_user_id"`
ChannelID int64 `db:"channel_id"`
ChannelName string `db:"channel_name"`
TimestampCreated int64 `db:"timestamp_created"`
Confirmed int `db:"confirmed"`
}
func (s SubscriptionDB) Model() Subscription {
return Subscription{
SubscriptionID: s.SubscriptionID,
SubscriberUserID: s.SubscriberUserID,
ChannelOwnerUserID: s.ChannelOwnerUserID,
ChannelID: s.ChannelID,
ChannelName: s.ChannelName,
TimestampCreated: time.UnixMilli(s.TimestampCreated),
Confirmed: s.Confirmed != 0,
}
}
func DecodeSubscription(r *sql.Rows) (Subscription, error) {
var data SubscriptionDB
err := scan.RowStrict(&data, r)
if err != nil {
return Subscription{}, err
}
return data.Model(), nil
}
func DecodeSubscriptions(r *sql.Rows) ([]Subscription, error) {
var data []SubscriptionDB
err := scan.RowsStrict(&data, r)
if err != nil {
return nil, err
}
return langext.ArrMap(data, func(v SubscriptionDB) Subscription { return v.Model() }), nil
}

View File

@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"github.com/blockloop/scan" "github.com/blockloop/scan"
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"time" "time"
) )
@ -17,8 +18,8 @@ type User struct {
TimestampLastRead *time.Time TimestampLastRead *time.Time
TimestampLastSent *time.Time TimestampLastSent *time.Time
MessagesSent int MessagesSent int
QuotaToday int QuotaUsed int
QuotaDay *string QuotaUsedDay *string
IsPro bool IsPro bool
ProToken *string ProToken *string
} }
@ -34,12 +35,41 @@ func (u User) JSON() UserJSON {
TimestampLastRead: timeOptFmt(u.TimestampLastRead, time.RFC3339Nano), TimestampLastRead: timeOptFmt(u.TimestampLastRead, time.RFC3339Nano),
TimestampLastSent: timeOptFmt(u.TimestampLastSent, time.RFC3339Nano), TimestampLastSent: timeOptFmt(u.TimestampLastSent, time.RFC3339Nano),
MessagesSent: u.MessagesSent, MessagesSent: u.MessagesSent,
QuotaToday: u.QuotaToday, QuotaUsed: u.QuotaUsed,
QuotaDay: u.QuotaDay, QuotaUsedDay: u.QuotaUsedDay,
IsPro: u.IsPro, IsPro: u.IsPro,
} }
} }
func (u User) MaxContentLength() int {
if u.IsPro {
return 16384
} else {
return 2048
}
}
func (u User) QuotaPerDay() int {
if u.IsPro {
return 1000
} else {
return 50
}
}
func (u User) QuotaUsedToday() int {
now := time.Now().In(timeext.TimezoneBerlin).Format("2006-01-02")
if u.QuotaUsedDay != nil && *u.QuotaUsedDay == now {
return u.QuotaUsed
} else {
return 0
}
}
func (u User) QuotaRemainingToday() int {
return u.QuotaPerDay() - u.QuotaUsedToday()
}
type UserJSON struct { type UserJSON struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Username *string `json:"username"` Username *string `json:"username"`
@ -50,8 +80,8 @@ type UserJSON struct {
TimestampLastRead *string `json:"timestamp_last_read"` TimestampLastRead *string `json:"timestamp_last_read"`
TimestampLastSent *string `json:"timestamp_last_sent"` TimestampLastSent *string `json:"timestamp_last_sent"`
MessagesSent int `json:"messages_sent"` MessagesSent int `json:"messages_sent"`
QuotaToday int `json:"quota_today"` QuotaUsed int `json:"quota_used"`
QuotaDay *string `json:"quota_day"` QuotaUsedDay *string `json:"quota_used_day"`
IsPro bool `json:"is_pro"` IsPro bool `json:"is_pro"`
} }
@ -65,8 +95,8 @@ type UserDB struct {
TimestampLastRead *int64 `db:"timestamp_lastread"` TimestampLastRead *int64 `db:"timestamp_lastread"`
TimestampLastSent *int64 `db:"timestamp_lastsent"` TimestampLastSent *int64 `db:"timestamp_lastsent"`
MessagesSent int `db:"messages_sent"` MessagesSent int `db:"messages_sent"`
QuotaToday int `db:"quota_today"` QuotaUsed int `db:"quota_used"`
QuotaDay *string `db:"quota_day"` QuotaUsedDay *string `db:"quota_used_day"`
IsPro bool `db:"is_pro"` IsPro bool `db:"is_pro"`
ProToken *string `db:"pro_token"` ProToken *string `db:"pro_token"`
} }
@ -82,8 +112,8 @@ func (u UserDB) Model() User {
TimestampLastRead: timeOptFromMilli(u.TimestampLastRead), TimestampLastRead: timeOptFromMilli(u.TimestampLastRead),
TimestampLastSent: timeOptFromMilli(u.TimestampLastSent), TimestampLastSent: timeOptFromMilli(u.TimestampLastSent),
MessagesSent: u.MessagesSent, MessagesSent: u.MessagesSent,
QuotaToday: u.QuotaToday, QuotaUsed: u.QuotaUsed,
QuotaDay: u.QuotaDay, QuotaUsedDay: u.QuotaUsedDay,
IsPro: u.IsPro, IsPro: u.IsPro,
} }
} }

View File

@ -9,6 +9,88 @@
"host": "scn.blackforestbytes.com", "host": "scn.blackforestbytes.com",
"basePath": "/", "basePath": "/",
"paths": { "paths": {
"/": {
"post": {
"summary": "Send a new message",
"parameters": [
{
"type": "string",
"name": "message_content",
"in": "query"
},
{
"type": "string",
"name": "message_title",
"in": "query"
},
{
"type": "integer",
"name": "priority",
"in": "query"
},
{
"type": "integer",
"name": "sendTimestamp",
"in": "query"
},
{
"type": "string",
"name": "userMessageID",
"in": "query"
},
{
"type": "string",
"name": "user_id",
"in": "query"
},
{
"type": "string",
"name": "user_key",
"in": "query"
},
{
"description": " ",
"name": "post_body",
"in": "body",
"schema": {
"$ref": "#/definitions/handler.SendMessage.body"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.ClientJSON"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/api-v2/user/": { "/api-v2/user/": {
"post": { "post": {
"summary": "Create a new user", "summary": "Create a new user",
@ -688,6 +770,88 @@
} }
} }
} }
},
"/send": {
"post": {
"summary": "Send a new message",
"parameters": [
{
"type": "string",
"name": "message_content",
"in": "query"
},
{
"type": "string",
"name": "message_title",
"in": "query"
},
{
"type": "integer",
"name": "priority",
"in": "query"
},
{
"type": "integer",
"name": "sendTimestamp",
"in": "query"
},
{
"type": "string",
"name": "userMessageID",
"in": "query"
},
{
"type": "string",
"name": "user_id",
"in": "query"
},
{
"type": "string",
"name": "user_key",
"in": "query"
},
{
"description": " ",
"name": "post_body",
"in": "body",
"schema": {
"$ref": "#/definitions/handler.SendMessage.body"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.ClientJSON"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
} }
}, },
"definitions": { "definitions": {
@ -894,6 +1058,32 @@
} }
} }
}, },
"handler.SendMessage.body": {
"type": "object",
"properties": {
"message_content": {
"type": "string"
},
"message_title": {
"type": "string"
},
"priority": {
"type": "integer"
},
"sendTimestamp": {
"type": "integer"
},
"userMessageID": {
"type": "string"
},
"user_id": {
"type": "string"
},
"user_key": {
"type": "string"
}
}
},
"handler.Update.response": { "handler.Update.response": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -132,6 +132,23 @@ definitions:
success: success:
type: string type: string
type: object type: object
handler.SendMessage.body:
properties:
message_content:
type: string
message_title:
type: string
priority:
type: integer
sendTimestamp:
type: integer
user_id:
type: string
user_key:
type: string
userMessageID:
type: string
type: object
handler.Update.response: handler.Update.response:
properties: properties:
is_pro: is_pro:
@ -272,6 +289,57 @@ info:
title: SimpleCloudNotifier API title: SimpleCloudNotifier API
version: "2.0" version: "2.0"
paths: paths:
/:
post:
parameters:
- in: query
name: message_content
type: string
- in: query
name: message_title
type: string
- in: query
name: priority
type: integer
- in: query
name: sendTimestamp
type: integer
- in: query
name: userMessageID
type: string
- in: query
name: user_id
type: string
- in: query
name: user_key
type: string
- description: ' '
in: body
name: post_body
schema:
$ref: '#/definitions/handler.SendMessage.body'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.ClientJSON'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ginresp.apiError'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ginresp.apiError'
"404":
description: Not Found
schema:
$ref: '#/definitions/ginresp.apiError'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
summary: Send a new message
/api-v2/user/: /api-v2/user/:
post: post:
operationId: api-user-create operationId: api-user-create
@ -720,4 +788,55 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/ginresp.apiError' $ref: '#/definitions/ginresp.apiError'
/send:
post:
parameters:
- in: query
name: message_content
type: string
- in: query
name: message_title
type: string
- in: query
name: priority
type: integer
- in: query
name: sendTimestamp
type: integer
- in: query
name: userMessageID
type: string
- in: query
name: user_id
type: string
- in: query
name: user_key
type: string
- description: ' '
in: body
name: post_body
schema:
$ref: '#/definitions/handler.SendMessage.body'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.ClientJSON'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ginresp.apiError'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ginresp.apiError'
"404":
description: Not Found
schema:
$ref: '#/definitions/ginresp.apiError'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
summary: Send a new message
swagger: "2.0" swagger: "2.0"