`POST:/users` works

This commit is contained in:
Mike Schwörer 2022-11-18 21:25:40 +01:00
parent 34a27d9ca4
commit 5991631bfa
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
44 changed files with 2131 additions and 737 deletions

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="DB" uuid="9efcb84c-0b66-4a1f-9c98-f11a17482c42">
<data-source source="LOCAL" name="DataSource" uuid="4345c0f7-e4f6-49f3-8694-3827ae63cc61">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>

View File

@ -1,7 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/db/schema_2.0.sql" dialect="SQLite" />
<file url="file://$PROJECT_DIR$/db/schema_3.0.ddl" dialect="SQLite" />
<file url="PROJECT" dialect="SQLite" />
</component>
<component name="SqlResolveMappings">
<file url="file://$PROJECT_DIR$/db/database.go" scope="{&quot;node&quot;:{ &quot;@negative&quot;:&quot;1&quot;, &quot;group&quot;:{ &quot;@kind&quot;:&quot;root&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;b3228d61-4c36-41ce-803f-63bd80e198b3&quot; }, &quot;group&quot;:{ &quot;@kind&quot;:&quot;schema&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;schema_3.0.ddl&quot; } } } } } }}" />
<file url="PROJECT" scope="{&quot;node&quot;:{ &quot;@negative&quot;:&quot;1&quot;, &quot;group&quot;:{ &quot;@kind&quot;:&quot;root&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;b3228d61-4c36-41ce-803f-63bd80e198b3&quot; }, &quot;group&quot;:{ &quot;@kind&quot;:&quot;schema&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;schema_3.0.ddl&quot; } } } } } }}" />
</component>
</project>

View File

@ -5,11 +5,15 @@ type APIError int
const (
NO_ERROR APIError = 0000
MISSING_UID APIError = 1101
MISSING_TOK APIError = 1102
MISSING_TITLE APIError = 1103
INVALID_PRIO APIError = 1104
REQ_METHOD APIError = 1105
MISSING_UID APIError = 1101
MISSING_TOK APIError = 1102
MISSING_TITLE APIError = 1103
INVALID_PRIO APIError = 1104
REQ_METHOD APIError = 1105
INVALID_CLIENTTYPE APIError = 1106
MISSING_QUERY_PARAM APIError = 1151
MISSING_BODY_PARAM APIError = 1152
MISSING_URI_PARAM APIError = 1153
NO_TITLE APIError = 1201
TITLE_TOO_LONG APIError = 1202
@ -24,6 +28,12 @@ const (
QUOTA_REACHED APIError = 2101
FAILED_VERIFY_PRO_TOKEN APIError = 3001
INVALID_PRO_TOKEN APIError = 3002
COMMIT_FAILED = 9001
DATABASE_ERROR = 9002
FIREBASE_COM_FAILED APIError = 9901
FIREBASE_COM_ERRORED APIError = 9902
INTERNAL_EXCEPTION APIError = 9903

167
server/api/handler/api.go Normal file
View File

@ -0,0 +1,167 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/models"
"blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/logic"
"github.com/gin-gonic/gin"
"net/http"
)
type APIHandler struct {
app *logic.Application
}
// CreateUser swaggerdoc
//
// @Summary Create a new user
// @ID api-user-create
//
// @Param post_body body handler.CreateUser.body false " "
//
// @Success 200 {object} models.UserJSON
// @Failure 400 {object} ginresp.apiError
// @Failure 500 {object} ginresp.apiError
//
// @Router /api-v2/user/ [POST]
func (h APIHandler) CreateUser(g *gin.Context) ginresp.HTTPResponse {
type body struct {
FCMToken string `form:"fcm_token"`
ProToken *string `form:"pro_token"`
Username *string `form:"username"`
AgentModel string `form:"agent_model"`
AgentVersion string `form:"agent_version"`
ClientType string `form:"client_type"`
}
ctx := h.app.StartRequest(g)
defer ctx.Cancel()
var b body
if err := g.ShouldBindJSON(&b); err != nil {
return ginresp.InternAPIError(apierr.MISSING_BODY_PARAM, "Failed to read body", err)
}
var clientType models.ClientType
if b.ClientType == string(models.ClientTypeAndroid) {
clientType = models.ClientTypeAndroid
} else if b.ClientType == string(models.ClientTypeIOS) {
clientType = models.ClientTypeIOS
} else {
return ginresp.InternAPIError(apierr.INVALID_CLIENTTYPE, "Invalid ClientType", nil)
}
if b.ProToken != nil {
ptok, err := h.app.VerifyProToken(*b.ProToken)
if err != nil {
return ginresp.InternAPIError(apierr.FAILED_VERIFY_PRO_TOKEN, "Failed to query purchase status", err)
}
if !ptok {
return ginresp.InternAPIError(apierr.INVALID_PRO_TOKEN, "Purchase token could not be verified", nil)
}
}
readKey := h.app.GenerateRandomAuthKey()
sendKey := h.app.GenerateRandomAuthKey()
adminKey := h.app.GenerateRandomAuthKey()
err := h.app.Database.ClearFCMTokens(ctx, b.FCMToken)
if err != nil {
return ginresp.InternAPIError(apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
}
if b.ProToken != nil {
err := h.app.Database.ClearProTokens(ctx, b.FCMToken)
if err != nil {
return ginresp.InternAPIError(apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
}
}
userobj, err := h.app.Database.CreateUser(ctx, readKey, sendKey, adminKey, b.ProToken, b.Username)
if err != nil {
return ginresp.InternAPIError(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)
if err != nil {
return ginresp.InternAPIError(apierr.DATABASE_ERROR, "Failed to create user in db", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, userobj.JSON()))
}
func (h APIHandler) GetUser(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) UpdateUser(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) ListClients(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) GetClient(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) AddClient(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) DeleteClient(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) ListChannels(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) GetChannel(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) GetChannelMessages(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) ListChannelSubscriptions(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) GetSubscription(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) CancelSubscription(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) CreateSubscription(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) ListMessages(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) GetMessage(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func (h APIHandler) DeleteMessage(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func NewAPIHandler(app *logic.Application) APIHandler {
return APIHandler{
app: app,
}
}

View File

@ -34,7 +34,7 @@ type pingResponseInfo struct {
// Ping swaggerdoc
//
// @Success 200 {object} pingResponse
// @Failure 500 {object} ginresp.errBody
// @Failure 500 {object} ginresp.apiError
// @Router /ping [get]
// @Router /ping [post]
// @Router /ping [put]
@ -60,7 +60,7 @@ func (h CommonHandler) Ping(g *gin.Context) ginresp.HTTPResponse {
// DatabaseTest swaggerdoc
//
// @Success 200 {object} handler.DatabaseTest.response
// @Failure 500 {object} ginresp.errBody
// @Failure 500 {object} ginresp.apiError
// @Router /db-test [get]
func (h CommonHandler) DatabaseTest(g *gin.Context) ginresp.HTTPResponse {
type response struct {
@ -88,7 +88,7 @@ func (h CommonHandler) DatabaseTest(g *gin.Context) ginresp.HTTPResponse {
// Health swaggerdoc
//
// @Success 200 {object} handler.Health.response
// @Failure 500 {object} ginresp.errBody
// @Failure 500 {object} ginresp.apiError
// @Router /health [get]
func (h CommonHandler) Health(*gin.Context) ginresp.HTTPResponse {
type response struct {
@ -96,3 +96,14 @@ func (h CommonHandler) Health(*gin.Context) ginresp.HTTPResponse {
}
return ginresp.JSON(http.StatusOK, response{Status: "ok"})
}
func (h CommonHandler) NoRoute(g *gin.Context) ginresp.HTTPResponse {
return ginresp.JSON(http.StatusNotFound, gin.H{
"FullPath": g.FullPath(),
"Method": g.Request.Method,
"URL": g.Request.URL.String(),
"RequestURI": g.Request.RequestURI,
"Proto": g.Request.Proto,
"Header": g.Request.Header,
})
}

View File

@ -1,17 +1,10 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/models"
"blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/logic"
"context"
"database/sql"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
"time"
)
type CompatHandler struct {
@ -32,8 +25,8 @@ func NewCompatHandler(app *logic.Application) CompatHandler {
// @Param pro query string true "if the user is a paid account" Enums(true, false)
// @Param pro_token query string true "the (android) IAP token"
// @Success 200 {object} handler.Register.response
// @Failure 500 {object} ginresp.internAPIError
// @Router /register.php [get]
// @Failure 500 {object} ginresp.apiError
// @Router /api/register.php [get]
func (h CompatHandler) Register(g *gin.Context) ginresp.HTTPResponse {
type query struct {
FCMToken *string `form:"fcm_token"`
@ -50,81 +43,9 @@ func (h CompatHandler) Register(g *gin.Context) ginresp.HTTPResponse {
IsPro int `json:"is_pro"`
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
//TODO
var q query
if err := g.ShouldBindQuery(&q); err != nil {
return ginresp.InternAPIError(0, "Failed to read arguments")
}
if q.FCMToken == nil {
return ginresp.InternAPIError(0, "Missing parameter [[fcm_token]]")
}
if q.Pro == nil {
return ginresp.InternAPIError(0, "Missing parameter [[pro]]")
}
if q.ProToken == nil {
return ginresp.InternAPIError(0, "Missing parameter [[pro_token]]")
}
isProInt := 0
isProBool := false
if *q.Pro == "true" {
isProInt = 1
isProBool = true
} else {
q.ProToken = nil
}
if isProBool {
ptok, err := h.app.VerifyProToken(*q.ProToken)
if err != nil {
return ginresp.InternAPIError(0, fmt.Sprintf("Failed to query purchaste status: %v", err))
}
if !ptok {
return ginresp.InternAPIError(0, "Purchase token could not be verified")
}
}
userKey := h.app.GenerateRandomAuthKey()
return h.app.RunTransaction(ctx, nil, func(tx *sql.Tx) (ginresp.HTTPResponse, bool) {
res, err := tx.ExecContext(ctx, "INSERT INTO users (user_key, fcm_token, is_pro, pro_token, timestamp_accessed) VALUES (?, ?, ?, ?, NOW())", userKey, *q.FCMToken, isProInt, q.ProToken)
if err != nil {
return ginresp.InternAPIError(0, fmt.Sprintf("Failed to create user: %v", err)), false
}
userId, err := res.LastInsertId()
if err != nil {
return ginresp.InternAPIError(0, fmt.Sprintf("Failed to get user_id: %v", err)), false
}
_, err = tx.ExecContext(ctx, "UPDATE users SET fcm_token=NULL WHERE user_id <> ? AND fcm_token=?", userId, q.FCMToken)
if err != nil {
return ginresp.InternAPIError(0, fmt.Sprintf("Failed to update fcm: %v", err)), false
}
if isProInt == 1 {
_, err := tx.ExecContext(ctx, "UPDATE users SET is_pro=0, pro_token=NULL WHERE user_id <> ? AND pro_token = ?", userId, q.ProToken)
if err != nil {
return ginresp.InternAPIError(0, fmt.Sprintf("Failed to update ispro: %v", err)), false
}
}
return ginresp.JSON(http.StatusOK, response{
Success: true,
Message: "New user registered",
UserID: strconv.FormatInt(userId, 10),
UserKey: userKey,
QuotaUsed: 0,
QuotaMax: h.app.QuotaMax(isProBool),
IsPro: isProInt,
}), true
})
return ginresp.NotImplemented()
}
// Info swaggerdoc
@ -134,8 +55,8 @@ func (h CompatHandler) Register(g *gin.Context) ginresp.HTTPResponse {
// @Param user_id query string true "the user_id"
// @Param user_key query string true "the user_key"
// @Success 200 {object} handler.Info.response
// @Failure 500 {object} ginresp.internAPIError
// @Router /info.php [get]
// @Failure 500 {object} ginresp.apiError
// @Router /api/info.php [get]
func (h CompatHandler) Info(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID string `form:"user_id"`
@ -155,7 +76,7 @@ func (h CompatHandler) Info(g *gin.Context) ginresp.HTTPResponse {
//TODO
return ginresp.InternAPIError(0, "NotImplemented")
return ginresp.NotImplemented()
}
// Ack swaggerdoc
@ -166,8 +87,8 @@ func (h CompatHandler) Info(g *gin.Context) ginresp.HTTPResponse {
// @Param user_key query string true "the user_key"
// @Param scn_msg_id query string true "the message id"
// @Success 200 {object} handler.Ack.response
// @Failure 500 {object} ginresp.internAPIError
// @Router /ack.php [get]
// @Failure 500 {object} ginresp.apiError
// @Router /api/ack.php [get]
func (h CompatHandler) Ack(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID string `form:"user_id"`
@ -183,7 +104,7 @@ func (h CompatHandler) Ack(g *gin.Context) ginresp.HTTPResponse {
//TODO
return ginresp.InternAPIError(0, "NotImplemented")
return ginresp.NotImplemented()
}
// Requery swaggerdoc
@ -193,8 +114,8 @@ func (h CompatHandler) Ack(g *gin.Context) ginresp.HTTPResponse {
// @Param user_id query string true "the user_id"
// @Param user_key query string true "the user_key"
// @Success 200 {object} handler.Requery.response
// @Failure 500 {object} ginresp.internAPIError
// @Router /requery.php [get]
// @Failure 500 {object} ginresp.apiError
// @Router /api/requery.php [get]
func (h CompatHandler) Requery(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID string `form:"user_id"`
@ -209,7 +130,7 @@ func (h CompatHandler) Requery(g *gin.Context) ginresp.HTTPResponse {
//TODO
return ginresp.InternAPIError(0, "NotImplemented")
return ginresp.NotImplemented()
}
// Update swaggerdoc
@ -220,8 +141,8 @@ func (h CompatHandler) Requery(g *gin.Context) ginresp.HTTPResponse {
// @Param user_key query string true "the user_key"
// @Param fcm_token query string true "the (android) fcm token"
// @Success 200 {object} handler.Update.response
// @Failure 500 {object} ginresp.internAPIError
// @Router /update.php [get]
// @Failure 500 {object} ginresp.apiError
// @Router /api/update.php [get]
func (h CompatHandler) Update(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID string `form:"user_id"`
@ -240,7 +161,7 @@ func (h CompatHandler) Update(g *gin.Context) ginresp.HTTPResponse {
//TODO
return ginresp.InternAPIError(0, "NotImplemented")
return ginresp.NotImplemented()
}
// Expand swaggerdoc
@ -248,8 +169,8 @@ func (h CompatHandler) Update(g *gin.Context) ginresp.HTTPResponse {
// @Summary Get a whole (potentially truncated) message
// @ID compat-expand
// @Success 200 {object} handler.Expand.response
// @Failure 500 {object} ginresp.internAPIError
// @Router /expand.php [get]
// @Failure 500 {object} ginresp.apiError
// @Router /api/expand.php [get]
func (h CompatHandler) Expand(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID string `form:"user_id"`
@ -264,7 +185,7 @@ func (h CompatHandler) Expand(g *gin.Context) ginresp.HTTPResponse {
//TODO
return ginresp.InternAPIError(0, "NotImplemented")
return ginresp.NotImplemented()
}
// Upgrade swaggerdoc
@ -276,8 +197,8 @@ func (h CompatHandler) Expand(g *gin.Context) ginresp.HTTPResponse {
// @Param pro query string true "if the user is a paid account" Enums(true, false)
// @Param pro_token query string true "the (android) IAP token"
// @Success 200 {object} handler.Upgrade.response
// @Failure 500 {object} ginresp.internAPIError
// @Router /upgrade.php [get]
// @Failure 500 {object} ginresp.apiError
// @Router /api/upgrade.php [get]
func (h CompatHandler) Upgrade(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID string `form:"user_id"`
@ -293,47 +214,5 @@ func (h CompatHandler) Upgrade(g *gin.Context) ginresp.HTTPResponse {
//TODO
return ginresp.InternAPIError(0, "NotImplemented")
}
// Send swaggerdoc
//
// @Summary Send a message
// @Description (all arguments can either be supplied in the query or in the json body)
// @ID compat-send
// @Accept json
// @Produce json
// @Param _ query handler.Send.query false " "
// @Param post_body body handler.Send.body false " "
// @Success 200 {object} handler.Send.response
// @Failure 500 {object} ginresp.sendAPIError
// @Router /send.php [post]
func (h CompatHandler) Send(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID string `form:"user_id" required:"true"`
UserKey string `form:"user_key" required:"true"`
Title string `form:"title" required:"true"`
Content *string `form:"content"`
Priority *string `form:"priority"`
MessageID *string `form:"msg_id"`
Timestamp *string `form:"timestamp"`
}
type body struct {
UserID string `json:"user_id" required:"true"`
UserKey string `json:"user_key" required:"true"`
Title string `json:"title" required:"true"`
Content *string `json:"content"`
Priority *string `json:"priority"`
MessageID *string `json:"msg_id"`
Timestamp *string `json:"timestamp"`
}
type response struct {
Success string `json:"success"`
Message string `json:"message"`
//TODO
}
//TODO
return ginresp.SendAPIError(apierr.INTERNAL_EXCEPTION, -1, "NotImplemented")
return ginresp.NotImplemented()
}

View File

@ -0,0 +1,21 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/logic"
"github.com/gin-gonic/gin"
)
type MessageHandler struct {
app *logic.Application
}
func (h MessageHandler) SendMessage(g *gin.Context) ginresp.HTTPResponse {
return ginresp.NotImplemented()
}
func NewMessageHandler(app *logic.Application) MessageHandler {
return MessageHandler{
app: app,
}
}

View File

@ -0,0 +1,98 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/website"
"github.com/gin-gonic/gin"
"net/http"
"strings"
)
type WebsiteHandler struct {
app *logic.Application
}
func NewWebsiteHandler(app *logic.Application) WebsiteHandler {
return WebsiteHandler{
app: app,
}
}
func (h WebsiteHandler) Index(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "index.html")
}
func (h WebsiteHandler) APIDocs(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "api.html")
}
func (h WebsiteHandler) APIDocsMore(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "api_more.html")
}
func (h WebsiteHandler) MessageSent(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "message_sent.html")
}
func (h WebsiteHandler) FaviconIco(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "favicon.ico")
}
func (h WebsiteHandler) FaviconPNG(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "favicon.png")
}
func (h WebsiteHandler) Javascript(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
Filename string `uri:"fn"`
}
var u uri
if err := g.ShouldBindUri(&u); err != nil {
return ginresp.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return h.serveAsset(g, "js/"+u.Filename)
}
func (h WebsiteHandler) CSS(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
Filename string `uri:"fn"`
}
var u uri
if err := g.ShouldBindUri(&u); err != nil {
return ginresp.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return h.serveAsset(g, "css/"+u.Filename)
}
func (h WebsiteHandler) serveAsset(g *gin.Context, fn string) ginresp.HTTPResponse {
data, err := website.Assets.ReadFile(fn)
if err != nil {
return ginresp.Status(http.StatusNotFound)
}
mime := "text/plain"
lowerFN := strings.ToLower(fn)
if strings.HasSuffix(lowerFN, ".html") || strings.HasSuffix(lowerFN, ".htm") {
mime = "text/html"
} else if strings.HasSuffix(lowerFN, ".css") {
mime = "text/css"
} else if strings.HasSuffix(lowerFN, ".js") {
mime = "text/javascript"
} else if strings.HasSuffix(lowerFN, ".json") {
mime = "application/json"
} else if strings.HasSuffix(lowerFN, ".jpeg") || strings.HasSuffix(lowerFN, ".jpg") {
mime = "image/jpeg"
} else if strings.HasSuffix(lowerFN, ".png") {
mime = "image/png"
} else if strings.HasSuffix(lowerFN, ".svg") {
mime = "image/svg+xml"
}
return ginresp.Data(http.StatusOK, mime, data)
}

View File

@ -0,0 +1,23 @@
package models
import "time"
type ClientType string
const (
ClientTypeAndroid ClientType = "ANDROID"
ClientTypeIOS ClientType = "IOS"
)
type Client struct {
ClientID int64
UserID int64
Type ClientType
FCMToken *string
TimestampCreated time.Time
AgentModel string
AgentVersion string
}
type ClientJSON struct {
}

51
server/api/models/user.go Normal file
View File

@ -0,0 +1,51 @@
package models
import "time"
type User struct {
UserID int64
Username *string
ReadKey string
SendKey string
AdminKey string
TimestampCreated time.Time
TimestampLastRead *time.Time
TimestampLastSent *time.Time
MessagesSent int
QuotaToday int
QuotaDay *string
IsPro bool
ProToken *string
}
func (u User) JSON() UserJSON {
return UserJSON{
UserID: u.UserID,
Username: u.Username,
ReadKey: u.ReadKey,
SendKey: u.SendKey,
AdminKey: u.AdminKey,
TimestampCreated: u.TimestampCreated.Format(time.RFC3339Nano),
TimestampLastRead: timeOptFmt(u.TimestampLastRead, time.RFC3339Nano),
TimestampLastSent: timeOptFmt(u.TimestampLastSent, time.RFC3339Nano),
MessagesSent: u.MessagesSent,
QuotaToday: u.QuotaToday,
QuotaDay: u.QuotaDay,
IsPro: u.IsPro,
}
}
type UserJSON struct {
UserID int64 `json:"user_id"`
Username *string `json:"username"`
ReadKey string `json:"read_key"`
SendKey string `json:"send_key"`
AdminKey string `json:"admin_key"`
TimestampCreated string `json:"timestamp_created"`
TimestampLastRead *string `json:"timestamp_last_read"`
TimestampLastSent *string `json:"timestamp_last_sent"`
MessagesSent int `json:"messages_sent"`
QuotaToday int `json:"quota_today"`
QuotaDay *string `json:"quota_day"`
IsPro bool `json:"is_pro"`
}

View File

@ -0,0 +1,14 @@
package models
import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
func timeOptFmt(t *time.Time, fmt string) *string {
if t == nil {
return nil
} else {
return langext.Ptr(t.Format(fmt))
}
}

View File

@ -12,16 +12,22 @@ import (
type Router struct {
app *logic.Application
commonHandler handler.CommonHandler
compatHandler handler.CompatHandler
commonHandler handler.CommonHandler
compatHandler handler.CompatHandler
websiteHandler handler.WebsiteHandler
apiHandler handler.APIHandler
messageHandler handler.MessageHandler
}
func NewRouter(app *logic.Application) *Router {
return &Router{
app: app,
commonHandler: handler.NewCommonHandler(app),
compatHandler: handler.NewCompatHandler(app),
commonHandler: handler.NewCommonHandler(app),
compatHandler: handler.NewCompatHandler(app),
websiteHandler: handler.NewWebsiteHandler(app),
apiHandler: handler.NewAPIHandler(app),
messageHandler: handler.NewMessageHandler(app),
}
}
@ -30,23 +36,101 @@ func NewRouter(app *logic.Application) *Router {
// @version 2.0
// @description API for SCN
// @host scn.blackforestbytes.com
// @BasePath /api/
// @BasePath /
func (r *Router) Init(e *gin.Engine) {
e.Any("/ping", ginresp.Wrap(r.commonHandler.Ping))
e.POST("/db-test", ginresp.Wrap(r.commonHandler.DatabaseTest))
e.GET("/health", ginresp.Wrap(r.commonHandler.Health))
// ================ General ================
e.GET("documentation/swagger", ginext.RedirectTemporary("/documentation/swagger/"))
e.GET("documentation/swagger/", ginresp.Wrap(swagger.Handle))
e.GET("documentation/swagger/:fn", ginresp.Wrap(swagger.Handle))
e.Any("/api/common/ping", ginresp.Wrap(r.commonHandler.Ping))
e.POST("/api/common/db-test", ginresp.Wrap(r.commonHandler.DatabaseTest))
e.GET("/api/common/health", ginresp.Wrap(r.commonHandler.Health))
// ================ Swagger ================
e.GET("/documentation/swagger", ginext.RedirectTemporary("/documentation/swagger/"))
e.GET("/documentation/swagger/", ginresp.Wrap(swagger.Handle))
e.GET("/documentation/swagger/:fn", ginresp.Wrap(swagger.Handle))
// ================ Website ================
e.GET("/", ginresp.Wrap(r.websiteHandler.Index))
e.GET("/index.php", ginresp.Wrap(r.websiteHandler.Index))
e.GET("/index.html", ginresp.Wrap(r.websiteHandler.Index))
e.GET("/index", ginresp.Wrap(r.websiteHandler.Index))
e.GET("/api", ginresp.Wrap(r.websiteHandler.APIDocs))
e.GET("/api.php", ginresp.Wrap(r.websiteHandler.APIDocs))
e.GET("/api.html", ginresp.Wrap(r.websiteHandler.APIDocs))
e.GET("/api_more", ginresp.Wrap(r.websiteHandler.APIDocsMore))
e.GET("/api_more.php", ginresp.Wrap(r.websiteHandler.APIDocsMore))
e.GET("/api_more.html", ginresp.Wrap(r.websiteHandler.APIDocsMore))
e.GET("/message_sent", ginresp.Wrap(r.websiteHandler.MessageSent))
e.GET("/message_sent.php", ginresp.Wrap(r.websiteHandler.MessageSent))
e.GET("/message_sent.html", ginresp.Wrap(r.websiteHandler.MessageSent))
e.GET("/favicon.ico", ginresp.Wrap(r.websiteHandler.FaviconIco))
e.GET("/favicon.png", ginresp.Wrap(r.websiteHandler.FaviconPNG))
e.GET("/js/:fn", ginresp.Wrap(r.websiteHandler.Javascript))
e.GET("/css/:fn", ginresp.Wrap(r.websiteHandler.CSS))
// ================ Compat (v1) ================
compat := e.Group("/api/")
{
compat.GET("/register.php", ginresp.Wrap(r.compatHandler.Register))
compat.GET("/info.php", ginresp.Wrap(r.compatHandler.Info))
compat.GET("/ack.php", ginresp.Wrap(r.compatHandler.Ack))
compat.GET("/requery.php", ginresp.Wrap(r.compatHandler.Requery))
compat.GET("/update.php", ginresp.Wrap(r.compatHandler.Update))
compat.GET("/expand.php", ginresp.Wrap(r.compatHandler.Expand))
compat.GET("/upgrade.php", ginresp.Wrap(r.compatHandler.Upgrade))
}
// ================ Manage API ================
apiv2 := e.Group("/api-v2/")
{
apiv2.POST("/user/", ginresp.Wrap(r.apiHandler.CreateUser))
apiv2.GET("/user/:uid", ginresp.Wrap(r.apiHandler.GetUser))
apiv2.PATCH("/user/:uid", ginresp.Wrap(r.apiHandler.UpdateUser))
apiv2.GET("/user/:uid/clients", ginresp.Wrap(r.apiHandler.ListClients))
apiv2.GET("/user/:uid/clients/:cid", ginresp.Wrap(r.apiHandler.GetClient))
apiv2.POST("/user/:uid/clients", ginresp.Wrap(r.apiHandler.AddClient))
apiv2.DELETE("/user/:uid/clients", ginresp.Wrap(r.apiHandler.DeleteClient))
apiv2.GET("/user/:uid/channels", ginresp.Wrap(r.apiHandler.ListChannels))
apiv2.GET("/user/:uid/channels/:cid", ginresp.Wrap(r.apiHandler.GetChannel))
apiv2.GET("/user/:uid/channels/:cid/messages", ginresp.Wrap(r.apiHandler.GetChannelMessages))
apiv2.GET("/user/:uid/channels/:cid/subscriptions", ginresp.Wrap(r.apiHandler.ListChannelSubscriptions))
apiv2.GET("/user/:uid/subscriptions", ginresp.Wrap(r.apiHandler.ListUserSubscriptions))
apiv2.GET("/user/:uid/subscriptions/:sid", ginresp.Wrap(r.apiHandler.GetSubscription))
apiv2.DELETE("/user/:uid/subscriptions/:sid", ginresp.Wrap(r.apiHandler.CancelSubscription))
apiv2.POST("/user/:uid/subscriptions", ginresp.Wrap(r.apiHandler.CreateSubscription))
apiv2.GET("/messages", ginresp.Wrap(r.apiHandler.ListMessages))
apiv2.GET("/messages/:mid", ginresp.Wrap(r.apiHandler.GetMessage))
apiv2.DELETE("/messages/:mid", ginresp.Wrap(r.apiHandler.DeleteMessage))
apiv2.POST("/messages", ginresp.Wrap(r.messageHandler.SendMessage))
}
// ================ Send API ================
sendAPI := e.Group("")
{
sendAPI.POST("/", ginresp.Wrap(r.messageHandler.SendMessage))
sendAPI.POST("/send", ginresp.Wrap(r.messageHandler.SendMessage))
sendAPI.POST("/send.php")
}
if r.app.Config.ReturnRawErrors {
e.NoRoute(ginresp.Wrap(r.commonHandler.NoRoute))
}
e.POST("/send.php", ginresp.Wrap(r.compatHandler.Send))
e.GET("/register.php", ginresp.Wrap(r.compatHandler.Register))
e.GET("/info.php", ginresp.Wrap(r.compatHandler.Info))
e.GET("/ack.php", ginresp.Wrap(r.compatHandler.Ack))
e.GET("/requery.php", ginresp.Wrap(r.compatHandler.Requery))
e.GET("/update.php", ginresp.Wrap(r.compatHandler.Update))
e.GET("/expand.php", ginresp.Wrap(r.compatHandler.Expand))
e.GET("/upgrade.php", ginresp.Wrap(r.compatHandler.Upgrade))
}

View File

@ -7,7 +7,6 @@ import (
"blackforestbytes.com/simplecloudnotifier/common/ginext"
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/logic"
"context"
"fmt"
"github.com/rs/zerolog/log"
)
@ -19,13 +18,17 @@ func main() {
log.Info().Msg(fmt.Sprintf("Starting with config-namespace <%s>", conf.Namespace))
sqlite, err := db.NewDatabase(context.Background(), conf)
sqlite, err := db.NewDatabase(conf)
if err != nil {
panic(err)
}
app := logic.NewApp(sqlite)
if err := app.Migrate(); err != nil {
panic(err)
}
ginengine := ginext.NewEngine(conf)
router := api.NewRouter(app)

View File

@ -1,14 +1,9 @@
package ginresp
type sendAPIError struct {
type apiError struct {
Success bool `json:"success"`
Error int `json:"error"`
ErrorHighlight int `json:"errhighlight"`
Message string `json:"message"`
}
type internAPIError struct {
Success bool `json:"success"`
ErrorID int `json:"errid,omitempty"`
Message string `json:"message"`
RawError error `json:"errorObject,omitempty"`
}

View File

@ -1,6 +0,0 @@
package ginresp
type errBody struct {
Success bool `json:"success"`
Message string `json:"message"`
}

View File

@ -1,13 +1,14 @@
package ginresp
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"github.com/gin-gonic/gin"
"net/http"
)
type HTTPResponse interface {
Write(context *gin.Context)
Write(g *gin.Context)
}
type jsonHTTPResponse struct {
@ -73,13 +74,21 @@ func Text(sc int, data string) HTTPResponse {
}
func InternalError(e error) HTTPResponse {
return &errHTTPResponse{statusCode: http.StatusInternalServerError, data: errBody{Success: false, Message: e.Error()}}
return &errHTTPResponse{statusCode: http.StatusInternalServerError, data: apiError{Success: false, Error: int(apierr.INTERNAL_EXCEPTION), Message: e.Error()}}
}
func InternAPIError(errid int, msg string) HTTPResponse {
return &errHTTPResponse{statusCode: http.StatusInternalServerError, data: internAPIError{Success: false, ErrorID: errid, Message: msg}}
func InternAPIError(errorid apierr.APIError, msg string, e error) HTTPResponse {
if scn.Conf.ReturnRawErrors {
return &errHTTPResponse{statusCode: http.StatusInternalServerError, data: apiError{Success: false, Error: int(errorid), Message: msg, RawError: e}}
} else {
return &errHTTPResponse{statusCode: http.StatusInternalServerError, data: apiError{Success: false, Error: int(errorid), Message: msg}}
}
}
func SendAPIError(errorid apierr.APIError, highlight int, msg string) HTTPResponse {
return &errHTTPResponse{statusCode: http.StatusInternalServerError, data: sendAPIError{Success: false, Error: int(errorid), ErrorHighlight: highlight, Message: msg}}
return &errHTTPResponse{statusCode: http.StatusInternalServerError, data: apiError{Success: false, Error: int(errorid), ErrorHighlight: highlight, Message: msg}}
}
func NotImplemented() HTTPResponse {
return &errHTTPResponse{statusCode: http.StatusInternalServerError, data: apiError{Success: false, Error: -1, ErrorHighlight: 0, Message: "Not Implemented"}}
}

View File

@ -6,15 +6,15 @@ type WHandlerFunc func(*gin.Context) HTTPResponse
func Wrap(fn WHandlerFunc) gin.HandlerFunc {
return func(context *gin.Context) {
return func(g *gin.Context) {
wrap := fn(context)
wrap := fn(g)
if context.Writer.Written() {
if g.Writer.Written() {
panic("Writing in WrapperFunc is not supported")
}
wrap.Write(context)
wrap.Write(g)
}

View File

@ -3,48 +3,59 @@ package server
import (
"github.com/rs/zerolog/log"
"os"
"time"
)
type Config struct {
Namespace string
GinDebug bool
ServerIP string
ServerPort string
DBFile string
Namespace string
GinDebug bool
ServerIP string
ServerPort string
DBFile string
RequestTimeout time.Duration
ReturnRawErrors bool
}
var Conf Config
var configLoc = Config{
Namespace: "local",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "8080",
DBFile: ".run-data/db.sqlite3",
Namespace: "local",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "8080",
DBFile: ".run-data/db.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
}
var configDev = Config{
Namespace: "develop",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
Namespace: "develop",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
}
var configStag = Config{
Namespace: "staging",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
Namespace: "staging",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
}
var configProd = Config{
Namespace: "production",
GinDebug: false,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
Namespace: "production",
GinDebug: false,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: false,
}
var allConfig = []Config{

15
server/db/context.go Normal file
View File

@ -0,0 +1,15 @@
package db
import (
"database/sql"
"time"
)
type TxContext interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
GetOrCreateTransaction(db *Database) (*sql.Tx, error)
}

View File

@ -8,43 +8,60 @@ import (
"errors"
"fmt"
_ "github.com/mattn/go-sqlite3"
"time"
)
//go:embed schema_1.0.sql
//go:embed schema_1.0.ddl
var schema_1_0 string
//go:embed schema_2.0.sql
//go:embed schema_2.0.ddl
var schema_2_0 string
func NewDatabase(ctx context.Context, conf scn.Config) (*sql.DB, error) {
//go:embed schema_3.0.ddl
var schema_3_0 string
type Database struct {
db *sql.DB
}
func NewDatabase(conf scn.Config) (*Database, error) {
db, err := sql.Open("sqlite3", conf.DBFile)
if err != nil {
return nil, err
}
schema, err := getSchemaFromDB(ctx, db)
return &Database{db}, nil
}
func (db *Database) Migrate(ctx context.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
schema, err := db.ReadSchema(ctx)
if schema == 0 {
_, err = db.ExecContext(ctx, schema_1_0)
_, err = db.db.ExecContext(ctx, schema_3_0)
if err != nil {
return nil, err
return err
}
return db, nil
return nil
} else if schema == 1 {
return nil, errors.New("cannot autom. upgrade schema 1")
return errors.New("cannot autom. upgrade schema 1")
} else if schema == 2 {
return db, nil
return errors.New("cannot autom. upgrade schema 2") //TODO
} else if schema == 3 {
return nil // current
} else {
return nil, errors.New(fmt.Sprintf("Unknown DB schema: %d", schema))
return errors.New(fmt.Sprintf("Unknown DB schema: %d", schema))
}
}
func getSchemaFromDB(ctx context.Context, db *sql.DB) (int, error) {
func (db *Database) ReadSchema(ctx context.Context) (int, error) {
r1, err := db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='meta'")
r1, err := db.db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='meta'")
if err != nil {
return 0, err
}
@ -53,7 +70,7 @@ func getSchemaFromDB(ctx context.Context, db *sql.DB) (int, error) {
return 0, nil
}
r2, err := db.QueryContext(ctx, "SELECT value_int FROM meta WHERE key='schema'")
r2, err := db.db.QueryContext(ctx, "SELECT value_int FROM meta WHERE meta_key='schema'")
if err != nil {
return 0, err
}
@ -69,3 +86,11 @@ func getSchemaFromDB(ctx context.Context, db *sql.DB) (int, error) {
return schema, nil
}
func (db *Database) Ping() error {
return db.db.Ping()
}
func (db *Database) BeginTx(ctx context.Context) (*sql.Tx, error) {
return db.db.BeginTx(ctx, nil)
}

112
server/db/methods.go Normal file
View File

@ -0,0 +1,112 @@
package db
import (
"blackforestbytes.com/simplecloudnotifier/api/models"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
func (db *Database) CreateUser(ctx TxContext, readKey string, sendKey string, adminKey string, protoken *string, username *string) (models.User, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.User{}, err
}
now := time.Now().UTC()
res, err := tx.ExecContext(ctx, "INSERT INTO users (username, read_key, send_key, admin_key, is_pro, pro_token, timestamp_created) VALUES (?, ?, ?, ?, ?, ?, ?)",
username,
readKey,
sendKey,
adminKey,
bool2DB(protoken != nil),
protoken,
time2DB(now))
if err != nil {
return models.User{}, err
}
liid, err := res.LastInsertId()
if err != nil {
return models.User{}, err
}
return models.User{
UserID: liid,
Username: username,
ReadKey: readKey,
SendKey: sendKey,
AdminKey: adminKey,
TimestampCreated: now,
TimestampLastRead: nil,
TimestampLastSent: nil,
MessagesSent: 0,
QuotaToday: 0,
QuotaDay: nil,
IsPro: protoken != nil,
ProToken: protoken,
}, nil
}
func (db *Database) CreateClient(ctx TxContext, userid int64, ctype models.ClientType, fcmToken string, agentModel string, agentVersion string) (models.Client, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Client{}, err
}
now := time.Now().UTC()
res, err := tx.ExecContext(ctx, "INSERT INTO clients (user_id, type, fcm_token, timestamp_created, agent_model, agent_version) VALUES (?, ?, ?, ?, ?, ?)",
userid,
string(ctype),
fcmToken,
time2DB(now),
agentModel,
agentVersion)
if err != nil {
return models.Client{}, err
}
liid, err := res.LastInsertId()
if err != nil {
return models.Client{}, err
}
return models.Client{
ClientID: liid,
UserID: userid,
Type: ctype,
FCMToken: langext.Ptr(fcmToken),
TimestampCreated: now,
AgentModel: agentModel,
AgentVersion: agentVersion,
}, nil
}
func (db *Database) ClearFCMTokens(ctx TxContext, fcmtoken string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "DELETE FROM clients WHERE fcm_token = ?", fcmtoken)
if err != nil {
return err
}
return nil
}
func (db *Database) ClearProTokens(ctx TxContext, protoken string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "UPDATE users SET is_pro=0, pro_token=NULL WHERE pro_token = ?", protoken)
if err != nil {
return err
}
return nil
}

View File

@ -8,9 +8,9 @@ CREATE TABLE users
send_key TEXT NOT NULL,
admin_key TEXT NOT NULL,
timestamp_created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
timestamp_lastread TEXT NULL DEFAULT NULL,
timestamp_lastsent TEXT NULL DEFAULT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastread INTEGER NULL DEFAULT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
@ -18,24 +18,23 @@ CREATE TABLE users
quota_day TEXT NULL DEFAULT NULL,
is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0,
pro_token TEXT NULL DEFAULT NULL,
PRIMARY KEY (user_id)
pro_token TEXT NULL DEFAULT NULL
);
CREATE UNIQUE INDEX "idx_users_protoken" ON users (pro_token);
CREATE UNIQUE INDEX "idx_users_protoken" ON users (pro_token) WHERE pro_token IS NOT NULL;
CREATE TABLE clients
(
client_id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
type TEXT CHECK(type IN ('ANDROID', 'IOS')) NOT NULL,
fcm_token TEXT NULL,
type TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
fcm_token TEXT NULL,
PRIMARY KEY (client_id)
agent_model TEXT NOT NULL,
agent_version TEXT NOT NULL
);
CREATE INDEX "idx_clients_userid" ON clients (user_id);
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token);
@ -54,11 +53,9 @@ CREATE TABLE channels
messages_sent INTEGER NOT NULL DEFAULT '0',
timestamp_created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
timestamp_lastread TEXT NULL DEFAULT NULL,
timestamp_lastsent TEXT NULL DEFAULT NULL,
PRIMARY KEY (channel_id)
timestamp_created INTEGER NOT NULL,
timestamp_lastread INTEGER NULL DEFAULT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL
);
CREATE UNIQUE INDEX "idx_channels_identity" ON channels (owner_user_id, name);
@ -68,9 +65,7 @@ CREATE TABLE subscriptions
subscriber_user_id INTEGER NOT NULL,
channel_owner_user_id INTEGER NOT NULL,
channel_name TEXT NOT NULL,
PRIMARY KEY (subscription_id)
channel_name TEXT NOT NULL
);
CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_name);
@ -83,15 +78,13 @@ CREATE TABLE messages
channel_id INTEGER NOT NULL,
timestamp_real TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
timestamp_client TEXT NULL,
timestamp_real INTEGER NOT NULL,
timestamp_client INTEGER NULL,
title TEXT NOT NULL,
content TEXT NULL,
priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL,
usr_message_id TEXT NULL,
PRIMARY KEY (scn_message_id)
usr_message_id TEXT NULL
);
CREATE INDEX "idx_messages_channel" ON messages (sender_user_id, channel_name);
CREATE INDEX "idx_messages_idempotency" ON messages (sender_user_id, usr_message_id);
@ -105,16 +98,15 @@ CREATE TABLE deliveries
receiver_user_id INTEGER NOT NULL,
receiver_client_id INTEGER NOT NULL,
timestamp_created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
timestamp_finalized TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
timestamp_created INTEGER NOT NULL,
timestamp_finalized INTEGER NOT NULL,
status TEXT CHECK(status IN ('RETRY','SUCCESS','FAILED')) NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
next_delivery INTEGER NULL DEFAULT NULL,
fcm_message_id TEXT NULL,
PRIMARY KEY (delivery_id)
fcm_message_id TEXT NULL
);
CREATE INDEX "idx_deliveries_receiver" ON deliveries (scn_message_id, receiver_client_id);

View File

@ -0,0 +1,7 @@
CREATE TABLE sqlite_master (
type text,
name text,
tbl_name text,
rootpage integer,
sql text
);

15
server/db/utils.go Normal file
View File

@ -0,0 +1,15 @@
package db
import "time"
func bool2DB(b bool) int {
if b {
return 1
} else {
return 0
}
}
func time2DB(t time.Time) int64 {
return t.UnixMilli()
}

View File

@ -2,10 +2,8 @@ package logic
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/db"
"context"
"database/sql"
"fmt"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"math/rand"
@ -20,10 +18,10 @@ import (
type Application struct {
Config scn.Config
Gin *gin.Engine
Database *sql.DB
Database *db.Database
}
func NewApp(db *sql.DB) *Application {
func NewApp(db *db.Database) *Application {
return &Application{Database: db}
}
@ -74,30 +72,6 @@ func (app *Application) GenerateRandomAuthKey() string {
return k
}
func (app *Application) RunTransaction(ctx context.Context, opt *sql.TxOptions, fn func(tx *sql.Tx) (ginresp.HTTPResponse, bool)) ginresp.HTTPResponse {
tx, err := app.Database.BeginTx(ctx, opt)
if err != nil {
return ginresp.InternAPIError(0, fmt.Sprintf("Failed to create transaction: %v", err))
}
res, commit := fn(tx)
if commit {
err = tx.Commit()
if err != nil {
return ginresp.InternAPIError(0, fmt.Sprintf("Failed to commit transaction: %v", err))
}
} else {
err = tx.Rollback()
if err != nil {
return ginresp.InternAPIError(0, fmt.Sprintf("Failed to rollback transaction: %v", err))
}
}
return res
}
func (app *Application) QuotaMax(ispro bool) int {
if ispro {
return 1000
@ -109,3 +83,16 @@ func (app *Application) QuotaMax(ispro bool) int {
func (app *Application) VerifyProToken(token string) (bool, error) {
return false, nil //TODO implement pro verification
}
func (app *Application) Migrate() error {
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
return app.Database.Migrate(ctx)
}
func (app *Application) StartRequest(g *gin.Context) *AppContext {
ctx, cancel := context.WithTimeout(context.Background(), app.Config.RequestTimeout)
return &AppContext{inner: ctx, cancelFunc: cancel}
}

75
server/logic/context.go Normal file
View File

@ -0,0 +1,75 @@
package logic
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/db"
"context"
"database/sql"
"errors"
"time"
)
type AppContext struct {
inner context.Context
cancelFunc context.CancelFunc
cancelled bool
transaction *sql.Tx
}
func (ac *AppContext) Deadline() (deadline time.Time, ok bool) {
return ac.inner.Deadline()
}
func (ac *AppContext) Done() <-chan struct{} {
return ac.inner.Done()
}
func (ac *AppContext) Err() error {
return ac.inner.Err()
}
func (ac *AppContext) Value(key any) any {
return ac.inner.Value(key)
}
func (ac *AppContext) Cancel() {
ac.cancelled = true
if ac.transaction != nil {
err := ac.transaction.Rollback()
if err != nil {
panic("failed to rollback transaction: " + err.Error())
}
ac.transaction = nil
}
ac.cancelFunc()
}
func (ac *AppContext) FinishSuccess(res ginresp.HTTPResponse) ginresp.HTTPResponse {
if ac.cancelled {
panic("Cannot finish a cancelled request")
}
if ac.transaction != nil {
err := ac.transaction.Commit()
if err != nil {
return ginresp.InternAPIError(apierr.COMMIT_FAILED, "Failed to comit changes to DB", err)
}
ac.transaction = nil
}
return res
}
func (ac *AppContext) GetOrCreateTransaction(db *db.Database) (*sql.Tx, error) {
if ac.cancelled {
return nil, errors.New("context cancelled")
}
if ac.transaction != nil {
return ac.transaction, nil
}
tx, err := db.BeginTx(ac)
if err != nil {
return nil, err
}
ac.transaction = tx
return tx, nil
}

View File

@ -7,9 +7,45 @@
"version": "2.0"
},
"host": "scn.blackforestbytes.com",
"basePath": "/api/",
"basePath": "/",
"paths": {
"/ack.php": {
"/api-v2/user/": {
"post": {
"summary": "Create a new user",
"operationId": "api-user-create",
"parameters": [
{
"description": " ",
"name": "post_body",
"in": "body",
"schema": {
"$ref": "#/definitions/handler.CreateUser.body"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.UserJSON"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/api/ack.php": {
"get": {
"summary": "Acknowledge that a message was received",
"operationId": "compat-ack",
@ -46,31 +82,13 @@
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.internAPIError"
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/db-test": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.DatabaseTest.response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.errBody"
}
}
}
}
},
"/expand.php": {
"/api/expand.php": {
"get": {
"summary": "Get a whole (potentially truncated) message",
"operationId": "compat-expand",
@ -84,31 +102,13 @@
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.internAPIError"
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/health": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.Health.response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.errBody"
}
}
}
}
},
"/info.php": {
"/api/info.php": {
"get": {
"summary": "Get information about the current user",
"operationId": "compat-info",
@ -138,95 +138,13 @@
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.internAPIError"
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/ping": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.errBody"
}
}
}
},
"put": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.errBody"
}
}
}
},
"post": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.errBody"
}
}
}
},
"delete": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.errBody"
}
}
}
},
"patch": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.errBody"
}
}
}
}
},
"/register.php": {
"/api/register.php": {
"get": {
"summary": "Register a new account",
"operationId": "compat-register",
@ -267,13 +185,13 @@
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.internAPIError"
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/requery.php": {
"/api/requery.php": {
"get": {
"summary": "Return all not-acknowledged messages",
"operationId": "compat-requery",
@ -303,85 +221,13 @@
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.internAPIError"
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/send.php": {
"post": {
"description": "(all arguments can either be supplied in the query or in the json body)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"summary": "Send a message",
"operationId": "compat-send",
"parameters": [
{
"type": "string",
"name": "content",
"in": "query"
},
{
"type": "string",
"name": "messageID",
"in": "query"
},
{
"type": "string",
"name": "priority",
"in": "query"
},
{
"type": "string",
"name": "timestamp",
"in": "query"
},
{
"type": "string",
"name": "title",
"in": "query"
},
{
"type": "string",
"name": "userID",
"in": "query"
},
{
"type": "string",
"name": "userKey",
"in": "query"
},
{
"description": " ",
"name": "post_body",
"in": "body",
"schema": {
"$ref": "#/definitions/handler.Send.body"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.Send.response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.sendAPIError"
}
}
}
}
},
"/update.php": {
"/api/update.php": {
"get": {
"summary": "Set the fcm-token (android)",
"operationId": "compat-update",
@ -418,13 +264,13 @@
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.internAPIError"
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/upgrade.php": {
"/api/upgrade.php": {
"get": {
"summary": "Upgrade a free account to a paid account",
"operationId": "compat-upgrade",
@ -472,7 +318,125 @@
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.internAPIError"
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/db-test": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.DatabaseTest.response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/health": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.Health.response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
}
},
"/ping": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
},
"put": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
},
"post": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
},
"delete": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
},
"patch": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.pingResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ginresp.apiError"
}
}
}
@ -480,32 +444,7 @@
}
},
"definitions": {
"ginresp.errBody": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"success": {
"type": "boolean"
}
}
},
"ginresp.internAPIError": {
"type": "object",
"properties": {
"errid": {
"type": "integer"
},
"message": {
"type": "string"
},
"success": {
"type": "boolean"
}
}
},
"ginresp.sendAPIError": {
"ginresp.apiError": {
"type": "object",
"properties": {
"errhighlight": {
@ -514,6 +453,7 @@
"error": {
"type": "integer"
},
"errorObject": {},
"message": {
"type": "string"
},
@ -539,6 +479,29 @@
}
}
},
"handler.CreateUser.body": {
"type": "object",
"properties": {
"agentModel": {
"type": "string"
},
"agentVersion": {
"type": "string"
},
"clientType": {
"type": "string"
},
"fcmtoken": {
"type": "string"
},
"proToken": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"handler.DatabaseTest.response": {
"type": "object",
"properties": {
@ -656,43 +619,6 @@
}
}
},
"handler.Send.body": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"msg_id": {
"type": "string"
},
"priority": {
"type": "string"
},
"timestamp": {
"type": "string"
},
"title": {
"type": "string"
},
"user_id": {
"type": "string"
},
"user_key": {
"type": "string"
}
}
},
"handler.Send.response": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"success": {
"type": "string"
}
}
},
"handler.Update.response": {
"type": "object",
"properties": {
@ -818,6 +744,47 @@
"type": "string"
}
}
},
"models.UserJSON": {
"type": "object",
"properties": {
"admin_key": {
"type": "string"
},
"is_pro": {
"type": "boolean"
},
"messages_sent": {
"type": "integer"
},
"quota_day": {
"type": "string"
},
"quota_today": {
"type": "integer"
},
"read_key": {
"type": "string"
},
"send_key": {
"type": "string"
},
"timestamp_created": {
"type": "string"
},
"timestamp_last_read": {
"type": "string"
},
"timestamp_last_sent": {
"type": "string"
},
"user_id": {
"type": "integer"
},
"username": {
"type": "string"
}
}
}
}
}

View File

@ -1,27 +1,12 @@
basePath: /api/
basePath: /
definitions:
ginresp.errBody:
properties:
message:
type: string
success:
type: boolean
type: object
ginresp.internAPIError:
properties:
errid:
type: integer
message:
type: string
success:
type: boolean
type: object
ginresp.sendAPIError:
ginresp.apiError:
properties:
errhighlight:
type: integer
error:
type: integer
errorObject: {}
message:
type: string
success:
@ -38,6 +23,21 @@ definitions:
success:
type: string
type: object
handler.CreateUser.body:
properties:
agentModel:
type: string
agentVersion:
type: string
clientType:
type: string
fcmtoken:
type: string
proToken:
type: string
username:
type: string
type: object
handler.DatabaseTest.response:
properties:
libVersion:
@ -114,30 +114,6 @@ definitions:
success:
type: string
type: object
handler.Send.body:
properties:
content:
type: string
msg_id:
type: string
priority:
type: string
timestamp:
type: string
title:
type: string
user_id:
type: string
user_key:
type: string
type: object
handler.Send.response:
properties:
message:
type: string
success:
type: string
type: object
handler.Update.response:
properties:
is_pro:
@ -220,6 +196,33 @@ definitions:
usr_msg_id:
type: string
type: object
models.UserJSON:
properties:
admin_key:
type: string
is_pro:
type: boolean
messages_sent:
type: integer
quota_day:
type: string
quota_today:
type: integer
read_key:
type: string
send_key:
type: string
timestamp_created:
type: string
timestamp_last_read:
type: string
timestamp_last_sent:
type: string
user_id:
type: integer
username:
type: string
type: object
host: scn.blackforestbytes.com
info:
contact: {}
@ -227,7 +230,30 @@ info:
title: SimpleCloudNotifier API
version: "2.0"
paths:
/ack.php:
/api-v2/user/:
post:
operationId: api-user-create
parameters:
- description: ' '
in: body
name: post_body
schema:
$ref: '#/definitions/handler.CreateUser.body'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.UserJSON'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ginresp.apiError'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
summary: Create a new user
/api/ack.php:
get:
operationId: compat-ack
parameters:
@ -254,20 +280,9 @@ paths:
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.internAPIError'
$ref: '#/definitions/ginresp.apiError'
summary: Acknowledge that a message was received
/db-test:
get:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.DatabaseTest.response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.errBody'
/expand.php:
/api/expand.php:
get:
operationId: compat-expand
responses:
@ -278,20 +293,9 @@ paths:
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.internAPIError'
$ref: '#/definitions/ginresp.apiError'
summary: Get a whole (potentially truncated) message
/health:
get:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.Health.response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.errBody'
/info.php:
/api/info.php:
get:
operationId: compat-info
parameters:
@ -313,60 +317,9 @@ paths:
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.internAPIError'
$ref: '#/definitions/ginresp.apiError'
summary: Get information about the current user
/ping:
delete:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.errBody'
get:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.errBody'
patch:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.errBody'
post:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.errBody'
put:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.errBody'
/register.php:
/api/register.php:
get:
operationId: compat-register
parameters:
@ -396,9 +349,9 @@ paths:
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.internAPIError'
$ref: '#/definitions/ginresp.apiError'
summary: Register a new account
/requery.php:
/api/requery.php:
get:
operationId: compat-requery
parameters:
@ -420,55 +373,9 @@ paths:
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.internAPIError'
$ref: '#/definitions/ginresp.apiError'
summary: Return all not-acknowledged messages
/send.php:
post:
consumes:
- application/json
description: (all arguments can either be supplied in the query or in the json
body)
operationId: compat-send
parameters:
- in: query
name: content
type: string
- in: query
name: messageID
type: string
- in: query
name: priority
type: string
- in: query
name: timestamp
type: string
- in: query
name: title
type: string
- in: query
name: userID
type: string
- in: query
name: userKey
type: string
- description: ' '
in: body
name: post_body
schema:
$ref: '#/definitions/handler.Send.body'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.Send.response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.sendAPIError'
summary: Send a message
/update.php:
/api/update.php:
get:
operationId: compat-update
parameters:
@ -495,9 +402,9 @@ paths:
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.internAPIError'
$ref: '#/definitions/ginresp.apiError'
summary: Set the fcm-token (android)
/upgrade.php:
/api/upgrade.php:
get:
operationId: compat-upgrade
parameters:
@ -532,6 +439,79 @@ paths:
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.internAPIError'
$ref: '#/definitions/ginresp.apiError'
summary: Upgrade a free account to a paid account
/db-test:
get:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.DatabaseTest.response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
/health:
get:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.Health.response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
/ping:
delete:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
get:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
patch:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
post:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
put:
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.pingResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ginresp.apiError'
swagger: "2.0"

47
server/website/api.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/css/mini-default.min.css"> <!-- https://minicss.org/docs -->
<title>Simple Cloud Notifications - API</title>
<!--<link rel="stylesheet" href="/css/mini-nord.min.css">-->
<!--<link rel="stylesheet" href="/css/mini-dark.min.css">-->
<link rel="stylesheet" href="/css/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favicon.png"/>
<link rel="icon" type="image/png" href="/favicon.ico"/>
</head>
<body>
<div id="copyinfo">
<a tabindex="-1" href="https://www.blackforestbytes.com">&#169; blackforestbytes</a>
<a tabindex="-1" href="https://www.mikescher.com">made by Mike Schw&ouml;rer</a>
</div>
<div id="mainpnl">
<a tabindex="-1" href="https://play.google.com/store/apps/details?id=com.blackforestbytes.simplecloudnotifier" class="button bordered" id="tl_link"><span class="icn-google-play"></span></a>
<a tabindex="-1" href="/" class="button bordered" id="tr_link">Send</a>
<a tabindex="-1" href="/" class="linkcaption"><h1>Simple Cloud Notifier</h1></a>
<p>Get your user-id and user-key from the app and send notifications to your phone by performing a POST request against <code>https://simplecloudnotifier.blackforestbytes.com/</code></p>
<pre>curl \
--data "user_id={userid}" \
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "content={message_body}" \
--data "priority={0|1|2}" \
--data "msg_id={unique_message_id}" \
https://scn.blackforestbytes.com/</pre>
<p>The <code>content</code>, <code>priority</code> and <code>msg_id</code> parameters are optional, you can also send message with only a title and the default priority</p>
<pre>curl \
--data "user_id={userid}" \
--data "user_key={userkey}" \
--data "title={message_title}" \
https://scn.blackforestbytes.com/</pre>
<a href="/api_more" class="button bordered tertiary" style="float: right; min-width: 100px; text-align: center">More</a>
</div>
</body>
</html>

View File

@ -0,0 +1,299 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/css/mini-default.min.css"> <!-- https://minicss.org/docs -->
<title>Simple Cloud Notifications - API</title>
<!--<link rel="stylesheet" href="/css/mini-nord.min.css">-->
<!--<link rel="stylesheet" href="/css/mini-dark.min.css">-->
<link rel="stylesheet" href="/css/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favicon.png"/>
<link rel="icon" type="image/png" href="/favicon.ico"/>
</head>
<body>
<div id="copyinfo">
<a tabindex="-1" href="https://www.blackforestbytes.com">&#169; blackforestbytes</a>
<a tabindex="-1" href="https://www.mikescher.com">made by Mike Schw&ouml;rer</a>
</div>
<div id="mainpnl">
<a tabindex="-1" href="https://play.google.com/store/apps/details?id=com.blackforestbytes.simplecloudnotifier" class="button bordered" id="tl_link"><span class="icn-google-play"></span></a>
<a tabindex="-1" href="/" class="button bordered" id="tr_link">Send</a>
<a tabindex="-1" href="/" class="linkcaption"><h1>Simple Cloud Notifier</h1></a>
<h2>Introduction</h2>
<div class="section">
<p>
With this API you can send push notifications to your phone.
</p>
<p>
To recieve them you will need to install the <a href="https://play.google.com/store/apps/details?id=com.blackforestbytes.simplecloudnotifier">SimpleCloudNotifier</a> app from the play store.
When you open the app you can click on the account tab to see you unique <code>user_id</code> and <code>user_key</code>.
These two values are used to identify and authenticate your device so that send messages can be routed to your phone.
</p>
<p>
You can at any time generate a new <code>user_key</code> in the app and invalidate the old one.
</p>
<p>
There is also a <a href="/">web interface</a> for this API to manually send notifications to your phone or to test your setup.
</p>
</div>
<h2>Quota</h2>
<div class="section">
<p>
By default you can send up to 100 messages per day per device.
If you need more you can upgrade your account in the app to get 1000 messages per day, this has the additional benefit of removing ads and supporting the development of the app (and making sure I can pay the server costs).
</p>
</div>
<h2>API Requests</h2>
<div class="section">
<p>
To send a new notification you send a <code>POST</code> request to the URL <code>https://scn.blackforestbytes.com/</code>.
All Parameters can either directly be submitted as URL parameters or they can be put into the POST body.
</p>
<p>
You <i>need</i> to supply a valid <code>user_id</code> - <code>user_key</code> pair and a <code>title</code> for your message, all other parameter are optional.
</p>
</div>
<h2>API Response</h2>
<div class="section">
<p>
If the operation was successful the API will respond with an HTTP statuscode 200 and an JSON payload indicating the send message and your remaining quota
</p>
<pre class="red-code">{
"success":true,
"message":"Message sent",
"response":
{
"multicast_id":8000000000000000006,
"success":1,
"failure":0,
"canonical_ids":0,
"results": [{"message_id":"0:10000000000000000000000000000000d"}]
},
"quota":17,
"quota_max":100
}</pre>
<p>
If the operation is <b>not</b> successful the API will respond with an 4xx HTTP statuscode.
</p>
<table class="scode_table">
<thead>
<tr>
<th>Statuscode</th>
<th>Explanation</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="Statuscode">200 (OK)</td>
<td data-label="Explanation">Message sent</td>
</tr>
<tr>
<td data-label="Statuscode">400 (Bad Request)</td>
<td data-label="Explanation">The request is invalid (missing parameters or wrong values)</td>
</tr>
<tr>
<td data-label="Statuscode">401 (Unauthorized)</td>
<td data-label="Explanation">The user_id was not found or the user_key is wrong</td>
</tr>
<tr>
<td data-label="Statuscode">403 (Forbidden)</td>
<td data-label="Explanation">The user has exceeded its daily quota - wait 24 hours or upgrade your account</td>
</tr>
<tr>
<td data-label="Statuscode">412 (Precondition Failed)</td>
<td data-label="Explanation">There is no device connected with this account - open the app and press the refresh button in the account tab</td>
</tr>
<tr>
<td data-label="Statuscode">500 (Internal Server Error)</td>
<td data-label="Explanation">There was an internal error while sending your data - try again later</td>
</tr>
</tbody>
</table>
<p>
There is also always a JSON payload with additional information.
The <code>success</code> field is always there and in the error state you the <code>message</code> field to get a descritpion of the problem.
</p>
<pre class="red-code">{
"success":false,
"error":2101,
"errhighlight":-1,
"message":"Daily quota reached (100)"
}</pre>
</div>
<h2>Message Content</h2>
<div class="section">
<p>
Every message must have a title set.
But you also (optionally) add more content, while the title has a max length of 120 characters, the conntent can be up to 10.000 characters.
You can see the whole message with title and content in the app or when clicking on the notification.
</p>
<p>
If needed the content can be supplied in the <code>content</code> parameter.
</p>
<pre>curl \
--data "user_id={userid}" \
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "content={message_content}" \
https://scn.blackforestbytes.com/</pre>
</div>
<h2>Message Priority</h2>
<div class="section">
<p>
Currently you can send a message with three different priorities: 0 (low), 1 (normal) and 2 (high).
In the app you can then configure a different behaviour for different priorities, e.g. only playing a sound if the notification is high priority.
</p>
<p>
Priorites are either 0, 1 or 2 and are supplied in the <code>priority</code> parameter.
If no priority is supplied the message will get the default priority of 1.
</p>
<pre>curl \
--data "user_id={userid}" \
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "priority={0|1|2}" \
https://scn.blackforestbytes.com/s</pre>
</div>
<h2>Message Uniqueness</h2>
<div class="section">
<p>
Sometimes your script can run in an environment with an unstable connection and you want to implement an automatic re-try mechanism to send a message again if the last try failed due to bad connectivity.
</p>
<p>
To ensure that a message is only send once you can generate a unique id for your message (I would recommend a simple <code>uuidgen</code>).
If you send a message with an UUID that was already used in the near past the API still returns OK, but no new message is sent.
</p>
<p>
The message_id is optional - but if you want to use it you need to supply it via the <code>msg_id</code> parameter.
</p>
<pre>curl \
--data "user_id={userid}" \
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "msg_id={message_id}" \
https://scn.blackforestbytes.com/</pre>
<p>
Be aware that the server only saves send messages for a short amount of time. Because of that you can only use this to prevent duplicates in a short time-frame, older messages with the same ID are probably already deleted and the message will be send again.
</p>
</div>
<h2>Custom Time</h2>
<div class="section">
<p>
You can modify the displayed timestamp of a message by sending the <code>timestamp</code> parameter. The format must be a valid UNIX timestamp (elapsed seconds since 1970-01-01 GMT)
</p>
<p>
The custom timestamp must be within 48 hours of the current time. This parameter is only intended to supply a more precise value in case the message sending was delayed.
</p>
<pre>curl \
--data "user_id={userid}" \
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "timestamp={unix_timestamp}" \
https://scn.blackforestbytes.com/</pre>
</div>
<h2>Bash script example</h2>
<div class="section">
<p>
Depending on your use case it can be useful to create a bash script that handles things like resending messages if you have connection problems or waiting if there is no quota left.<br/>
Here is an example how such a scrippt could look like, you can put it into <code>/usr/local/sbin</code> and call it with <code>scn_send "title" "content"</code>
</p>
<pre style="color:#000000;" class="yellow-code"><span style="color:#3f7f59; font-weight:bold;">#!/usr/bin/env bash</span>
<span style="color:#3f7f59; ">#</span>
<span style="color:#3f7f59; "># Call with `scn_send title`</span>
<span style="color:#3f7f59; "># or `scn_send title content`</span>
<span style="color:#3f7f59; "># or `scn_send title content priority`</span>
<span style="color:#3f7f59; ">#</span>
<span style="color:#3f7f59; ">#</span>
<span style="color:#7f0055; font-weight:bold; ">if</span> [ <span style="color:#2a00ff; ">"$#"</span> -lt 1 ]; <span style="color:#7f0055; font-weight:bold; ">then</span>
<span style="color:#7f0055; font-weight:bold; ">echo</span> <span style="color:#2a00ff; ">"no title supplied via parameter"</span>
<span style="color:#7f0055; font-weight:bold; ">exit</span> 1
<span style="color:#7f0055; font-weight:bold; ">fi</span>
<span style="color:#3f7f59; ">################################################################################</span>
<span style="color:#3f7f59; "># INSERT YOUR DATA HERE #</span>
<span style="color:#3f7f59; ">################################################################################</span>
user_id=999
user_key=<span style="color:#2a00ff; ">"????????????????????????????????????????????????????????????????"</span>
<span style="color:#3f7f59; ">################################################################################</span>
title=$1
content=<span style="color:#2a00ff; ">""</span>
sendtime=$(date +%s)
<span style="color:#7f0055; font-weight:bold; ">if</span> [ <span style="color:#2a00ff; ">"$#"</span> -gt 1 ]; <span style="color:#7f0055; font-weight:bold; ">then</span>
content=$2
<span style="color:#7f0055; font-weight:bold; ">fi</span>
priority=1
<span style="color:#7f0055; font-weight:bold; ">if</span> [ <span style="color:#2a00ff; ">"$#"</span> -gt 2 ]; <span style="color:#7f0055; font-weight:bold; ">then</span>
priority=$3
<span style="color:#7f0055; font-weight:bold; ">fi</span>
usr_msg_id=$(uuidgen)
<span style="color:#7f0055; font-weight:bold; ">while</span> true ; <span style="color:#7f0055; font-weight:bold; ">do</span>
curlresp=$(curl -s -o <span style="color:#3f3fbf; ">/dev/null</span> -w <span style="color:#2a00ff; ">"%{http_code}"</span> <span style="color:#2a00ff; ">\</span>
-d <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">user_id</span><span style="color:#2a00ff; ">=</span><span style="color:#2a00ff; ">$user_id</span><span style="color:#2a00ff; ">"</span> -d <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">user_key</span><span style="color:#2a00ff; ">=</span><span style="color:#2a00ff; ">$user_key</span><span style="color:#2a00ff; ">"</span> -d <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">title</span><span style="color:#2a00ff; ">=</span><span style="color:#2a00ff; ">$title</span><span style="color:#2a00ff; ">"</span> -d <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">timestamp</span><span style="color:#2a00ff; ">=</span><span style="color:#2a00ff; ">$sendtime</span><span style="color:#2a00ff; ">"</span> <span style="color:#2a00ff; ">\</span>
-d <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">content</span><span style="color:#2a00ff; ">=</span><span style="color:#2a00ff; ">$content</span><span style="color:#2a00ff; ">"</span> -d <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">priority</span><span style="color:#2a00ff; ">=</span><span style="color:#2a00ff; ">$priority</span><span style="color:#2a00ff; ">"</span> -d <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">msg_id</span><span style="color:#2a00ff; ">=</span><span style="color:#2a00ff; ">$usr_msg_id</span><span style="color:#2a00ff; ">"</span> <span style="color:#2a00ff; ">\</span>
https:<span style="color:#3f3fbf; ">/</span><span style="color:#3f3fbf; ">/scn.blackforestbytes.com/</span>)
<span style="color:#7f0055; font-weight:bold; ">if</span> [ <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">$curlresp</span><span style="color:#2a00ff; ">"</span> == 200 ] ; <span style="color:#7f0055; font-weight:bold; ">then</span>
<span style="color:#7f0055; font-weight:bold; ">echo</span> <span style="color:#2a00ff; ">"Successfully send"</span>
<span style="color:#7f0055; font-weight:bold; ">exit</span> 0
<span style="color:#7f0055; font-weight:bold; ">fi</span>
<span style="color:#7f0055; font-weight:bold; ">if</span> [ <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">$curlresp</span><span style="color:#2a00ff; ">"</span> == 400 ] ; <span style="color:#7f0055; font-weight:bold; ">then</span>
<span style="color:#7f0055; font-weight:bold; ">echo</span> <span style="color:#2a00ff; ">"Bad request - something went wrong"</span>
<span style="color:#7f0055; font-weight:bold; ">exit</span> 1
<span style="color:#7f0055; font-weight:bold; ">fi</span>
<span style="color:#7f0055; font-weight:bold; ">if</span> [ <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">$curlresp</span><span style="color:#2a00ff; ">"</span> == 401 ] ; <span style="color:#7f0055; font-weight:bold; ">then</span>
<span style="color:#7f0055; font-weight:bold; ">echo</span> <span style="color:#2a00ff; ">"Unauthorized - wrong </span><span style="color:#3f3fbf; ">userid/userkey</span><span style="color:#2a00ff; ">"</span>
<span style="color:#7f0055; font-weight:bold; ">exit</span> 1
<span style="color:#7f0055; font-weight:bold; ">fi</span>
<span style="color:#7f0055; font-weight:bold; ">if</span> [ <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">$curlresp</span><span style="color:#2a00ff; ">"</span> == 403 ] ; <span style="color:#7f0055; font-weight:bold; ">then</span>
<span style="color:#7f0055; font-weight:bold; ">echo</span> <span style="color:#2a00ff; ">"Quota exceeded - wait one hour before re-try"</span>
sleep 3600
<span style="color:#7f0055; font-weight:bold; ">fi</span>
<span style="color:#7f0055; font-weight:bold; ">if</span> [ <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">$curlresp</span><span style="color:#2a00ff; ">"</span> == 412 ] ; <span style="color:#7f0055; font-weight:bold; ">then</span>
<span style="color:#7f0055; font-weight:bold; ">echo</span> <span style="color:#2a00ff; ">"Precondition Failed - No device linked"</span>
<span style="color:#7f0055; font-weight:bold; ">exit</span> 1
<span style="color:#7f0055; font-weight:bold; ">fi</span>
<span style="color:#7f0055; font-weight:bold; ">if</span> [ <span style="color:#2a00ff; ">"</span><span style="color:#2a00ff; ">$curlresp</span><span style="color:#2a00ff; ">"</span> == 500 ] ; <span style="color:#7f0055; font-weight:bold; ">then</span>
<span style="color:#7f0055; font-weight:bold; ">echo</span> <span style="color:#2a00ff; ">"Internal server error - waiting for better times"</span>
sleep 60
<span style="color:#7f0055; font-weight:bold; ">fi</span>
<span style="color:#3f7f59; "># if none of the above matched we probably hav no network ...</span>
<span style="color:#7f0055; font-weight:bold; ">echo</span> <span style="color:#2a00ff; ">"Send failed (response code </span><span style="color:#2a00ff; ">$curlresp</span><span style="color:#2a00ff; ">) ... try again in 5s"</span>
sleep 5
<span style="color:#7f0055; font-weight:bold; ">done</span>
</pre>
<p>
Be aware that the server only saves send messages for a short amount of time. Because of that you can only use this to prevent duplicates in a short time-frame, older messages with the same ID are probably already deleted and the message will be send again.
</p>
</div>
</div>
</body>
</html>

1
server/website/css/mini-dark.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
server/website/css/mini-nord.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,250 @@
html
{
height: 100%;
}
body
{
display: flex;
justify-content: center;
align-items: center;
min-height: 100%;
}
@keyframes blink-shadow {
0% { box-shadow: 0 0 32px #DDD; }
50% { box-shadow: none; }
100% { box-shadow: 0 0 32px #DDD; }
}
#mainpnl
{
box-shadow: 0 0 32px #DDD;
//animation:blink-shadow ease-in-out 4s infinite;
width: 87%;
min-width: 300px;
max-width: 900px;
position: relative;
min-height: 570px;
background: var(--form-back-color);
color: var(--form-fore-color);
border: .0625rem solid var(--form-border-color);
border-radius: var(--universal-border-radius);
margin: 32px .5rem;
padding: calc(2 * var(--universal-padding)) var(--universal-padding);
}
.red-code
{
border-left: .25rem solid #E53935;
}
.yellow-code
{
border-left: .25rem solid #FFCB05;
}
#mainpnl input,
#mainpnl textarea
{
width: 100%;
}
.responsive-label {
align-items:center;
}
@media (min-width: 768px) {
.responsive-label .col-md-3 {
text-align:right
}
}
#mainpnl h1
{
text-align: center;
margin-top: 0;
margin-bottom: 24px;
font-weight: bold;
color: #FFF;
text-shadow: #000 0 0 2px, #888 0 0 8px;
}
@media (max-width: 600px) {
#mainpnl h1 {
font-size: calc(0.85rem * var(--heading-ratio) * var(--heading-ratio) * var(--heading-ratio) * var(--heading-ratio));
margin-top: 40px;
}
}
#mainpnl button
{
width: 100%;
margin-left: 4px;
margin-right: 4px;
}
#copyinfo
{
margin: 4px;
position: fixed;
bottom: 0;
right: 0;
//z-index: -999;
display: flex;
flex-direction: column;
text-align: right;
}
#copyinfo a,
#copyinfo a:visited,
#copyinfo a:active
{
font-family: "Courier New", monospace;
color: #AAA;
text-decoration: none;
display: block;
line-height: 1em;
}
#copyinfo a:hover
{
font-family: "Courier New", monospace;
color: #0288D1;
}
#tr_link
{
position: absolute;
top: 0;
right: 0;
margin: -1px -1px 0 0;
border-top-left-radius: 0;
border-bottom-right-radius: 0;
min-width: 40px;
text-align: center;
}
#tl_link
{
position: absolute;
top: 0;
left: 0;
margin: -1px 0 0 -1px;
border-top-right-radius: 0;
border-bottom-left-radius: 0;
padding: 4px 4px 0 4px;
}
.icn-google-play {
display: inline-block;
width: 32px;
height: 32px;
background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjBweCIgeT0iMHB4IgogICAgIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIKICAgICB2aWV3Qm94PSIwIDAgNDggNDgiCiAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDsiPjxnIGlkPSJzdXJmYWNlMSI+PHBhdGggc3R5bGU9IiBmaWxsOiM0REI2QUM7IiBkPSJNIDcuNzAzMTI1IDQuMDQyOTY5IEMgNy4yOTI5NjkgNC4xNDg0MzggNyA0LjUwNzgxMyA3IDUuMTIxMDk0IEMgNyA2LjkyMTg3NSA3IDIzLjkxNDA2MyA3IDIzLjkxNDA2MyBDIDcgMjMuOTE0MDYzIDcgNDIuMjgxMjUgNyA0My4wODk4NDQgQyA3IDQzLjUzNTE1NiA3LjE5NTMxMyA0My44MzU5MzggNy41IDQzLjk0NTMxMyBMIDI3LjY3OTY4OCAyMy44ODI4MTMgWiAiPjwvcGF0aD48cGF0aCBzdHlsZT0iIGZpbGw6I0RDRTc3NTsiIGQ9Ik0gMzMuMjM4MjgxIDE4LjM1OTM3NSBMIDI0LjkyOTY4OCAxMy41NjI1IEMgMjQuOTI5Njg4IDEzLjU2MjUgOS42ODM1OTQgNC43NjE3MTkgOC43ODkwNjMgNC4yNDIxODggQyA4LjQwMjM0NCA0LjAxOTUzMSA4LjAxOTUzMSAzLjk2MDkzOCA3LjcwMzEyNSA0LjA0Mjk2OSBMIDI3LjY4MzU5NCAyMy44ODI4MTMgWiAiPjwvcGF0aD48cGF0aCBzdHlsZT0iIGZpbGw6I0QzMkYyRjsiIGQ9Ik0gOC40MTc5NjkgNDMuODAwNzgxIEMgOC45NDkyMTkgNDMuNDkyMTg4IDIzLjY5OTIxOSAzNC45NzY1NjMgMzMuMjgxMjUgMjkuNDQ1MzEzIEwgMjcuNjc5Njg4IDIzLjg4MjgxMyBMIDcuNSA0My45NDUzMTMgQyA3Ljc0NjA5NCA0NC4wMzkwNjMgOC4wNjY0MDYgNDQuMDAzOTA2IDguNDE3OTY5IDQzLjgwMDc4MSBaICI+PC9wYXRoPjxwYXRoIHN0eWxlPSIgZmlsbDojRkJDMDJEOyIgZD0iTSA0MS4zOTg0MzggMjMuMDcwMzEzIEMgNDAuNjAxNTYzIDIyLjY0MDYyNSAzMy4yOTY4NzUgMTguMzk0NTMxIDMzLjI5Njg3NSAxOC4zOTQ1MzEgTCAzMy4yMzgyODEgMTguMzU5Mzc1IEwgMjcuNjc5Njg4IDIzLjg4MjgxMyBMIDMzLjI4MTI1IDI5LjQ0NTMxMyBDIDM3LjcxNDg0NCAyNi44ODY3MTkgNDEuMDQyOTY5IDI0Ljk2NDg0NCA0MS4zMzk4NDQgMjQuNzkyOTY5IEMgNDIuMjg1MTU2IDI0LjI0NjA5NCA0Mi4xOTUzMTMgMjMuNSA0MS4zOTg0MzggMjMuMDcwMzEzIFogIj48L3BhdGg+PC9nPjwvc3ZnPg==') 50% 50% no-repeat;
background-size: 100%;
}
#btnSend
{
height: 42px;
}
#btnSend .spinnerbox .spinner
{
margin: 0;
padding: 0;
height: 16px;
width: 16px;
}
#btnSend .spinnerbox
{
margin: -8px;
display: flex;
justify-content: center;
align-items: center;
align-content: center;
}
input[type='number'] {
-moz-appearance:textfield;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
}
.input-invalid,
.input-invalid:hover,
.input-invalid:active
{
border-color: var(--input-invalid-color) !important;
box-shadow: none !important;
}
.card.success {
--card-back-color: rgb(48, 135, 50);
--card-border-color: rgba(0, 0, 0, 0.3);;
}
.fullcenterflex
{
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
align-content: center;
pointer-events: none;
}
.fullcenterflex .card
{
pointer-events: auto;
}
a.card,
a.card:active,
a.card:visited,
a.card:hover
{
color: #000;
text-decoration: none;
}
a.card:hover
{
box-shadow: 0 0 16px #AAA;
}
table.scode_table {
max-height: none;
}
table.scode_table td:nth-child(2) {
flex-grow: 3;
}
table.scode_table th:nth-child(2) {
flex-grow: 3;
}
#mainpnl h2 {
margin-top: 1.75rem;
}
.linkcaption:hover,
.linkcaption:focus {
text-decoration: none;
}
pre, pre span
{
font-family: Menlo, Consolas, monospace;
background: #F9F9F9;;
}

15
server/website/css/toastify.min.css vendored Normal file
View File

@ -0,0 +1,15 @@
/**
* Minified by jsDelivr using clean-css v4.2.0.
* Original file: /npm/toastify.js@1.3.0/src/toastify.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
/*!
* Toastify js 1.2.2
* https://github.com/apvarun/toastify-js
* @license MIT licensed
*
* Copyright (C) 2018 Varun A P
*/
.toastify{padding:12px 20px;color:#fff;display:inline-block;box-shadow:0 3px 6px -1px rgba(0,0,0,.12),0 10px 36px -4px rgba(77,96,232,.3);background:-webkit-linear-gradient(315deg,#73a5ff,#5477f5);background:linear-gradient(135deg,#73a5ff,#5477f5);position:fixed;opacity:0;transition:all .4s cubic-bezier(.215,.61,.355,1);border-radius:2px;cursor:pointer;text-decoration:none;max-width:calc(50% - 20px)}.toastify.on{opacity:1}.toast-close{opacity:.4;padding:0 5px}.right{right:15px}.left{left:15px}.top{top:-150px}.bottom{bottom:-150px}.rounded{border-radius:25px}.avatar{width:1.5em;height:1.5em;margin:0 5px;border-radius:2px}@media only screen and (max-width:360px){.left,.right{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content}}
/*# sourceMappingURL=/sm/734ed69e2fe87a4469526acc0a10708fa8e0211c7d4359f9e034ceb89bb5d540.map */

BIN
server/website/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
server/website/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

69
server/website/index.html Normal file
View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Simple Cloud Notifications</title>
<link rel="stylesheet" href="/css/toastify.min.css"/>
<link rel="stylesheet" href="/css/mini-default.min.css"> <!-- https://minicss.org/ -->
<!--<link rel="stylesheet" href="/css/mini-nord.min.css">-->
<!--<link rel="stylesheet" href="/css/mini-dark.min.css">-->
<link rel="stylesheet" href="/css/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favicon.png"/>
<link rel="icon" type="image/png" href="/favicon.ico"/>
</head>
<body>
<div id="copyinfo">
<a tabindex="-1" href="https://www.blackforestbytes.com">&#169; blackforestbytes</a>
<a tabindex="-1" href="https://www.mikescher.com">made by Mike Schw&ouml;rer</a>
</div>
<form id="mainpnl">
<a tabindex="-1" href="https://play.google.com/store/apps/details?id=com.blackforestbytes.simplecloudnotifier" class="button bordered" id="tl_link"><span class="icn-google-play"></span></a>
<a tabindex="-1" href="/api" class="button bordered" id="tr_link">API</a>
<a tabindex="-1" href="/" class="linkcaption"><h1>Simple Cloud Notifier</h1></a>
<div class="row responsive-label">
<div class="col-sm-12 col-md-3"><label for="uid" class="doc">UserID</label></div>
<div class="col-sm-12 col-md"><input placeholder="UserID" id="uid" class="doc" <?php echo (isset($_GET['preset_user_id']) ? (' value="'.$_GET['preset_user_id'].'" '):(''));?> type="number"></div>
</div>
<div class="row responsive-label">
<div class="col-sm-12 col-md-3"><label for="ukey" class="doc">Authentification Key</label></div>
<div class="col-sm-12 col-md"><input placeholder="Key" id="ukey" class="doc" <?php echo (isset($_GET['preset_user_key']) ? (' value="'.$_GET['preset_user_key'].'" '):(''));?> type="text" maxlength="64"></div>
</div>
<div class="row responsive-label">
<div class="col-sm-12 col-md-3"><label for="prio" class="doc">Priority</label></div>
<div class="col-sm-12 col-md">
<select id="prio" class="doc" type="text" style="width:100%;">
<option value="0" <?php echo (( isset($_GET['preset_priority'])&&$_GET['preset_priority']==='0') ? 'selected':'');?>>Low</option>
<option value="1" <?php echo ((!isset($_GET['preset_priority'])||$_GET['preset_priority']==='1') ? 'selected':'');?>>Normal</option>
<option value="2" <?php echo (( isset($_GET['preset_priority'])&&$_GET['preset_priority']==='2') ? 'selected':'');?>>High</option>
</select>
</div>
</div>
<div class="row responsive-label">
<div class="col-sm-12 col-md-3"><label for="msg" class="doc">Message Title</label></div>
<div class="col-sm-12 col-md"><input placeholder="Message" id="msg" class="doc" <?php echo (isset($_GET['preset_title']) ? (' value="'.$_GET['preset_title'].'" '):(''));?> type="text" maxlength="80"></div>
</div>
<div class="row responsive-label">
<div class="col-sm-12 col-md-3"><label for="txt" class="doc">Message Content</label></div>
<div class="col-sm-12 col-md"><textarea id="txt" class="doc" <?php echo (isset($_GET['preset_content']) ? (' value="'.$_GET['preset_content'].'" '):(''));?> rows="8" maxlength="2048"></textarea></div>
</div>
<div class="row">
<div class="col-sm-12 col-md-3"></div>
<div class="col-sm-12 col-md"><button type="submit" class="primary bordered" id="btnSend">Send</button></div>
</div>
</form>
<script src="/js/logic.js" type="text/javascript" ></script>
<script src="/js/toastify.js"></script>
</body>
</html>

View File

@ -0,0 +1,90 @@
function send()
{
let me = document.getElementById("btnSend");
if (me.classList.contains("btn-disabled")) return;
me.innerHTML = "<div class=\"spinnerbox\"><div class=\"spinner primary\"></div></div>";
me.classList.add("btn-disabled");
let uid = document.getElementById("uid");
let key = document.getElementById("ukey");
let msg = document.getElementById("msg");
let txt = document.getElementById("txt");
let pio = document.getElementById("prio");
uid.classList.remove('input-invalid');
key.classList.remove('input-invalid');
msg.classList.remove('input-invalid');
txt.classList.remove('input-invalid');
pio.classList.remove('input-invalid');
let data = new FormData();
data.append('user_id', uid.value);
data.append('user_key', key.value);
data.append('title', msg.value);
data.append('content', txt.value);
data.append('priority', pio.value);
let xhr = new XMLHttpRequest();
xhr.open('POST', '/', true);
xhr.onreadystatechange = function ()
{
if (xhr.readyState !== 4) return;
console.log('Status: ' + xhr.status);
if (xhr.status === 200 || xhr.status === 401 || xhr.status === 403 || xhr.status === 412)
{
let resp = JSON.parse(xhr.responseText);
if (!resp.success || xhr.status !== 200)
{
if (resp.errhighlight === 101) uid.classList.add('input-invalid');
if (resp.errhighlight === 102) key.classList.add('input-invalid');
if (resp.errhighlight === 103) msg.classList.add('input-invalid');
if (resp.errhighlight === 104) txt.classList.add('input-invalid');
if (resp.errhighlight === 105) pio.classList.add('input-invalid');
Toastify({
text: resp.message,
gravity: "top",
positionLeft: false,
backgroundColor: "#D32F2F",
}).showToast();
}
else
{
window.location.href =
'/message_sent' +
'?ok=' + 1 +
'&message_count=' + resp.messagecount +
'&quota=' + resp.quota +
'&quota_remain=' + (resp.quota_max-resp.quota) +
'&quota_max=' + resp.quota_max +
'&preset_user_id=' + uid.value +
'&preset_user_key=' + key.value;
}
}
else
{
Toastify({
text: 'Request failed: Statuscode=' + xhr.status,
gravity: "top",
positionLeft: false,
backgroundColor: "#D32F2F",
}).showToast();
}
me.classList.remove("btn-disabled");
me.innerHTML = "Send";
};
xhr.send(data);
}
window.addEventListener("load",function ()
{
let btnSend = document.getElementById("btnSend");
if (btnSend !== undefined) btnSend.onclick = function () { send(); return false; };
},false);

View File

@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using UglifyJS v3.4.3.
* Original file: /npm/toastify-js@1.3.0/src/toastify.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(t,o){"object"==typeof module&&module.exports?(require("./toastify.css"),module.exports=o()):t.Toastify=o()}(this,function(t){var i=function(t){return new i.lib.init(t)};function r(t,o){return!(!t||"string"!=typeof o)&&!!(t.className&&-1<t.className.trim().split(/\s+/gi).indexOf(o))}return i.lib=i.prototype={toastify:"1.2.2",constructor:i,init:function(t){return t||(t={}),this.options={},this.options.text=t.text||"Hi there!",this.options.duration=t.duration||3e3,this.options.selector=t.selector,this.options.callback=t.callback||function(){},this.options.destination=t.destination,this.options.newWindow=t.newWindow||!1,this.options.close=t.close||!1,this.options.gravity="bottom"==t.gravity?"bottom":"top",this.options.positionLeft=t.positionLeft||!1,this.options.backgroundColor=t.backgroundColor,this.options.avatar=t.avatar||"",this.options.className=t.className||"",this},buildToast:function(){if(!this.options)throw"Toastify is not initialized";var t=document.createElement("div");if(t.className="toastify on "+this.options.className,!0===this.options.positionLeft?t.className+=" left":t.className+=" right",t.className+=" "+this.options.gravity,this.options.backgroundColor&&(t.style.background=this.options.backgroundColor),t.innerHTML=this.options.text,""!==this.options.avatar){var o=document.createElement("img");o.src=this.options.avatar,o.className="avatar",!0===this.options.positionLeft?t.appendChild(o):t.insertAdjacentElement("beforeend",o)}if(!0===this.options.close){var i=document.createElement("span");i.innerHTML="&#10006;",i.className="toast-close",i.addEventListener("click",function(t){t.stopPropagation(),this.removeElement(t.target.parentElement),window.clearTimeout(t.target.parentElement.timeOutValue)}.bind(this));var n=0<window.innerWidth?window.innerWidth:screen.width;!0===this.options.positionLeft&&360<n?t.insertAdjacentElement("afterbegin",i):t.appendChild(i)}return void 0!==this.options.destination&&t.addEventListener("click",function(t){t.stopPropagation(),!0===this.options.newWindow?window.open(this.options.destination,"_blank"):window.location=this.options.destination}.bind(this)),t},showToast:function(){var t,o=this.buildToast();if(!(t=void 0===this.options.selector?document.body:document.getElementById(this.options.selector)))throw"Root element is not defined";return t.insertBefore(o,t.firstChild),i.reposition(),o.timeOutValue=window.setTimeout(function(){this.removeElement(o)}.bind(this),this.options.duration),this},removeElement:function(t){t.className=t.className.replace(" on",""),window.setTimeout(function(){t.parentNode.removeChild(t),this.options.callback.call(t),i.reposition()}.bind(this),400)}},i.reposition=function(){for(var t,o={top:15,bottom:15},i={top:15,bottom:15},n={top:15,bottom:15},e=document.getElementsByClassName("toastify"),s=0;s<e.length;s++){t=!0===r(e[s],"top")?"top":"bottom";var a=e[s].offsetHeight;(0<window.innerWidth?window.innerWidth:screen.width)<=360?(e[s].style[t]=n[t]+"px",n[t]+=a+15):!0===r(e[s],"left")?(e[s].style[t]=o[t]+"px",o[t]+=a+15):(e[s].style[t]=i[t]+"px",i[t]+=a+15)}return this},i.lib.init.prototype=i.lib,i});
//# sourceMappingURL=/sm/3f68e387be4f7a323a891120e4e01e3bee54a927113a386cf5e598b3cd442fcc.map

View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Simple Cloud Notifications</title>
<link rel="stylesheet" href="/css/mini-default.min.css"> <!-- https://minicss.org/docs -->
<!--<link rel="stylesheet" href="/css/mini-nord.min.css">-->
<!--<link rel="stylesheet" href="/css/mini-dark.min.css">-->
<link rel="stylesheet" href="/css/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favicon.png"/>
<link rel="icon" type="image/png" href="/favicon.ico"/>
</head>
<body>
<div id="copyinfo">
<a tabindex="-1" href="https://www.blackforestbytes.com">&#169; blackforestbytes</a>
<a tabindex="-1" href="https://www.mikescher.com">made by Mike Schw&ouml;rer</a>
</div>
<div id="mainpnl">
<div class="fullcenterflex">
<?php if (isset($_GET['ok']) && $_GET['ok'] === "1" ): ?>
<a class="card success" href="/index.php?preset_user_id=<?php echo isset($_GET['preset_user_id'])?$_GET['preset_user_id']:'ERR';?>&preset_user_key=<?php echo isset($_GET['preset_user_key'])?$_GET['preset_user_key']:'ERR';?>">
<div class="section">
<h3 class="doc">Message sent</h3>
<p class="doc">Message succesfully sent<br>
<?php echo isset($_GET['quota_remain'])?$_GET['quota_remain']:'ERR';?>/<?php echo isset($_GET['quota_max'])?$_GET['quota_max']:'ERR';?> remaining</p>
</div>
</a>
<?php else: ?>
<a class="card error" href="/index.php">
<div class="section">
<h3 class="doc">Failure</h3>
<p class="doc">Unknown error</p>
</div>
</a>
<?php endif; ?>
</div>
<a tabindex="-1" href="https://play.google.com/store/apps/details?id=com.blackforestbytes.simplecloudnotifier" class="button bordered" id="tl_link"><span class="icn-google-play"></span></a>
<a tabindex="-1" href="/index.php" class="button bordered" id="tr_link">Send</a>
<a tabindex="-1" href="/" class="linkcaption"><h1>Simple Cloud Notifier</h1></a>
</div>
</body>
</html>

View File

@ -0,0 +1,8 @@
package website
import "embed"
//go:embed *
//go:embed css/*
//go:embed js/*
var Assets embed.FS