move server/* to scnserver/*
This commit is contained in:
90
scnserver/.gitignore
vendored
Normal file
90
scnserver/.gitignore
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
|
||||
|
||||
_build
|
||||
.run-data
|
||||
|
||||
DOCKER_GIT_INFO
|
||||
|
||||
|
||||
##############
|
||||
|
||||
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
.idea/**/aws.xml
|
||||
.idea/**/contentModel.xml
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
.idea/**/mongoSettings.xml
|
||||
.idea/**/sonarlint/
|
||||
.idea/**/sonarIssues.xml
|
||||
.idea/**/markdown-navigator.xml
|
||||
.idea/**/markdown-navigator-enh.xml
|
||||
.idea/**/markdown-navigator/
|
||||
.idea/**/azureSettings.xml
|
||||
|
||||
.idea/replstate.xml
|
||||
.idea/sonarlint/
|
||||
.idea/httpRequests
|
||||
.idea/caches/build_file_checksums.ser
|
||||
.idea/$CACHE_FILE$
|
||||
.idea/codestream.xml
|
||||
|
||||
.idea_modules/
|
||||
|
||||
|
||||
|
||||
|
||||
cmake-build-*/
|
||||
*.iws
|
||||
out/
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
|
||||
|
||||
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
|
||||
|
||||
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
.nfs*
|
||||
|
||||
|
||||
|
||||
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
*.icloud
|
8
scnserver/.idea/.gitignore
generated
vendored
Normal file
8
scnserver/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
11
scnserver/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
11
scnserver/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
8
scnserver/.idea/modules.xml
generated
Normal file
8
scnserver/.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/server.iml" filepath="$PROJECT_DIR$/.idea/server.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
19
scnserver/.idea/server.iml
generated
Normal file
19
scnserver/.idea/server.iml
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true">
|
||||
<buildTags>
|
||||
<option name="customFlags">
|
||||
<array>
|
||||
<option value="timetzdata" />
|
||||
<option value="sqlite_fts5" />
|
||||
<option value="sqlite_foreign_keys" />
|
||||
</array>
|
||||
</option>
|
||||
</buildTags>
|
||||
</component>
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
11
scnserver/.idea/sqldialects.xml
generated
Normal file
11
scnserver/.idea/sqldialects.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/db/schema/schema_3.ddl" dialect="SQLite" />
|
||||
<file url="PROJECT" dialect="SQLite" />
|
||||
</component>
|
||||
<component name="SqlResolveMappings">
|
||||
<file url="file://$PROJECT_DIR$/db/database.go" scope="{"node":{ "@negative":"1", "group":{ "@kind":"root", "node":{ "name":{ "@qname":"b3228d61-4c36-41ce-803f-63bd80e198b3" }, "group":{ "@kind":"schema", "node":{ "name":{ "@qname":"schema_3.0.ddl" } } } } } }}" />
|
||||
<file url="PROJECT" scope="{"node":{ "@negative":"1", "group":{ "@kind":"root", "node":{ "name":{ "@qname":"b3228d61-4c36-41ce-803f-63bd80e198b3" }, "group":{ "@kind":"schema", "node":{ "name":{ "@qname":"schema_3.0.ddl" } } } } } }}" />
|
||||
</component>
|
||||
</project>
|
6
scnserver/.idea/vcs.xml
generated
Normal file
6
scnserver/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
12
scnserver/Dockerfile
Normal file
12
scnserver/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM debian:bookworm
|
||||
|
||||
COPY _build/scn_backend /app/scnserver
|
||||
COPY DOCKER_GIT_INFO /app/DOCKER_GIT_INFO
|
||||
|
||||
RUN mkdir /data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["/app/scnserver"]
|
74
scnserver/Makefile
Normal file
74
scnserver/Makefile
Normal file
@@ -0,0 +1,74 @@
|
||||
DOCKER_REPO=registry.blackforestbytes.com
|
||||
DOCKER_NAME=mikescher/simplecloudnotifier
|
||||
PORT=9090
|
||||
|
||||
NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD)
|
||||
HASH=$(shell git rev-parse HEAD)
|
||||
|
||||
build: swagger fmt
|
||||
mkdir -p _build
|
||||
rm -f ./_build/scn_backend
|
||||
CGO_ENABLED=1 go build -v -o _build/scn_backend -tags "timetzdata sqlite_fts5 sqlite_foreign_keys" ./cmd/scnserver
|
||||
|
||||
run: build
|
||||
mkdir -p .run-data
|
||||
_build/scn_backend
|
||||
|
||||
docker: build
|
||||
[ ! -f "DOCKER_GIT_INFO" ] || rm DOCKER_GIT_INFO
|
||||
git rev-parse --abbrev-ref HEAD >> DOCKER_GIT_INFO
|
||||
git rev-parse HEAD >> DOCKER_GIT_INFO
|
||||
git log -1 --format=%cd --date=iso >> DOCKER_GIT_INFO
|
||||
git config --get remote.origin.url >> DOCKER_GIT_INFO
|
||||
docker build \
|
||||
-t "$(DOCKER_NAME):$(HASH)" \
|
||||
-t "$(DOCKER_NAME):$(NAMESPACE)-latest" \
|
||||
-t "$(DOCKER_NAME):latest" \
|
||||
-t "$(DOCKER_REPO)/$(DOCKER_NAME):$(HASH)" \
|
||||
-t "$(DOCKER_REPO)/$(DOCKER_NAME):$(NAMESPACE)-latest" \
|
||||
-t "$(DOCKER_REPO)/$(DOCKER_NAME):latest" \
|
||||
.
|
||||
|
||||
.PHONY: swagger
|
||||
swagger:
|
||||
which swag || go install github.com/swaggo/swag/cmd/swag@latest
|
||||
swag init -generalInfo api/router.go --propertyStrategy snakecase --output ./swagger/ --outputTypes "json,yaml"
|
||||
|
||||
run-docker-local: docker
|
||||
mkdir -p .run-data
|
||||
docker run --rm \
|
||||
--init \
|
||||
--env "CONF_NS=local-docker" \
|
||||
--volume "$(shell pwd)/.run-data/docker-local:/data" \
|
||||
--publish "8080:80" \
|
||||
$(DOCKER_NAME):latest
|
||||
|
||||
inspect-docker: docker
|
||||
mkdir -p .run-data
|
||||
docker run -ti \
|
||||
--rm \
|
||||
--volume "$(shell pwd)/.run-data/docker-inspect:/data" \
|
||||
$(DOCKER_NAME):latest \
|
||||
bash
|
||||
|
||||
push-docker: docker
|
||||
docker image push "$(DOCKER_REPO)/$(DOCKER_NAME):$(HASH)"
|
||||
docker image push "$(DOCKER_REPO)/$(DOCKER_NAME):$(NAMESPACE)-latest"
|
||||
docker image push "$(DOCKER_REPO)/$(DOCKER_NAME):latest"
|
||||
|
||||
clean:
|
||||
rm -rf _build/*
|
||||
rm -rf .run-data/*
|
||||
git clean -fdx
|
||||
go clean
|
||||
go clean -testcache
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
swag fmt
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
go test ./test/...
|
||||
|
||||
|
32
scnserver/README.md
Normal file
32
scnserver/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
|
||||
TODO
|
||||
========
|
||||
|
||||
-------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
- migration script for existing data
|
||||
|
||||
- app-store link in HTML
|
||||
|
||||
- route to re-check all pro-token (for me)
|
||||
|
||||
- tests (!)
|
||||
|
||||
- deploy
|
||||
|
||||
- diff my currently used scnsend script vs the one in the docs here
|
||||
|
||||
-------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
- in my script: use (backupname || hostname) for sendername
|
||||
|
||||
-------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
- (?) return subscribtions in list-channels (?)
|
||||
|
||||
- (?) ack/read deliveries && return ack-count (? or not, how to query?)
|
||||
|
||||
- (?) "login" on website and list/search/filter messages
|
||||
|
||||
- (?) make channels deleteable (soft-delete) (what do with messages in channel?)
|
54
scnserver/api/apierr/enums.go
Normal file
54
scnserver/api/apierr/enums.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package apierr
|
||||
|
||||
type APIError int
|
||||
|
||||
//goland:noinspection GoSnakeCaseUsage
|
||||
const (
|
||||
UNDEFINED APIError = -1
|
||||
|
||||
NO_ERROR APIError = 0000
|
||||
|
||||
MISSING_UID APIError = 1101
|
||||
MISSING_TOK APIError = 1102
|
||||
MISSING_TITLE APIError = 1103
|
||||
INVALID_PRIO APIError = 1104
|
||||
REQ_METHOD APIError = 1105
|
||||
INVALID_CLIENTTYPE APIError = 1106
|
||||
PAGETOKEN_ERROR APIError = 1121
|
||||
BINDFAIL_QUERY_PARAM APIError = 1151
|
||||
BINDFAIL_BODY_PARAM APIError = 1152
|
||||
BINDFAIL_URI_PARAM APIError = 1153
|
||||
INVALID_ENUM_VALUE APIError = 1171
|
||||
|
||||
NO_TITLE APIError = 1201
|
||||
TITLE_TOO_LONG APIError = 1202
|
||||
CONTENT_TOO_LONG APIError = 1203
|
||||
USR_MSG_ID_TOO_LONG APIError = 1204
|
||||
TIMESTAMP_OUT_OF_RANGE APIError = 1205
|
||||
SENDERNAME_TOO_LONG APIError = 1206
|
||||
CHANNEL_TOO_LONG APIError = 1207
|
||||
|
||||
USER_NOT_FOUND APIError = 1301
|
||||
CLIENT_NOT_FOUND APIError = 1302
|
||||
CHANNEL_NOT_FOUND APIError = 1303
|
||||
SUBSCRIPTION_NOT_FOUND APIError = 1304
|
||||
MESSAGE_NOT_FOUND APIError = 1305
|
||||
USER_AUTH_FAILED APIError = 1311
|
||||
|
||||
NO_DEVICE_LINKED APIError = 1401
|
||||
|
||||
CHANNEL_ALREADY_EXISTS APIError = 1501
|
||||
|
||||
QUOTA_REACHED APIError = 2101
|
||||
|
||||
FAILED_VERIFY_PRO_TOKEN APIError = 3001
|
||||
INVALID_PRO_TOKEN APIError = 3002
|
||||
|
||||
COMMIT_FAILED = 9001
|
||||
DATABASE_ERROR = 9002
|
||||
PERM_QUERY_FAIL = 9003
|
||||
|
||||
FIREBASE_COM_FAILED APIError = 9901
|
||||
FIREBASE_COM_ERRORED APIError = 9902
|
||||
INTERNAL_EXCEPTION APIError = 9903
|
||||
)
|
16
scnserver/api/apihighlight/highlights.go
Normal file
16
scnserver/api/apihighlight/highlights.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package apihighlight
|
||||
|
||||
type ErrHighlight int
|
||||
|
||||
//goland:noinspection GoSnakeCaseUsage
|
||||
const (
|
||||
NONE ErrHighlight = -1
|
||||
USER_ID ErrHighlight = 101
|
||||
USER_KEY ErrHighlight = 102
|
||||
TITLE ErrHighlight = 103
|
||||
CONTENT ErrHighlight = 104
|
||||
PRIORITY ErrHighlight = 105
|
||||
CHANNEL ErrHighlight = 106
|
||||
SENDER_NAME ErrHighlight = 107
|
||||
USER_MESSAGE_ID ErrHighlight = 108
|
||||
)
|
21
scnserver/api/ginext/cors.go
Normal file
21
scnserver/api/ginext/cors.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package ginext
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func CorsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusOK)
|
||||
} else {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
}
|
31
scnserver/api/ginext/gin.go
Normal file
31
scnserver/api/ginext/gin.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package ginext
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var SuppressGinLogs = false
|
||||
|
||||
func NewEngine(cfg scn.Config) *gin.Engine {
|
||||
engine := gin.New()
|
||||
|
||||
engine.RedirectFixedPath = false
|
||||
engine.RedirectTrailingSlash = false
|
||||
|
||||
if cfg.Cors {
|
||||
engine.Use(CorsMiddleware())
|
||||
}
|
||||
|
||||
if cfg.GinDebug {
|
||||
ginlogger := gin.Logger()
|
||||
engine.Use(func(context *gin.Context) {
|
||||
if SuppressGinLogs {
|
||||
return
|
||||
}
|
||||
ginlogger(context)
|
||||
})
|
||||
}
|
||||
|
||||
return engine
|
||||
}
|
24
scnserver/api/ginext/handler.go
Normal file
24
scnserver/api/ginext/handler.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package ginext
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func RedirectFound(newuri string) gin.HandlerFunc {
|
||||
return func(g *gin.Context) {
|
||||
g.Redirect(http.StatusFound, newuri)
|
||||
}
|
||||
}
|
||||
|
||||
func RedirectTemporary(newuri string) gin.HandlerFunc {
|
||||
return func(g *gin.Context) {
|
||||
g.Redirect(http.StatusTemporaryRedirect, newuri)
|
||||
}
|
||||
}
|
||||
|
||||
func RedirectPermanent(newuri string) gin.HandlerFunc {
|
||||
return func(g *gin.Context) {
|
||||
g.Redirect(http.StatusPermanentRedirect, newuri)
|
||||
}
|
||||
}
|
16
scnserver/api/ginresp/apiError.go
Normal file
16
scnserver/api/ginresp/apiError.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package ginresp
|
||||
|
||||
type apiError struct {
|
||||
Success bool `json:"success"`
|
||||
Error int `json:"error"`
|
||||
ErrorHighlight int `json:"errhighlight"`
|
||||
Message string `json:"message"`
|
||||
RawError *string `json:"errorObj,omitempty"`
|
||||
Trace string `json:"traceObj,omitempty"`
|
||||
}
|
||||
|
||||
type compatAPIError struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorID int `json:"errid,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
139
scnserver/api/ginresp/resp.go
Normal file
139
scnserver/api/ginresp/resp.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package ginresp
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apihighlight"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
type HTTPResponse interface {
|
||||
Write(g *gin.Context)
|
||||
}
|
||||
|
||||
type jsonHTTPResponse struct {
|
||||
statusCode int
|
||||
data any
|
||||
}
|
||||
|
||||
func (j jsonHTTPResponse) Write(g *gin.Context) {
|
||||
g.JSON(j.statusCode, j.data)
|
||||
}
|
||||
|
||||
type emptyHTTPResponse struct {
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (j emptyHTTPResponse) Write(g *gin.Context) {
|
||||
g.Status(j.statusCode)
|
||||
}
|
||||
|
||||
type textHTTPResponse struct {
|
||||
statusCode int
|
||||
data string
|
||||
}
|
||||
|
||||
func (j textHTTPResponse) Write(g *gin.Context) {
|
||||
g.String(j.statusCode, "%s", j.data)
|
||||
}
|
||||
|
||||
type dataHTTPResponse struct {
|
||||
statusCode int
|
||||
data []byte
|
||||
contentType string
|
||||
}
|
||||
|
||||
func (j dataHTTPResponse) Write(g *gin.Context) {
|
||||
g.Data(j.statusCode, j.contentType, j.data)
|
||||
}
|
||||
|
||||
type errorHTTPResponse struct {
|
||||
statusCode int
|
||||
data any
|
||||
error error
|
||||
}
|
||||
|
||||
func (j errorHTTPResponse) Write(g *gin.Context) {
|
||||
g.JSON(j.statusCode, j.data)
|
||||
}
|
||||
|
||||
func Status(sc int) HTTPResponse {
|
||||
return &emptyHTTPResponse{statusCode: sc}
|
||||
}
|
||||
|
||||
func JSON(sc int, data any) HTTPResponse {
|
||||
return &jsonHTTPResponse{statusCode: sc, data: data}
|
||||
}
|
||||
|
||||
func Data(sc int, contentType string, data []byte) HTTPResponse {
|
||||
return &dataHTTPResponse{statusCode: sc, contentType: contentType, data: data}
|
||||
}
|
||||
|
||||
func Text(sc int, data string) HTTPResponse {
|
||||
return &textHTTPResponse{statusCode: sc, data: data}
|
||||
}
|
||||
|
||||
func InternalError(e error) HTTPResponse {
|
||||
return createApiError(nil, "InternalError", 500, apierr.INTERNAL_EXCEPTION, 0, e.Error(), e)
|
||||
}
|
||||
|
||||
func APIError(g *gin.Context, status int, errorid apierr.APIError, msg string, e error) HTTPResponse {
|
||||
return createApiError(g, "APIError", status, errorid, 0, msg, e)
|
||||
}
|
||||
|
||||
func SendAPIError(g *gin.Context, status int, errorid apierr.APIError, highlight apihighlight.ErrHighlight, msg string, e error) HTTPResponse {
|
||||
return createApiError(g, "SendAPIError", status, errorid, highlight, msg, e)
|
||||
}
|
||||
|
||||
func NotImplemented(g *gin.Context) HTTPResponse {
|
||||
return createApiError(g, "NotImplemented", 500, apierr.UNDEFINED, 0, "Not Implemented", nil)
|
||||
}
|
||||
|
||||
func createApiError(g *gin.Context, ident string, status int, errorid apierr.APIError, highlight apihighlight.ErrHighlight, msg string, e error) HTTPResponse {
|
||||
reqUri := ""
|
||||
if g != nil && g.Request != nil {
|
||||
reqUri = g.Request.Method + " :: " + g.Request.RequestURI
|
||||
}
|
||||
|
||||
log.Error().
|
||||
Int("errorid", int(errorid)).
|
||||
Int("highlight", int(highlight)).
|
||||
Str("uri", reqUri).
|
||||
AnErr("err", e).
|
||||
Stack().
|
||||
Msg(fmt.Sprintf("[%s] %s", ident, msg))
|
||||
|
||||
if scn.Conf.ReturnRawErrors {
|
||||
return &errorHTTPResponse{
|
||||
statusCode: status,
|
||||
data: apiError{
|
||||
Success: false,
|
||||
Error: int(errorid),
|
||||
ErrorHighlight: int(highlight),
|
||||
Message: msg,
|
||||
RawError: langext.Ptr(langext.Conditional(e == nil, "", fmt.Sprintf("%+v", e))),
|
||||
Trace: string(debug.Stack()),
|
||||
},
|
||||
error: e,
|
||||
}
|
||||
} else {
|
||||
return &errorHTTPResponse{
|
||||
statusCode: status,
|
||||
data: apiError{
|
||||
Success: false,
|
||||
Error: int(errorid),
|
||||
ErrorHighlight: int(highlight),
|
||||
Message: msg,
|
||||
},
|
||||
error: e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CompatAPIError(errid int, msg string) HTTPResponse {
|
||||
return &jsonHTTPResponse{statusCode: 200, data: compatAPIError{Success: false, ErrorID: errid, Message: msg}}
|
||||
}
|
80
scnserver/api/ginresp/wrapper.go
Normal file
80
scnserver/api/ginresp/wrapper.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package ginresp
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WHandlerFunc func(*gin.Context) HTTPResponse
|
||||
|
||||
func Wrap(fn WHandlerFunc) gin.HandlerFunc {
|
||||
|
||||
maxRetry := scn.Conf.RequestMaxRetry
|
||||
retrySleep := scn.Conf.RequestRetrySleep
|
||||
|
||||
return func(g *gin.Context) {
|
||||
|
||||
reqctx := g.Request.Context()
|
||||
|
||||
if g.Request.Body != nil {
|
||||
g.Request.Body = dataext.NewBufferedReadCloser(g.Request.Body)
|
||||
}
|
||||
|
||||
for ctr := 1; ; ctr++ {
|
||||
|
||||
wrap := fn(g)
|
||||
|
||||
if g.Writer.Written() {
|
||||
panic("Writing in WrapperFunc is not supported")
|
||||
}
|
||||
|
||||
if ctr < maxRetry && isSqlite3Busy(wrap) {
|
||||
log.Warn().Int("counter", ctr).Str("url", g.Request.URL.String()).Msg("Retry request (ErrBusy)")
|
||||
|
||||
err := resetBody(g)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
time.Sleep(retrySleep)
|
||||
continue
|
||||
}
|
||||
|
||||
if reqctx.Err() == nil {
|
||||
wrap.Write(g)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func resetBody(g *gin.Context) error {
|
||||
if g.Request.Body == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := g.Request.Body.(dataext.BufferedReadCloser).Reset()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSqlite3Busy(r HTTPResponse) bool {
|
||||
if errwrap, ok := r.(*errorHTTPResponse); ok && errwrap != nil {
|
||||
if s3err, ok := (errwrap.error).(sqlite3.Error); ok {
|
||||
if s3err.Code == sqlite3.ErrBusy {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
1399
scnserver/api/handler/api.go
Normal file
1399
scnserver/api/handler/api.go
Normal file
File diff suppressed because it is too large
Load Diff
212
scnserver/api/handler/common.go
Normal file
212
scnserver/api/handler/common.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CommonHandler struct {
|
||||
app *logic.Application
|
||||
}
|
||||
|
||||
func NewCommonHandler(app *logic.Application) CommonHandler {
|
||||
return CommonHandler{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
type pingResponse struct {
|
||||
Message string `json:"message"`
|
||||
Info pingResponseInfo `json:"info"`
|
||||
}
|
||||
type pingResponseInfo struct {
|
||||
Method string `json:"method"`
|
||||
Request string `json:"request"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
URI string `json:"uri"`
|
||||
Address string `json:"addr"`
|
||||
}
|
||||
|
||||
// Ping swaggerdoc
|
||||
//
|
||||
// @Summary Simple endpoint to test connection (any http method)
|
||||
// @Tags Common
|
||||
//
|
||||
// @Success 200 {object} pingResponse
|
||||
// @Failure 500 {object} ginresp.apiError
|
||||
//
|
||||
// @Router /api/ping [get]
|
||||
// @Router /api/ping [post]
|
||||
// @Router /api/ping [put]
|
||||
// @Router /api/ping [delete]
|
||||
// @Router /api/ping [patch]
|
||||
func (h CommonHandler) Ping(g *gin.Context) ginresp.HTTPResponse {
|
||||
buf := new(bytes.Buffer)
|
||||
_, _ = buf.ReadFrom(g.Request.Body)
|
||||
resuestBody := buf.String()
|
||||
|
||||
return ginresp.JSON(http.StatusOK, pingResponse{
|
||||
Message: "Pong",
|
||||
Info: pingResponseInfo{
|
||||
Method: g.Request.Method,
|
||||
Request: resuestBody,
|
||||
Headers: g.Request.Header,
|
||||
URI: g.Request.RequestURI,
|
||||
Address: g.Request.RemoteAddr,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DatabaseTest swaggerdoc
|
||||
//
|
||||
// @Summary Check for a wroking database connection
|
||||
// @ID api-common-dbtest
|
||||
// @Tags Common
|
||||
//
|
||||
// @Success 200 {object} handler.DatabaseTest.response
|
||||
// @Failure 500 {object} ginresp.apiError
|
||||
//
|
||||
// @Router /api/db-test [post]
|
||||
func (h CommonHandler) DatabaseTest(g *gin.Context) ginresp.HTTPResponse {
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
LibVersion string `json:"libVersion"`
|
||||
LibVersionNumber int `json:"libVersionNumber"`
|
||||
SourceID string `json:"sourceID"`
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
libVersion, libVersionNumber, sourceID := sqlite3.Version()
|
||||
|
||||
err := h.app.Database.Ping(ctx)
|
||||
if err != nil {
|
||||
return ginresp.InternalError(err)
|
||||
}
|
||||
|
||||
return ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
LibVersion: libVersion,
|
||||
LibVersionNumber: libVersionNumber,
|
||||
SourceID: sourceID,
|
||||
})
|
||||
}
|
||||
|
||||
// Health swaggerdoc
|
||||
//
|
||||
// @Summary Server Health-checks
|
||||
// @ID api-common-health
|
||||
// @Tags Common
|
||||
//
|
||||
// @Success 200 {object} handler.Health.response
|
||||
// @Failure 500 {object} ginresp.apiError
|
||||
//
|
||||
// @Router /api/health [get]
|
||||
func (h CommonHandler) Health(g *gin.Context) ginresp.HTTPResponse {
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, libVersionNumber, _ := sqlite3.Version()
|
||||
|
||||
if libVersionNumber < 3039000 {
|
||||
ginresp.InternalError(errors.New("sqlite version too low"))
|
||||
}
|
||||
|
||||
err := h.app.Database.Ping(ctx)
|
||||
if err != nil {
|
||||
return ginresp.InternalError(err)
|
||||
}
|
||||
|
||||
uuidKey, _ := langext.NewHexUUID()
|
||||
uuidWrite, _ := langext.NewHexUUID()
|
||||
|
||||
err = h.app.Database.WriteMetaString(ctx, uuidKey, uuidWrite)
|
||||
if err != nil {
|
||||
return ginresp.InternalError(err)
|
||||
}
|
||||
|
||||
uuidRead, err := h.app.Database.ReadMetaString(ctx, uuidKey)
|
||||
if err != nil {
|
||||
return ginresp.InternalError(err)
|
||||
}
|
||||
|
||||
if uuidRead == nil || uuidWrite != *uuidRead {
|
||||
return ginresp.InternalError(errors.New("writing into DB was not consistent"))
|
||||
}
|
||||
|
||||
err = h.app.Database.DeleteMeta(ctx, uuidKey)
|
||||
if err != nil {
|
||||
return ginresp.InternalError(err)
|
||||
}
|
||||
|
||||
return ginresp.JSON(http.StatusOK, response{Status: "ok"})
|
||||
}
|
||||
|
||||
// Sleep swaggerdoc
|
||||
//
|
||||
// @Summary Return 200 after x seconds
|
||||
// @ID api-common-sleep
|
||||
// @Tags Common
|
||||
//
|
||||
// @Param secs path number true "sleep delay (in seconds)"
|
||||
//
|
||||
// @Success 200 {object} handler.Sleep.response
|
||||
// @Failure 400 {object} ginresp.apiError
|
||||
// @Failure 500 {object} ginresp.apiError
|
||||
//
|
||||
// @Router /api/sleep/{secs} [post]
|
||||
func (h CommonHandler) Sleep(g *gin.Context) ginresp.HTTPResponse {
|
||||
type uri struct {
|
||||
Seconds float64 `uri:"secs"`
|
||||
}
|
||||
type response struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
t0 := time.Now().Format(time.RFC3339Nano)
|
||||
|
||||
var u uri
|
||||
if err := g.ShouldBindUri(&u); err != nil {
|
||||
return ginresp.APIError(g, 400, apierr.BINDFAIL_URI_PARAM, "Failed to read uri", err)
|
||||
}
|
||||
|
||||
time.Sleep(timeext.FromSeconds(u.Seconds))
|
||||
|
||||
t1 := time.Now().Format(time.RFC3339Nano)
|
||||
|
||||
return ginresp.JSON(http.StatusOK, response{
|
||||
Start: t0,
|
||||
End: t1,
|
||||
Duration: u.Seconds,
|
||||
})
|
||||
}
|
||||
|
||||
func (h CommonHandler) NoRoute(g *gin.Context) ginresp.HTTPResponse {
|
||||
return ginresp.JSON(http.StatusNotFound, gin.H{
|
||||
"": "================ ROUTE NOT FOUND ================",
|
||||
"FullPath": g.FullPath(),
|
||||
"Method": g.Request.Method,
|
||||
"URL": g.Request.URL.String(),
|
||||
"RequestURI": g.Request.RequestURI,
|
||||
"Proto": g.Request.Proto,
|
||||
"Header": g.Request.Header,
|
||||
"~": "================ ROUTE NOT FOUND ================",
|
||||
})
|
||||
}
|
670
scnserver/api/handler/compat.go
Normal file
670
scnserver/api/handler/compat.go
Normal file
@@ -0,0 +1,670 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/db"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"database/sql"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type CompatHandler struct {
|
||||
app *logic.Application
|
||||
database *db.Database
|
||||
}
|
||||
|
||||
func NewCompatHandler(app *logic.Application) CompatHandler {
|
||||
return CompatHandler{
|
||||
app: app,
|
||||
database: app.Database,
|
||||
}
|
||||
}
|
||||
|
||||
// Register swaggerdoc
|
||||
//
|
||||
// @Summary Register a new account
|
||||
// @ID compat-register
|
||||
// @Tags API-v1
|
||||
//
|
||||
// @Deprecated
|
||||
//
|
||||
// @Param fcm_token query string true "the (android) fcm token"
|
||||
// @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"
|
||||
//
|
||||
// @Param fcm_token formData string true "the (android) fcm token"
|
||||
// @Param pro formData string true "if the user is a paid account" Enums(true, false)
|
||||
// @Param pro_token formData string true "the (android) IAP token"
|
||||
//
|
||||
// @Success 200 {object} handler.Register.response
|
||||
// @Failure 200 {object} ginresp.compatAPIError
|
||||
//
|
||||
// @Router /api/register.php [get]
|
||||
func (h CompatHandler) Register(g *gin.Context) ginresp.HTTPResponse {
|
||||
type query struct {
|
||||
FCMToken *string `json:"fcm_token" form:"fcm_token"`
|
||||
Pro *string `json:"pro" form:"pro"`
|
||||
ProToken *string `json:"pro_token" form:"pro_token"`
|
||||
}
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserKey string `json:"user_key"`
|
||||
QuotaUsed int `json:"quota"`
|
||||
QuotaMax int `json:"quota_max"`
|
||||
IsPro int `json:"is_pro"`
|
||||
}
|
||||
|
||||
var datq query
|
||||
var datb query
|
||||
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb)
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
data := dataext.ObjectMerge(datb, datq)
|
||||
|
||||
if data.FCMToken == nil {
|
||||
return ginresp.CompatAPIError(0, "Missing parameter [[fcm_token]]")
|
||||
}
|
||||
if data.Pro == nil {
|
||||
return ginresp.CompatAPIError(0, "Missing parameter [[pro]]")
|
||||
}
|
||||
if data.ProToken == nil {
|
||||
return ginresp.CompatAPIError(0, "Missing parameter [[pro_token]]")
|
||||
}
|
||||
|
||||
if *data.Pro != "true" {
|
||||
data.ProToken = nil
|
||||
}
|
||||
|
||||
if data.ProToken != nil {
|
||||
ptok, err := h.app.VerifyProToken(ctx, "ANDROID|v2|"+*data.ProToken)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query purchase status")
|
||||
}
|
||||
|
||||
if !ptok {
|
||||
return ginresp.CompatAPIError(0, "Purchase token could not be verified")
|
||||
}
|
||||
}
|
||||
|
||||
readKey := h.app.GenerateRandomAuthKey()
|
||||
sendKey := h.app.GenerateRandomAuthKey()
|
||||
adminKey := h.app.GenerateRandomAuthKey()
|
||||
|
||||
err := h.database.ClearFCMTokens(ctx, *data.FCMToken)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to clear existing fcm tokens")
|
||||
}
|
||||
|
||||
if data.ProToken != nil {
|
||||
err := h.database.ClearProTokens(ctx, *data.ProToken)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to clear existing fcm tokens")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.database.CreateUser(ctx, readKey, sendKey, adminKey, data.ProToken, nil)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to create user in db")
|
||||
}
|
||||
|
||||
_, err = h.database.CreateClient(ctx, user.UserID, models.ClientTypeAndroid, *data.FCMToken, "compat", "compat")
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to create client in db")
|
||||
}
|
||||
|
||||
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
Message: "New user registered",
|
||||
UserID: user.UserID.IntID(),
|
||||
UserKey: user.AdminKey,
|
||||
QuotaUsed: user.QuotaUsedToday(),
|
||||
QuotaMax: user.QuotaPerDay(),
|
||||
IsPro: langext.Conditional(user.IsPro, 1, 0),
|
||||
}))
|
||||
}
|
||||
|
||||
// Info swaggerdoc
|
||||
//
|
||||
// @Summary Get information about the current user
|
||||
// @ID compat-info
|
||||
// @Tags API-v1
|
||||
//
|
||||
// @Deprecated
|
||||
//
|
||||
// @Param user_id query string true "the user_id"
|
||||
// @Param user_key query string true "the user_key"
|
||||
//
|
||||
// @Param user_id formData string true "the user_id"
|
||||
// @Param user_key formData string true "the user_key"
|
||||
//
|
||||
// @Success 200 {object} handler.Info.response
|
||||
// @Failure 200 {object} ginresp.compatAPIError
|
||||
//
|
||||
// @Router /api/info.php [get]
|
||||
func (h CompatHandler) Info(g *gin.Context) ginresp.HTTPResponse {
|
||||
type query struct {
|
||||
UserID *int64 `json:"user_id" form:"user_id"`
|
||||
UserKey *string `json:"user_key" form:"user_key"`
|
||||
}
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserKey string `json:"user_key"`
|
||||
QuotaUsed int `json:"quota"`
|
||||
QuotaMax int `json:"quota_max"`
|
||||
IsPro int `json:"is_pro"`
|
||||
FCMSet bool `json:"fcm_token_set"`
|
||||
UnackCount int `json:"unack_count"`
|
||||
}
|
||||
|
||||
var datq query
|
||||
var datb query
|
||||
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb)
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
data := dataext.ObjectMerge(datb, datq)
|
||||
|
||||
if data.UserID == nil {
|
||||
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
|
||||
}
|
||||
if data.UserKey == nil {
|
||||
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
|
||||
}
|
||||
|
||||
user, err := h.database.GetUser(ctx, models.UserID(*data.UserID))
|
||||
if err == sql.ErrNoRows {
|
||||
return ginresp.CompatAPIError(201, "User not found")
|
||||
}
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query user")
|
||||
}
|
||||
|
||||
if user.AdminKey != *data.UserKey {
|
||||
return ginresp.CompatAPIError(204, "Authentification failed")
|
||||
}
|
||||
|
||||
clients, err := h.database.ListClients(ctx, user.UserID)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query clients")
|
||||
}
|
||||
|
||||
fcmSet := langext.ArrAny(clients, func(c models.Client) bool { return c.FCMToken != nil })
|
||||
|
||||
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
Message: "ok",
|
||||
UserID: user.UserID.IntID(),
|
||||
UserKey: user.AdminKey,
|
||||
QuotaUsed: user.QuotaUsedToday(),
|
||||
QuotaMax: user.QuotaPerDay(),
|
||||
IsPro: langext.Conditional(user.IsPro, 1, 0),
|
||||
FCMSet: fcmSet,
|
||||
UnackCount: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
// Ack swaggerdoc
|
||||
//
|
||||
// @Summary Acknowledge that a message was received
|
||||
// @ID compat-ack
|
||||
// @Tags API-v1
|
||||
//
|
||||
// @Deprecated
|
||||
//
|
||||
// @Param user_id query string true "the user_id"
|
||||
// @Param user_key query string true "the user_key"
|
||||
// @Param scn_msg_id query string true "the message id"
|
||||
//
|
||||
// @Param user_id formData string true "the user_id"
|
||||
// @Param user_key formData string true "the user_key"
|
||||
// @Param scn_msg_id formData string true "the message id"
|
||||
//
|
||||
// @Success 200 {object} handler.Ack.response
|
||||
// @Failure 200 {object} ginresp.compatAPIError
|
||||
//
|
||||
// @Router /api/ack.php [get]
|
||||
func (h CompatHandler) Ack(g *gin.Context) ginresp.HTTPResponse {
|
||||
type query struct {
|
||||
UserID *int64 `json:"user_id" form:"user_id"`
|
||||
UserKey *string `json:"user_key" form:"user_key"`
|
||||
MessageID *int64 `json:"scn_msg_id" form:"scn_msg_id"`
|
||||
}
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
PrevAckValue int `json:"prev_ack"`
|
||||
NewAckValue int `json:"new_ack"`
|
||||
}
|
||||
|
||||
var datq query
|
||||
var datb query
|
||||
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb)
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
data := dataext.ObjectMerge(datb, datq)
|
||||
|
||||
if data.UserID == nil {
|
||||
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
|
||||
}
|
||||
if data.UserKey == nil {
|
||||
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
|
||||
}
|
||||
if data.MessageID == nil {
|
||||
return ginresp.CompatAPIError(103, "Missing parameter [[scn_msg_id]]")
|
||||
}
|
||||
|
||||
user, err := h.database.GetUser(ctx, models.UserID(*data.UserID))
|
||||
if err == sql.ErrNoRows {
|
||||
return ginresp.CompatAPIError(201, "User not found")
|
||||
}
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query user")
|
||||
}
|
||||
|
||||
if user.AdminKey != *data.UserKey {
|
||||
return ginresp.CompatAPIError(204, "Authentification failed")
|
||||
}
|
||||
|
||||
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
Message: "ok",
|
||||
PrevAckValue: 0,
|
||||
NewAckValue: 1,
|
||||
}))
|
||||
}
|
||||
|
||||
// Requery swaggerdoc
|
||||
//
|
||||
// @Summary Return all not-acknowledged messages
|
||||
// @ID compat-requery
|
||||
// @Tags API-v1
|
||||
//
|
||||
// @Deprecated
|
||||
//
|
||||
// @Param user_id query string true "the user_id"
|
||||
// @Param user_key query string true "the user_key"
|
||||
//
|
||||
// @Param user_id formData string true "the user_id"
|
||||
// @Param user_key formData string true "the user_key"
|
||||
//
|
||||
// @Success 200 {object} handler.Requery.response
|
||||
// @Failure 200 {object} ginresp.compatAPIError
|
||||
//
|
||||
// @Router /api/requery.php [get]
|
||||
func (h CompatHandler) Requery(g *gin.Context) ginresp.HTTPResponse {
|
||||
type query struct {
|
||||
UserID *int64 `json:"user_id" form:"user_id"`
|
||||
UserKey *string `json:"user_key" form:"user_key"`
|
||||
}
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Count int `json:"count"`
|
||||
Data []models.CompatMessage `json:"data"`
|
||||
}
|
||||
|
||||
var datq query
|
||||
var datb query
|
||||
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb)
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
data := dataext.ObjectMerge(datb, datq)
|
||||
|
||||
if data.UserID == nil {
|
||||
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
|
||||
}
|
||||
if data.UserKey == nil {
|
||||
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
|
||||
}
|
||||
|
||||
user, err := h.database.GetUser(ctx, models.UserID(*data.UserID))
|
||||
if err == sql.ErrNoRows {
|
||||
return ginresp.CompatAPIError(201, "User not found")
|
||||
}
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query user")
|
||||
}
|
||||
|
||||
if user.AdminKey != *data.UserKey {
|
||||
return ginresp.CompatAPIError(204, "Authentification failed")
|
||||
}
|
||||
|
||||
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
Message: "ok",
|
||||
Count: 0,
|
||||
Data: make([]models.CompatMessage, 0),
|
||||
}))
|
||||
}
|
||||
|
||||
// Update swaggerdoc
|
||||
//
|
||||
// @Summary Set the fcm-token (android)
|
||||
// @ID compat-update
|
||||
// @Tags API-v1
|
||||
//
|
||||
// @Deprecated
|
||||
//
|
||||
// @Param user_id query string true "the user_id"
|
||||
// @Param user_key query string true "the user_key"
|
||||
// @Param fcm_token query string true "the (android) fcm token"
|
||||
//
|
||||
// @Param user_id formData string true "the user_id"
|
||||
// @Param user_key formData string true "the user_key"
|
||||
// @Param fcm_token formData string true "the (android) fcm token"
|
||||
//
|
||||
// @Success 200 {object} handler.Update.response
|
||||
// @Failure 200 {object} ginresp.compatAPIError
|
||||
//
|
||||
// @Router /api/update.php [get]
|
||||
func (h CompatHandler) Update(g *gin.Context) ginresp.HTTPResponse {
|
||||
type query struct {
|
||||
UserID *int64 `json:"user_id" form:"user_id"`
|
||||
UserKey *string `json:"user_key" form:"user_key"`
|
||||
FCMToken *string `json:"fcm_token" form:"fcm_token"`
|
||||
}
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserKey string `json:"user_key"`
|
||||
QuotaUsed int `json:"quota"`
|
||||
QuotaMax int `json:"quota_max"`
|
||||
IsPro int `json:"is_pro"`
|
||||
}
|
||||
|
||||
var datq query
|
||||
var datb query
|
||||
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb)
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
data := dataext.ObjectMerge(datb, datq)
|
||||
|
||||
if data.UserID == nil {
|
||||
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
|
||||
}
|
||||
if data.UserKey == nil {
|
||||
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
|
||||
}
|
||||
|
||||
user, err := h.database.GetUser(ctx, models.UserID(*data.UserID))
|
||||
if err == sql.ErrNoRows {
|
||||
return ginresp.CompatAPIError(201, "User not found")
|
||||
}
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query user")
|
||||
}
|
||||
|
||||
if user.AdminKey != *data.UserKey {
|
||||
return ginresp.CompatAPIError(204, "Authentification failed")
|
||||
}
|
||||
|
||||
clients, err := h.database.ListClients(ctx, user.UserID)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to list clients")
|
||||
}
|
||||
|
||||
newAdminKey := h.app.GenerateRandomAuthKey()
|
||||
newReadKey := h.app.GenerateRandomAuthKey()
|
||||
newSendKey := h.app.GenerateRandomAuthKey()
|
||||
|
||||
err = h.database.UpdateUserKeys(ctx, user.UserID, newSendKey, newReadKey, newAdminKey)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to update keys")
|
||||
}
|
||||
|
||||
if data.FCMToken != nil {
|
||||
|
||||
for _, client := range clients {
|
||||
|
||||
err = h.database.DeleteClient(ctx, client.ClientID)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to delete client")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_, err = h.database.CreateClient(ctx, user.UserID, models.ClientTypeAndroid, *data.FCMToken, "compat", "compat")
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to delete client")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
user, err = h.database.GetUser(ctx, user.UserID)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query user")
|
||||
}
|
||||
|
||||
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
Message: "user updated",
|
||||
UserID: user.UserID.IntID(),
|
||||
UserKey: user.AdminKey,
|
||||
QuotaUsed: user.QuotaUsedToday(),
|
||||
QuotaMax: user.QuotaPerDay(),
|
||||
IsPro: langext.Conditional(user.IsPro, 1, 0),
|
||||
}))
|
||||
}
|
||||
|
||||
// Expand swaggerdoc
|
||||
//
|
||||
// @Summary Get a whole (potentially truncated) message
|
||||
// @ID compat-expand
|
||||
// @Tags API-v1
|
||||
//
|
||||
// @Deprecated
|
||||
//
|
||||
// @Param user_id query string true "The user_id"
|
||||
// @Param user_key query string true "The user_key"
|
||||
// @Param scn_msg_id query string true "The message-id"
|
||||
//
|
||||
// @Param user_id formData string true "The user_id"
|
||||
// @Param user_key formData string true "The user_key"
|
||||
// @Param scn_msg_id formData string true "The message-id"
|
||||
//
|
||||
// @Success 200 {object} handler.Expand.response
|
||||
// @Failure 200 {object} ginresp.compatAPIError
|
||||
//
|
||||
// @Router /api/expand.php [get]
|
||||
func (h CompatHandler) Expand(g *gin.Context) ginresp.HTTPResponse {
|
||||
type query struct {
|
||||
UserID *int64 `json:"user_id" form:"user_id"`
|
||||
UserKey *string `json:"user_key" form:"user_key"`
|
||||
MessageID *int64 `json:"scn_msg_id" form:"scn_msg_id"`
|
||||
}
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data models.CompatMessage `json:"data"`
|
||||
}
|
||||
|
||||
var datq query
|
||||
var datb query
|
||||
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb)
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
data := dataext.ObjectMerge(datb, datq)
|
||||
|
||||
if data.UserID == nil {
|
||||
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
|
||||
}
|
||||
if data.UserKey == nil {
|
||||
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
|
||||
}
|
||||
if data.MessageID == nil {
|
||||
return ginresp.CompatAPIError(103, "Missing parameter [[scn_msg_id]]")
|
||||
}
|
||||
|
||||
user, err := h.database.GetUser(ctx, models.UserID(*data.UserID))
|
||||
if err == sql.ErrNoRows {
|
||||
return ginresp.CompatAPIError(201, "User not found")
|
||||
}
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query user")
|
||||
}
|
||||
|
||||
if user.AdminKey != *data.UserKey {
|
||||
return ginresp.CompatAPIError(204, "Authentification failed")
|
||||
}
|
||||
|
||||
msg, err := h.database.GetMessage(ctx, models.SCNMessageID(*data.MessageID), false)
|
||||
if err == sql.ErrNoRows {
|
||||
return ginresp.CompatAPIError(301, "Message not found")
|
||||
}
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query message")
|
||||
}
|
||||
|
||||
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
Message: "ok",
|
||||
Data: models.CompatMessage{
|
||||
Title: msg.Title,
|
||||
Body: langext.Coalesce(msg.Content, ""),
|
||||
Trimmed: langext.Ptr(false),
|
||||
Priority: msg.Priority,
|
||||
Timestamp: msg.Timestamp().Unix(),
|
||||
UserMessageID: msg.UserMessageID,
|
||||
SCNMessageID: msg.SCNMessageID.IntID(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// Upgrade swaggerdoc
|
||||
//
|
||||
// @Summary Upgrade a free account to a paid account
|
||||
// @ID compat-upgrade
|
||||
// @Tags API-v1
|
||||
//
|
||||
// @Deprecated
|
||||
//
|
||||
// @Param user_id query string true "the user_id"
|
||||
// @Param user_key query string true "the user_key"
|
||||
// @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"
|
||||
//
|
||||
// @Param user_id formData string true "the user_id"
|
||||
// @Param user_key formData string true "the user_key"
|
||||
// @Param pro formData string true "if the user is a paid account" Enums(true, false)
|
||||
// @Param pro_token formData string true "the (android) IAP token"
|
||||
//
|
||||
// @Success 200 {object} handler.Upgrade.response
|
||||
// @Failure 200 {object} ginresp.compatAPIError
|
||||
//
|
||||
// @Router /api/upgrade.php [get]
|
||||
func (h CompatHandler) Upgrade(g *gin.Context) ginresp.HTTPResponse {
|
||||
type query struct {
|
||||
UserID *int64 `json:"user_id" form:"user_id"`
|
||||
UserKey *string `json:"user_key" form:"user_key"`
|
||||
Pro *string `json:"pro" form:"pro"`
|
||||
ProToken *string `json:"pro_token" form:"pro_token"`
|
||||
}
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
QuotaUsed int `json:"quota"`
|
||||
QuotaMax int `json:"quota_max"`
|
||||
IsPro bool `json:"is_pro"`
|
||||
}
|
||||
|
||||
var datq query
|
||||
var datb query
|
||||
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb)
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
data := dataext.ObjectMerge(datb, datq)
|
||||
|
||||
if data.UserID == nil {
|
||||
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
|
||||
}
|
||||
if data.UserKey == nil {
|
||||
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
|
||||
}
|
||||
if data.Pro == nil {
|
||||
return ginresp.CompatAPIError(103, "Missing parameter [[pro]]")
|
||||
}
|
||||
if data.ProToken == nil {
|
||||
return ginresp.CompatAPIError(104, "Missing parameter [[pro_token]]")
|
||||
}
|
||||
|
||||
user, err := h.database.GetUser(ctx, models.UserID(*data.UserID))
|
||||
if err == sql.ErrNoRows {
|
||||
return ginresp.CompatAPIError(201, "User not found")
|
||||
}
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query user")
|
||||
}
|
||||
|
||||
if user.AdminKey != *data.UserKey {
|
||||
return ginresp.CompatAPIError(204, "Authentification failed")
|
||||
}
|
||||
|
||||
if *data.Pro != "true" {
|
||||
data.ProToken = nil
|
||||
}
|
||||
|
||||
if data.ProToken != nil {
|
||||
ptok, err := h.app.VerifyProToken(ctx, "ANDROID|v2|"+*data.ProToken)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query purchase status")
|
||||
}
|
||||
|
||||
if !ptok {
|
||||
return ginresp.CompatAPIError(0, "Purchase token could not be verified")
|
||||
}
|
||||
|
||||
err = h.database.UpdateUserProToken(ctx, user.UserID, data.ProToken)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to update user")
|
||||
}
|
||||
} else {
|
||||
err = h.database.UpdateUserProToken(ctx, user.UserID, nil)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to update user")
|
||||
}
|
||||
}
|
||||
|
||||
user, err = h.database.GetUser(ctx, user.UserID)
|
||||
if err != nil {
|
||||
return ginresp.CompatAPIError(0, "Failed to query user")
|
||||
}
|
||||
|
||||
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
Message: "user updated",
|
||||
UserID: user.UserID.IntID(),
|
||||
QuotaUsed: user.QuotaUsedToday(),
|
||||
QuotaMax: user.QuotaPerDay(),
|
||||
IsPro: user.IsPro,
|
||||
}))
|
||||
}
|
317
scnserver/api/handler/message.go
Normal file
317
scnserver/api/handler/message.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
hl "blackforestbytes.com/simplecloudnotifier/api/apihighlight"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/db"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/mathext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MessageHandler struct {
|
||||
app *logic.Application
|
||||
database *db.Database
|
||||
}
|
||||
|
||||
func NewMessageHandler(app *logic.Application) MessageHandler {
|
||||
return MessageHandler{
|
||||
app: app,
|
||||
database: app.Database,
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessageCompat swaggerdoc
|
||||
//
|
||||
// @Deprecated
|
||||
//
|
||||
// @Summary Send a new message (compatibility)
|
||||
// @Description All parameter can be set via query-parameter or form-data body. Only UserID, UserKey and Title are required
|
||||
// @Tags External
|
||||
//
|
||||
// @Param query_data query handler.SendMessageCompat.combined false " "
|
||||
// @Param form_data formData handler.SendMessageCompat.combined false " "
|
||||
//
|
||||
// @Success 200 {object} handler.sendMessageInternal.response
|
||||
// @Failure 400 {object} ginresp.apiError
|
||||
// @Failure 401 {object} ginresp.apiError
|
||||
// @Failure 403 {object} ginresp.apiError
|
||||
// @Failure 500 {object} ginresp.apiError
|
||||
//
|
||||
// @Router /send.php [POST]
|
||||
func (h MessageHandler) SendMessageCompat(g *gin.Context) ginresp.HTTPResponse {
|
||||
type combined struct {
|
||||
UserID *models.UserID `json:"user_id" form:"user_id"`
|
||||
UserKey *string `json:"user_key" form:"user_key"`
|
||||
Title *string `json:"title" form:"title"`
|
||||
Content *string `json:"content" form:"content"`
|
||||
Priority *int `json:"priority" form:"priority"`
|
||||
UserMessageID *string `json:"msg_id" form:"msg_id"`
|
||||
SendTimestamp *float64 `json:"timestamp" form:"timestamp"`
|
||||
}
|
||||
|
||||
var f combined
|
||||
var q combined
|
||||
ctx, errResp := h.app.StartRequest(g, nil, &q, nil, &f)
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
data := dataext.ObjectMerge(f, q)
|
||||
|
||||
return h.sendMessageInternal(g, ctx, data.UserID, data.UserKey, nil, nil, data.Title, data.Content, data.Priority, data.UserMessageID, data.SendTimestamp, nil)
|
||||
}
|
||||
|
||||
// SendMessage swaggerdoc
|
||||
//
|
||||
// @Summary Send a new message
|
||||
// @Description All parameter can be set via query-parameter or the json body. Only UserID, UserKey and Title are required
|
||||
// @Tags External
|
||||
//
|
||||
// @Param query_data query handler.SendMessage.combined false " "
|
||||
// @Param post_body body handler.SendMessage.combined false " "
|
||||
// @Param form_body formData handler.SendMessage.combined false " "
|
||||
//
|
||||
// @Success 200 {object} handler.sendMessageInternal.response
|
||||
// @Failure 400 {object} ginresp.apiError
|
||||
// @Failure 401 {object} ginresp.apiError "The user_id was not found or the user_key is wrong"
|
||||
// @Failure 403 {object} ginresp.apiError "The user has exceeded its daily quota - wait 24 hours or upgrade your account"
|
||||
// @Failure 500 {object} ginresp.apiError "An internal server error occurred - try again later"
|
||||
//
|
||||
// @Router / [POST]
|
||||
// @Router /send [POST]
|
||||
func (h MessageHandler) SendMessage(g *gin.Context) ginresp.HTTPResponse {
|
||||
type combined struct {
|
||||
UserID *models.UserID `json:"user_id" form:"user_id" example:"7725" `
|
||||
UserKey *string `json:"user_key" form:"user_key" example:"P3TNH8mvv14fm" `
|
||||
Channel *string `json:"channel" form:"channel" example:"test" `
|
||||
ChanKey *string `json:"chan_key" form:"chan_key" example:"qhnUbKcLgp6tg" `
|
||||
Title *string `json:"title" form:"title" example:"Hello World" `
|
||||
Content *string `json:"content" form:"content" example:"This is a message" `
|
||||
Priority *int `json:"priority" form:"priority" example:"1" enums:"0,1,2" `
|
||||
UserMessageID *string `json:"msg_id" form:"msg_id" example:"db8b0e6a-a08c-4646" `
|
||||
SendTimestamp *float64 `json:"timestamp" form:"timestamp" example:"1669824037" `
|
||||
SenderName *string `json:"sender_name" form:"sender_name" example:"example-server" `
|
||||
}
|
||||
|
||||
var b combined
|
||||
var q combined
|
||||
var f combined
|
||||
ctx, errResp := h.app.StartRequest(g, nil, &q, &b, &f)
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
// query has highest prio, then form, then json
|
||||
data := dataext.ObjectMerge(dataext.ObjectMerge(b, f), q)
|
||||
|
||||
return h.sendMessageInternal(g, ctx, data.UserID, data.UserKey, data.Channel, data.ChanKey, data.Title, data.Content, data.Priority, data.UserMessageID, data.SendTimestamp, data.SenderName)
|
||||
|
||||
}
|
||||
|
||||
func (h MessageHandler) sendMessageInternal(g *gin.Context, ctx *logic.AppContext, UserID *models.UserID, UserKey *string, Channel *string, ChanKey *string, Title *string, Content *string, Priority *int, UserMessageID *string, SendTimestamp *float64, SenderName *string) ginresp.HTTPResponse {
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorID apierr.APIError `json:"error"`
|
||||
ErrorHighlight int `json:"errhighlight"`
|
||||
Message string `json:"message"`
|
||||
SuppressSend bool `json:"suppress_send"`
|
||||
MessageCount int `json:"messagecount"`
|
||||
Quota int `json:"quota"`
|
||||
IsPro bool `json:"is_pro"`
|
||||
QuotaMax int `json:"quota_max"`
|
||||
SCNMessageID models.SCNMessageID `json:"scn_msg_id"`
|
||||
}
|
||||
|
||||
if Title != nil {
|
||||
Title = langext.Ptr(strings.TrimSpace(*Title))
|
||||
}
|
||||
if UserMessageID != nil {
|
||||
UserMessageID = langext.Ptr(strings.TrimSpace(*UserMessageID))
|
||||
}
|
||||
|
||||
if UserID == nil {
|
||||
return ginresp.SendAPIError(g, 400, apierr.MISSING_UID, hl.USER_ID, "Missing parameter [[user_id]]", nil)
|
||||
}
|
||||
if UserKey == nil {
|
||||
return ginresp.SendAPIError(g, 400, apierr.MISSING_TOK, hl.USER_KEY, "Missing parameter [[user_token]]", nil)
|
||||
}
|
||||
if Title == nil {
|
||||
return ginresp.SendAPIError(g, 400, apierr.MISSING_TITLE, hl.TITLE, "Missing parameter [[title]]", nil)
|
||||
}
|
||||
if Priority != nil && (*Priority != 0 && *Priority != 1 && *Priority != 2) {
|
||||
return ginresp.SendAPIError(g, 400, apierr.INVALID_PRIO, hl.PRIORITY, "Invalid priority", nil)
|
||||
}
|
||||
if len(*Title) == 0 {
|
||||
return ginresp.SendAPIError(g, 400, apierr.NO_TITLE, hl.TITLE, "No title specified", nil)
|
||||
}
|
||||
|
||||
user, err := h.database.GetUser(ctx, *UserID)
|
||||
if err == sql.ErrNoRows {
|
||||
return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query user", err)
|
||||
}
|
||||
|
||||
channelName := user.DefaultChannel()
|
||||
if Channel != nil {
|
||||
channelName = h.app.NormalizeChannelName(*Channel)
|
||||
}
|
||||
|
||||
if len(*Title) > user.MaxTitleLength() {
|
||||
return ginresp.SendAPIError(g, 400, apierr.TITLE_TOO_LONG, hl.TITLE, fmt.Sprintf("Title too long (max %d characters)", user.MaxTitleLength()), nil)
|
||||
}
|
||||
if Content != nil && len(*Content) > user.MaxContentLength() {
|
||||
return ginresp.SendAPIError(g, 400, apierr.CONTENT_TOO_LONG, hl.CONTENT, fmt.Sprintf("Content too long (%d characters; max := %d characters)", len(*Content), user.MaxContentLength()), nil)
|
||||
}
|
||||
if len(channelName) > user.MaxChannelNameLength() {
|
||||
return ginresp.SendAPIError(g, 400, apierr.CHANNEL_TOO_LONG, hl.CHANNEL, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil)
|
||||
}
|
||||
if SenderName != nil && len(*SenderName) > user.MaxSenderName() {
|
||||
return ginresp.SendAPIError(g, 400, apierr.SENDERNAME_TOO_LONG, hl.SENDER_NAME, fmt.Sprintf("SenderName too long (max %d characters)", user.MaxSenderName()), nil)
|
||||
}
|
||||
if UserMessageID != nil && len(*UserMessageID) > user.MaxUserMessageID() {
|
||||
return ginresp.SendAPIError(g, 400, apierr.USR_MSG_ID_TOO_LONG, hl.USER_MESSAGE_ID, fmt.Sprintf("MessageID too long (max %d characters)", user.MaxUserMessageID()), nil)
|
||||
}
|
||||
if SendTimestamp != nil && mathext.Abs(*SendTimestamp-float64(time.Now().Unix())) > timeext.FromHours(user.MaxTimestampDiffHours()).Seconds() {
|
||||
return ginresp.SendAPIError(g, 400, apierr.TIMESTAMP_OUT_OF_RANGE, hl.NONE, fmt.Sprintf("The timestamp mus be within %d hours of now()", user.MaxTimestampDiffHours()), nil)
|
||||
}
|
||||
|
||||
if UserMessageID != nil {
|
||||
msg, err := h.database.GetMessageByUserMessageID(ctx, *UserMessageID)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query existing message", err)
|
||||
}
|
||||
if msg != nil {
|
||||
//the found message can be deleted (!), but we still return NO_ERROR here...
|
||||
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
ErrorID: apierr.NO_ERROR,
|
||||
ErrorHighlight: -1,
|
||||
Message: "Message already sent",
|
||||
SuppressSend: true,
|
||||
MessageCount: user.MessagesSent,
|
||||
Quota: user.QuotaUsedToday(),
|
||||
IsPro: user.IsPro,
|
||||
QuotaMax: user.QuotaPerDay(),
|
||||
SCNMessageID: msg.SCNMessageID,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if user.QuotaRemainingToday() <= 0 {
|
||||
return ginresp.SendAPIError(g, 403, apierr.QUOTA_REACHED, hl.NONE, fmt.Sprintf("Daily quota reached (%d)", user.QuotaPerDay()), nil)
|
||||
}
|
||||
|
||||
var channel models.Channel
|
||||
if ChanKey != nil {
|
||||
// foreign channel (+ channel send-key)
|
||||
|
||||
foreignChan, err := h.database.GetChannelByNameAndSendKey(ctx, channelName, *ChanKey)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query (foreign) channel", err)
|
||||
}
|
||||
if foreignChan == nil {
|
||||
return ginresp.SendAPIError(g, 400, apierr.CHANNEL_NOT_FOUND, hl.CHANNEL, "(Foreign) Channel not found", err)
|
||||
}
|
||||
channel = *foreignChan
|
||||
} else {
|
||||
// own channel
|
||||
|
||||
channel, err = h.app.GetOrCreateChannel(ctx, *UserID, channelName)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query/create (owned) channel", err)
|
||||
}
|
||||
}
|
||||
|
||||
selfChanAdmin := *UserID == channel.OwnerUserID && *UserKey == user.AdminKey
|
||||
selfChanSend := *UserID == channel.OwnerUserID && *UserKey == user.SendKey
|
||||
forgChanSend := *UserID != channel.OwnerUserID && ChanKey != nil && *ChanKey == channel.SendKey
|
||||
|
||||
if !selfChanAdmin && !selfChanSend && !forgChanSend {
|
||||
return ginresp.SendAPIError(g, 401, apierr.USER_AUTH_FAILED, hl.USER_KEY, "You are not authorized for this action", nil)
|
||||
}
|
||||
|
||||
var sendTimestamp *time.Time = nil
|
||||
if SendTimestamp != nil {
|
||||
sendTimestamp = langext.Ptr(timeext.UnixFloatSeconds(*SendTimestamp))
|
||||
}
|
||||
|
||||
priority := langext.Coalesce(Priority, user.DefaultPriority())
|
||||
|
||||
clientIP := g.ClientIP()
|
||||
|
||||
msg, err := h.database.CreateMessage(ctx, *UserID, channel, sendTimestamp, *Title, Content, priority, UserMessageID, clientIP, SenderName)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create message in db", err)
|
||||
}
|
||||
|
||||
subscriptions, err := h.database.ListSubscriptionsByChannel(ctx, channel.ChannelID)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query subscriptions", err)
|
||||
}
|
||||
|
||||
err = h.database.IncUserMessageCounter(ctx, user)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc user msg-counter", err)
|
||||
}
|
||||
|
||||
err = h.database.IncChannelMessageCounter(ctx, channel)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc channel msg-counter", err)
|
||||
}
|
||||
|
||||
for _, sub := range subscriptions {
|
||||
clients, err := h.database.ListClients(ctx, sub.SubscriberUserID)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query clients", err)
|
||||
}
|
||||
|
||||
if !sub.Confirmed {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, client := range clients {
|
||||
|
||||
fcmDelivID, err := h.app.DeliverMessage(ctx, client, msg)
|
||||
if err != nil {
|
||||
_, err = h.database.CreateRetryDelivery(ctx, client, msg)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create delivery", err)
|
||||
}
|
||||
} else {
|
||||
_, err = h.database.CreateSuccessDelivery(ctx, client, msg, *fcmDelivID)
|
||||
if err != nil {
|
||||
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create delivery", err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
|
||||
Success: true,
|
||||
ErrorID: apierr.NO_ERROR,
|
||||
ErrorHighlight: -1,
|
||||
Message: "Message sent",
|
||||
SuppressSend: false,
|
||||
MessageCount: user.MessagesSent + 1,
|
||||
Quota: user.QuotaUsedToday() + 1,
|
||||
IsPro: user.IsPro,
|
||||
QuotaMax: user.QuotaPerDay(),
|
||||
SCNMessageID: msg.SCNMessageID,
|
||||
}))
|
||||
}
|
174
scnserver/api/handler/website.go
Normal file
174
scnserver/api/handler/website.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/website"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type WebsiteHandler struct {
|
||||
app *logic.Application
|
||||
rexTemplate *regexp.Regexp
|
||||
rexConfig *regexp.Regexp
|
||||
}
|
||||
|
||||
func NewWebsiteHandler(app *logic.Application) WebsiteHandler {
|
||||
return WebsiteHandler{
|
||||
app: app,
|
||||
rexTemplate: regexp.MustCompile("{{template\\|[A-Za-z0-9_\\-\\[\\].]+}}"),
|
||||
rexConfig: regexp.MustCompile("{{config\\|[A-Za-z0-9_\\-.]+}}"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) Index(g *gin.Context) ginresp.HTTPResponse {
|
||||
return h.serveAsset(g, "index.html", true)
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) APIDocs(g *gin.Context) ginresp.HTTPResponse {
|
||||
return h.serveAsset(g, "api.html", true)
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) APIDocsMore(g *gin.Context) ginresp.HTTPResponse {
|
||||
return h.serveAsset(g, "api_more.html", true)
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) MessageSent(g *gin.Context) ginresp.HTTPResponse {
|
||||
return h.serveAsset(g, "message_sent.html", true)
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) FaviconIco(g *gin.Context) ginresp.HTTPResponse {
|
||||
return h.serveAsset(g, "favicon.ico", false)
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) FaviconPNG(g *gin.Context) ginresp.HTTPResponse {
|
||||
return h.serveAsset(g, "favicon.png", false)
|
||||
}
|
||||
|
||||
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, false)
|
||||
}
|
||||
|
||||
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, false)
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) serveAsset(g *gin.Context, fn string, repl bool) ginresp.HTTPResponse {
|
||||
data, err := website.Assets.ReadFile(fn)
|
||||
if err != nil {
|
||||
return ginresp.Status(http.StatusNotFound)
|
||||
}
|
||||
|
||||
if repl {
|
||||
failed := false
|
||||
data = h.rexTemplate.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||
prefix := len("{{template|")
|
||||
suffix := len("}}")
|
||||
fnSub := string(match[prefix : len(match)-suffix])
|
||||
|
||||
fnSub = strings.ReplaceAll(fnSub, "[theme]", h.getTheme(g))
|
||||
|
||||
subdata, err := website.Assets.ReadFile(fnSub)
|
||||
if err != nil {
|
||||
log.Error().Str("templ", string(match)).Str("fnSub", fnSub).Str("source", fn).Msg("Failed to replace template")
|
||||
failed = true
|
||||
}
|
||||
return subdata
|
||||
})
|
||||
if failed {
|
||||
return ginresp.InternalError(errors.New("template replacement failed"))
|
||||
}
|
||||
|
||||
data = h.rexConfig.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||
prefix := len("{{config|")
|
||||
suffix := len("}}")
|
||||
cfgKey := match[prefix : len(match)-suffix]
|
||||
|
||||
cval, ok := h.getReplConfig(string(cfgKey))
|
||||
if !ok {
|
||||
log.Error().Str("templ", string(match)).Str("source", fn).Msg("Failed to replace config")
|
||||
failed = true
|
||||
}
|
||||
return []byte(cval)
|
||||
})
|
||||
if failed {
|
||||
return ginresp.InternalError(errors.New("config replacement failed"))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) getReplConfig(key string) (string, bool) {
|
||||
key = strings.TrimSpace(strings.ToLower(key))
|
||||
|
||||
if key == "baseurl" {
|
||||
return h.app.Config.BaseURL, true
|
||||
}
|
||||
if key == "ip" {
|
||||
return h.app.Config.ServerIP, true
|
||||
}
|
||||
if key == "port" {
|
||||
return h.app.Config.ServerPort, true
|
||||
}
|
||||
if key == "namespace" {
|
||||
return h.app.Config.Namespace, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) getTheme(g *gin.Context) string {
|
||||
if c, err := g.Cookie("theme"); err != nil {
|
||||
return "light"
|
||||
} else if c == "light" {
|
||||
return "light"
|
||||
} else if c == "dark" {
|
||||
return "dark"
|
||||
} else {
|
||||
return "light"
|
||||
}
|
||||
}
|
153
scnserver/api/router.go
Normal file
153
scnserver/api/router.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginext"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/handler"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/swagger"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
app *logic.Application
|
||||
|
||||
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),
|
||||
websiteHandler: handler.NewWebsiteHandler(app),
|
||||
apiHandler: handler.NewAPIHandler(app),
|
||||
messageHandler: handler.NewMessageHandler(app),
|
||||
}
|
||||
}
|
||||
|
||||
// Init swaggerdocs
|
||||
//
|
||||
// @title SimpleCloudNotifier API
|
||||
// @version 2.0
|
||||
// @description API for SCN
|
||||
// @host scn.blackforestbytes.com
|
||||
//
|
||||
// @tag.name External
|
||||
// @tag.name API-v1
|
||||
// @tag.name API-v2
|
||||
// @tag.name Common
|
||||
//
|
||||
// @BasePath /
|
||||
func (r *Router) Init(e *gin.Engine) {
|
||||
|
||||
// ================ General ================
|
||||
|
||||
commonAPI := e.Group("/api")
|
||||
{
|
||||
commonAPI.Any("/ping", ginresp.Wrap(r.commonHandler.Ping))
|
||||
commonAPI.POST("/db-test", ginresp.Wrap(r.commonHandler.DatabaseTest))
|
||||
commonAPI.GET("/health", ginresp.Wrap(r.commonHandler.Health))
|
||||
commonAPI.POST("/sleep/:secs", ginresp.Wrap(r.commonHandler.Sleep))
|
||||
}
|
||||
|
||||
// ================ Swagger ================
|
||||
|
||||
docs := e.Group("/documentation")
|
||||
{
|
||||
docs.GET("/swagger", ginext.RedirectTemporary("/documentation/swagger/"))
|
||||
docs.GET("/swagger/*sub", ginresp.Wrap(swagger.Handle))
|
||||
}
|
||||
|
||||
// ================ Website ================
|
||||
|
||||
frontend := e.Group("")
|
||||
{
|
||||
frontend.GET("/", ginresp.Wrap(r.websiteHandler.Index))
|
||||
frontend.GET("/index.php", ginresp.Wrap(r.websiteHandler.Index))
|
||||
frontend.GET("/index.html", ginresp.Wrap(r.websiteHandler.Index))
|
||||
frontend.GET("/index", ginresp.Wrap(r.websiteHandler.Index))
|
||||
|
||||
frontend.GET("/api", ginresp.Wrap(r.websiteHandler.APIDocs))
|
||||
frontend.GET("/api.php", ginresp.Wrap(r.websiteHandler.APIDocs))
|
||||
frontend.GET("/api.html", ginresp.Wrap(r.websiteHandler.APIDocs))
|
||||
|
||||
frontend.GET("/api_more", ginresp.Wrap(r.websiteHandler.APIDocsMore))
|
||||
frontend.GET("/api_more.php", ginresp.Wrap(r.websiteHandler.APIDocsMore))
|
||||
frontend.GET("/api_more.html", ginresp.Wrap(r.websiteHandler.APIDocsMore))
|
||||
|
||||
frontend.GET("/message_sent", ginresp.Wrap(r.websiteHandler.MessageSent))
|
||||
frontend.GET("/message_sent.php", ginresp.Wrap(r.websiteHandler.MessageSent))
|
||||
frontend.GET("/message_sent.html", ginresp.Wrap(r.websiteHandler.MessageSent))
|
||||
|
||||
frontend.GET("/favicon.ico", ginresp.Wrap(r.websiteHandler.FaviconIco))
|
||||
frontend.GET("/favicon.png", ginresp.Wrap(r.websiteHandler.FaviconPNG))
|
||||
|
||||
frontend.GET("/js/:fn", ginresp.Wrap(r.websiteHandler.Javascript))
|
||||
frontend.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/")
|
||||
{
|
||||
|
||||
apiv2.POST("/users", ginresp.Wrap(r.apiHandler.CreateUser))
|
||||
apiv2.GET("/users/:uid", ginresp.Wrap(r.apiHandler.GetUser))
|
||||
apiv2.PATCH("/users/:uid", ginresp.Wrap(r.apiHandler.UpdateUser))
|
||||
|
||||
apiv2.GET("/users/:uid/clients", ginresp.Wrap(r.apiHandler.ListClients))
|
||||
apiv2.GET("/users/:uid/clients/:cid", ginresp.Wrap(r.apiHandler.GetClient))
|
||||
apiv2.POST("/users/:uid/clients", ginresp.Wrap(r.apiHandler.AddClient))
|
||||
apiv2.DELETE("/users/:uid/clients/:cid", ginresp.Wrap(r.apiHandler.DeleteClient))
|
||||
|
||||
apiv2.GET("/users/:uid/channels", ginresp.Wrap(r.apiHandler.ListChannels))
|
||||
apiv2.POST("/users/:uid/channels", ginresp.Wrap(r.apiHandler.CreateChannel))
|
||||
apiv2.GET("/users/:uid/channels/:cid", ginresp.Wrap(r.apiHandler.GetChannel))
|
||||
apiv2.PATCH("/users/:uid/channels/:cid", ginresp.Wrap(r.apiHandler.UpdateChannel))
|
||||
apiv2.GET("/users/:uid/channels/:cid/messages", ginresp.Wrap(r.apiHandler.ListChannelMessages))
|
||||
apiv2.GET("/users/:uid/channels/:cid/subscriptions", ginresp.Wrap(r.apiHandler.ListChannelSubscriptions))
|
||||
|
||||
apiv2.GET("/users/:uid/subscriptions", ginresp.Wrap(r.apiHandler.ListUserSubscriptions))
|
||||
apiv2.GET("/users/:uid/subscriptions/:sid", ginresp.Wrap(r.apiHandler.GetSubscription))
|
||||
apiv2.DELETE("/users/:uid/subscriptions/:sid", ginresp.Wrap(r.apiHandler.CancelSubscription))
|
||||
apiv2.POST("/users/:uid/subscriptions", ginresp.Wrap(r.apiHandler.CreateSubscription))
|
||||
apiv2.PATCH("/users/:uid/subscriptions", ginresp.Wrap(r.apiHandler.UpdateSubscription))
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// ================ Send API ================
|
||||
|
||||
sendAPI := e.Group("")
|
||||
{
|
||||
sendAPI.POST("/", ginresp.Wrap(r.messageHandler.SendMessage))
|
||||
sendAPI.POST("/send", ginresp.Wrap(r.messageHandler.SendMessage))
|
||||
sendAPI.POST("/send.php", ginresp.Wrap(r.messageHandler.SendMessageCompat))
|
||||
}
|
||||
|
||||
if r.app.Config.ReturnRawErrors {
|
||||
e.NoRoute(ginresp.Wrap(r.commonHandler.NoRoute))
|
||||
}
|
||||
|
||||
}
|
68
scnserver/cmd/scnserver/main.go
Normal file
68
scnserver/cmd/scnserver/main.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"blackforestbytes.com/simplecloudnotifier/api"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginext"
|
||||
"blackforestbytes.com/simplecloudnotifier/db"
|
||||
"blackforestbytes.com/simplecloudnotifier/google"
|
||||
"blackforestbytes.com/simplecloudnotifier/jobs"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/push"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
conf := scn.Conf
|
||||
|
||||
scn.Init(conf)
|
||||
|
||||
log.Info().Msg(fmt.Sprintf("Starting with config-namespace <%s>", conf.Namespace))
|
||||
|
||||
sqlite, err := db.NewDatabase(conf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
app := logic.NewApp(sqlite)
|
||||
|
||||
if err := app.Migrate(); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to migrate DB")
|
||||
return
|
||||
}
|
||||
|
||||
ginengine := ginext.NewEngine(conf)
|
||||
|
||||
router := api.NewRouter(app)
|
||||
|
||||
var nc push.NotificationClient
|
||||
if conf.DummyFirebase {
|
||||
nc = push.NewDummy()
|
||||
} else {
|
||||
nc, err = push.NewFirebaseConn(conf)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to init firebase")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var apc google.AndroidPublisherClient
|
||||
if conf.DummyGoogleAPI {
|
||||
apc = google.NewDummy()
|
||||
} else {
|
||||
apc, err = google.NewAndroidPublisherAPI(conf)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to init google-api")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
jobRetry := jobs.NewDeliveryRetryJob(app)
|
||||
|
||||
app.Init(conf, ginengine, nc, apc, []logic.Job{jobRetry})
|
||||
|
||||
router.Init(ginengine)
|
||||
|
||||
app.Run()
|
||||
}
|
281
scnserver/config.go
Normal file
281
scnserver/config.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/confext"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Namespace string
|
||||
BaseURL string `env:"SCN_URL"`
|
||||
GinDebug bool `env:"SCN_GINDEBUG"`
|
||||
LogLevel zerolog.Level `env:"SCN_LOGLEVEL"`
|
||||
ServerIP string `env:"SCN_IP"`
|
||||
ServerPort string `env:"SCN_PORT"`
|
||||
DBFile string `env:"SCN_DB_FILE"`
|
||||
DBJournal string `env:"SCN_DB_JOURNAL"`
|
||||
DBTimeout time.Duration `env:"SCN_DB_TIMEOUT"`
|
||||
DBMaxOpenConns int `env:"SCN_DB_MAXOPENCONNECTIONS"`
|
||||
DBMaxIdleConns int `env:"SCN_DB_MAXIDLECONNECTIONS"`
|
||||
DBConnMaxLifetime time.Duration `env:"SCN_DB_CONNEXTIONMAXLIFETIME"`
|
||||
DBConnMaxIdleTime time.Duration `env:"SCN_DB_CONNEXTIONMAXIDLETIME"`
|
||||
DBCheckForeignKeys bool `env:"SCN_DB_CHECKFOREIGNKEYS"`
|
||||
DBSingleConn bool `env:"SCN_DB_SINGLECONNECTION"`
|
||||
RequestTimeout time.Duration `env:"SCN_REQUEST_TIMEOUT"`
|
||||
RequestMaxRetry int `env:"SCN_REQUEST_MAXRETRY"`
|
||||
RequestRetrySleep time.Duration `env:"SCN_REQUEST_RETRYSLEEP"`
|
||||
ReturnRawErrors bool `env:"SCN_ERROR_RETURN"`
|
||||
DummyFirebase bool `env:"SCN_DUMMY_FB"`
|
||||
DummyGoogleAPI bool `env:"SCN_DUMMY_GOOG"`
|
||||
FirebaseTokenURI string `env:"SCN_FB_TOKENURI"`
|
||||
FirebaseProjectID string `env:"SCN_FB_PROJECTID"`
|
||||
FirebasePrivKeyID string `env:"SCN_FB_PRIVATEKEYID"`
|
||||
FirebaseClientMail string `env:"SCN_FB_CLIENTEMAIL"`
|
||||
FirebasePrivateKey string `env:"SCN_FB_PRIVATEKEY"`
|
||||
GoogleAPITokenURI string `env:"SCN_GOOG_TOKENURI"`
|
||||
GoogleAPIPrivKeyID string `env:"SCN_GOOG_PRIVATEKEYID"`
|
||||
GoogleAPIClientMail string `env:"SCN_GOOG_CLIENTEMAIL"`
|
||||
GoogleAPIPrivateKey string `env:"SCN_GOOG_PRIVATEKEY"`
|
||||
GooglePackageName string `env:"SCN_GOOG_PACKAGENAME"`
|
||||
GoogleProProductID string `env:"SCN_GOOG_PROPRODUCTID"`
|
||||
Cors bool `env:"SCN_CORS"`
|
||||
}
|
||||
|
||||
var Conf Config
|
||||
|
||||
var configLocHost = func() Config {
|
||||
return Config{
|
||||
Namespace: "local-host",
|
||||
BaseURL: "http://localhost:8080",
|
||||
GinDebug: true,
|
||||
LogLevel: zerolog.DebugLevel,
|
||||
ServerIP: "0.0.0.0",
|
||||
ServerPort: "8080",
|
||||
DBFile: ".run-data/db.sqlite3",
|
||||
DBJournal: "WAL",
|
||||
DBTimeout: 5 * time.Second,
|
||||
DBCheckForeignKeys: false,
|
||||
DBSingleConn: false,
|
||||
DBMaxOpenConns: 5,
|
||||
DBMaxIdleConns: 5,
|
||||
DBConnMaxLifetime: 60 * time.Minute,
|
||||
DBConnMaxIdleTime: 60 * time.Minute,
|
||||
RequestTimeout: 16 * time.Second,
|
||||
RequestMaxRetry: 8,
|
||||
RequestRetrySleep: 100 * time.Millisecond,
|
||||
ReturnRawErrors: true,
|
||||
DummyFirebase: true,
|
||||
FirebaseTokenURI: "",
|
||||
FirebaseProjectID: "",
|
||||
FirebasePrivKeyID: "",
|
||||
FirebaseClientMail: "",
|
||||
FirebasePrivateKey: "",
|
||||
DummyGoogleAPI: true,
|
||||
GoogleAPITokenURI: "",
|
||||
GoogleAPIPrivKeyID: "",
|
||||
GoogleAPIClientMail: "",
|
||||
GoogleAPIPrivateKey: "",
|
||||
GooglePackageName: "",
|
||||
GoogleProProductID: "",
|
||||
Cors: true,
|
||||
}
|
||||
}
|
||||
|
||||
var configLocDocker = func() Config {
|
||||
return Config{
|
||||
Namespace: "local-docker",
|
||||
BaseURL: "http://localhost:8080",
|
||||
GinDebug: true,
|
||||
LogLevel: zerolog.DebugLevel,
|
||||
ServerIP: "0.0.0.0",
|
||||
ServerPort: "80",
|
||||
DBFile: "/data/scn_docker.sqlite3",
|
||||
DBJournal: "WAL",
|
||||
DBTimeout: 5 * time.Second,
|
||||
DBCheckForeignKeys: false,
|
||||
DBSingleConn: false,
|
||||
DBMaxOpenConns: 5,
|
||||
DBMaxIdleConns: 5,
|
||||
DBConnMaxLifetime: 60 * time.Minute,
|
||||
DBConnMaxIdleTime: 60 * time.Minute,
|
||||
RequestTimeout: 16 * time.Second,
|
||||
RequestMaxRetry: 8,
|
||||
RequestRetrySleep: 100 * time.Millisecond,
|
||||
ReturnRawErrors: true,
|
||||
DummyFirebase: true,
|
||||
FirebaseTokenURI: "",
|
||||
FirebaseProjectID: "",
|
||||
FirebasePrivKeyID: "",
|
||||
FirebaseClientMail: "",
|
||||
FirebasePrivateKey: "",
|
||||
DummyGoogleAPI: true,
|
||||
GoogleAPITokenURI: "",
|
||||
GoogleAPIPrivKeyID: "",
|
||||
GoogleAPIClientMail: "",
|
||||
GoogleAPIPrivateKey: "",
|
||||
GooglePackageName: "",
|
||||
GoogleProProductID: "",
|
||||
Cors: true,
|
||||
}
|
||||
}
|
||||
|
||||
var configDev = func() Config {
|
||||
return Config{
|
||||
Namespace: "develop",
|
||||
BaseURL: confEnv("SCN_URL"),
|
||||
GinDebug: true,
|
||||
LogLevel: zerolog.DebugLevel,
|
||||
ServerIP: "0.0.0.0",
|
||||
ServerPort: "80",
|
||||
DBFile: "/data/scn.sqlite3",
|
||||
DBJournal: "WAL",
|
||||
DBTimeout: 5 * time.Second,
|
||||
DBCheckForeignKeys: false,
|
||||
DBSingleConn: false,
|
||||
DBMaxOpenConns: 5,
|
||||
DBMaxIdleConns: 5,
|
||||
DBConnMaxLifetime: 60 * time.Minute,
|
||||
DBConnMaxIdleTime: 60 * time.Minute,
|
||||
RequestTimeout: 16 * time.Second,
|
||||
RequestMaxRetry: 8,
|
||||
RequestRetrySleep: 100 * time.Millisecond,
|
||||
ReturnRawErrors: true,
|
||||
DummyFirebase: false,
|
||||
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
|
||||
FirebaseProjectID: confEnv("SCN_FB_PROJECTID"),
|
||||
FirebasePrivKeyID: confEnv("SCN_FB_PRIVATEKEYID"),
|
||||
FirebaseClientMail: confEnv("SCN_FB_CLIENTEMAIL"),
|
||||
FirebasePrivateKey: confEnv("SCN_FB_PRIVATEKEY"),
|
||||
DummyGoogleAPI: false,
|
||||
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
|
||||
GoogleAPIPrivKeyID: confEnv("SCN_GOOG_PRIVATEKEYID"),
|
||||
GoogleAPIClientMail: confEnv("SCN_GOOG_CLIENTEMAIL"),
|
||||
GoogleAPIPrivateKey: confEnv("SCN_GOOG_PRIVATEKEY"),
|
||||
GooglePackageName: confEnv("SCN_GOOG_PACKAGENAME"),
|
||||
GoogleProProductID: confEnv("SCN_GOOG_PROPRODUCTID"),
|
||||
Cors: true,
|
||||
}
|
||||
}
|
||||
|
||||
var configStag = func() Config {
|
||||
return Config{
|
||||
Namespace: "staging",
|
||||
BaseURL: confEnv("SCN_URL"),
|
||||
GinDebug: true,
|
||||
LogLevel: zerolog.DebugLevel,
|
||||
ServerIP: "0.0.0.0",
|
||||
ServerPort: "80",
|
||||
DBFile: "/data/scn.sqlite3",
|
||||
DBJournal: "WAL",
|
||||
DBTimeout: 5 * time.Second,
|
||||
DBCheckForeignKeys: false,
|
||||
DBSingleConn: false,
|
||||
DBMaxOpenConns: 5,
|
||||
DBMaxIdleConns: 5,
|
||||
DBConnMaxLifetime: 60 * time.Minute,
|
||||
DBConnMaxIdleTime: 60 * time.Minute,
|
||||
RequestTimeout: 16 * time.Second,
|
||||
RequestMaxRetry: 8,
|
||||
RequestRetrySleep: 100 * time.Millisecond,
|
||||
ReturnRawErrors: true,
|
||||
DummyFirebase: false,
|
||||
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
|
||||
FirebaseProjectID: confEnv("SCN_FB_PROJECTID"),
|
||||
FirebasePrivKeyID: confEnv("SCN_FB_PRIVATEKEYID"),
|
||||
FirebaseClientMail: confEnv("SCN_FB_CLIENTEMAIL"),
|
||||
FirebasePrivateKey: confEnv("SCN_FB_PRIVATEKEY"),
|
||||
DummyGoogleAPI: false,
|
||||
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
|
||||
GoogleAPIPrivKeyID: confEnv("SCN_GOOG_PRIVATEKEYID"),
|
||||
GoogleAPIClientMail: confEnv("SCN_GOOG_CLIENTEMAIL"),
|
||||
GoogleAPIPrivateKey: confEnv("SCN_GOOG_PRIVATEKEY"),
|
||||
GooglePackageName: confEnv("SCN_GOOG_PACKAGENAME"),
|
||||
GoogleProProductID: confEnv("SCN_GOOG_PROPRODUCTID"),
|
||||
Cors: true,
|
||||
}
|
||||
}
|
||||
|
||||
var configProd = func() Config {
|
||||
return Config{
|
||||
Namespace: "production",
|
||||
BaseURL: confEnv("SCN_URL"),
|
||||
GinDebug: false,
|
||||
LogLevel: zerolog.InfoLevel,
|
||||
ServerIP: "0.0.0.0",
|
||||
ServerPort: "80",
|
||||
DBFile: "/data/scn.sqlite3",
|
||||
DBJournal: "WAL",
|
||||
DBTimeout: 5 * time.Second,
|
||||
DBCheckForeignKeys: false,
|
||||
DBSingleConn: false,
|
||||
DBMaxOpenConns: 5,
|
||||
DBMaxIdleConns: 5,
|
||||
DBConnMaxLifetime: 60 * time.Minute,
|
||||
DBConnMaxIdleTime: 60 * time.Minute,
|
||||
RequestTimeout: 16 * time.Second,
|
||||
RequestMaxRetry: 8,
|
||||
RequestRetrySleep: 100 * time.Millisecond,
|
||||
ReturnRawErrors: false,
|
||||
DummyFirebase: false,
|
||||
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
|
||||
FirebaseProjectID: confEnv("SCN_SCN_FB_PROJECTID"),
|
||||
FirebasePrivKeyID: confEnv("SCN_SCN_FB_PRIVATEKEYID"),
|
||||
FirebaseClientMail: confEnv("SCN_SCN_FB_CLIENTEMAIL"),
|
||||
FirebasePrivateKey: confEnv("SCN_SCN_FB_PRIVATEKEY"),
|
||||
DummyGoogleAPI: false,
|
||||
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
|
||||
GoogleAPIPrivKeyID: confEnv("SCN_SCN_GOOG_PRIVATEKEYID"),
|
||||
GoogleAPIClientMail: confEnv("SCN_SCN_GOOG_CLIENTEMAIL"),
|
||||
GoogleAPIPrivateKey: confEnv("SCN_SCN_GOOG_PRIVATEKEY"),
|
||||
GooglePackageName: confEnv("SCN_SCN_GOOG_PACKAGENAME"),
|
||||
GoogleProProductID: confEnv("SCN_SCN_GOOG_PROPRODUCTID"),
|
||||
Cors: true,
|
||||
}
|
||||
}
|
||||
|
||||
var allConfig = map[string]func() Config{
|
||||
"local-host": configLocHost,
|
||||
"local-docker": configLocDocker,
|
||||
"develop": configDev,
|
||||
"staging": configStag,
|
||||
"production": configProd,
|
||||
}
|
||||
|
||||
func getConfig(ns string) (Config, bool) {
|
||||
if ns == "" {
|
||||
ns = "local-host"
|
||||
}
|
||||
if cfn, ok := allConfig[ns]; ok {
|
||||
c := cfn()
|
||||
err := confext.ApplyEnvOverrides(&c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
func confEnv(key string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
} else {
|
||||
log.Fatal().Msg(fmt.Sprintf("Missing required environment variable '%s'", key))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
ns := os.Getenv("SCN_NAMESPACE")
|
||||
|
||||
cfg, ok := getConfig(ns)
|
||||
if !ok {
|
||||
log.Fatal().Str("ns", ns).Msg("Unknown config-namespace")
|
||||
}
|
||||
|
||||
Conf = cfg
|
||||
}
|
239
scnserver/db/channels.go
Normal file
239
scnserver/db/channels.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"database/sql"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (db *Database) GetChannelByName(ctx TxContext, userid models.UserID, chanName string) (*models.Channel, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM channels WHERE owner_user_id = :uid AND name = :nam LIMIT 1", sq.PP{
|
||||
"uid": userid,
|
||||
"nam": chanName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel, err := models.DecodeChannel(rows)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
func (db *Database) GetChannelByNameAndSendKey(ctx TxContext, chanName string, sendKey string) (*models.Channel, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM channels WHERE name = :chan_name OR send_key = :send_key LIMIT 1", sq.PP{
|
||||
"chan_name": chanName,
|
||||
"send_key": sendKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel, err := models.DecodeChannel(rows)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
func (db *Database) CreateChannel(ctx TxContext, userid models.UserID, name string, subscribeKey string, sendKey string) (models.Channel, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
res, err := tx.Exec(ctx, "INSERT INTO channels (owner_user_id, name, subscribe_key, send_key, timestamp_created) VALUES (:ouid, :nam, :subkey, :sendkey, :ts)", sq.PP{
|
||||
"ouid": userid,
|
||||
"nam": name,
|
||||
"subkey": subscribeKey,
|
||||
"sendkey": sendKey,
|
||||
"ts": time2DB(now),
|
||||
})
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
liid, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
return models.Channel{
|
||||
ChannelID: models.ChannelID(liid),
|
||||
OwnerUserID: userid,
|
||||
Name: name,
|
||||
SubscribeKey: subscribeKey,
|
||||
SendKey: sendKey,
|
||||
TimestampCreated: now,
|
||||
TimestampLastSent: nil,
|
||||
MessagesSent: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Database) ListChannelsByOwner(ctx TxContext, userid models.UserID) ([]models.Channel, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM channels WHERE owner_user_id = :ouid", sq.PP{"ouid": userid})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := models.DecodeChannels(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (db *Database) ListChannelsBySubscriber(ctx TxContext, userid models.UserID, confirmed bool) ([]models.Channel, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
confCond := ""
|
||||
if confirmed {
|
||||
confCond = " AND sub.confirmed = 1"
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM channels LEFT JOIN subscriptions sub on channels.channel_id = sub.channel_id WHERE sub.subscriber_user_id = :suid "+confCond, sq.PP{
|
||||
"suid": userid,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := models.DecodeChannels(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (db *Database) ListChannelsByAccess(ctx TxContext, userid models.UserID, confirmed bool) ([]models.Channel, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
confCond := "OR sub.subscriber_user_id = ?"
|
||||
if confirmed {
|
||||
confCond = "OR (sub.subscriber_user_id = ? AND sub.confirmed = 1)"
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM channels LEFT JOIN subscriptions sub on channels.channel_id = sub.channel_id WHERE owner_user_id = :ouid "+confCond, sq.PP{
|
||||
"ouid": userid,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := models.DecodeChannels(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (db *Database) GetChannel(ctx TxContext, userid models.UserID, channelid models.ChannelID) (models.Channel, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM channels WHERE owner_user_id = :ouid AND channel_id = :cid LIMIT 1", sq.PP{
|
||||
"ouid": userid,
|
||||
"cid": channelid,
|
||||
})
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
client, err := models.DecodeChannel(rows)
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (db *Database) IncChannelMessageCounter(ctx TxContext, channel models.Channel) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE channels SET messages_sent = :ctr, timestamp_lastsent = :ts WHERE channel_id = :cid", sq.PP{
|
||||
"ctr": channel.MessagesSent + 1,
|
||||
"cid": time2DB(time.Now()),
|
||||
"ts": channel.ChannelID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateChannelSendKey(ctx TxContext, channelid models.ChannelID, newkey string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE channels SET send_key = :key WHERE channel_id = :cid", sq.PP{
|
||||
"key": newkey,
|
||||
"cid": channelid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateChannelSubscribeKey(ctx TxContext, channelid models.ChannelID, newkey string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE channels SET subscribe_key = :key WHERE channel_id = :cid", sq.PP{
|
||||
"key": newkey,
|
||||
"cid": channelid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
127
scnserver/db/clients.go
Normal file
127
scnserver/db/clients.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (db *Database) CreateClient(ctx TxContext, userid models.UserID, 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.Exec(ctx, "INSERT INTO clients (user_id, type, fcm_token, timestamp_created, agent_model, agent_version) VALUES (:uid, :typ, :fcm, :ts, :am, :av)", sq.PP{
|
||||
"uid": userid,
|
||||
"typ": string(ctype),
|
||||
"fcm": fcmToken,
|
||||
"ts": time2DB(now),
|
||||
"am": agentModel,
|
||||
"av": agentVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
|
||||
liid, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
|
||||
return models.Client{
|
||||
ClientID: models.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.Exec(ctx, "DELETE FROM clients WHERE fcm_token = :fcm", sq.PP{"fcm": fcmtoken})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) ListClients(ctx TxContext, userid models.UserID) ([]models.Client, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM clients WHERE user_id = :uid", sq.PP{"uid": userid})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := models.DecodeClients(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (db *Database) GetClient(ctx TxContext, userid models.UserID, clientid models.ClientID) (models.Client, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM clients WHERE user_id = :uid AND client_id = :cid LIMIT 1", sq.PP{
|
||||
"uid": userid,
|
||||
"cid": clientid,
|
||||
})
|
||||
if err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
|
||||
client, err := models.DecodeClient(rows)
|
||||
if err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (db *Database) DeleteClient(ctx TxContext, clientid models.ClientID) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE client_id = :cid", sq.PP{"cid": clientid})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) DeleteClientsByFCM(ctx TxContext, fcmtoken string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE fcm_token = :fcm", sq.PP{"fcm": fcmtoken})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
15
scnserver/db/context.go
Normal file
15
scnserver/db/context.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TxContext interface {
|
||||
Deadline() (deadline time.Time, ok bool)
|
||||
Done() <-chan struct{}
|
||||
Err() error
|
||||
Value(key any) any
|
||||
|
||||
GetOrCreateTransaction(db *Database) (sq.Tx, error)
|
||||
}
|
145
scnserver/db/cursortoken/token.go
Normal file
145
scnserver/db/cursortoken/token.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package cursortoken
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
CTMStart = "START"
|
||||
CTMNormal = "NORMAL"
|
||||
CTMEnd = "END"
|
||||
)
|
||||
|
||||
type CursorToken struct {
|
||||
Mode Mode
|
||||
Timestamp int64
|
||||
Id int64
|
||||
Direction string
|
||||
FilterHash string
|
||||
}
|
||||
|
||||
type cursorTokenSerialize struct {
|
||||
Timestamp *int64 `json:"ts,omitempty"`
|
||||
Id *int64 `json:"id,omitempty"`
|
||||
Direction *string `json:"dir,omitempty"`
|
||||
FilterHash *string `json:"f,omitempty"`
|
||||
}
|
||||
|
||||
func Start() CursorToken {
|
||||
return CursorToken{
|
||||
Mode: CTMStart,
|
||||
Timestamp: 0,
|
||||
Id: 0,
|
||||
Direction: "",
|
||||
FilterHash: "",
|
||||
}
|
||||
}
|
||||
|
||||
func End() CursorToken {
|
||||
return CursorToken{
|
||||
Mode: CTMEnd,
|
||||
Timestamp: 0,
|
||||
Id: 0,
|
||||
Direction: "",
|
||||
FilterHash: "",
|
||||
}
|
||||
}
|
||||
|
||||
func Normal(ts time.Time, id int64, dir string, filter string) CursorToken {
|
||||
return CursorToken{
|
||||
Mode: CTMNormal,
|
||||
Timestamp: ts.UnixMilli(),
|
||||
Id: id,
|
||||
Direction: dir,
|
||||
FilterHash: filter,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CursorToken) Token() string {
|
||||
if c.Mode == CTMStart {
|
||||
return "@start"
|
||||
}
|
||||
if c.Mode == CTMEnd {
|
||||
return "@end"
|
||||
}
|
||||
|
||||
// We kinda manually implement omitempty for the CursorToken here
|
||||
// because omitempty does not work for time.Time and otherwise we would always
|
||||
// get weird time values when decoding a token that initially didn't have an Timestamp set
|
||||
// For this usecase we treat Unix=0 as an empty timestamp
|
||||
|
||||
sertok := cursorTokenSerialize{}
|
||||
|
||||
if c.Id != 0 {
|
||||
sertok.Id = &c.Id
|
||||
}
|
||||
|
||||
if c.Timestamp != 0 {
|
||||
sertok.Timestamp = &c.Timestamp
|
||||
}
|
||||
|
||||
if c.Direction != "" {
|
||||
sertok.Direction = &c.Direction
|
||||
}
|
||||
|
||||
if c.FilterHash != "" {
|
||||
sertok.FilterHash = &c.FilterHash
|
||||
}
|
||||
|
||||
body, err := json.Marshal(sertok)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return "tok_" + base32.StdEncoding.EncodeToString(body)
|
||||
}
|
||||
|
||||
func Decode(tok string) (CursorToken, error) {
|
||||
if tok == "" {
|
||||
return Start(), nil
|
||||
}
|
||||
if strings.ToLower(tok) == "@start" {
|
||||
return Start(), nil
|
||||
}
|
||||
if strings.ToLower(tok) == "@end" {
|
||||
return End(), nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(tok, "tok_") {
|
||||
return CursorToken{}, errors.New("could not decode token, missing prefix")
|
||||
}
|
||||
|
||||
body, err := base32.StdEncoding.DecodeString(tok[len("tok_"):])
|
||||
if err != nil {
|
||||
return CursorToken{}, err
|
||||
}
|
||||
|
||||
var tokenDeserialize cursorTokenSerialize
|
||||
err = json.Unmarshal(body, &tokenDeserialize)
|
||||
if err != nil {
|
||||
return CursorToken{}, err
|
||||
}
|
||||
|
||||
token := CursorToken{Mode: CTMNormal}
|
||||
|
||||
if tokenDeserialize.Timestamp != nil {
|
||||
token.Timestamp = *tokenDeserialize.Timestamp
|
||||
}
|
||||
if tokenDeserialize.Id != nil {
|
||||
token.Id = *tokenDeserialize.Id
|
||||
}
|
||||
if tokenDeserialize.Direction != nil {
|
||||
token.Direction = *tokenDeserialize.Direction
|
||||
}
|
||||
if tokenDeserialize.FilterHash != nil {
|
||||
token.FilterHash = *tokenDeserialize.FilterHash
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
117
scnserver/db/database.go
Normal file
117
scnserver/db/database.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
server "blackforestbytes.com/simplecloudnotifier"
|
||||
"blackforestbytes.com/simplecloudnotifier/db/schema"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
db sq.DB
|
||||
}
|
||||
|
||||
func NewDatabase(conf server.Config) (*Database, error) {
|
||||
url := fmt.Sprintf("file:%s?_journal=%s&_timeout=%d&_fk=%s", conf.DBFile, conf.DBJournal, conf.DBTimeout.Milliseconds(), langext.FormatBool(conf.DBCheckForeignKeys, "true", "false"))
|
||||
|
||||
xdb, err := sqlx.Open("sqlite3", url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if conf.DBSingleConn {
|
||||
xdb.SetMaxOpenConns(1)
|
||||
} else {
|
||||
xdb.SetMaxOpenConns(5)
|
||||
xdb.SetMaxIdleConns(5)
|
||||
xdb.SetConnMaxLifetime(60 * time.Minute)
|
||||
xdb.SetConnMaxIdleTime(60 * time.Minute)
|
||||
}
|
||||
|
||||
qqdb := sq.NewDB(xdb)
|
||||
|
||||
scndb := &Database{qqdb}
|
||||
|
||||
qqdb.SetListener(scndb)
|
||||
|
||||
return scndb, nil
|
||||
}
|
||||
|
||||
func (db *Database) Migrate(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
|
||||
defer cancel()
|
||||
|
||||
currschema, err := db.ReadSchema(ctx)
|
||||
if currschema == 0 {
|
||||
|
||||
_, err = db.db.Exec(ctx, schema.Schema3, sq.PP{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.WriteMetaInt(ctx, "schema", 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
} else if currschema == 1 {
|
||||
return errors.New("cannot autom. upgrade schema 1")
|
||||
} else if currschema == 2 {
|
||||
return errors.New("cannot autom. upgrade schema 2") //TODO
|
||||
} else if currschema == 3 {
|
||||
return nil // current
|
||||
} else {
|
||||
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (db *Database) Ping(ctx context.Context) error {
|
||||
return db.db.Ping(ctx)
|
||||
}
|
||||
|
||||
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
|
||||
return db.db.BeginTransaction(ctx, sql.LevelDefault)
|
||||
}
|
||||
|
||||
func (db *Database) OnQuery(txID *uint16, sql string, _ *sq.PP) {
|
||||
if txID == nil {
|
||||
log.Debug().Msg(fmt.Sprintf("[SQL-QUERY] %s", fmtSQLPrint(sql)))
|
||||
} else {
|
||||
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-QUERY] %s", *txID, fmtSQLPrint(sql)))
|
||||
}
|
||||
}
|
||||
|
||||
func (db *Database) OnExec(txID *uint16, sql string, _ *sq.PP) {
|
||||
if txID == nil {
|
||||
log.Debug().Msg(fmt.Sprintf("[SQL-EXEC] %s", fmtSQLPrint(sql)))
|
||||
} else {
|
||||
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-EXEC] %s", *txID, fmtSQLPrint(sql)))
|
||||
}
|
||||
}
|
||||
|
||||
func (db *Database) OnPing() {
|
||||
log.Debug().Msg("[SQL-PING]")
|
||||
}
|
||||
|
||||
func (db *Database) OnTxBegin(txid uint16) {
|
||||
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-START]", txid))
|
||||
}
|
||||
|
||||
func (db *Database) OnTxCommit(txid uint16) {
|
||||
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-COMMIT]", txid))
|
||||
}
|
||||
|
||||
func (db *Database) OnTxRollback(txid uint16) {
|
||||
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-ROLLBACK]", txid))
|
||||
}
|
187
scnserver/db/deliveries.go
Normal file
187
scnserver/db/deliveries.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (db *Database) CreateRetryDelivery(ctx TxContext, client models.Client, msg models.Message) (models.Delivery, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.Delivery{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
next := scn.NextDeliveryTimestamp(now)
|
||||
|
||||
res, err := tx.Exec(ctx, "INSERT INTO deliveries (scn_message_id, receiver_user_id, receiver_client_id, timestamp_created, timestamp_finalized, status, fcm_message_id, next_delivery) VALUES (:mid, :ruid, :rcid, :tsc, :tsf, :stat, :fcm, :next)", sq.PP{
|
||||
"mid": msg.SCNMessageID,
|
||||
"ruid": client.UserID,
|
||||
"rcid": client.ClientID,
|
||||
"tsc": time2DB(now),
|
||||
"tsf": nil,
|
||||
"stat": models.DeliveryStatusRetry,
|
||||
"fcm": nil,
|
||||
"next": time2DB(next),
|
||||
})
|
||||
if err != nil {
|
||||
return models.Delivery{}, err
|
||||
}
|
||||
|
||||
liid, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return models.Delivery{}, err
|
||||
}
|
||||
|
||||
return models.Delivery{
|
||||
DeliveryID: models.DeliveryID(liid),
|
||||
SCNMessageID: msg.SCNMessageID,
|
||||
ReceiverUserID: client.UserID,
|
||||
ReceiverClientID: client.ClientID,
|
||||
TimestampCreated: now,
|
||||
TimestampFinalized: nil,
|
||||
Status: models.DeliveryStatusRetry,
|
||||
RetryCount: 0,
|
||||
NextDelivery: langext.Ptr(next),
|
||||
FCMMessageID: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Database) CreateSuccessDelivery(ctx TxContext, client models.Client, msg models.Message, fcmDelivID string) (models.Delivery, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.Delivery{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
res, err := tx.Exec(ctx, "INSERT INTO deliveries (scn_message_id, receiver_user_id, receiver_client_id, timestamp_created, timestamp_finalized, status, fcm_message_id, next_delivery) VALUES (:mid, :ruid, :rcid, :tsc, :tsf, :stat, :fcm, :next)", sq.PP{
|
||||
"mid": msg.SCNMessageID,
|
||||
"ruid": client.UserID,
|
||||
"rcid": client.ClientID,
|
||||
"tsc": time2DB(now),
|
||||
"tsf": time2DB(now),
|
||||
"stat": models.DeliveryStatusSuccess,
|
||||
"fcm": fcmDelivID,
|
||||
"next": nil,
|
||||
})
|
||||
if err != nil {
|
||||
return models.Delivery{}, err
|
||||
}
|
||||
|
||||
liid, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return models.Delivery{}, err
|
||||
}
|
||||
|
||||
return models.Delivery{
|
||||
DeliveryID: models.DeliveryID(liid),
|
||||
SCNMessageID: msg.SCNMessageID,
|
||||
ReceiverUserID: client.UserID,
|
||||
ReceiverClientID: client.ClientID,
|
||||
TimestampCreated: now,
|
||||
TimestampFinalized: langext.Ptr(now),
|
||||
Status: models.DeliveryStatusSuccess,
|
||||
RetryCount: 0,
|
||||
NextDelivery: nil,
|
||||
FCMMessageID: langext.Ptr(fcmDelivID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Database) ListRetrieableDeliveries(ctx TxContext, pageSize int) ([]models.Delivery, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM deliveries WHERE status = 'RETRY' AND next_delivery < :next LIMIT :lim", sq.PP{
|
||||
"next": time2DB(time.Now()),
|
||||
"lim": pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := models.DecodeDeliveries(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (db *Database) SetDeliverySuccess(ctx TxContext, delivery models.Delivery, fcmDelivID string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'SUCCESS', next_delivery = NULL, retry_count = :rc, timestamp_finalized = :ts, fcm_message_id = :fcm WHERE delivery_id = :did", sq.PP{
|
||||
"rc": delivery.RetryCount + 1,
|
||||
"ts": time2DB(time.Now()),
|
||||
"fcm": fcmDelivID,
|
||||
"did": delivery.DeliveryID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) SetDeliveryFailed(ctx TxContext, delivery models.Delivery) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'FAILED', next_delivery = NULL, retry_count = :rc, timestamp_finalized = :ts WHERE delivery_id = :did",
|
||||
sq.PP{
|
||||
"rc": delivery.RetryCount + 1,
|
||||
"ts": time2DB(time.Now()),
|
||||
"did": delivery.DeliveryID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) SetDeliveryRetry(ctx TxContext, delivery models.Delivery) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'RETRY', next_delivery = :next, retry_count = :rc WHERE delivery_id = :did", sq.PP{
|
||||
"next": scn.NextDeliveryTimestamp(time.Now()),
|
||||
"rc": delivery.RetryCount + 1,
|
||||
"did": delivery.DeliveryID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) CancelPendingDeliveries(ctx TxContext, scnMessageID models.SCNMessageID) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'FAILED', next_delivery = NULL, timestamp_finalized = :ts WHERE scn_message_id = :mid AND status = 'RETRY'", sq.PP{
|
||||
"ts": time.Now(),
|
||||
"mid": scnMessageID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
162
scnserver/db/messages.go
Normal file
162
scnserver/db/messages.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/db/cursortoken"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"database/sql"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (db *Database) GetMessageByUserMessageID(ctx TxContext, usrMsgId string) (*models.Message, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM messages WHERE usr_message_id = :umid LIMIT 1", sq.PP{"umid": usrMsgId})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg, err := models.DecodeMessage(rows)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func (db *Database) GetMessage(ctx TxContext, scnMessageID models.SCNMessageID, allowDeleted bool) (models.Message, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.Message{}, err
|
||||
}
|
||||
|
||||
var sqlcmd string
|
||||
if allowDeleted {
|
||||
sqlcmd = "SELECT * FROM messages WHERE scn_message_id = :mid LIMIT 1"
|
||||
} else {
|
||||
sqlcmd = "SELECT * FROM messages WHERE scn_message_id = :mid AND deleted=0 LIMIT 1"
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, sqlcmd, sq.PP{"mid": scnMessageID})
|
||||
if err != nil {
|
||||
return models.Message{}, err
|
||||
}
|
||||
|
||||
msg, err := models.DecodeMessage(rows)
|
||||
if err != nil {
|
||||
return models.Message{}, err
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (db *Database) CreateMessage(ctx TxContext, senderUserID models.UserID, channel models.Channel, timestampSend *time.Time, title string, content *string, priority int, userMsgId *string, senderIP string, senderName *string) (models.Message, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.Message{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
res, err := tx.Exec(ctx, "INSERT INTO messages (sender_user_id, owner_user_id, channel_name, channel_id, timestamp_real, timestamp_client, title, content, priority, usr_message_id, sender_ip, sender_name) VALUES (:suid, :ouid, :cnam, :cid, :tsr, :tsc, :tit, :cnt, :prio, :umid, :ip, :snam)", sq.PP{
|
||||
"suid": senderUserID,
|
||||
"ouid": channel.OwnerUserID,
|
||||
"cnam": channel.Name,
|
||||
"cid": channel.ChannelID,
|
||||
"tsr": time2DB(now),
|
||||
"tsc": time2DBOpt(timestampSend),
|
||||
"tit": title,
|
||||
"cnt": content,
|
||||
"prio": priority,
|
||||
"umid": userMsgId,
|
||||
"ip": senderIP,
|
||||
"snam": senderName,
|
||||
})
|
||||
if err != nil {
|
||||
return models.Message{}, err
|
||||
}
|
||||
|
||||
liid, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return models.Message{}, err
|
||||
}
|
||||
|
||||
return models.Message{
|
||||
SCNMessageID: models.SCNMessageID(liid),
|
||||
SenderUserID: senderUserID,
|
||||
OwnerUserID: channel.OwnerUserID,
|
||||
ChannelName: channel.Name,
|
||||
ChannelID: channel.ChannelID,
|
||||
SenderIP: senderIP,
|
||||
SenderName: senderName,
|
||||
TimestampReal: now,
|
||||
TimestampClient: timestampSend,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Priority: priority,
|
||||
UserMessageID: userMsgId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Database) DeleteMessage(ctx TxContext, scnMessageID models.SCNMessageID) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE messages SET deleted=1 WHERE scn_message_id = :mid AND deleted=0", sq.PP{"mid": scnMessageID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) ListMessages(ctx TxContext, filter models.MessageFilter, pageSize int, inTok cursortoken.CursorToken) ([]models.Message, cursortoken.CursorToken, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, cursortoken.CursorToken{}, err
|
||||
}
|
||||
|
||||
if inTok.Mode == cursortoken.CTMEnd {
|
||||
return make([]models.Message, 0), cursortoken.End(), nil
|
||||
}
|
||||
|
||||
pageCond := "1=1"
|
||||
if inTok.Mode == cursortoken.CTMNormal {
|
||||
pageCond = "timestamp_real < :tokts OR (timestamp_real = :tokts AND scn_message_id < :tokid )"
|
||||
}
|
||||
|
||||
filterCond, filterJoin, prepParams, err := filter.SQL()
|
||||
|
||||
orderClause := "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC LIMIT :lim"
|
||||
|
||||
sqlQuery := "SELECT " + "messages.*" + " FROM messages " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause
|
||||
|
||||
prepParams["lim"] = pageSize + 1
|
||||
prepParams["tokts"] = inTok.Timestamp
|
||||
prepParams["tokid"] = inTok.Id
|
||||
|
||||
rows, err := tx.Query(ctx, sqlQuery, prepParams)
|
||||
if err != nil {
|
||||
return nil, cursortoken.CursorToken{}, err
|
||||
}
|
||||
|
||||
data, err := models.DecodeMessages(rows)
|
||||
if err != nil {
|
||||
return nil, cursortoken.CursorToken{}, err
|
||||
}
|
||||
|
||||
if len(data) <= pageSize {
|
||||
return data, cursortoken.End(), nil
|
||||
} else {
|
||||
outToken := cursortoken.Normal(data[pageSize-1].Timestamp(), data[pageSize-1].SCNMessageID.IntID(), "DESC", filter.Hash())
|
||||
return data[0:pageSize], outToken, nil
|
||||
}
|
||||
}
|
242
scnserver/db/meta.go
Normal file
242
scnserver/db/meta.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
)
|
||||
|
||||
func (db *Database) ReadSchema(ctx context.Context) (retval int, reterr error) {
|
||||
|
||||
r1, err := db.db.Query(ctx, "SELECT name FROM sqlite_master WHERE type = :typ AND name = :name", sq.PP{"typ": "table", "name": "meta"})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
err = r1.Close()
|
||||
if err != nil {
|
||||
// overwrite return values
|
||||
retval = 0
|
||||
reterr = err
|
||||
}
|
||||
}()
|
||||
|
||||
if !r1.Next() {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
err = r1.Close()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
r2, err := db.db.Query(ctx, "SELECT value_int FROM meta WHERE meta_key = :key", sq.PP{"key": "schema"})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
// overwrite return values
|
||||
retval = 0
|
||||
reterr = err
|
||||
}
|
||||
}()
|
||||
|
||||
if !r2.Next() {
|
||||
return 0, errors.New("no schema entry in meta table")
|
||||
}
|
||||
|
||||
var dbschema int
|
||||
err = r2.Scan(&dbschema)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return dbschema, nil
|
||||
}
|
||||
|
||||
func (db *Database) WriteMetaString(ctx context.Context, key string, value string) error {
|
||||
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_txt) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_txt = :val", sq.PP{
|
||||
"key": key,
|
||||
"val": value,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) WriteMetaInt(ctx context.Context, key string, value int64) error {
|
||||
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_int) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_int = :val", sq.PP{
|
||||
"key": key,
|
||||
"val": value,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) WriteMetaReal(ctx context.Context, key string, value float64) error {
|
||||
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_real) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_real = :val", sq.PP{
|
||||
"key": key,
|
||||
"val": value,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) WriteMetaBlob(ctx context.Context, key string, value []byte) error {
|
||||
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_blob) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_blob = :val", sq.PP{
|
||||
"key": key,
|
||||
"val": value,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) ReadMetaString(ctx context.Context, key string) (retval *string, reterr error) {
|
||||
r2, err := db.db.Query(ctx, "SELECT value_txt FROM meta WHERE meta_key = :key", sq.PP{"key": key})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
// overwrite return values
|
||||
retval = nil
|
||||
reterr = err
|
||||
}
|
||||
}()
|
||||
if !r2.Next() {
|
||||
return nil, errors.New("no matching entry in meta table")
|
||||
}
|
||||
|
||||
var value string
|
||||
err = r2.Scan(&value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return langext.Ptr(value), nil
|
||||
}
|
||||
|
||||
func (db *Database) ReadMetaInt(ctx context.Context, key string) (retval *int64, reterr error) {
|
||||
r2, err := db.db.Query(ctx, "SELECT value_int FROM meta WHERE meta_key = :key", sq.PP{"key": key})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
// overwrite return values
|
||||
retval = nil
|
||||
reterr = err
|
||||
}
|
||||
}()
|
||||
|
||||
if !r2.Next() {
|
||||
return nil, errors.New("no matching entry in meta table")
|
||||
}
|
||||
|
||||
var value int64
|
||||
err = r2.Scan(&value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return langext.Ptr(value), nil
|
||||
}
|
||||
|
||||
func (db *Database) ReadMetaReal(ctx context.Context, key string) (retval *float64, reterr error) {
|
||||
r2, err := db.db.Query(ctx, "SELECT value_real FROM meta WHERE meta_key = :key", sq.PP{"key": key})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
// overwrite return values
|
||||
retval = nil
|
||||
reterr = err
|
||||
}
|
||||
}()
|
||||
|
||||
if !r2.Next() {
|
||||
return nil, errors.New("no matching entry in meta table")
|
||||
}
|
||||
|
||||
var value float64
|
||||
err = r2.Scan(&value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return langext.Ptr(value), nil
|
||||
}
|
||||
|
||||
func (db *Database) ReadMetaBlob(ctx context.Context, key string) (retval *[]byte, reterr error) {
|
||||
r2, err := db.db.Query(ctx, "SELECT value_blob FROM meta WHERE meta_key = :key", sq.PP{"key": key})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
// overwrite return values
|
||||
retval = nil
|
||||
reterr = err
|
||||
}
|
||||
}()
|
||||
|
||||
if !r2.Next() {
|
||||
return nil, errors.New("no matching entry in meta table")
|
||||
}
|
||||
|
||||
var value []byte
|
||||
err = r2.Scan(&value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = r2.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return langext.Ptr(value), nil
|
||||
}
|
||||
|
||||
func (db *Database) DeleteMeta(ctx context.Context, key string) error {
|
||||
_, err := db.db.Exec(ctx, "DELETE FROM meta WHERE meta_key = :key", sq.PP{"key": key})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
12
scnserver/db/schema/assets.go
Normal file
12
scnserver/db/schema/assets.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package schema
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed schema_1.ddl
|
||||
var Schema1 string
|
||||
|
||||
//go:embed schema_2.ddl
|
||||
var Schema2 string
|
||||
|
||||
//go:embed schema_3.ddl
|
||||
var Schema3 string
|
38
scnserver/db/schema/schema_1.ddl
Normal file
38
scnserver/db/schema/schema_1.ddl
Normal file
@@ -0,0 +1,38 @@
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
CREATE TABLE `users`
|
||||
(
|
||||
`user_id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_key` VARCHAR(64) NOT NULL,
|
||||
`fcm_token` VARCHAR(256) NULL DEFAULT NULL,
|
||||
`messages_sent` INT(11) NOT NULL DEFAULT '0',
|
||||
`timestamp_created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`timestamp_accessed` DATETIME NULL DEFAULT NULL,
|
||||
|
||||
`quota_today` INT(11) NOT NULL DEFAULT '0',
|
||||
`quota_day` DATE NULL DEFAULT NULL,
|
||||
|
||||
`is_pro` BIT NOT NULL DEFAULT 0,
|
||||
`pro_token` VARCHAR(256) NULL DEFAULT NULL,
|
||||
|
||||
PRIMARY KEY (`user_id`)
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS `messages`;
|
||||
CREATE TABLE `messages`
|
||||
(
|
||||
`scn_message_id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`sender_user_id` INT(11) NOT NULL,
|
||||
|
||||
`timestamp_real` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`ack` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
|
||||
`title` VARCHAR(256) NOT NULL,
|
||||
`content` LONGTEXT NULL,
|
||||
`priority` INT(11) NOT NULL,
|
||||
`sendtime` BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
`fcm_message_id` VARCHAR(256) NULL,
|
||||
`usr_message_id` VARCHAR(256) NULL,
|
||||
|
||||
PRIMARY KEY (`scn_message_id`)
|
||||
);
|
47
scnserver/db/schema/schema_2.ddl
Normal file
47
scnserver/db/schema/schema_2.ddl
Normal file
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE `users`
|
||||
(
|
||||
`user_id` INTEGER AUTO_INCREMENT,
|
||||
`user_key` TEXT NOT NULL,
|
||||
`fcm_token` TEXT NULL DEFAULT NULL,
|
||||
`messages_sent` INTEGER NOT NULL DEFAULT '0',
|
||||
`timestamp_created` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`timestamp_accessed` TEXT NULL DEFAULT NULL,
|
||||
|
||||
`quota_today` INTEGER NOT NULL DEFAULT '0',
|
||||
`quota_day` TEXT NULL DEFAULT NULL,
|
||||
|
||||
`is_pro` INTEGER NOT NULL DEFAULT 0,
|
||||
`pro_token` TEXT NULL DEFAULT NULL,
|
||||
|
||||
PRIMARY KEY (`user_id`)
|
||||
);
|
||||
|
||||
CREATE TABLE `messages`
|
||||
(
|
||||
`scn_message_id` INTEGER AUTO_INCREMENT,
|
||||
`sender_user_id` INTEGER NOT NULL,
|
||||
|
||||
`timestamp_real` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`ack` INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
`title` TEXT NOT NULL,
|
||||
`content` TEXT NULL,
|
||||
`priority` INTEGER NOT NULL,
|
||||
`sendtime` INTEGER NOT NULL,
|
||||
|
||||
`fcm_message_id` TEXT NULL,
|
||||
`usr_message_id` TEXT NULL,
|
||||
|
||||
PRIMARY KEY (`scn_message_id`)
|
||||
);
|
||||
|
||||
CREATE TABLE `meta`
|
||||
(
|
||||
`key` TEXT NOT NULL,
|
||||
`value_int` INTEGER NULL,
|
||||
`value_txt` TEXT NULL,
|
||||
|
||||
PRIMARY KEY (`key`)
|
||||
);
|
||||
|
||||
INSERT INTO meta (key, value_int) VALUES ('schema', 2)
|
170
scnserver/db/schema/schema_3.ddl
Normal file
170
scnserver/db/schema/schema_3.ddl
Normal file
@@ -0,0 +1,170 @@
|
||||
CREATE TABLE users
|
||||
(
|
||||
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
username TEXT NULL DEFAULT NULL,
|
||||
|
||||
send_key TEXT NOT NULL,
|
||||
read_key TEXT NOT NULL,
|
||||
admin_key TEXT NOT 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',
|
||||
|
||||
quota_used INTEGER NOT NULL DEFAULT '0',
|
||||
quota_used_day TEXT NULL DEFAULT NULL,
|
||||
|
||||
is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0,
|
||||
pro_token TEXT NULL DEFAULT NULL
|
||||
) STRICT;
|
||||
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,
|
||||
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT CHECK(type IN ('ANDROID', 'IOS')) NOT NULL,
|
||||
fcm_token TEXT NULL,
|
||||
|
||||
timestamp_created INTEGER NOT NULL,
|
||||
|
||||
agent_model TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX "idx_clients_userid" ON clients (user_id);
|
||||
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token);
|
||||
|
||||
|
||||
CREATE TABLE channels
|
||||
(
|
||||
channel_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
owner_user_id INTEGER NOT NULL,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
|
||||
subscribe_key TEXT NOT NULL,
|
||||
send_key TEXT NOT NULL,
|
||||
|
||||
timestamp_created INTEGER NOT NULL,
|
||||
timestamp_lastsent INTEGER NULL DEFAULT NULL,
|
||||
|
||||
messages_sent INTEGER NOT NULL DEFAULT '0'
|
||||
) STRICT;
|
||||
CREATE UNIQUE INDEX "idx_channels_identity" ON channels (owner_user_id, name);
|
||||
|
||||
CREATE TABLE subscriptions
|
||||
(
|
||||
subscription_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
subscriber_user_id INTEGER NOT NULL,
|
||||
channel_owner_user_id INTEGER NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
channel_id INTEGER NOT NULL,
|
||||
|
||||
timestamp_created INTEGER NOT NULL,
|
||||
|
||||
confirmed INTEGER CHECK(confirmed IN (0, 1)) NOT NULL
|
||||
) STRICT;
|
||||
CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_name);
|
||||
|
||||
|
||||
CREATE TABLE messages
|
||||
(
|
||||
scn_message_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender_user_id INTEGER NOT NULL,
|
||||
owner_user_id INTEGER NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
channel_id INTEGER NOT NULL,
|
||||
sender_ip TEXT NOT NULL,
|
||||
sender_name 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,
|
||||
|
||||
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0'
|
||||
) STRICT;
|
||||
CREATE INDEX "idx_messages_owner_channel" ON messages (owner_user_id, channel_name COLLATE BINARY);
|
||||
CREATE INDEX "idx_messages_owner_channel_nc" ON messages (owner_user_id, channel_name COLLATE NOCASE);
|
||||
CREATE INDEX "idx_messages_channel" ON messages (channel_name COLLATE BINARY);
|
||||
CREATE INDEX "idx_messages_channel_nc" ON messages (channel_name COLLATE NOCASE);
|
||||
CREATE UNIQUE INDEX "idx_messages_idempotency" ON messages (owner_user_id, usr_message_id COLLATE BINARY);
|
||||
CREATE INDEX "idx_messages_senderip" ON messages (sender_ip COLLATE BINARY);
|
||||
CREATE INDEX "idx_messages_sendername" ON messages (sender_name COLLATE BINARY);
|
||||
CREATE INDEX "idx_messages_sendername_nc" ON messages (sender_name COLLATE NOCASE);
|
||||
CREATE INDEX "idx_messages_title" ON messages (title COLLATE BINARY);
|
||||
CREATE INDEX "idx_messages_title_nc" ON messages (title COLLATE NOCASE);
|
||||
CREATE INDEX "idx_messages_deleted" ON messages (deleted);
|
||||
|
||||
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5
|
||||
(
|
||||
channel_name,
|
||||
sender_name,
|
||||
title,
|
||||
content,
|
||||
|
||||
tokenize = unicode61,
|
||||
content = 'messages',
|
||||
content_rowid = 'scn_message_id'
|
||||
);
|
||||
|
||||
CREATE TRIGGER fts_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts (rowid, channel_name, sender_name, title, content) VALUES (new.scn_message_id, new.channel_name, new.sender_name, new.title, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER fts_update AFTER UPDATE ON messages BEGIN
|
||||
INSERT INTO messages_fts (messages_fts, rowid, channel_name, sender_name, title, content) VALUES ('delete', old.scn_message_id, old.channel_name, old.sender_name, old.title, old.content);
|
||||
INSERT INTO messages_fts ( rowid, channel_name, sender_name, title, content) VALUES ( new.scn_message_id, new.channel_name, new.sender_name, new.title, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER fts_delete AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts (messages_fts, rowid, channel_name, sender_name, title, content) VALUES ('delete', old.scn_message_id, old.channel_name, old.sender_name, old.title, old.content);
|
||||
END;
|
||||
|
||||
|
||||
|
||||
CREATE TABLE deliveries
|
||||
(
|
||||
delivery_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
scn_message_id INTEGER NOT NULL,
|
||||
receiver_user_id INTEGER NOT NULL,
|
||||
receiver_client_id INTEGER NOT NULL,
|
||||
|
||||
timestamp_created INTEGER NOT NULL,
|
||||
timestamp_finalized INTEGER 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
|
||||
) STRICT;
|
||||
CREATE INDEX "idx_deliveries_receiver" ON deliveries (scn_message_id, receiver_client_id);
|
||||
|
||||
|
||||
CREATE TABLE `meta`
|
||||
(
|
||||
meta_key TEXT NOT NULL,
|
||||
value_int INTEGER NULL,
|
||||
value_txt TEXT NULL,
|
||||
value_real REAL NULL,
|
||||
value_blob BLOB NULL,
|
||||
|
||||
PRIMARY KEY (meta_key)
|
||||
) STRICT;
|
||||
|
||||
|
||||
INSERT INTO meta (meta_key, value_int) VALUES ('schema', 3)
|
7
scnserver/db/schema/schema_sqlite.ddl
Normal file
7
scnserver/db/schema/schema_sqlite.ddl
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE sqlite_master (
|
||||
type text,
|
||||
name text,
|
||||
tbl_name text,
|
||||
rootpage integer,
|
||||
sql text
|
||||
);
|
157
scnserver/db/subscriptions.go
Normal file
157
scnserver/db/subscriptions.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"database/sql"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (db *Database) CreateSubscription(ctx TxContext, subscriberUID models.UserID, channel models.Channel, confirmed bool) (models.Subscription, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.Subscription{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
res, err := tx.Exec(ctx, "INSERT INTO subscriptions (subscriber_user_id, channel_owner_user_id, channel_name, channel_id, timestamp_created, confirmed) VALUES (:suid, :ouid, :cnam, :cid, :ts, :conf)", sq.PP{
|
||||
"suid": subscriberUID,
|
||||
"ouid": channel.OwnerUserID,
|
||||
"cnam": channel.Name,
|
||||
"cid": channel.ChannelID,
|
||||
"ts": time2DB(now),
|
||||
"conf": confirmed,
|
||||
})
|
||||
if err != nil {
|
||||
return models.Subscription{}, err
|
||||
}
|
||||
|
||||
liid, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return models.Subscription{}, err
|
||||
}
|
||||
|
||||
return models.Subscription{
|
||||
SubscriptionID: models.SubscriptionID(liid),
|
||||
SubscriberUserID: subscriberUID,
|
||||
ChannelOwnerUserID: channel.OwnerUserID,
|
||||
ChannelID: channel.ChannelID,
|
||||
ChannelName: channel.Name,
|
||||
TimestampCreated: now,
|
||||
Confirmed: confirmed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Database) ListSubscriptionsByChannel(ctx TxContext, channelID models.ChannelID) ([]models.Subscription, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE channel_id = :cid", sq.PP{"cid": channelID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := models.DecodeSubscriptions(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (db *Database) ListSubscriptionsByOwner(ctx TxContext, ownerUserID models.UserID) ([]models.Subscription, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE channel_owner_user_id = :ouid", sq.PP{"ouid": ownerUserID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := models.DecodeSubscriptions(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (db *Database) GetSubscription(ctx TxContext, subid models.SubscriptionID) (models.Subscription, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.Subscription{}, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE subscription_id = :sid LIMIT 1", sq.PP{"sid": subid})
|
||||
if err != nil {
|
||||
return models.Subscription{}, err
|
||||
}
|
||||
|
||||
sub, err := models.DecodeSubscription(rows)
|
||||
if err != nil {
|
||||
return models.Subscription{}, err
|
||||
}
|
||||
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (db *Database) GetSubscriptionBySubscriber(ctx TxContext, subscriberId models.UserID, channelId models.ChannelID) (*models.Subscription, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE subscriber_user_id = :suid AND channel_id = :cid LIMIT 1", sq.PP{
|
||||
"suid": subscriberId,
|
||||
"cid": channelId,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := models.DecodeSubscription(rows)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *Database) DeleteSubscription(ctx TxContext, subid models.SubscriptionID) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "DELETE FROM subscriptions WHERE subscription_id = :sid", sq.PP{"sid": subid})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateSubscriptionConfirmed(ctx TxContext, subscriptionID models.SubscriptionID, confirmed bool) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE subscriptions SET confirmed = :conf WHERE subscription_id = :sid", sq.PP{
|
||||
"conf": confirmed,
|
||||
"sid": subscriptionID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
251
scnserver/db/users.go
Normal file
251
scnserver/db/users.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"database/sql"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"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.Exec(ctx, "INSERT INTO users (username, read_key, send_key, admin_key, is_pro, pro_token, timestamp_created) VALUES (:un, :rk, :sk, :ak, :pro, :tok, :ts)", sq.PP{
|
||||
"un": username,
|
||||
"rk": readKey,
|
||||
"sk": sendKey,
|
||||
"ak": adminKey,
|
||||
"pro": bool2DB(protoken != nil),
|
||||
"tok": protoken,
|
||||
"ts": time2DB(now),
|
||||
})
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
|
||||
liid, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
|
||||
return models.User{
|
||||
UserID: models.UserID(liid),
|
||||
Username: username,
|
||||
ReadKey: readKey,
|
||||
SendKey: sendKey,
|
||||
AdminKey: adminKey,
|
||||
TimestampCreated: now,
|
||||
TimestampLastRead: nil,
|
||||
TimestampLastSent: nil,
|
||||
MessagesSent: 0,
|
||||
QuotaUsed: 0,
|
||||
QuotaUsedDay: nil,
|
||||
IsPro: protoken != nil,
|
||||
ProToken: protoken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Database) ClearProTokens(ctx TxContext, protoken string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET is_pro=0, pro_token=NULL WHERE pro_token = :tok", sq.PP{"tok": protoken})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) GetUserByKey(ctx TxContext, key string) (*models.User, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM users WHERE admin_key = :key OR send_key = :key OR read_key = :key LIMIT 1", sq.PP{"key": key})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := models.DecodeUser(rows)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *Database) GetUser(ctx TxContext, userid models.UserID) (models.User, error) {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, "SELECT * FROM users WHERE user_id = :uid LIMIT 1", sq.PP{"uid": userid})
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
|
||||
user, err := models.DecodeUser(rows)
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateUserUsername(ctx TxContext, userid models.UserID, username *string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET username = :nam WHERE user_id = :uid", sq.PP{
|
||||
"nam": username,
|
||||
"uid": userid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateUserProToken(ctx TxContext, userid models.UserID, protoken *string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET pro_token = :tok, is_pro = :pro WHERE user_id = :uid", sq.PP{
|
||||
"tok": protoken,
|
||||
"pro": bool2DB(protoken != nil),
|
||||
"uid": userid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) IncUserMessageCounter(ctx TxContext, user models.User) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quota := user.QuotaUsedToday() + 1
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET timestamp_lastsent = :ts, messages_sent = :ctr, quota_used = :qu, quota_used_day = :qd WHERE user_id = :uid", sq.PP{
|
||||
"ts": time2DB(time.Now()),
|
||||
"ctr": user.MessagesSent + 1,
|
||||
"qu": quota,
|
||||
"qd": scn.QuotaDayString(),
|
||||
"uid": user.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateUserLastRead(ctx TxContext, userid models.UserID) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET timestamp_lastread = :ts WHERE user_id = :uid", sq.PP{
|
||||
"ts": time2DB(time.Now()),
|
||||
"uid": userid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateUserKeys(ctx TxContext, userid models.UserID, sendKey string, readKey string, adminKey string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET send_key = :sk, read_key = :rk, admin_key = :ak WHERE user_id = ?", sq.PP{
|
||||
"sk": sendKey,
|
||||
"rk": readKey,
|
||||
"ak": adminKey,
|
||||
"uid": userid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateUserSendKey(ctx TxContext, userid models.UserID, newkey string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET send_key = :sk WHERE user_id = :uid", sq.PP{
|
||||
"sk": newkey,
|
||||
"uid": userid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateUserReadKey(ctx TxContext, userid models.UserID, newkey string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET read_key = :rk WHERE user_id = :uid", sq.PP{
|
||||
"rk": newkey,
|
||||
"uid": userid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateUserAdminKey(ctx TxContext, userid models.UserID, newkey string) error {
|
||||
tx, err := ctx.GetOrCreateTransaction(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET admin_key = :ak WHERE user_id = :uid", sq.PP{
|
||||
"ak": newkey,
|
||||
"uid": userid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
37
scnserver/db/utils.go
Normal file
37
scnserver/db/utils.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func bool2DB(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func time2DB(t time.Time) int64 {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
|
||||
func time2DBOpt(t *time.Time) *int64 {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return langext.Ptr(t.UnixMilli())
|
||||
}
|
||||
|
||||
func fmtSQLPrint(sql string) string {
|
||||
if strings.Contains(sql, ";") {
|
||||
return "(...multi...)"
|
||||
}
|
||||
|
||||
sql = strings.ReplaceAll(sql, "\r", "")
|
||||
sql = strings.ReplaceAll(sql, "\n", " ")
|
||||
|
||||
return sql
|
||||
}
|
46
scnserver/go.mod
Normal file
46
scnserver/go.mod
Normal file
@@ -0,0 +1,46 @@
|
||||
module blackforestbytes.com/simplecloudnotifier
|
||||
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.8.1
|
||||
github.com/jmoiron/sqlx v1.3.5
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
github.com/rs/zerolog v1.28.0
|
||||
github.com/swaggo/swag v1.8.7
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.37
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.10.0 // indirect
|
||||
github.com/goccy/go-json v0.9.7 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.7 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
|
||||
golang.org/x/net v0.2.0 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/loremipsum.v1 v1.1.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
157
scnserver/go.sum
Normal file
157
scnserver/go.sum
Normal file
@@ -0,0 +1,157 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
|
||||
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0=
|
||||
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM=
|
||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
|
||||
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/swaggo/swag v1.8.7 h1:2K9ivTD3teEO+2fXV6zrZKDqk5IuU2aJtBDo8U7omWU=
|
||||
github.com/swaggo/swag v1.8.7/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
|
||||
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
||||
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
|
||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.31 h1:DC2RZe7/tSDDbPRbjDcYa+BLRlY0SgLTAkI2DPw5WJQ=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.31/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.32 h1:DJoRBNhq4rrOBXA/nD6WEm7L3vylLkMifU9/sWEiF7M=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.32/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.33 h1:NQRgsEs2j8eY9V45Ynq84+F0FgBfvapOGv4JZMh0eaI=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.33/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.34 h1:fi6nA+7vDiAbIjs+meIo/jGXw4rig/nrjF/QNWSKN08=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.34/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.35 h1:K5IMnAns68D6DmkryCN8CrLcmlo9zmdeCcCN0ljP/3E=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.35/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.36 h1:iOUYz2NEiObCCdBnkt8DPi1N8gH5H9q6qyJQpWp36rA=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.36/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.37 h1:6XP5UOqiougzH0Xtzs5tIU4c0sAXmdMPCvGhRxqVwLU=
|
||||
gogs.mikescher.com/BlackForestBytes/goext v0.0.37/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/loremipsum.v1 v1.1.0 h1:j6TAjs6Db5AMfLwTzs51Kq4Qx7dCufw/IJ0hpMbjU8U=
|
||||
gopkg.in/loremipsum.v1 v1.1.0/go.mod h1:bgP3Lq/dzIvYEMrwxIwVMx/W8aQ5167rlu+UO7zGdf0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
151
scnserver/google/androidPublisher.go
Normal file
151
scnserver/google/androidPublisher.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/get
|
||||
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products#ProductPurchase
|
||||
|
||||
type AndroidPublisher struct {
|
||||
client http.Client
|
||||
auth *GoogleOAuth2
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewAndroidPublisherAPI(conf scn.Config) (AndroidPublisherClient, error) {
|
||||
|
||||
googauth, err := NewAuth(conf.GoogleAPITokenURI, conf.GoogleAPIPrivKeyID, conf.GoogleAPIClientMail, conf.GoogleAPIPrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AndroidPublisher{
|
||||
client: http.Client{Timeout: 5 * time.Second},
|
||||
auth: googauth,
|
||||
baseURL: "https://androidpublisher.googleapis.com/androidpublisher",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type PurchaseType int
|
||||
|
||||
const (
|
||||
PurchaseTypeTest PurchaseType = 0 // i.e. purchased from a license testing account
|
||||
PurchaseTypePromo PurchaseType = 1 // i.e. purchased using a promo code
|
||||
PurchaseTypeRewarded PurchaseType = 2 // i.e. from watching a video ad instead of paying
|
||||
)
|
||||
|
||||
type ConsumptionState int
|
||||
|
||||
const (
|
||||
ConsumptionStateYetToBeConsumed ConsumptionState = 0
|
||||
ConsumptionStateConsumed ConsumptionState = 1
|
||||
)
|
||||
|
||||
type PurchaseState int
|
||||
|
||||
const (
|
||||
PurchaseStatePurchased PurchaseState = 0
|
||||
PurchaseStateCanceled PurchaseState = 1
|
||||
PurchaseStatePending PurchaseState = 2
|
||||
)
|
||||
|
||||
type AcknowledgementState int
|
||||
|
||||
const (
|
||||
AcknowledgementStateYetToBeAcknowledged AcknowledgementState = 0
|
||||
AcknowledgementStateAcknowledged AcknowledgementState = 1
|
||||
)
|
||||
|
||||
type ProductPurchase struct {
|
||||
Kind string `json:"kind"`
|
||||
PurchaseTimeMillis string `json:"purchaseTimeMillis"`
|
||||
PurchaseState *PurchaseState `json:"purchaseState"`
|
||||
ConsumptionState ConsumptionState `json:"consumptionState"`
|
||||
DeveloperPayload string `json:"developerPayload"`
|
||||
OrderId string `json:"orderId"`
|
||||
PurchaseType *PurchaseType `json:"purchaseType"`
|
||||
AcknowledgementState AcknowledgementState `json:"acknowledgementState"`
|
||||
PurchaseToken *string `json:"purchaseToken"`
|
||||
ProductId *string `json:"productId"`
|
||||
Quantity *int `json:"quantity"`
|
||||
ObfuscatedExternalAccountId string `json:"obfuscatedExternalAccountId"`
|
||||
ObfuscatedExternalProfileId string `json:"obfuscatedExternalProfileId"`
|
||||
RegionCode string `json:"regionCode"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (ap AndroidPublisher) GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error) {
|
||||
|
||||
uri := fmt.Sprintf("%s/v3/applications/%s/purchases/products/%s/tokens/%s", ap.baseURL, packageName, productId, token)
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, "GET", uri, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tok, err := ap.auth.Token(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Refreshing FB token failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set("Authorization", "Bearer "+tok)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := ap.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
respBodyBin, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode == 400 {
|
||||
|
||||
var errBody struct {
|
||||
Error apiError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBodyBin, &errBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errBody.Error.Code == 400 {
|
||||
return nil, nil // probably token not found
|
||||
}
|
||||
}
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
if bstr, err := io.ReadAll(response.Body); err == nil {
|
||||
return nil, errors.New(fmt.Sprintf("GetProducts-Request returned %d: %s", response.StatusCode, string(bstr)))
|
||||
} else {
|
||||
return nil, errors.New(fmt.Sprintf("GetProducts-Request returned %d", response.StatusCode))
|
||||
}
|
||||
}
|
||||
|
||||
var respBody ProductPurchase
|
||||
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if respBody.Kind != "androidpublisher#productPurchase" {
|
||||
return nil, errors.New(fmt.Sprintf("Invalid ProductPurchase.kind: '%s'", respBody.Kind))
|
||||
}
|
||||
|
||||
return &respBody, nil
|
||||
}
|
9
scnserver/google/client.go
Normal file
9
scnserver/google/client.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type AndroidPublisherClient interface {
|
||||
GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error)
|
||||
}
|
38
scnserver/google/dummy.go
Normal file
38
scnserver/google/dummy.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DummyGoogleAPIClient struct{}
|
||||
|
||||
func NewDummy() AndroidPublisherClient {
|
||||
return &DummyGoogleAPIClient{}
|
||||
}
|
||||
|
||||
func (d DummyGoogleAPIClient) GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error) {
|
||||
if strings.HasPrefix(token, "PURCHASED:") {
|
||||
return &ProductPurchase{
|
||||
Kind: "",
|
||||
PurchaseTimeMillis: fmt.Sprintf("%d", time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli()),
|
||||
PurchaseState: langext.Ptr(PurchaseStatePurchased),
|
||||
ConsumptionState: ConsumptionStateConsumed,
|
||||
DeveloperPayload: "{}",
|
||||
OrderId: "000",
|
||||
PurchaseType: nil,
|
||||
AcknowledgementState: AcknowledgementStateAcknowledged,
|
||||
PurchaseToken: nil,
|
||||
ProductId: langext.Ptr("1234-5678"),
|
||||
Quantity: nil,
|
||||
ObfuscatedExternalAccountId: "000",
|
||||
ObfuscatedExternalProfileId: "000",
|
||||
RegionCode: "DE",
|
||||
}, nil
|
||||
}
|
||||
return nil, nil // = purchase not found
|
||||
}
|
174
scnserver/google/oauth2.go
Normal file
174
scnserver/google/oauth2.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GoogleOAuth2 struct {
|
||||
client *http.Client
|
||||
|
||||
scopes []string
|
||||
tokenURL string
|
||||
privateKeyID string
|
||||
clientMail string
|
||||
|
||||
currToken *string
|
||||
tokenExpiry *time.Time
|
||||
privateKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
func NewAuth(tokenURL string, privKeyID string, cmail string, pemstr string) (*GoogleOAuth2, error) {
|
||||
|
||||
pkey, err := decodePemKey(pemstr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GoogleOAuth2{
|
||||
client: &http.Client{Timeout: 3 * time.Second},
|
||||
tokenURL: tokenURL,
|
||||
privateKey: pkey,
|
||||
privateKeyID: privKeyID,
|
||||
clientMail: cmail,
|
||||
scopes: []string{
|
||||
"https://www.googleapis.com/auth/androidpublisher",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodePemKey(pemstr string) (*rsa.PrivateKey, error) {
|
||||
var raw []byte
|
||||
|
||||
block, _ := pem.Decode([]byte(pemstr))
|
||||
|
||||
if block != nil {
|
||||
raw = block.Bytes
|
||||
} else {
|
||||
raw = []byte(pemstr)
|
||||
}
|
||||
|
||||
pkey8, err1 := x509.ParsePKCS8PrivateKey(raw)
|
||||
if err1 == nil {
|
||||
privkey, ok := pkey8.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("private key is invalid")
|
||||
}
|
||||
return privkey, nil
|
||||
}
|
||||
|
||||
pkey1, err2 := x509.ParsePKCS1PrivateKey(raw)
|
||||
if err2 == nil {
|
||||
return pkey1, nil
|
||||
}
|
||||
|
||||
return nil, errors.New(fmt.Sprintf("failed to parse private-key: [ %v | %v ]", err1, err2))
|
||||
}
|
||||
|
||||
func (a *GoogleOAuth2) Token(ctx context.Context) (string, error) {
|
||||
if a.currToken == nil || a.tokenExpiry == nil || a.tokenExpiry.Before(time.Now()) {
|
||||
err := a.Refresh(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return *a.currToken, nil
|
||||
}
|
||||
|
||||
func (a *GoogleOAuth2) Refresh(ctx context.Context) error {
|
||||
|
||||
assertion, err := a.encodeAssertion(a.privateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := url.Values{
|
||||
"assertion": []string{assertion},
|
||||
"grant_type": []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"},
|
||||
}.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
reqNow := time.Now()
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if bstr, err := io.ReadAll(resp.Body); err == nil {
|
||||
return errors.New(fmt.Sprintf("Auth-Request returned %d: %s", resp.StatusCode, string(bstr)))
|
||||
} else {
|
||||
return errors.New(fmt.Sprintf("Auth-Request returned %d", resp.StatusCode))
|
||||
}
|
||||
}
|
||||
|
||||
respBodyBin, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var respBody struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.currToken = langext.Ptr(respBody.AccessToken)
|
||||
a.tokenExpiry = langext.Ptr(reqNow.Add(timeext.FromSeconds(respBody.ExpiresIn)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *GoogleOAuth2) encodeAssertion(key *rsa.PrivateKey) (string, error) {
|
||||
headBin, err := json.Marshal(gin.H{"alg": "RS256", "typ": "JWT", "kid": a.privateKeyID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
head := base64.RawURLEncoding.EncodeToString(headBin)
|
||||
|
||||
now := time.Now().Add(-10 * time.Second) // jwt hack against unsynced clocks
|
||||
|
||||
claimBin, err := json.Marshal(gin.H{"iss": a.clientMail, "scope": strings.Join(a.scopes, " "), "aud": a.tokenURL, "exp": now.Add(time.Hour).Unix(), "iat": now.Unix()})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claim := base64.RawURLEncoding.EncodeToString(claimBin)
|
||||
|
||||
checksum := sha256.New()
|
||||
checksum.Write([]byte(head + "." + claim))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, checksum.Sum(nil))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return head + "." + claim + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
34
scnserver/init.go
Normal file
34
scnserver/init.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func Init(cfg Config) {
|
||||
cw := zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "2006-01-02 15:04:05 Z07:00",
|
||||
}
|
||||
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
multi := zerolog.MultiLevelWriter(cw)
|
||||
logger := zerolog.New(multi).With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger()
|
||||
|
||||
log.Logger = logger
|
||||
|
||||
if cfg.GinDebug {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
} else {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(cfg.LogLevel)
|
||||
|
||||
log.Debug().Msg("Initialized")
|
||||
}
|
157
scnserver/jobs/DeliveryRetryJob.go
Normal file
157
scnserver/jobs/DeliveryRetryJob.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"github.com/rs/zerolog/log"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeliveryRetryJob struct {
|
||||
app *logic.Application
|
||||
running bool
|
||||
stopChannel chan bool
|
||||
}
|
||||
|
||||
func NewDeliveryRetryJob(app *logic.Application) *DeliveryRetryJob {
|
||||
return &DeliveryRetryJob{
|
||||
app: app,
|
||||
running: true,
|
||||
stopChannel: make(chan bool, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (j *DeliveryRetryJob) Start() {
|
||||
if !j.running {
|
||||
panic("cannot re-start job")
|
||||
}
|
||||
|
||||
go j.mainLoop()
|
||||
}
|
||||
|
||||
func (j *DeliveryRetryJob) Stop() {
|
||||
j.running = false
|
||||
}
|
||||
|
||||
func (j *DeliveryRetryJob) mainLoop() {
|
||||
fastRerun := false
|
||||
|
||||
for j.running {
|
||||
if fastRerun {
|
||||
j.sleep(1 * time.Second)
|
||||
} else {
|
||||
j.sleep(30 * time.Second)
|
||||
}
|
||||
if !j.running {
|
||||
return
|
||||
}
|
||||
|
||||
fastRerun = j.run()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (j *DeliveryRetryJob) run() bool {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Error().Interface("recover", rec).Msg("Recovered panic in DeliveryRetryJob")
|
||||
}
|
||||
}()
|
||||
|
||||
ctx := j.app.NewSimpleTransactionContext(10 * time.Second)
|
||||
defer ctx.Cancel()
|
||||
|
||||
deliveries, err := j.app.Database.ListRetrieableDeliveries(ctx, 32)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to query retrieable deliveries")
|
||||
return false
|
||||
}
|
||||
|
||||
err = ctx.CommitTransaction()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to commit")
|
||||
return false
|
||||
}
|
||||
|
||||
if len(deliveries) == 32 {
|
||||
log.Warn().Msg("The delivery pipeline is greater than 32 (too much for a single cycle)")
|
||||
}
|
||||
|
||||
for _, delivery := range deliveries {
|
||||
j.redeliver(ctx, delivery)
|
||||
}
|
||||
|
||||
return len(deliveries) == 32
|
||||
}
|
||||
|
||||
func (j *DeliveryRetryJob) redeliver(ctx *logic.SimpleContext, delivery models.Delivery) {
|
||||
|
||||
client, err := j.app.Database.GetClient(ctx, delivery.ReceiverUserID, delivery.ReceiverClientID)
|
||||
if err != nil {
|
||||
log.Err(err).Int64("ReceiverUserID", delivery.ReceiverUserID.IntID()).Int64("ReceiverClientID", delivery.ReceiverClientID.IntID()).Msg("Failed to get client")
|
||||
ctx.RollbackTransaction()
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := j.app.Database.GetMessage(ctx, delivery.SCNMessageID, true)
|
||||
if err != nil {
|
||||
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Msg("Failed to get message")
|
||||
ctx.RollbackTransaction()
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Deleted {
|
||||
err = j.app.Database.SetDeliveryFailed(ctx, delivery)
|
||||
if err != nil {
|
||||
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
|
||||
ctx.RollbackTransaction()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
|
||||
fcmDelivID, err := j.app.DeliverMessage(ctx, client, msg)
|
||||
if err == nil {
|
||||
err = j.app.Database.SetDeliverySuccess(ctx, delivery, *fcmDelivID)
|
||||
if err != nil {
|
||||
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
|
||||
ctx.RollbackTransaction()
|
||||
return
|
||||
}
|
||||
} else if delivery.RetryCount+1 > delivery.MaxRetryCount() {
|
||||
err = j.app.Database.SetDeliveryFailed(ctx, delivery)
|
||||
if err != nil {
|
||||
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
|
||||
ctx.RollbackTransaction()
|
||||
return
|
||||
}
|
||||
log.Warn().Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Delivery failed after <max> retries (set to FAILURE)")
|
||||
} else {
|
||||
err = j.app.Database.SetDeliveryRetry(ctx, delivery)
|
||||
if err != nil {
|
||||
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
|
||||
ctx.RollbackTransaction()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
err = ctx.CommitTransaction()
|
||||
|
||||
}
|
||||
|
||||
func (j *DeliveryRetryJob) sleep(d time.Duration) {
|
||||
if !j.running {
|
||||
return
|
||||
}
|
||||
afterCh := time.After(d)
|
||||
for {
|
||||
select {
|
||||
case <-j.stopChannel:
|
||||
j.stopChannel <- true
|
||||
return
|
||||
case <-afterCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
99
scnserver/logic/appcontext.go
Normal file
99
scnserver/logic/appcontext.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/db"
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AppContext struct {
|
||||
inner context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
cancelled bool
|
||||
transaction sq.Tx
|
||||
permissions PermissionSet
|
||||
ginContext *gin.Context
|
||||
}
|
||||
|
||||
func CreateAppContext(g *gin.Context, innerCtx context.Context, cancelFn context.CancelFunc) *AppContext {
|
||||
return &AppContext{
|
||||
inner: innerCtx,
|
||||
cancelFunc: cancelFn,
|
||||
cancelled: false,
|
||||
transaction: nil,
|
||||
permissions: NewEmptyPermissions(),
|
||||
ginContext: g,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Error().Str("uri", ac.RequestURI()).Msg("Rollback transaction (ctx-cancel)")
|
||||
err := ac.transaction.Rollback()
|
||||
if err != nil {
|
||||
log.Err(err).Stack().Msg("Failed to rollback transaction")
|
||||
}
|
||||
ac.transaction = nil
|
||||
}
|
||||
ac.cancelFunc()
|
||||
}
|
||||
|
||||
func (ac *AppContext) RequestURI() string {
|
||||
if ac.ginContext != nil && ac.ginContext.Request != nil {
|
||||
return ac.ginContext.Request.Method + " :: " + ac.ginContext.Request.RequestURI
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
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.APIError(ac.ginContext, 500, apierr.COMMIT_FAILED, "Failed to comit changes to DB", err)
|
||||
}
|
||||
ac.transaction = nil
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (ac *AppContext) GetOrCreateTransaction(db *db.Database) (sq.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
|
||||
}
|
336
scnserver/logic/application.go
Normal file
336
scnserver/logic/application.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/db"
|
||||
"blackforestbytes.com/simplecloudnotifier/google"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"blackforestbytes.com/simplecloudnotifier/push"
|
||||
"context"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/syncext"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
Config scn.Config
|
||||
Gin *gin.Engine
|
||||
Database *db.Database
|
||||
Pusher push.NotificationClient
|
||||
AndroidPublisher google.AndroidPublisherClient
|
||||
Jobs []Job
|
||||
stopChan chan bool
|
||||
Port string
|
||||
IsRunning *syncext.AtomicBool
|
||||
}
|
||||
|
||||
func NewApp(db *db.Database) *Application {
|
||||
return &Application{
|
||||
Database: db,
|
||||
stopChan: make(chan bool),
|
||||
IsRunning: syncext.NewAtomicBool(false),
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) Init(cfg scn.Config, g *gin.Engine, fb push.NotificationClient, apc google.AndroidPublisherClient, jobs []Job) {
|
||||
app.Config = cfg
|
||||
app.Gin = g
|
||||
app.Pusher = fb
|
||||
app.AndroidPublisher = apc
|
||||
app.Jobs = jobs
|
||||
}
|
||||
|
||||
func (app *Application) Stop() {
|
||||
// non-blocking send
|
||||
select {
|
||||
case app.stopChan <- true:
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) Run() {
|
||||
httpserver := &http.Server{
|
||||
Addr: net.JoinHostPort(app.Config.ServerIP, app.Config.ServerPort),
|
||||
Handler: app.Gin,
|
||||
}
|
||||
|
||||
errChan := make(chan error)
|
||||
|
||||
go func() {
|
||||
|
||||
ln, err := net.Listen("tcp", httpserver.Addr)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
_, port, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("address", httpserver.Addr).Msg("HTTP-Server started on http://localhost:" + port)
|
||||
|
||||
app.Port = port
|
||||
|
||||
app.IsRunning.Set(true) // the net.Listener a few lines above is at this point actually already buffering requests
|
||||
|
||||
errChan <- httpserver.Serve(ln)
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
for _, job := range app.Jobs {
|
||||
job.Start()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-stop:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Info().Msg("Stopping HTTP-Server")
|
||||
|
||||
err := httpserver.Shutdown(ctx)
|
||||
|
||||
if err != nil {
|
||||
log.Info().Err(err).Msg("Error while stopping the http-server")
|
||||
} else {
|
||||
log.Info().Msg("Stopped HTTP-Server")
|
||||
}
|
||||
|
||||
case err := <-errChan:
|
||||
log.Error().Err(err).Msg("HTTP-Server failed")
|
||||
|
||||
case _ = <-app.stopChan:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Info().Msg("Manually stopping HTTP-Server")
|
||||
|
||||
err := httpserver.Shutdown(ctx)
|
||||
|
||||
if err != nil {
|
||||
log.Info().Err(err).Msg("Error while stopping the http-server")
|
||||
} else {
|
||||
log.Info().Msg("Manually stopped HTTP-Server")
|
||||
}
|
||||
}
|
||||
|
||||
for _, job := range app.Jobs {
|
||||
job.Stop()
|
||||
}
|
||||
|
||||
app.IsRunning.Set(false)
|
||||
}
|
||||
|
||||
func (app *Application) GenerateRandomAuthKey() string {
|
||||
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
k := ""
|
||||
for i := 0; i < 64; i++ {
|
||||
k += string(charset[rand.Int()%len(charset)])
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func (app *Application) QuotaMax(ispro bool) int {
|
||||
if ispro {
|
||||
return 1000
|
||||
} else {
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) VerifyProToken(ctx *AppContext, token string) (bool, error) {
|
||||
if strings.HasPrefix(token, "ANDROID|v2|") {
|
||||
subToken := token[len("ANDROID|v2|"):]
|
||||
return app.VerifyAndroidProToken(ctx, subToken)
|
||||
}
|
||||
if strings.HasPrefix(token, "IOS|v2|") {
|
||||
subToken := token[len("IOS|v2|"):]
|
||||
return app.VerifyIOSProToken(ctx, subToken)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (app *Application) VerifyAndroidProToken(ctx *AppContext, token string) (bool, error) {
|
||||
|
||||
purchase, err := app.AndroidPublisher.GetProductPurchase(ctx, app.Config.GooglePackageName, app.Config.GoogleProProductID, token)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if purchase == nil {
|
||||
return false, nil
|
||||
}
|
||||
if purchase.PurchaseState == nil {
|
||||
return false, nil
|
||||
}
|
||||
if *purchase.PurchaseState != google.PurchaseStatePurchased {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (app *Application) VerifyIOSProToken(ctx *AppContext, token string) (bool, error) {
|
||||
return false, nil //TODO IOS
|
||||
}
|
||||
|
||||
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, uri any, query any, body any, form any) (*AppContext, *ginresp.HTTPResponse) {
|
||||
|
||||
if uri != nil {
|
||||
if err := g.ShouldBindUri(uri); err != nil {
|
||||
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_URI_PARAM, "Failed to read uri", err))
|
||||
}
|
||||
}
|
||||
|
||||
if query != nil {
|
||||
if err := g.ShouldBindQuery(query); err != nil {
|
||||
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_QUERY_PARAM, "Failed to read query", err))
|
||||
}
|
||||
}
|
||||
|
||||
if body != nil && g.ContentType() == "application/json" {
|
||||
if err := g.ShouldBindJSON(body); err != nil {
|
||||
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "Failed to read body", err))
|
||||
}
|
||||
}
|
||||
|
||||
if form != nil && g.ContentType() == "multipart/form-data" {
|
||||
if err := g.ShouldBindWith(form, binding.Form); err != nil {
|
||||
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "Failed to read multipart-form", err))
|
||||
}
|
||||
}
|
||||
|
||||
ictx, cancel := context.WithTimeout(context.Background(), app.Config.RequestTimeout)
|
||||
actx := CreateAppContext(g, ictx, cancel)
|
||||
|
||||
authheader := g.GetHeader("Authorization")
|
||||
|
||||
perm, err := app.getPermissions(actx, authheader)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.PERM_QUERY_FAIL, "Failed to determine permissions", err))
|
||||
}
|
||||
|
||||
actx.permissions = perm
|
||||
|
||||
return actx, nil
|
||||
}
|
||||
|
||||
func (app *Application) NewSimpleTransactionContext(timeout time.Duration) *SimpleContext {
|
||||
ictx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
return CreateSimpleContext(ictx, cancel)
|
||||
}
|
||||
|
||||
func (app *Application) getPermissions(ctx *AppContext, hdr string) (PermissionSet, error) {
|
||||
if hdr == "" {
|
||||
return NewEmptyPermissions(), nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(hdr, "SCN ") {
|
||||
return NewEmptyPermissions(), nil
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(hdr[4:])
|
||||
|
||||
user, err := app.Database.GetUserByKey(ctx, key)
|
||||
if err != nil {
|
||||
return PermissionSet{}, err
|
||||
}
|
||||
|
||||
if user != nil && user.SendKey == key {
|
||||
return PermissionSet{UserID: langext.Ptr(user.UserID), KeyType: PermKeyTypeUserSend}, nil
|
||||
}
|
||||
if user != nil && user.ReadKey == key {
|
||||
return PermissionSet{UserID: langext.Ptr(user.UserID), KeyType: PermKeyTypeUserRead}, nil
|
||||
}
|
||||
if user != nil && user.AdminKey == key {
|
||||
return PermissionSet{UserID: langext.Ptr(user.UserID), KeyType: PermKeyTypeUserAdmin}, nil
|
||||
}
|
||||
|
||||
return NewEmptyPermissions(), nil
|
||||
}
|
||||
|
||||
func (app *Application) GetOrCreateChannel(ctx *AppContext, userid models.UserID, chanName string) (models.Channel, error) {
|
||||
chanName = app.NormalizeChannelName(chanName)
|
||||
|
||||
existingChan, err := app.Database.GetChannelByName(ctx, userid, chanName)
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
if existingChan != nil {
|
||||
return *existingChan, nil
|
||||
}
|
||||
|
||||
subscribeKey := app.GenerateRandomAuthKey()
|
||||
sendKey := app.GenerateRandomAuthKey()
|
||||
|
||||
newChan, err := app.Database.CreateChannel(ctx, userid, chanName, subscribeKey, sendKey)
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
_, err = app.Database.CreateSubscription(ctx, userid, newChan, true)
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
return newChan, nil
|
||||
}
|
||||
|
||||
func (app *Application) NormalizeChannelName(v string) string {
|
||||
rex := regexp.MustCompile("[^[:alnum:]\\-_]")
|
||||
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.ToLower(v)
|
||||
v = rex.ReplaceAllString(v, "")
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (app *Application) NormalizeUsername(v string) string {
|
||||
rex := regexp.MustCompile("[^[:alnum:]\\-_ ]")
|
||||
|
||||
v = strings.TrimSpace(v)
|
||||
v = rex.ReplaceAllString(v, "")
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (app *Application) DeliverMessage(ctx context.Context, client models.Client, msg models.Message) (*string, error) {
|
||||
if client.FCMToken != nil {
|
||||
fcmDelivID, err := app.Pusher.SendNotification(ctx, client, msg)
|
||||
if err != nil {
|
||||
log.Warn().Int64("SCNMessageID", msg.SCNMessageID.IntID()).Int64("ClientID", client.ClientID.IntID()).Err(err).Msg("FCM Delivery failed")
|
||||
return nil, err
|
||||
}
|
||||
return langext.Ptr(fcmDelivID), nil
|
||||
} else {
|
||||
return langext.Ptr(""), nil
|
||||
}
|
||||
}
|
6
scnserver/logic/jobs.go
Normal file
6
scnserver/logic/jobs.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package logic
|
||||
|
||||
type Job interface {
|
||||
Start()
|
||||
Stop()
|
||||
}
|
118
scnserver/logic/permissions.go
Normal file
118
scnserver/logic/permissions.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
)
|
||||
|
||||
type PermKeyType string
|
||||
|
||||
const (
|
||||
PermKeyTypeNone PermKeyType = "NONE" // (nothing)
|
||||
PermKeyTypeUserSend PermKeyType = "USER_SEND" // send-messages
|
||||
PermKeyTypeUserRead PermKeyType = "USER_READ" // send-messages, list-messages, read-user
|
||||
PermKeyTypeUserAdmin PermKeyType = "USER_ADMIN" // send-messages, list-messages, read-user, delete-messages, update-user
|
||||
)
|
||||
|
||||
type PermissionSet struct {
|
||||
UserID *models.UserID
|
||||
KeyType PermKeyType
|
||||
}
|
||||
|
||||
func NewEmptyPermissions() PermissionSet {
|
||||
return PermissionSet{
|
||||
UserID: nil,
|
||||
KeyType: PermKeyTypeNone,
|
||||
}
|
||||
}
|
||||
|
||||
func (ac *AppContext) CheckPermissionUserRead(userid models.UserID) *ginresp.HTTPResponse {
|
||||
p := ac.permissions
|
||||
if p.UserID != nil && *p.UserID == userid && p.KeyType == PermKeyTypeUserRead {
|
||||
return nil
|
||||
}
|
||||
if p.UserID != nil && *p.UserID == userid && p.KeyType == PermKeyTypeUserAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
return langext.Ptr(ginresp.APIError(ac.ginContext, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil))
|
||||
}
|
||||
|
||||
func (ac *AppContext) CheckPermissionRead() *ginresp.HTTPResponse {
|
||||
p := ac.permissions
|
||||
if p.UserID != nil && p.KeyType == PermKeyTypeUserRead {
|
||||
return nil
|
||||
}
|
||||
if p.UserID != nil && p.KeyType == PermKeyTypeUserAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
return langext.Ptr(ginresp.APIError(ac.ginContext, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil))
|
||||
}
|
||||
|
||||
func (ac *AppContext) CheckPermissionUserAdmin(userid models.UserID) *ginresp.HTTPResponse {
|
||||
p := ac.permissions
|
||||
if p.UserID != nil && *p.UserID == userid && p.KeyType == PermKeyTypeUserAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
return langext.Ptr(ginresp.APIError(ac.ginContext, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil))
|
||||
}
|
||||
|
||||
func (ac *AppContext) CheckPermissionSend() *ginresp.HTTPResponse {
|
||||
p := ac.permissions
|
||||
if p.UserID != nil && p.KeyType == PermKeyTypeUserSend {
|
||||
return nil
|
||||
}
|
||||
if p.UserID != nil && p.KeyType == PermKeyTypeUserAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
return langext.Ptr(ginresp.APIError(ac.ginContext, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil))
|
||||
}
|
||||
|
||||
func (ac *AppContext) CheckPermissionAny() *ginresp.HTTPResponse {
|
||||
p := ac.permissions
|
||||
if p.KeyType == PermKeyTypeNone {
|
||||
return langext.Ptr(ginresp.APIError(ac.ginContext, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ac *AppContext) CheckPermissionMessageReadDirect(msg models.Message) bool {
|
||||
p := ac.permissions
|
||||
if p.UserID != nil && msg.OwnerUserID == *p.UserID && p.KeyType == PermKeyTypeUserRead {
|
||||
return true
|
||||
}
|
||||
if p.UserID != nil && msg.OwnerUserID == *p.UserID && p.KeyType == PermKeyTypeUserAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (ac *AppContext) GetPermissionUserID() *models.UserID {
|
||||
if ac.permissions.UserID == nil {
|
||||
return nil
|
||||
} else {
|
||||
return langext.Ptr(*ac.permissions.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func (ac *AppContext) IsPermissionUserRead() bool {
|
||||
p := ac.permissions
|
||||
return p.KeyType == PermKeyTypeUserRead || p.KeyType == PermKeyTypeUserAdmin
|
||||
}
|
||||
|
||||
func (ac *AppContext) IsPermissionUserSend() bool {
|
||||
p := ac.permissions
|
||||
return p.KeyType == PermKeyTypeUserSend || p.KeyType == PermKeyTypeUserAdmin
|
||||
}
|
||||
|
||||
func (ac *AppContext) IsPermissionUserAdmin() bool {
|
||||
p := ac.permissions
|
||||
return p.KeyType == PermKeyTypeUserAdmin
|
||||
}
|
95
scnserver/logic/simplecontext.go
Normal file
95
scnserver/logic/simplecontext.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/db"
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SimpleContext struct {
|
||||
inner context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
cancelled bool
|
||||
transaction sq.Tx
|
||||
}
|
||||
|
||||
func CreateSimpleContext(innerCtx context.Context, cancelFn context.CancelFunc) *SimpleContext {
|
||||
return &SimpleContext{
|
||||
inner: innerCtx,
|
||||
cancelFunc: cancelFn,
|
||||
cancelled: false,
|
||||
transaction: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SimpleContext) Deadline() (deadline time.Time, ok bool) {
|
||||
return sc.inner.Deadline()
|
||||
}
|
||||
|
||||
func (sc *SimpleContext) Done() <-chan struct{} {
|
||||
return sc.inner.Done()
|
||||
}
|
||||
|
||||
func (sc *SimpleContext) Err() error {
|
||||
return sc.inner.Err()
|
||||
}
|
||||
|
||||
func (sc *SimpleContext) Value(key any) any {
|
||||
return sc.inner.Value(key)
|
||||
}
|
||||
|
||||
func (sc *SimpleContext) Cancel() {
|
||||
sc.cancelled = true
|
||||
if sc.transaction != nil {
|
||||
log.Error().Msg("Rollback transaction")
|
||||
err := sc.transaction.Rollback()
|
||||
if err != nil {
|
||||
panic("failed to rollback transaction: " + err.Error())
|
||||
}
|
||||
sc.transaction = nil
|
||||
}
|
||||
sc.cancelFunc()
|
||||
}
|
||||
|
||||
func (sc *SimpleContext) GetOrCreateTransaction(db *db.Database) (sq.Tx, error) {
|
||||
if sc.cancelled {
|
||||
return nil, errors.New("context cancelled")
|
||||
}
|
||||
if sc.transaction != nil {
|
||||
return sc.transaction, nil
|
||||
}
|
||||
tx, err := db.BeginTx(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc.transaction = tx
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (sc *SimpleContext) CommitTransaction() error {
|
||||
if sc.transaction == nil {
|
||||
return nil
|
||||
}
|
||||
err := sc.transaction.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc.transaction = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *SimpleContext) RollbackTransaction() {
|
||||
if sc.transaction == nil {
|
||||
return
|
||||
}
|
||||
err := sc.transaction.Rollback()
|
||||
if err != nil {
|
||||
log.Err(err).Stack().Msg("Failed to rollback transaction")
|
||||
panic(err)
|
||||
}
|
||||
sc.transaction = nil
|
||||
return
|
||||
}
|
84
scnserver/models/channel.go
Normal file
84
scnserver/models/channel.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
ChannelID ChannelID
|
||||
OwnerUserID UserID
|
||||
Name string
|
||||
SubscribeKey string
|
||||
SendKey string
|
||||
TimestampCreated time.Time
|
||||
TimestampLastSent *time.Time
|
||||
MessagesSent int
|
||||
}
|
||||
|
||||
func (c Channel) JSON(includeKey bool) ChannelJSON {
|
||||
return ChannelJSON{
|
||||
ChannelID: c.ChannelID,
|
||||
OwnerUserID: c.OwnerUserID,
|
||||
Name: c.Name,
|
||||
SubscribeKey: langext.Conditional(includeKey, langext.Ptr(c.SubscribeKey), nil),
|
||||
SendKey: langext.Conditional(includeKey, langext.Ptr(c.SendKey), nil),
|
||||
TimestampCreated: c.TimestampCreated.Format(time.RFC3339Nano),
|
||||
TimestampLastSent: timeOptFmt(c.TimestampLastSent, time.RFC3339Nano),
|
||||
MessagesSent: c.MessagesSent,
|
||||
}
|
||||
}
|
||||
|
||||
type ChannelJSON struct {
|
||||
ChannelID ChannelID `json:"channel_id"`
|
||||
OwnerUserID UserID `json:"owner_user_id"`
|
||||
Name string `json:"name"`
|
||||
SubscribeKey *string `json:"subscribe_key"` // can be nil, depending on endpoint
|
||||
SendKey *string `json:"send_key"` // can be nil, depending on endpoint
|
||||
TimestampCreated string `json:"timestamp_created"`
|
||||
TimestampLastSent *string `json:"timestamp_lastsent"`
|
||||
MessagesSent int `json:"messages_sent"`
|
||||
}
|
||||
|
||||
type ChannelDB struct {
|
||||
ChannelID ChannelID `db:"channel_id"`
|
||||
OwnerUserID UserID `db:"owner_user_id"`
|
||||
Name string `db:"name"`
|
||||
SubscribeKey string `db:"subscribe_key"`
|
||||
SendKey string `db:"send_key"`
|
||||
TimestampCreated int64 `db:"timestamp_created"`
|
||||
TimestampLastRead *int64 `db:"timestamp_lastread"`
|
||||
TimestampLastSent *int64 `db:"timestamp_lastsent"`
|
||||
MessagesSent int `db:"messages_sent"`
|
||||
}
|
||||
|
||||
func (c ChannelDB) Model() Channel {
|
||||
return Channel{
|
||||
ChannelID: c.ChannelID,
|
||||
OwnerUserID: c.OwnerUserID,
|
||||
Name: c.Name,
|
||||
SubscribeKey: c.SubscribeKey,
|
||||
SendKey: c.SendKey,
|
||||
TimestampCreated: time.UnixMilli(c.TimestampCreated),
|
||||
TimestampLastSent: timeOptFromMilli(c.TimestampLastSent),
|
||||
MessagesSent: c.MessagesSent,
|
||||
}
|
||||
}
|
||||
|
||||
func DecodeChannel(r *sqlx.Rows) (Channel, error) {
|
||||
data, err := sq.ScanSingle[ChannelDB](r, true)
|
||||
if err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
return data.Model(), nil
|
||||
}
|
||||
|
||||
func DecodeChannels(r *sqlx.Rows) ([]Channel, error) {
|
||||
data, err := sq.ScanAll[ChannelDB](r, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return langext.ArrMap(data, func(v ChannelDB) Channel { return v.Model() }), nil
|
||||
}
|
85
scnserver/models/client.go
Normal file
85
scnserver/models/client.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ClientType string
|
||||
|
||||
const (
|
||||
ClientTypeAndroid ClientType = "ANDROID"
|
||||
ClientTypeIOS ClientType = "IOS"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
ClientID ClientID
|
||||
UserID UserID
|
||||
Type ClientType
|
||||
FCMToken *string
|
||||
TimestampCreated time.Time
|
||||
AgentModel string
|
||||
AgentVersion string
|
||||
}
|
||||
|
||||
func (c Client) JSON() ClientJSON {
|
||||
return ClientJSON{
|
||||
ClientID: c.ClientID,
|
||||
UserID: c.UserID,
|
||||
Type: c.Type,
|
||||
FCMToken: c.FCMToken,
|
||||
TimestampCreated: c.TimestampCreated.Format(time.RFC3339Nano),
|
||||
AgentModel: c.AgentModel,
|
||||
AgentVersion: c.AgentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
type ClientJSON struct {
|
||||
ClientID ClientID `json:"client_id"`
|
||||
UserID UserID `json:"user_id"`
|
||||
Type ClientType `json:"type"`
|
||||
FCMToken *string `json:"fcm_token"`
|
||||
TimestampCreated string `json:"timestamp_created"`
|
||||
AgentModel string `json:"agent_model"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
}
|
||||
|
||||
type ClientDB struct {
|
||||
ClientID ClientID `db:"client_id"`
|
||||
UserID UserID `db:"user_id"`
|
||||
Type ClientType `db:"type"`
|
||||
FCMToken *string `db:"fcm_token"`
|
||||
TimestampCreated int64 `db:"timestamp_created"`
|
||||
AgentModel string `db:"agent_model"`
|
||||
AgentVersion string `db:"agent_version"`
|
||||
}
|
||||
|
||||
func (c ClientDB) Model() Client {
|
||||
return Client{
|
||||
ClientID: c.ClientID,
|
||||
UserID: c.UserID,
|
||||
Type: c.Type,
|
||||
FCMToken: c.FCMToken,
|
||||
TimestampCreated: time.UnixMilli(c.TimestampCreated),
|
||||
AgentModel: c.AgentModel,
|
||||
AgentVersion: c.AgentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func DecodeClient(r *sqlx.Rows) (Client, error) {
|
||||
data, err := sq.ScanSingle[ClientDB](r, true)
|
||||
if err != nil {
|
||||
return Client{}, err
|
||||
}
|
||||
return data.Model(), nil
|
||||
}
|
||||
|
||||
func DecodeClients(r *sqlx.Rows) ([]Client, error) {
|
||||
data, err := sq.ScanAll[ClientDB](r, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return langext.ArrMap(data, func(v ClientDB) Client { return v.Model() }), nil
|
||||
}
|
11
scnserver/models/compat.go
Normal file
11
scnserver/models/compat.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package models
|
||||
|
||||
type CompatMessage struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Priority int `json:"priority"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
UserMessageID *string `json:"usr_msg_id"`
|
||||
SCNMessageID int64 `json:"scn_msg_id"`
|
||||
Trimmed *bool `json:"trimmed"`
|
||||
}
|
105
scnserver/models/delivery.go
Normal file
105
scnserver/models/delivery.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeliveryStatus string
|
||||
|
||||
const (
|
||||
DeliveryStatusRetry DeliveryStatus = "RETRY"
|
||||
DeliveryStatusSuccess DeliveryStatus = "SUCCESS"
|
||||
DeliveryStatusFailed DeliveryStatus = "FAILED"
|
||||
)
|
||||
|
||||
type Delivery struct {
|
||||
DeliveryID DeliveryID
|
||||
SCNMessageID SCNMessageID
|
||||
ReceiverUserID UserID
|
||||
ReceiverClientID ClientID
|
||||
TimestampCreated time.Time
|
||||
TimestampFinalized *time.Time
|
||||
Status DeliveryStatus
|
||||
RetryCount int
|
||||
NextDelivery *time.Time
|
||||
FCMMessageID *string
|
||||
}
|
||||
|
||||
func (d Delivery) JSON() DeliveryJSON {
|
||||
return DeliveryJSON{
|
||||
DeliveryID: d.DeliveryID,
|
||||
SCNMessageID: d.SCNMessageID,
|
||||
ReceiverUserID: d.ReceiverUserID,
|
||||
ReceiverClientID: d.ReceiverClientID,
|
||||
TimestampCreated: d.TimestampCreated.Format(time.RFC3339Nano),
|
||||
TimestampFinalized: timeOptFmt(d.TimestampFinalized, time.RFC3339Nano),
|
||||
Status: d.Status,
|
||||
RetryCount: d.RetryCount,
|
||||
NextDelivery: timeOptFmt(d.NextDelivery, time.RFC3339Nano),
|
||||
FCMMessageID: d.FCMMessageID,
|
||||
}
|
||||
}
|
||||
|
||||
func (d Delivery) MaxRetryCount() int {
|
||||
return 5
|
||||
}
|
||||
|
||||
type DeliveryJSON struct {
|
||||
DeliveryID DeliveryID `json:"delivery_id"`
|
||||
SCNMessageID SCNMessageID `json:"scn_message_id"`
|
||||
ReceiverUserID UserID `json:"receiver_user_id"`
|
||||
ReceiverClientID ClientID `json:"receiver_client_id"`
|
||||
TimestampCreated string `json:"timestamp_created"`
|
||||
TimestampFinalized *string `json:"tiestamp_finalized"`
|
||||
Status DeliveryStatus `json:"status"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
NextDelivery *string `json:"next_delivery"`
|
||||
FCMMessageID *string `json:"fcm_message_id"`
|
||||
}
|
||||
|
||||
type DeliveryDB struct {
|
||||
DeliveryID DeliveryID `db:"delivery_id"`
|
||||
SCNMessageID SCNMessageID `db:"scn_message_id"`
|
||||
ReceiverUserID UserID `db:"receiver_user_id"`
|
||||
ReceiverClientID ClientID `db:"receiver_client_id"`
|
||||
TimestampCreated int64 `db:"timestamp_created"`
|
||||
TimestampFinalized *int64 `db:"tiestamp_finalized"`
|
||||
Status DeliveryStatus `db:"status"`
|
||||
RetryCount int `db:"retry_count"`
|
||||
NextDelivery *int64 `db:"next_delivery"`
|
||||
FCMMessageID *string `db:"fcm_message_id"`
|
||||
}
|
||||
|
||||
func (d DeliveryDB) Model() Delivery {
|
||||
return Delivery{
|
||||
DeliveryID: d.DeliveryID,
|
||||
SCNMessageID: d.SCNMessageID,
|
||||
ReceiverUserID: d.ReceiverUserID,
|
||||
ReceiverClientID: d.ReceiverClientID,
|
||||
TimestampCreated: time.UnixMilli(d.TimestampCreated),
|
||||
TimestampFinalized: timeOptFromMilli(d.TimestampFinalized),
|
||||
Status: d.Status,
|
||||
RetryCount: d.RetryCount,
|
||||
NextDelivery: timeOptFromMilli(d.NextDelivery),
|
||||
FCMMessageID: d.FCMMessageID,
|
||||
}
|
||||
}
|
||||
|
||||
func DecodeDelivery(r *sqlx.Rows) (Delivery, error) {
|
||||
data, err := sq.ScanSingle[DeliveryDB](r, true)
|
||||
if err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
return data.Model(), nil
|
||||
}
|
||||
|
||||
func DecodeDeliveries(r *sqlx.Rows) ([]Delivery, error) {
|
||||
data, err := sq.ScanAll[DeliveryDB](r, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return langext.ArrMap(data, func(v DeliveryDB) Delivery { return v.Model() }), nil
|
||||
}
|
68
scnserver/models/ids.go
Normal file
68
scnserver/models/ids.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package models
|
||||
|
||||
import "strconv"
|
||||
|
||||
type EntityID interface {
|
||||
IntID() int64
|
||||
String() string
|
||||
}
|
||||
|
||||
type UserID int64
|
||||
|
||||
func (id UserID) IntID() int64 {
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func (id UserID) String() string {
|
||||
return strconv.FormatInt(int64(id), 10)
|
||||
}
|
||||
|
||||
type ChannelID int64
|
||||
|
||||
func (id ChannelID) IntID() int64 {
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func (id ChannelID) String() string {
|
||||
return strconv.FormatInt(int64(id), 10)
|
||||
}
|
||||
|
||||
type DeliveryID int64
|
||||
|
||||
func (id DeliveryID) IntID() int64 {
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func (id DeliveryID) String() string {
|
||||
return strconv.FormatInt(int64(id), 10)
|
||||
}
|
||||
|
||||
type SCNMessageID int64
|
||||
|
||||
func (id SCNMessageID) IntID() int64 {
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func (id SCNMessageID) String() string {
|
||||
return strconv.FormatInt(int64(id), 10)
|
||||
}
|
||||
|
||||
type SubscriptionID int64
|
||||
|
||||
func (id SubscriptionID) IntID() int64 {
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func (id SubscriptionID) String() string {
|
||||
return strconv.FormatInt(int64(id), 10)
|
||||
}
|
||||
|
||||
type ClientID int64
|
||||
|
||||
func (id ClientID) IntID() int64 {
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func (id ClientID) String() string {
|
||||
return strconv.FormatInt(int64(id), 10)
|
||||
}
|
162
scnserver/models/message.go
Normal file
162
scnserver/models/message.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ContentLengthTrim = 1900
|
||||
ContentLengthShort = 200
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
SCNMessageID SCNMessageID
|
||||
SenderUserID UserID
|
||||
OwnerUserID UserID
|
||||
ChannelName string
|
||||
ChannelID ChannelID
|
||||
SenderName *string
|
||||
SenderIP string
|
||||
TimestampReal time.Time
|
||||
TimestampClient *time.Time
|
||||
Title string
|
||||
Content *string
|
||||
Priority int
|
||||
UserMessageID *string
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
func (m Message) FullJSON() MessageJSON {
|
||||
return MessageJSON{
|
||||
SCNMessageID: m.SCNMessageID,
|
||||
SenderUserID: m.SenderUserID,
|
||||
OwnerUserID: m.OwnerUserID,
|
||||
ChannelName: m.ChannelName,
|
||||
ChannelID: m.ChannelID,
|
||||
SenderName: m.SenderName,
|
||||
SenderIP: m.SenderIP,
|
||||
Timestamp: m.Timestamp().Format(time.RFC3339Nano),
|
||||
Title: m.Title,
|
||||
Content: m.Content,
|
||||
Priority: m.Priority,
|
||||
UserMessageID: m.UserMessageID,
|
||||
Trimmed: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Message) TrimmedJSON() MessageJSON {
|
||||
return MessageJSON{
|
||||
SCNMessageID: m.SCNMessageID,
|
||||
SenderUserID: m.SenderUserID,
|
||||
OwnerUserID: m.OwnerUserID,
|
||||
ChannelName: m.ChannelName,
|
||||
ChannelID: m.ChannelID,
|
||||
SenderName: m.SenderName,
|
||||
SenderIP: m.SenderIP,
|
||||
Timestamp: m.Timestamp().Format(time.RFC3339Nano),
|
||||
Title: m.Title,
|
||||
Content: m.TrimmedContent(),
|
||||
Priority: m.Priority,
|
||||
UserMessageID: m.UserMessageID,
|
||||
Trimmed: m.NeedsTrim(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Message) Timestamp() time.Time {
|
||||
return langext.Coalesce(m.TimestampClient, m.TimestampReal)
|
||||
}
|
||||
|
||||
func (m Message) NeedsTrim() bool {
|
||||
return m.Content != nil && len(*m.Content) > ContentLengthTrim
|
||||
}
|
||||
|
||||
func (m Message) TrimmedContent() *string {
|
||||
if m.Content == nil {
|
||||
return nil
|
||||
}
|
||||
if !m.NeedsTrim() {
|
||||
return m.Content
|
||||
}
|
||||
return langext.Ptr(langext.Coalesce(m.Content, "")[0:ContentLengthTrim-3] + "...")
|
||||
}
|
||||
|
||||
func (m Message) ShortContent() string {
|
||||
if m.Content == nil {
|
||||
return ""
|
||||
}
|
||||
if len(*m.Content) < ContentLengthShort {
|
||||
return *m.Content
|
||||
}
|
||||
return (*m.Content)[0:ContentLengthShort-3] + "..."
|
||||
}
|
||||
|
||||
type MessageJSON struct {
|
||||
SCNMessageID SCNMessageID `json:"scn_message_id"`
|
||||
SenderUserID UserID `json:"sender_user_id"`
|
||||
OwnerUserID UserID `json:"owner_user_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelID ChannelID `json:"channel_id"`
|
||||
SenderName *string `json:"sender_name"`
|
||||
SenderIP string `json:"sender_ip"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Title string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
Priority int `json:"priority"`
|
||||
UserMessageID *string `json:"usr_message_id"`
|
||||
Trimmed bool `json:"trimmed"`
|
||||
}
|
||||
|
||||
type MessageDB struct {
|
||||
SCNMessageID SCNMessageID `db:"scn_message_id"`
|
||||
SenderUserID UserID `db:"sender_user_id"`
|
||||
OwnerUserID UserID `db:"owner_user_id"`
|
||||
ChannelName string `db:"channel_name"`
|
||||
ChannelID ChannelID `db:"channel_id"`
|
||||
SenderName *string `db:"sender_name"`
|
||||
SenderIP string `db:"sender_ip"`
|
||||
TimestampReal int64 `db:"timestamp_real"`
|
||||
TimestampClient *int64 `db:"timestamp_client"`
|
||||
Title string `db:"title"`
|
||||
Content *string `db:"content"`
|
||||
Priority int `db:"priority"`
|
||||
UserMessageID *string `db:"usr_message_id"`
|
||||
Deleted int `db:"deleted"`
|
||||
}
|
||||
|
||||
func (m MessageDB) Model() Message {
|
||||
return Message{
|
||||
SCNMessageID: m.SCNMessageID,
|
||||
SenderUserID: m.SenderUserID,
|
||||
OwnerUserID: m.OwnerUserID,
|
||||
ChannelName: m.ChannelName,
|
||||
ChannelID: m.ChannelID,
|
||||
SenderName: m.SenderName,
|
||||
SenderIP: m.SenderIP,
|
||||
TimestampReal: time.UnixMilli(m.TimestampReal),
|
||||
TimestampClient: timeOptFromMilli(m.TimestampClient),
|
||||
Title: m.Title,
|
||||
Content: m.Content,
|
||||
Priority: m.Priority,
|
||||
UserMessageID: m.UserMessageID,
|
||||
Deleted: m.Deleted != 0,
|
||||
}
|
||||
}
|
||||
|
||||
func DecodeMessage(r *sqlx.Rows) (Message, error) {
|
||||
data, err := sq.ScanSingle[MessageDB](r, true)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return data.Model(), nil
|
||||
}
|
||||
|
||||
func DecodeMessages(r *sqlx.Rows) ([]Message, error) {
|
||||
data, err := sq.ScanAll[MessageDB](r, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return langext.ArrMap(data, func(v MessageDB) Message { return v.Model() }), nil
|
||||
}
|
239
scnserver/models/messagefilter.go
Normal file
239
scnserver/models/messagefilter.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/mathext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MessageFilter struct {
|
||||
ConfirmedSubscriptionBy *UserID
|
||||
SearchString *[]string
|
||||
Sender *[]UserID
|
||||
Owner *[]UserID
|
||||
ChannelNameCS *[]string // case-sensitive
|
||||
ChannelNameCI *[]string // case-insensitive
|
||||
ChannelID *[]ChannelID
|
||||
SenderNameCS *[]string // case-sensitive
|
||||
SenderNameCI *[]string // case-insensitive
|
||||
SenderIP *[]string
|
||||
TimestampCoalesce *time.Time
|
||||
TimestampCoalesceAfter *time.Time
|
||||
TimestampCoalesceBefore *time.Time
|
||||
TimestampReal *time.Time
|
||||
TimestampRealAfter *time.Time
|
||||
TimestampRealBefore *time.Time
|
||||
TimestampClient *time.Time
|
||||
TimestampClientAfter *time.Time
|
||||
TimestampClientBefore *time.Time
|
||||
TitleCS *string // case-sensitive
|
||||
TitleCI *string // case-insensitive
|
||||
Priority *[]int
|
||||
UserMessageID *[]string
|
||||
OnlyDeleted bool
|
||||
IncludeDeleted bool
|
||||
}
|
||||
|
||||
func (f MessageFilter) SQL() (string, string, sq.PP, error) {
|
||||
|
||||
joinClause := ""
|
||||
if f.ConfirmedSubscriptionBy != nil {
|
||||
joinClause += " LEFT JOIN subscriptions subs on messages.channel_id = subs.channel_id "
|
||||
}
|
||||
if f.SearchString != nil {
|
||||
joinClause += " JOIN messages_fts mfts on (mfts.rowid = messages.scn_message_id) "
|
||||
}
|
||||
|
||||
sqlClauses := make([]string, 0)
|
||||
|
||||
params := sq.PP{}
|
||||
|
||||
if f.OnlyDeleted {
|
||||
sqlClauses = append(sqlClauses, "(deleted=1)")
|
||||
} else if f.IncludeDeleted {
|
||||
// nothing, return all
|
||||
} else {
|
||||
sqlClauses = append(sqlClauses, "(deleted=0)") // default
|
||||
}
|
||||
|
||||
if f.ConfirmedSubscriptionBy != nil {
|
||||
sqlClauses = append(sqlClauses, "(subs.subscriber_user_id = :sub_uid AND subs.confirmed = 1)")
|
||||
params["sub_uid"] = *f.ConfirmedSubscriptionBy
|
||||
}
|
||||
|
||||
if f.Sender != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.Sender {
|
||||
filter = append(filter, fmt.Sprintf("(sender_user_id = :sender_%d)", i))
|
||||
params[fmt.Sprintf("sender_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "("+strings.Join(filter, " OR ")+")")
|
||||
}
|
||||
|
||||
if f.Owner != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.Sender {
|
||||
filter = append(filter, fmt.Sprintf("(owner_user_id = :owner_%d)", i))
|
||||
params[fmt.Sprintf("owner_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "("+strings.Join(filter, " OR ")+")")
|
||||
}
|
||||
|
||||
if f.ChannelNameCI != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.ChannelNameCI {
|
||||
filter = append(filter, fmt.Sprintf("(channel_name = :channelnameci_%d COLLATE NOCASE)", i))
|
||||
params[fmt.Sprintf("channelnameci_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "("+strings.Join(filter, " OR ")+")")
|
||||
}
|
||||
|
||||
if f.ChannelNameCS != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.ChannelNameCS {
|
||||
filter = append(filter, fmt.Sprintf("(channel_name = :channelnamecs_%d COLLATE BINARY)", i))
|
||||
params[fmt.Sprintf("channelnamecs_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "("+strings.Join(filter, " OR ")+")")
|
||||
}
|
||||
|
||||
if f.ChannelID != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.ChannelID {
|
||||
filter = append(filter, fmt.Sprintf("(channel_id = :channelid_%d)", i))
|
||||
params[fmt.Sprintf("channelid_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "("+strings.Join(filter, " OR ")+")")
|
||||
}
|
||||
|
||||
if f.SenderNameCI != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.ChannelNameCI {
|
||||
filter = append(filter, fmt.Sprintf("(sender_name = :sendernameci_%d COLLATE NOCASE)", i))
|
||||
params[fmt.Sprintf("sendernameci_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "(sender_name IS NOT NULL AND ("+strings.Join(filter, " OR ")+"))")
|
||||
}
|
||||
|
||||
if f.SenderNameCS != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.ChannelNameCS {
|
||||
filter = append(filter, fmt.Sprintf("(sender_name = :sendernamecs_%d COLLATE BINARY)", i))
|
||||
params[fmt.Sprintf("sendernamecs_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "(sender_name IS NOT NULL AND ("+strings.Join(filter, " OR ")+"))")
|
||||
}
|
||||
|
||||
if f.SenderIP != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.SenderIP {
|
||||
filter = append(filter, fmt.Sprintf("(sender_ip = :senderip_%d)", i))
|
||||
params[fmt.Sprintf("senderip_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "("+strings.Join(filter, " OR ")+")")
|
||||
}
|
||||
|
||||
if f.TimestampCoalesce != nil {
|
||||
sqlClauses = append(sqlClauses, "(COALESCE(timestamp_client, timestamp_real) = :ts_equals)")
|
||||
params["ts_equals"] = (*f.TimestampCoalesce).UnixMilli()
|
||||
}
|
||||
|
||||
if f.TimestampCoalesceAfter != nil {
|
||||
sqlClauses = append(sqlClauses, "(COALESCE(timestamp_client, timestamp_real) > :ts_after)")
|
||||
params["ts_after"] = (*f.TimestampCoalesceAfter).UnixMilli()
|
||||
}
|
||||
|
||||
if f.TimestampCoalesceBefore != nil {
|
||||
sqlClauses = append(sqlClauses, "(COALESCE(timestamp_client, timestamp_real) < :ts_before)")
|
||||
params["ts_before"] = (*f.TimestampCoalesceBefore).UnixMilli()
|
||||
}
|
||||
|
||||
if f.TimestampReal != nil {
|
||||
sqlClauses = append(sqlClauses, "(timestamp_real = :ts_real_equals)")
|
||||
params["ts_real_equals"] = (*f.TimestampRealAfter).UnixMilli()
|
||||
}
|
||||
|
||||
if f.TimestampRealAfter != nil {
|
||||
sqlClauses = append(sqlClauses, "(timestamp_real > :ts_real_after)")
|
||||
params["ts_real_after"] = (*f.TimestampRealAfter).UnixMilli()
|
||||
}
|
||||
|
||||
if f.TimestampRealBefore != nil {
|
||||
sqlClauses = append(sqlClauses, "(timestamp_real < :ts_real_before)")
|
||||
params["ts_real_before"] = (*f.TimestampRealAfter).UnixMilli()
|
||||
}
|
||||
|
||||
if f.TimestampClient != nil {
|
||||
sqlClauses = append(sqlClauses, "(timestamp_client IS NOT NULL AND timestamp_client = :ts_client_equals)")
|
||||
params["ts_client_equals"] = (*f.TimestampClient).UnixMilli()
|
||||
}
|
||||
|
||||
if f.TimestampClientAfter != nil {
|
||||
sqlClauses = append(sqlClauses, "(timestamp_client IS NOT NULL AND timestamp_client > :ts_client_after)")
|
||||
params["ts_client_after"] = (*f.TimestampClientAfter).UnixMilli()
|
||||
}
|
||||
|
||||
if f.TimestampClientBefore != nil {
|
||||
sqlClauses = append(sqlClauses, "(timestamp_client IS NOT NULL AND timestamp_client < :ts_client_before)")
|
||||
params["ts_client_before"] = (*f.TimestampClientBefore).UnixMilli()
|
||||
}
|
||||
|
||||
if f.TitleCI != nil {
|
||||
sqlClauses = append(sqlClauses, "(title = :titleci COLLATE NOCASE)")
|
||||
params["titleci"] = *f.TitleCI
|
||||
}
|
||||
|
||||
if f.TitleCS != nil {
|
||||
sqlClauses = append(sqlClauses, "(title = :titleci COLLATE BINARY)")
|
||||
params["titleci"] = *f.TitleCI
|
||||
}
|
||||
|
||||
if f.Priority != nil {
|
||||
prioList := "(" + strings.Join(langext.ArrMap(*f.Priority, func(p int) string { return strconv.Itoa(p) }), ", ") + ")"
|
||||
sqlClauses = append(sqlClauses, "(priority IN "+prioList+")")
|
||||
}
|
||||
|
||||
if f.UserMessageID != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.UserMessageID {
|
||||
filter = append(filter, fmt.Sprintf("(usr_message_id = :usermessageid_%d)", i))
|
||||
params[fmt.Sprintf("usermessageid_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "(usr_message_id IS NOT NULL AND ("+strings.Join(filter, " OR ")+"))")
|
||||
}
|
||||
|
||||
if f.SearchString != nil {
|
||||
filter := make([]string, 0)
|
||||
for i, v := range *f.SearchString {
|
||||
filter = append(filter, fmt.Sprintf("(messages_fts match :searchstring_%d)", i))
|
||||
params[fmt.Sprintf("searchstring_%d", i)] = v
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "("+strings.Join(filter, " OR ")+")")
|
||||
}
|
||||
|
||||
sqlClause := ""
|
||||
if len(sqlClauses) > 0 {
|
||||
sqlClause = strings.Join(sqlClauses, " AND ")
|
||||
} else {
|
||||
sqlClause = "1=1"
|
||||
}
|
||||
|
||||
return sqlClause, joinClause, params, nil
|
||||
}
|
||||
|
||||
func (f MessageFilter) Hash() string {
|
||||
bh, err := dataext.StructHash(f, dataext.StructHashOptions{HashAlgo: sha512.New()})
|
||||
if err != nil {
|
||||
return "00000000"
|
||||
}
|
||||
|
||||
str := hex.EncodeToString(bh)
|
||||
return str[0:mathext.Min(8, len(bh))]
|
||||
}
|
78
scnserver/models/subscription.go
Normal file
78
scnserver/models/subscription.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Subscription struct {
|
||||
SubscriptionID SubscriptionID
|
||||
SubscriberUserID UserID
|
||||
ChannelOwnerUserID UserID
|
||||
ChannelID ChannelID
|
||||
ChannelName string
|
||||
TimestampCreated time.Time
|
||||
Confirmed bool
|
||||
}
|
||||
|
||||
func (s Subscription) JSON() SubscriptionJSON {
|
||||
return SubscriptionJSON{
|
||||
SubscriptionID: s.SubscriptionID,
|
||||
SubscriberUserID: s.SubscriberUserID,
|
||||
ChannelOwnerUserID: s.ChannelOwnerUserID,
|
||||
ChannelID: s.ChannelID,
|
||||
ChannelName: s.ChannelName,
|
||||
TimestampCreated: s.TimestampCreated.Format(time.RFC3339Nano),
|
||||
Confirmed: s.Confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
type SubscriptionJSON struct {
|
||||
SubscriptionID SubscriptionID `json:"subscription_id"`
|
||||
SubscriberUserID UserID `json:"subscriber_user_id"`
|
||||
ChannelOwnerUserID UserID `json:"channel_owner_user_id"`
|
||||
ChannelID ChannelID `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
TimestampCreated string `json:"timestamp_created"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
type SubscriptionDB struct {
|
||||
SubscriptionID SubscriptionID `db:"subscription_id"`
|
||||
SubscriberUserID UserID `db:"subscriber_user_id"`
|
||||
ChannelOwnerUserID UserID `db:"channel_owner_user_id"`
|
||||
ChannelID ChannelID `db:"channel_id"`
|
||||
ChannelName string `db:"channel_name"`
|
||||
TimestampCreated int64 `db:"timestamp_created"`
|
||||
Confirmed int `db:"confirmed"`
|
||||
}
|
||||
|
||||
func (s SubscriptionDB) Model() Subscription {
|
||||
return Subscription{
|
||||
SubscriptionID: s.SubscriptionID,
|
||||
SubscriberUserID: s.SubscriberUserID,
|
||||
ChannelOwnerUserID: s.ChannelOwnerUserID,
|
||||
ChannelID: s.ChannelID,
|
||||
ChannelName: s.ChannelName,
|
||||
TimestampCreated: time.UnixMilli(s.TimestampCreated),
|
||||
Confirmed: s.Confirmed != 0,
|
||||
}
|
||||
}
|
||||
|
||||
func DecodeSubscription(r *sqlx.Rows) (Subscription, error) {
|
||||
data, err := sq.ScanSingle[SubscriptionDB](r, true)
|
||||
if err != nil {
|
||||
return Subscription{}, err
|
||||
}
|
||||
return data.Model(), nil
|
||||
}
|
||||
|
||||
func DecodeSubscriptions(r *sqlx.Rows) ([]Subscription, error) {
|
||||
data, err := sq.ScanAll[SubscriptionDB](r, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return langext.ArrMap(data, func(v SubscriptionDB) Subscription { return v.Model() }), nil
|
||||
}
|
179
scnserver/models/user.go
Normal file
179
scnserver/models/user.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
UserID UserID
|
||||
Username *string
|
||||
SendKey string
|
||||
ReadKey string
|
||||
AdminKey string
|
||||
TimestampCreated time.Time
|
||||
TimestampLastRead *time.Time
|
||||
TimestampLastSent *time.Time
|
||||
MessagesSent int
|
||||
QuotaUsed int
|
||||
QuotaUsedDay *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,
|
||||
QuotaUsed: u.QuotaUsedToday(),
|
||||
QuotaPerDay: u.QuotaPerDay(),
|
||||
QuotaRemaining: u.QuotaRemainingToday(),
|
||||
IsPro: u.IsPro,
|
||||
DefaultChannel: u.DefaultChannel(),
|
||||
}
|
||||
}
|
||||
|
||||
func (u User) JSONWithClients(clients []Client) UserJSONWithClients {
|
||||
return UserJSONWithClients{
|
||||
UserJSON: u.JSON(),
|
||||
Clients: langext.ArrMap(clients, func(v Client) ClientJSON { return v.JSON() }),
|
||||
}
|
||||
}
|
||||
|
||||
func (u User) MaxContentLength() int {
|
||||
if u.IsPro {
|
||||
return 16384
|
||||
} else {
|
||||
return 2048
|
||||
}
|
||||
}
|
||||
|
||||
func (u User) MaxTitleLength() int {
|
||||
return 120
|
||||
}
|
||||
|
||||
func (u User) QuotaPerDay() int {
|
||||
if u.IsPro {
|
||||
return 1000
|
||||
} else {
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
func (u User) QuotaUsedToday() int {
|
||||
now := scn.QuotaDayString()
|
||||
if u.QuotaUsedDay != nil && *u.QuotaUsedDay == now {
|
||||
return u.QuotaUsed
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (u User) QuotaRemainingToday() int {
|
||||
return u.QuotaPerDay() - u.QuotaUsedToday()
|
||||
}
|
||||
|
||||
func (u User) DefaultChannel() string {
|
||||
return "main"
|
||||
}
|
||||
|
||||
func (u User) DefaultPriority() int {
|
||||
return 1
|
||||
}
|
||||
|
||||
func (u User) MaxChannelNameLength() int {
|
||||
return 120
|
||||
}
|
||||
|
||||
func (u User) MaxSenderName() int {
|
||||
return 120
|
||||
}
|
||||
|
||||
func (u User) MaxUserMessageID() int {
|
||||
return 64
|
||||
}
|
||||
|
||||
func (u User) MaxTimestampDiffHours() int {
|
||||
return 24
|
||||
}
|
||||
|
||||
type UserJSON struct {
|
||||
UserID UserID `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_lastread"`
|
||||
TimestampLastSent *string `json:"timestamp_lastsent"`
|
||||
MessagesSent int `json:"messages_sent"`
|
||||
QuotaUsed int `json:"quota_used"`
|
||||
QuotaRemaining int `json:"quota_remaining"`
|
||||
QuotaPerDay int `json:"quota_max"`
|
||||
IsPro bool `json:"is_pro"`
|
||||
DefaultChannel string `json:"default_channel"`
|
||||
}
|
||||
|
||||
type UserJSONWithClients struct {
|
||||
UserJSON
|
||||
Clients []ClientJSON `json:"clients"`
|
||||
}
|
||||
|
||||
type UserDB struct {
|
||||
UserID UserID `db:"user_id"`
|
||||
Username *string `db:"username"`
|
||||
SendKey string `db:"send_key"`
|
||||
ReadKey string `db:"read_key"`
|
||||
AdminKey string `db:"admin_key"`
|
||||
TimestampCreated int64 `db:"timestamp_created"`
|
||||
TimestampLastRead *int64 `db:"timestamp_lastread"`
|
||||
TimestampLastSent *int64 `db:"timestamp_lastsent"`
|
||||
MessagesSent int `db:"messages_sent"`
|
||||
QuotaUsed int `db:"quota_used"`
|
||||
QuotaUsedDay *string `db:"quota_used_day"`
|
||||
IsPro bool `db:"is_pro"`
|
||||
ProToken *string `db:"pro_token"`
|
||||
}
|
||||
|
||||
func (u UserDB) Model() User {
|
||||
return User{
|
||||
UserID: u.UserID,
|
||||
Username: u.Username,
|
||||
SendKey: u.SendKey,
|
||||
ReadKey: u.ReadKey,
|
||||
AdminKey: u.AdminKey,
|
||||
TimestampCreated: time.UnixMilli(u.TimestampCreated),
|
||||
TimestampLastRead: timeOptFromMilli(u.TimestampLastRead),
|
||||
TimestampLastSent: timeOptFromMilli(u.TimestampLastSent),
|
||||
MessagesSent: u.MessagesSent,
|
||||
QuotaUsed: u.QuotaUsed,
|
||||
QuotaUsedDay: u.QuotaUsedDay,
|
||||
IsPro: u.IsPro,
|
||||
}
|
||||
}
|
||||
|
||||
func DecodeUser(r *sqlx.Rows) (User, error) {
|
||||
data, err := sq.ScanSingle[UserDB](r, true)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return data.Model(), nil
|
||||
}
|
||||
|
||||
func DecodeUsers(r *sqlx.Rows) ([]User, error) {
|
||||
data, err := sq.ScanAll[UserDB](r, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return langext.ArrMap(data, func(v UserDB) User { return v.Model() }), nil
|
||||
}
|
21
scnserver/models/utils.go
Normal file
21
scnserver/models/utils.go
Normal file
@@ -0,0 +1,21 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func timeOptFromMilli(millis *int64) *time.Time {
|
||||
if millis == nil {
|
||||
return nil
|
||||
}
|
||||
return langext.Ptr(time.UnixMilli(*millis))
|
||||
}
|
17
scnserver/push/dummy.go
Normal file
17
scnserver/push/dummy.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"context"
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
type DummyConnector struct{}
|
||||
|
||||
func NewDummy() NotificationClient {
|
||||
return &DummyConnector{}
|
||||
}
|
||||
|
||||
func (d DummyConnector) SendNotification(ctx context.Context, client models.Client, msg models.Message) (string, error) {
|
||||
return "%DUMMY%", nil
|
||||
}
|
128
scnserver/push/firebase.go
Normal file
128
scnserver/push/firebase.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://firebase.google.com/docs/cloud-messaging/send-message#rest
|
||||
// https://firebase.google.com/docs/cloud-messaging/auth-server
|
||||
|
||||
type FirebaseConnector struct {
|
||||
fbProject string
|
||||
client http.Client
|
||||
auth *FirebaseOAuth2
|
||||
}
|
||||
|
||||
func NewFirebaseConn(conf scn.Config) (NotificationClient, error) {
|
||||
|
||||
fbauth, err := NewAuth(conf.FirebaseTokenURI, conf.FirebaseProjectID, conf.FirebaseClientMail, conf.FirebasePrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FirebaseConnector{
|
||||
fbProject: conf.FirebaseProjectID,
|
||||
client: http.Client{Timeout: 5 * time.Second},
|
||||
auth: fbauth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Id string
|
||||
Token string
|
||||
Platform string
|
||||
Title string
|
||||
Body string
|
||||
Priority int
|
||||
}
|
||||
|
||||
func (fb FirebaseConnector) SendNotification(ctx context.Context, client models.Client, msg models.Message) (string, error) {
|
||||
|
||||
uri := "https://fcm.googleapis.com/v1/projects/" + fb.fbProject + "/messages:send"
|
||||
|
||||
jsonBody := gin.H{
|
||||
"data": gin.H{
|
||||
"scn_msg_id": msg.SCNMessageID.String(),
|
||||
"usr_msg_id": langext.Coalesce(msg.UserMessageID, ""),
|
||||
"client_id": client.ClientID.String(),
|
||||
"timestamp": strconv.FormatInt(msg.Timestamp().Unix(), 10),
|
||||
"priority": strconv.Itoa(msg.Priority),
|
||||
"trimmed": langext.Conditional(msg.NeedsTrim(), "true", "false"),
|
||||
"title": msg.Title,
|
||||
"body": langext.Coalesce(msg.TrimmedContent(), ""),
|
||||
},
|
||||
"token": *client.FCMToken,
|
||||
"android": gin.H{
|
||||
"priority": "high",
|
||||
},
|
||||
"apns": gin.H{},
|
||||
}
|
||||
if client.Type == models.ClientTypeIOS {
|
||||
jsonBody["notification"] = gin.H{
|
||||
"title": msg.Title,
|
||||
"body": msg.ShortContent(),
|
||||
}
|
||||
}
|
||||
|
||||
bytesBody, err := json.Marshal(gin.H{"message": jsonBody})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, "POST", uri, bytes.NewBuffer(bytesBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tok, err := fb.auth.Token(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Refreshing FB token failed")
|
||||
return "", err
|
||||
}
|
||||
|
||||
request.Header.Set("Authorization", "Bearer "+tok)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := fb.client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
if bstr, err := io.ReadAll(response.Body); err == nil {
|
||||
return "", errors.New(fmt.Sprintf("FCM-Request returned %d: %s", response.StatusCode, string(bstr)))
|
||||
} else {
|
||||
return "", errors.New(fmt.Sprintf("FCM-Request returned %d", response.StatusCode))
|
||||
}
|
||||
}
|
||||
|
||||
respBodyBin, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var respBody struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return respBody.Name, nil
|
||||
}
|
10
scnserver/push/notificationClient.go
Normal file
10
scnserver/push/notificationClient.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"context"
|
||||
)
|
||||
|
||||
type NotificationClient interface {
|
||||
SendNotification(ctx context.Context, client models.Client, msg models.Message) (string, error)
|
||||
}
|
179
scnserver/push/oauth2.go
Normal file
179
scnserver/push/oauth2.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FirebaseOAuth2 struct {
|
||||
client *http.Client
|
||||
|
||||
scopes []string
|
||||
tokenURL string
|
||||
privateKeyID string
|
||||
clientMail string
|
||||
|
||||
currToken *string
|
||||
tokenExpiry *time.Time
|
||||
privateKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
func NewAuth(tokenURL string, privKeyID string, cmail string, pemstr string) (*FirebaseOAuth2, error) {
|
||||
|
||||
pkey, err := decodePemKey(pemstr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FirebaseOAuth2{
|
||||
client: &http.Client{Timeout: 3 * time.Second},
|
||||
tokenURL: tokenURL,
|
||||
privateKey: pkey,
|
||||
privateKeyID: privKeyID,
|
||||
clientMail: cmail,
|
||||
scopes: []string{
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/datastore",
|
||||
"https://www.googleapis.com/auth/devstorage.full_control",
|
||||
"https://www.googleapis.com/auth/firebase",
|
||||
"https://www.googleapis.com/auth/identitytoolkit",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodePemKey(pemstr string) (*rsa.PrivateKey, error) {
|
||||
var raw []byte
|
||||
|
||||
block, _ := pem.Decode([]byte(pemstr))
|
||||
|
||||
if block != nil {
|
||||
raw = block.Bytes
|
||||
} else {
|
||||
raw = []byte(pemstr)
|
||||
}
|
||||
|
||||
pkey8, err1 := x509.ParsePKCS8PrivateKey(raw)
|
||||
if err1 == nil {
|
||||
privkey, ok := pkey8.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("private key is invalid")
|
||||
}
|
||||
return privkey, nil
|
||||
}
|
||||
|
||||
pkey1, err2 := x509.ParsePKCS1PrivateKey(raw)
|
||||
if err2 == nil {
|
||||
return pkey1, nil
|
||||
}
|
||||
|
||||
return nil, errors.New(fmt.Sprintf("failed to parse private-key: [ %v | %v ]", err1, err2))
|
||||
}
|
||||
|
||||
func (a *FirebaseOAuth2) Token(ctx context.Context) (string, error) {
|
||||
if a.currToken == nil || a.tokenExpiry == nil || a.tokenExpiry.Before(time.Now()) {
|
||||
err := a.Refresh(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return *a.currToken, nil
|
||||
}
|
||||
|
||||
func (a *FirebaseOAuth2) Refresh(ctx context.Context) error {
|
||||
|
||||
assertion, err := a.encodeAssertion(a.privateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := url.Values{
|
||||
"assertion": []string{assertion},
|
||||
"grant_type": []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"},
|
||||
}.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
reqNow := time.Now()
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if bstr, err := io.ReadAll(resp.Body); err == nil {
|
||||
return errors.New(fmt.Sprintf("Auth-Request returned %d: %s", resp.StatusCode, string(bstr)))
|
||||
} else {
|
||||
return errors.New(fmt.Sprintf("Auth-Request returned %d", resp.StatusCode))
|
||||
}
|
||||
}
|
||||
|
||||
respBodyBin, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var respBody struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.currToken = langext.Ptr(respBody.AccessToken)
|
||||
a.tokenExpiry = langext.Ptr(reqNow.Add(timeext.FromSeconds(respBody.ExpiresIn)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *FirebaseOAuth2) encodeAssertion(key *rsa.PrivateKey) (string, error) {
|
||||
headBin, err := json.Marshal(gin.H{"alg": "RS256", "typ": "JWT", "kid": a.privateKeyID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
head := base64.RawURLEncoding.EncodeToString(headBin)
|
||||
|
||||
now := time.Now().Add(-10 * time.Second) // jwt hack against unsynced clocks
|
||||
|
||||
claimBin, err := json.Marshal(gin.H{"iss": a.clientMail, "scope": strings.Join(a.scopes, " "), "aud": a.tokenURL, "exp": now.Add(time.Hour).Unix(), "iat": now.Unix()})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claim := base64.RawURLEncoding.EncodeToString(claimBin)
|
||||
|
||||
checksum := sha256.New()
|
||||
checksum.Write([]byte(head + "." + claim))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, checksum.Sum(nil))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return head + "." + claim + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
41
scnserver/push/testSink.go
Normal file
41
scnserver/push/testSink.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"context"
|
||||
_ "embed"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
)
|
||||
|
||||
type SinkData struct {
|
||||
Message models.Message
|
||||
Client models.Client
|
||||
}
|
||||
|
||||
type TestSink struct {
|
||||
Data []SinkData
|
||||
}
|
||||
|
||||
func NewTestSink() NotificationClient {
|
||||
return &TestSink{}
|
||||
}
|
||||
|
||||
func (d *TestSink) Last() SinkData {
|
||||
return d.Data[len(d.Data)-1]
|
||||
}
|
||||
|
||||
func (d *TestSink) SendNotification(ctx context.Context, client models.Client, msg models.Message) (string, error) {
|
||||
id, err := langext.NewHexUUID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key := "TestSink[" + id + "]"
|
||||
|
||||
d.Data = append(d.Data, SinkData{
|
||||
Message: msg,
|
||||
Client: client,
|
||||
})
|
||||
|
||||
return key, nil
|
||||
}
|
35
scnserver/swagger/index.html
Normal file
35
scnserver/swagger/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>API Documentation</title>
|
||||
<link rel="stylesheet" href="swagger-ui.css" />
|
||||
<!-- <link rel="stylesheet" href="themes/theme-feeling-blue.css" /> -->
|
||||
<!-- <link rel="stylesheet" href="themes/theme-flattop.css" /> -->
|
||||
<!-- <link rel="stylesheet" href="themes/theme-material.css" /> -->
|
||||
<!-- <link rel="stylesheet" href="themes/theme-monokai.css" /> -->
|
||||
<!-- <link rel="stylesheet" href="themes/theme-muted.css" /> -->
|
||||
<!-- <link rel="stylesheet" href="themes/theme-newspaper.css" /> -->
|
||||
<!-- <link rel="stylesheet" href="themes/theme-outline.css" /> -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="swagger-ui-bundle.js" crossorigin></script>
|
||||
<script src="swagger-ui-standalone-preset.js" crossorigin></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: './swagger.json',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
defaultModelsExpandDepth: 0,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
3
scnserver/swagger/swagger-ui-bundle.js
Normal file
3
scnserver/swagger/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
scnserver/swagger/swagger-ui-standalone-preset.js
Normal file
3
scnserver/swagger/swagger-ui-standalone-preset.js
Normal file
File diff suppressed because one or more lines are too long
4
scnserver/swagger/swagger-ui.css
Normal file
4
scnserver/swagger/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
70
scnserver/swagger/swagger.go
Normal file
70
scnserver/swagger/swagger.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package swagger
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"embed"
|
||||
_ "embed"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed *.html
|
||||
//go:embed *.json
|
||||
//go:embed *.yaml
|
||||
//go:embed *.js
|
||||
//go:embed *.css
|
||||
//go:embed themes/*
|
||||
var assets embed.FS
|
||||
|
||||
func getAsset(fn string) ([]byte, string, bool) {
|
||||
data, err := assets.ReadFile(fn)
|
||||
if err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
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 data, mime, true
|
||||
}
|
||||
|
||||
func Handle(g *gin.Context) ginresp.HTTPResponse {
|
||||
type uri struct {
|
||||
Filename string `uri:"sub"`
|
||||
}
|
||||
|
||||
var u uri
|
||||
if err := g.ShouldBindUri(&u); err != nil {
|
||||
return ginresp.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
|
||||
u.Filename = strings.TrimLeft(u.Filename, "/")
|
||||
|
||||
if u.Filename == "" {
|
||||
index, _, _ := getAsset("index.html")
|
||||
return ginresp.Data(http.StatusOK, "text/html", index)
|
||||
}
|
||||
|
||||
if data, mime, ok := getAsset(u.Filename); ok {
|
||||
return ginresp.Data(http.StatusOK, mime, data)
|
||||
}
|
||||
|
||||
return ginresp.JSON(http.StatusNotFound, gin.H{"error": "AssetNotFound", "filename": u.Filename})
|
||||
}
|
3075
scnserver/swagger/swagger.json
Normal file
3075
scnserver/swagger/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
2064
scnserver/swagger/swagger.yaml
Normal file
2064
scnserver/swagger/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1672
scnserver/swagger/themes/theme-feeling-blue.css
Normal file
1672
scnserver/swagger/themes/theme-feeling-blue.css
Normal file
File diff suppressed because it is too large
Load Diff
1672
scnserver/swagger/themes/theme-flattop.css
Normal file
1672
scnserver/swagger/themes/theme-flattop.css
Normal file
File diff suppressed because it is too large
Load Diff
1719
scnserver/swagger/themes/theme-material.css
Normal file
1719
scnserver/swagger/themes/theme-material.css
Normal file
File diff suppressed because it is too large
Load Diff
1792
scnserver/swagger/themes/theme-monokai.css
Normal file
1792
scnserver/swagger/themes/theme-monokai.css
Normal file
File diff suppressed because it is too large
Load Diff
1673
scnserver/swagger/themes/theme-muted.css
Normal file
1673
scnserver/swagger/themes/theme-muted.css
Normal file
File diff suppressed because it is too large
Load Diff
1671
scnserver/swagger/themes/theme-newspaper.css
Normal file
1671
scnserver/swagger/themes/theme-newspaper.css
Normal file
File diff suppressed because it is too large
Load Diff
1652
scnserver/swagger/themes/theme-outline.css
Normal file
1652
scnserver/swagger/themes/theme-outline.css
Normal file
File diff suppressed because it is too large
Load Diff
164
scnserver/test/channel_test.go
Normal file
164
scnserver/test/channel_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
tt "blackforestbytes.com/simplecloudnotifier/test/util"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateChannel(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
uid := int(r0["user_id"].(float64))
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
type chanlist struct {
|
||||
Channels []gin.H `json:"channels"`
|
||||
}
|
||||
|
||||
{
|
||||
chan0 := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
|
||||
tt.AssertEqual(t, "chan-count", 0, len(chan0.Channels))
|
||||
}
|
||||
|
||||
tt.RequestAuthPost[gin.H](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": "test",
|
||||
})
|
||||
|
||||
{
|
||||
clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
|
||||
tt.AssertEqual(t, "chan.len", 1, len(clist.Channels))
|
||||
tt.AssertEqual(t, "chan.name", "test", clist.Channels[0]["name"])
|
||||
}
|
||||
|
||||
tt.RequestAuthPost[gin.H](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": "asdf",
|
||||
})
|
||||
|
||||
{
|
||||
clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
|
||||
tt.AssertEqual(t, "chan-count", 2, len(clist.Channels))
|
||||
tt.AssertArrAny(t, "chan.has('asdf')", clist.Channels, func(msg gin.H) bool { return msg["name"].(string) == "asdf" })
|
||||
tt.AssertArrAny(t, "chan.has('test')", clist.Channels, func(msg gin.H) bool { return msg["name"].(string) == "test" })
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChannelNameTooLong(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
uid := int(r0["user_id"].(float64))
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
tt.RequestAuthPostShouldFail(t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": langext.StrRepeat("X", 121),
|
||||
}, 400, apierr.CHANNEL_TOO_LONG)
|
||||
}
|
||||
|
||||
func TestChannelNameNormalization(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
uid := int(r0["user_id"].(float64))
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
type chanlist struct {
|
||||
Channels []gin.H `json:"channels"`
|
||||
}
|
||||
|
||||
{
|
||||
chan0 := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
|
||||
tt.AssertEqual(t, "chan-count", 0, len(chan0.Channels))
|
||||
}
|
||||
|
||||
tt.RequestAuthPost[gin.H](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": "tESt",
|
||||
})
|
||||
|
||||
{
|
||||
clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
|
||||
tt.AssertEqual(t, "chan.len", 1, len(clist.Channels))
|
||||
tt.AssertEqual(t, "chan.name", "test", clist.Channels[0]["name"])
|
||||
}
|
||||
|
||||
tt.RequestAuthPostShouldFail(t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": "test",
|
||||
}, 409, apierr.CHANNEL_ALREADY_EXISTS)
|
||||
|
||||
tt.RequestAuthPostShouldFail(t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": "TEST",
|
||||
}, 409, apierr.CHANNEL_ALREADY_EXISTS)
|
||||
|
||||
tt.RequestAuthPostShouldFail(t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": "Test",
|
||||
}, 409, apierr.CHANNEL_ALREADY_EXISTS)
|
||||
|
||||
tt.RequestAuthPostShouldFail(t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": "Test ",
|
||||
}, 409, apierr.CHANNEL_ALREADY_EXISTS)
|
||||
|
||||
tt.RequestAuthPostShouldFail(t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": " Test",
|
||||
}, 409, apierr.CHANNEL_ALREADY_EXISTS)
|
||||
|
||||
tt.RequestAuthPostShouldFail(t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
|
||||
"name": " T e s t ",
|
||||
}, 409, apierr.CHANNEL_ALREADY_EXISTS)
|
||||
|
||||
{
|
||||
clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
|
||||
tt.AssertEqual(t, "chan.len", 1, len(clist.Channels))
|
||||
tt.AssertEqual(t, "chan.name", "test", clist.Channels[0]["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannels(t *testing.T) {
|
||||
t.SkipNow() //TODO
|
||||
}
|
||||
|
||||
func TestListChannelsOwned(t *testing.T) {
|
||||
t.SkipNow() //TODO
|
||||
}
|
||||
|
||||
func TestListChannelsSubscribedAny(t *testing.T) {
|
||||
t.SkipNow() //TODO
|
||||
}
|
||||
|
||||
func TestListChannelsAllAny(t *testing.T) {
|
||||
t.SkipNow() //TODO
|
||||
}
|
||||
|
||||
func TestListChannelsSubscribed(t *testing.T) {
|
||||
t.SkipNow() //TODO
|
||||
}
|
||||
|
||||
func TestListChannelsAll(t *testing.T) {
|
||||
t.SkipNow() //TODO
|
||||
}
|
||||
|
||||
//TODO test missing channel-xx methods
|
149
scnserver/test/client_test.go
Normal file
149
scnserver/test/client_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
tt "blackforestbytes.com/simplecloudnotifier/test/util"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetClient(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
uid := fmt.Sprintf("%v", r0["user_id"])
|
||||
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r0["clients"].([]any)))
|
||||
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
fmt.Printf("uid := %s\n", uid)
|
||||
fmt.Printf("admin_key := %s\n", admintok)
|
||||
|
||||
r1 := tt.RequestAuthGet[gin.H](t, admintok, baseUrl, "/api/users/"+uid)
|
||||
|
||||
tt.AssertEqual(t, "uid", uid, fmt.Sprintf("%v", r1["user_id"]))
|
||||
tt.AssertEqual(t, "admin_key", admintok, r1["admin_key"])
|
||||
tt.AssertEqual(t, "username", nil, r1["username"])
|
||||
|
||||
type rt2 struct {
|
||||
Clients []gin.H `json:"clients"`
|
||||
}
|
||||
|
||||
r2 := tt.RequestAuthGet[rt2](t, admintok, baseUrl, "/api/users/"+uid+"/clients")
|
||||
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r2.Clients))
|
||||
|
||||
c0 := r2.Clients[0]
|
||||
|
||||
tt.AssertEqual(t, "agent_model", "DUMMY_PHONE", c0["agent_model"])
|
||||
tt.AssertEqual(t, "agent_version", "4X", c0["agent_version"])
|
||||
tt.AssertEqual(t, "fcm_token", "DUMMY_FCM", c0["fcm_token"])
|
||||
tt.AssertEqual(t, "client_type", "ANDROID", c0["type"])
|
||||
tt.AssertEqual(t, "user_id", uid, fmt.Sprintf("%v", c0["user_id"]))
|
||||
|
||||
cid := fmt.Sprintf("%v", c0["client_id"])
|
||||
|
||||
r3 := tt.RequestAuthGet[gin.H](t, admintok, baseUrl, "/api/users/"+uid+"/clients/"+cid)
|
||||
|
||||
tt.AssertJsonMapEqual(t, "client", r3, c0)
|
||||
}
|
||||
|
||||
func TestCreateAndDeleteClient(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
uid := fmt.Sprintf("%v", r0["user_id"])
|
||||
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r0["clients"].([]any)))
|
||||
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
fmt.Printf("uid := %s\n", uid)
|
||||
fmt.Printf("admin_key := %s\n", admintok)
|
||||
|
||||
r2 := tt.RequestAuthPost[gin.H](t, admintok, baseUrl, "/api/users/"+uid+"/clients", gin.H{
|
||||
"agent_model": "DUMMY_PHONE_2",
|
||||
"agent_version": "99X",
|
||||
"client_type": "IOS",
|
||||
"fcm_token": "DUMMY_FCM_2",
|
||||
})
|
||||
|
||||
cid2 := fmt.Sprintf("%v", r2["client_id"])
|
||||
|
||||
type rt3 struct {
|
||||
Clients []gin.H `json:"clients"`
|
||||
}
|
||||
|
||||
r3 := tt.RequestAuthGet[rt3](t, admintok, baseUrl, "/api/users/"+uid+"/clients")
|
||||
tt.AssertEqual(t, "len(clients)", 2, len(r3.Clients))
|
||||
|
||||
r4 := tt.RequestAuthDelete[gin.H](t, admintok, baseUrl, "/api/users/"+uid+"/clients/"+cid2, nil)
|
||||
tt.AssertEqual(t, "client_id", cid2, fmt.Sprintf("%v", r4["client_id"]))
|
||||
|
||||
r5 := tt.RequestAuthGet[rt3](t, admintok, baseUrl, "/api/users/"+uid+"/clients")
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r5.Clients))
|
||||
}
|
||||
|
||||
func TestReuseFCM(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM_001",
|
||||
})
|
||||
|
||||
uid := fmt.Sprintf("%v", r0["user_id"])
|
||||
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r0["clients"].([]any)))
|
||||
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
fmt.Printf("uid := %s\n", uid)
|
||||
fmt.Printf("admin_key := %s\n", admintok)
|
||||
|
||||
type rt2 struct {
|
||||
Clients []gin.H `json:"clients"`
|
||||
}
|
||||
|
||||
r1 := tt.RequestAuthGet[rt2](t, admintok, baseUrl, "/api/users/"+uid+"/clients")
|
||||
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r1.Clients))
|
||||
|
||||
r2 := tt.RequestAuthPost[gin.H](t, admintok, baseUrl, "/api/users/"+uid+"/clients", gin.H{
|
||||
"agent_model": "DUMMY_PHONE_2",
|
||||
"agent_version": "99X",
|
||||
"client_type": "IOS",
|
||||
"fcm_token": "DUMMY_FCM_001",
|
||||
})
|
||||
|
||||
cid2 := fmt.Sprintf("%v", r2["client_id"])
|
||||
|
||||
type rt3 struct {
|
||||
Clients []gin.H `json:"clients"`
|
||||
}
|
||||
|
||||
r3 := tt.RequestAuthGet[rt3](t, admintok, baseUrl, "/api/users/"+uid+"/clients")
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r3.Clients))
|
||||
|
||||
tt.AssertEqual(t, "clients->client_id", cid2, fmt.Sprintf("%v", r3.Clients[0]["client_id"]))
|
||||
}
|
||||
|
||||
//TODO test missing client-xx methods
|
3
scnserver/test/compat_test.go
Normal file
3
scnserver/test/compat_test.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package test
|
||||
|
||||
//TODO test compat methods
|
176
scnserver/test/message_test.go
Normal file
176
scnserver/test/message_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
tt "blackforestbytes.com/simplecloudnotifier/test/util"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearchMessageFTSSimple(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
type mglist struct {
|
||||
Messages []gin.H `json:"messages"`
|
||||
}
|
||||
|
||||
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/messages?filter=%s", url.QueryEscape("Friday")))
|
||||
tt.AssertEqual(t, "msgList.len", 2, len(msgList.Messages))
|
||||
tt.AssertArrAny(t, "msgList.any<1>", msgList.Messages, func(msg gin.H) bool { return msg["title"].(string) == "Invitation" })
|
||||
tt.AssertArrAny(t, "msgList.any<2>", msgList.Messages, func(msg gin.H) bool { return msg["title"].(string) == "Important notice" })
|
||||
}
|
||||
|
||||
func TestSearchMessageFTSMulti(t *testing.T) {
|
||||
//TODO search for messages by FTS
|
||||
}
|
||||
|
||||
//TODO more search/list/filter message tests
|
||||
|
||||
//TODO list messages by chan_key
|
||||
|
||||
//TODO (fail to) list messages from channel that you cannot see
|
||||
|
||||
func TestDeleteMessage(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
uid := int(r0["user_id"].(float64))
|
||||
sendtok := r0["send_key"].(string)
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
msg1 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
|
||||
"user_key": sendtok,
|
||||
"user_id": uid,
|
||||
"title": "Message_1",
|
||||
})
|
||||
|
||||
tt.RequestAuthGet[tt.Void](t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]))
|
||||
|
||||
tt.RequestAuthDelete[tt.Void](t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]), gin.H{})
|
||||
|
||||
tt.RequestAuthGetShouldFail(t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]), 404, apierr.MESSAGE_NOT_FOUND)
|
||||
}
|
||||
|
||||
func TestDeleteMessageAndResendUsrMsgId(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
uid := int(r0["user_id"].(float64))
|
||||
sendtok := r0["send_key"].(string)
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
msg1 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
|
||||
"user_key": sendtok,
|
||||
"user_id": uid,
|
||||
"title": "Message_1",
|
||||
"msg_id": "bef8dd3d-078e-4f89-abf4-5258ad22a2e4",
|
||||
})
|
||||
|
||||
tt.AssertEqual(t, "suppress_send", false, msg1["suppress_send"])
|
||||
|
||||
tt.RequestAuthGet[tt.Void](t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]))
|
||||
|
||||
msg2 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
|
||||
"user_key": sendtok,
|
||||
"user_id": uid,
|
||||
"title": "Message_1",
|
||||
"msg_id": "bef8dd3d-078e-4f89-abf4-5258ad22a2e4",
|
||||
})
|
||||
|
||||
tt.AssertEqual(t, "suppress_send", true, msg2["suppress_send"])
|
||||
|
||||
tt.RequestAuthDelete[tt.Void](t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]), gin.H{})
|
||||
|
||||
// even though message is deleted, we still get a `suppress_send` on send_message
|
||||
|
||||
msg3 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
|
||||
"user_key": sendtok,
|
||||
"user_id": uid,
|
||||
"title": "Message_1",
|
||||
"msg_id": "bef8dd3d-078e-4f89-abf4-5258ad22a2e4",
|
||||
})
|
||||
|
||||
tt.AssertEqual(t, "suppress_send", true, msg3["suppress_send"])
|
||||
|
||||
}
|
||||
|
||||
func TestGetMessageSimple(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
msgOut := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
|
||||
"user_key": data.User[0].SendKey,
|
||||
"user_id": data.User[0].UID,
|
||||
"title": "Message_1",
|
||||
})
|
||||
|
||||
msgIn := tt.RequestAuthGet[gin.H](t, data.User[0].AdminKey, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msgOut["scn_msg_id"]))
|
||||
|
||||
tt.AssertEqual(t, "msg.title", "Message_1", msgIn["title"])
|
||||
}
|
||||
|
||||
func TestGetMessageNotFound(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
tt.RequestAuthGetShouldFail(t, data.User[0].AdminKey, baseUrl, "/api/messages/8963586", 404, apierr.MESSAGE_NOT_FOUND)
|
||||
}
|
||||
|
||||
func TestGetMessageFull(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
ts := time.Now().Unix() - 735
|
||||
content := tt.ShortLipsum0(2)
|
||||
|
||||
msgOut := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
|
||||
"user_key": data.User[0].SendKey,
|
||||
"user_id": data.User[0].UID,
|
||||
"title": "Message_1",
|
||||
"content": content,
|
||||
"channel": "demo-channel-007",
|
||||
"msg_id": "580b5055-a9b5-4cee-b53c-28cf304d25b0",
|
||||
"priority": 0,
|
||||
"sender_name": "unit-test-[TestGetMessageFull]",
|
||||
"timestamp": ts,
|
||||
})
|
||||
|
||||
msgIn := tt.RequestAuthGet[gin.H](t, data.User[0].AdminKey, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msgOut["scn_msg_id"]))
|
||||
|
||||
tt.AssertEqual(t, "msg.title", "Message_1", msgIn["title"])
|
||||
tt.AssertEqual(t, "msg.content", content, msgIn["content"])
|
||||
tt.AssertEqual(t, "msg.channel", "demo-channel-007", msgIn["channel_name"])
|
||||
tt.AssertEqual(t, "msg.msg_id", "580b5055-a9b5-4cee-b53c-28cf304d25b0", msgIn["usr_message_id"])
|
||||
tt.AssertStrRepEqual(t, "msg.priority", 0, msgIn["priority"])
|
||||
tt.AssertEqual(t, "msg.sender_name", "unit-test-[TestGetMessageFull]", msgIn["sender_name"])
|
||||
tt.AssertEqual(t, "msg.timestamp", time.Unix(ts, 0).In(timeext.TimezoneBerlin).Format(time.RFC3339Nano), msgIn["timestamp"])
|
||||
}
|
||||
|
||||
//TODO test pagination
|
1471
scnserver/test/send_test.go
Normal file
1471
scnserver/test/send_test.go
Normal file
File diff suppressed because it is too large
Load Diff
3
scnserver/test/subscription_test.go
Normal file
3
scnserver/test/subscription_test.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package test
|
||||
|
||||
//TODO test missing subscription-xx methods
|
262
scnserver/test/user_test.go
Normal file
262
scnserver/test/user_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
tt "blackforestbytes.com/simplecloudnotifier/test/util"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateUserNoClient(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"no_client": true,
|
||||
})
|
||||
|
||||
tt.AssertEqual(t, "len(clients)", 0, len(r0["clients"].([]any)))
|
||||
|
||||
uid := fmt.Sprintf("%v", r0["user_id"])
|
||||
admintok := r0["admin_key"].(string)
|
||||
readtok := r0["read_key"].(string)
|
||||
sendtok := r0["send_key"].(string)
|
||||
|
||||
tt.RequestAuthGetShouldFail(t, sendtok, baseUrl, "/api/users/"+uid, 401, apierr.USER_AUTH_FAILED)
|
||||
tt.RequestAuthGetShouldFail(t, "", baseUrl, "/api/users/"+uid, 401, apierr.USER_AUTH_FAILED)
|
||||
|
||||
r1 := tt.RequestAuthGet[gin.H](t, readtok, baseUrl, "/api/users/"+uid)
|
||||
|
||||
tt.AssertEqual(t, "uid", uid, fmt.Sprintf("%v", r1["user_id"]))
|
||||
tt.AssertEqual(t, "admin_key", admintok, r1["admin_key"])
|
||||
}
|
||||
|
||||
func TestCreateUserDummyClient(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
uid := fmt.Sprintf("%v", r0["user_id"])
|
||||
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r0["clients"].([]any)))
|
||||
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
r1 := tt.RequestAuthGet[gin.H](t, admintok, baseUrl, "/api/users/"+uid)
|
||||
|
||||
tt.AssertEqual(t, "uid", uid, fmt.Sprintf("%v", r1["user_id"]))
|
||||
tt.AssertEqual(t, "admin_key", admintok, r1["admin_key"])
|
||||
tt.AssertEqual(t, "username", nil, r1["username"])
|
||||
|
||||
type rt2 struct {
|
||||
Clients []gin.H `json:"clients"`
|
||||
}
|
||||
|
||||
r2 := tt.RequestAuthGet[rt2](t, admintok, baseUrl, "/api/users/"+uid+"/clients")
|
||||
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r2.Clients))
|
||||
|
||||
c0 := r2.Clients[0]
|
||||
|
||||
tt.AssertEqual(t, "agent_model", "DUMMY_PHONE", c0["agent_model"])
|
||||
tt.AssertEqual(t, "agent_version", "4X", c0["agent_version"])
|
||||
tt.AssertEqual(t, "fcm_token", "DUMMY_FCM", c0["fcm_token"])
|
||||
tt.AssertEqual(t, "client_type", "ANDROID", c0["type"])
|
||||
}
|
||||
|
||||
func TestCreateUserWithUsername(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
"username": "my_user",
|
||||
})
|
||||
|
||||
tt.AssertEqual(t, "len(clients)", 1, len(r0["clients"].([]any)))
|
||||
|
||||
uid := fmt.Sprintf("%v", r0["user_id"])
|
||||
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
r1 := tt.RequestAuthGet[gin.H](t, admintok, baseUrl, "/api/users/"+uid)
|
||||
|
||||
tt.AssertEqual(t, "uid", uid, fmt.Sprintf("%v", r1["user_id"]))
|
||||
tt.AssertEqual(t, "admin_key", admintok, r1["admin_key"])
|
||||
tt.AssertEqual(t, "username", "my_user", r1["username"])
|
||||
}
|
||||
|
||||
func TestUpdateUsername(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
tt.AssertEqual(t, "username", nil, r0["username"])
|
||||
|
||||
uid := fmt.Sprintf("%v", r0["user_id"])
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
r1 := tt.RequestAuthPatch[gin.H](t, admintok, baseUrl, "/api/users/"+uid, gin.H{"username": "my_user_001"})
|
||||
tt.AssertEqual(t, "username", "my_user_001", r1["username"])
|
||||
|
||||
r2 := tt.RequestAuthGet[gin.H](t, admintok, baseUrl, "/api/users/"+uid)
|
||||
tt.AssertEqual(t, "username", "my_user_001", r2["username"])
|
||||
|
||||
r3 := tt.RequestAuthPatch[gin.H](t, admintok, baseUrl, "/api/users/"+uid, gin.H{"username": "my_user_002"})
|
||||
tt.AssertEqual(t, "username", "my_user_002", r3["username"])
|
||||
|
||||
r4 := tt.RequestAuthGet[gin.H](t, admintok, baseUrl, "/api/users/"+uid)
|
||||
tt.AssertEqual(t, "username", "my_user_002", r4["username"])
|
||||
|
||||
r5 := tt.RequestAuthPatch[gin.H](t, admintok, baseUrl, "/api/users/"+uid, gin.H{"username": ""})
|
||||
tt.AssertEqual(t, "username", nil, r5["username"])
|
||||
|
||||
r6 := tt.RequestAuthGet[gin.H](t, admintok, baseUrl, "/api/users/"+uid)
|
||||
tt.AssertEqual(t, "username", nil, r6["username"])
|
||||
}
|
||||
|
||||
func TestRecreateKeys(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
tt.AssertEqual(t, "username", nil, r0["username"])
|
||||
|
||||
uid := fmt.Sprintf("%v", r0["user_id"])
|
||||
|
||||
admintok := r0["admin_key"].(string)
|
||||
readtok := r0["read_key"].(string)
|
||||
sendtok := r0["send_key"].(string)
|
||||
|
||||
tt.RequestAuthPatchShouldFail(t, readtok, baseUrl, "/api/users/"+uid, gin.H{"read_key": true}, 401, apierr.USER_AUTH_FAILED)
|
||||
|
||||
tt.RequestAuthPatchShouldFail(t, sendtok, baseUrl, "/api/users/"+uid, gin.H{"read_key": true}, 401, apierr.USER_AUTH_FAILED)
|
||||
|
||||
r1 := tt.RequestAuthPatch[gin.H](t, admintok, baseUrl, "/api/users/"+uid, gin.H{})
|
||||
tt.AssertEqual(t, "admin_key", admintok, r1["admin_key"])
|
||||
tt.AssertEqual(t, "read_key", readtok, r1["read_key"])
|
||||
tt.AssertEqual(t, "send_key", sendtok, r1["send_key"])
|
||||
|
||||
r2 := tt.RequestAuthPatch[gin.H](t, admintok, baseUrl, "/api/users/"+uid, gin.H{"read_key": true})
|
||||
tt.AssertEqual(t, "admin_key", admintok, r2["admin_key"])
|
||||
tt.AssertNotEqual(t, "read_key", readtok, r2["read_key"])
|
||||
tt.AssertEqual(t, "send_key", sendtok, r2["send_key"])
|
||||
readtok = r2["read_key"].(string)
|
||||
|
||||
r3 := tt.RequestAuthPatch[gin.H](t, admintok, baseUrl, "/api/users/"+uid, gin.H{"read_key": true, "send_key": true})
|
||||
tt.AssertEqual(t, "admin_key", admintok, r3["admin_key"])
|
||||
tt.AssertNotEqual(t, "read_key", readtok, r3["read_key"])
|
||||
tt.AssertNotEqual(t, "send_key", sendtok, r3["send_key"])
|
||||
readtok = r3["read_key"].(string)
|
||||
sendtok = r3["send_key"].(string)
|
||||
|
||||
r4 := tt.RequestAuthGet[gin.H](t, admintok, baseUrl, "/api/users/"+uid)
|
||||
tt.AssertEqual(t, "admin_key", admintok, r4["admin_key"])
|
||||
tt.AssertEqual(t, "read_key", readtok, r4["read_key"])
|
||||
tt.AssertEqual(t, "send_key", sendtok, r4["send_key"])
|
||||
|
||||
r5 := tt.RequestAuthPatch[gin.H](t, admintok, baseUrl, "/api/users/"+uid, gin.H{"admin_key": true})
|
||||
tt.AssertNotEqual(t, "admin_key", admintok, r5["admin_key"])
|
||||
tt.AssertEqual(t, "read_key", readtok, r5["read_key"])
|
||||
tt.AssertEqual(t, "send_key", sendtok, r5["send_key"])
|
||||
admintokNew := r5["admin_key"].(string)
|
||||
|
||||
tt.RequestAuthGetShouldFail(t, admintok, baseUrl, "/api/users/"+uid, 401, apierr.USER_AUTH_FAILED)
|
||||
|
||||
r6 := tt.RequestAuthGet[gin.H](t, admintokNew, baseUrl, "/api/users/"+uid)
|
||||
tt.AssertEqual(t, "admin_key", admintokNew, r6["admin_key"])
|
||||
tt.AssertEqual(t, "read_key", readtok, r6["read_key"])
|
||||
tt.AssertEqual(t, "send_key", sendtok, r6["send_key"])
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
t.SkipNow() // TODO DeleteUser Not implemented
|
||||
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
uid := fmt.Sprintf("%v", r0["user_id"])
|
||||
admintok := r0["admin_key"].(string)
|
||||
|
||||
tt.RequestAuthGet[gin.H](t, admintok, baseUrl, "/api/users/"+uid)
|
||||
|
||||
tt.RequestAuthDeleteShouldFail(t, admintok, baseUrl, "/api/users/"+uid, nil, 401, apierr.USER_AUTH_FAILED)
|
||||
|
||||
tt.RequestAuthDelete[tt.Void](t, admintok, baseUrl, "/api/users/"+uid, nil)
|
||||
|
||||
tt.RequestAuthGetShouldFail(t, admintok, baseUrl, "/api/users/"+uid, 404, apierr.USER_NOT_FOUND)
|
||||
|
||||
}
|
||||
|
||||
func TestCreateProUser(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
{
|
||||
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"no_client": true,
|
||||
})
|
||||
|
||||
tt.AssertEqual(t, "is_pro", false, r0["is_pro"])
|
||||
}
|
||||
|
||||
{
|
||||
r1 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"no_client": true,
|
||||
"pro_token": "ANDROID|v2|PURCHASED:000",
|
||||
})
|
||||
|
||||
tt.AssertEqual(t, "is_pro", true, r1["is_pro"])
|
||||
}
|
||||
|
||||
{
|
||||
r2 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
})
|
||||
|
||||
tt.AssertEqual(t, "is_pro", false, r2["is_pro"])
|
||||
}
|
||||
|
||||
{
|
||||
r3 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
|
||||
"agent_model": "DUMMY_PHONE",
|
||||
"agent_version": "4X",
|
||||
"client_type": "ANDROID",
|
||||
"fcm_token": "DUMMY_FCM",
|
||||
"pro_token": "ANDROID|v2|PURCHASED:000",
|
||||
})
|
||||
|
||||
tt.AssertEqual(t, "is_pro", true, r3["is_pro"])
|
||||
}
|
||||
|
||||
}
|
201
scnserver/test/util/common.go
Normal file
201
scnserver/test/util/common.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func AssertJsonMapEqual(t *testing.T, key string, expected map[string]any, actual map[string]any) {
|
||||
mkeys := make(map[string]string)
|
||||
for k := range expected {
|
||||
mkeys[k] = k
|
||||
}
|
||||
for k := range actual {
|
||||
mkeys[k] = k
|
||||
}
|
||||
|
||||
for mapkey := range mkeys {
|
||||
|
||||
if _, ok := expected[mapkey]; !ok {
|
||||
TestFailFmt(t, "Missing Key expected['%s'] ( assertJsonMapEqual[%s] )", mapkey, key)
|
||||
}
|
||||
if _, ok := actual[mapkey]; !ok {
|
||||
TestFailFmt(t, "Missing Key actual['%s'] ( assertJsonMapEqual[%s] )", mapkey, key)
|
||||
}
|
||||
|
||||
AssertEqual(t, key+"."+mapkey, expected[mapkey], actual[mapkey])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func AssertEqual(t *testing.T, key string, expected any, actual any) {
|
||||
if expected != actual {
|
||||
t.Errorf("Value [%s] differs (%T <-> %T):\n", key, expected, actual)
|
||||
|
||||
strExp := fmt.Sprintf("%v", expected)
|
||||
strAct := fmt.Sprintf("%v", actual)
|
||||
|
||||
if strings.Contains(strAct, "\n") {
|
||||
t.Errorf("Actual:\n~~~~~~~~~~~~~~~~\n%v\n~~~~~~~~~~~~~~~~\n\n", actual)
|
||||
} else {
|
||||
t.Errorf("Actual := \"%v\"\n", actual)
|
||||
}
|
||||
|
||||
if strings.Contains(strExp, "\n") {
|
||||
t.Errorf("Expected:\n~~~~~~~~~~~~~~~~\n%v\n~~~~~~~~~~~~~~~~\n\n", expected)
|
||||
} else {
|
||||
t.Errorf("Expected := \"%v\"\n", expected)
|
||||
}
|
||||
|
||||
t.Error(string(debug.Stack()))
|
||||
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func AssertTrue(t *testing.T, key string, v bool) {
|
||||
if !v {
|
||||
t.Errorf("AssertTrue(%s) failed", key)
|
||||
t.Error(string(debug.Stack()))
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func AssertNotEqual(t *testing.T, key string, expected any, actual any) {
|
||||
if expected == actual {
|
||||
t.Errorf("Value [%s] does not differ (%T <-> %T):\n", key, expected, actual)
|
||||
|
||||
str1 := fmt.Sprintf("%v", expected)
|
||||
str2 := fmt.Sprintf("%v", actual)
|
||||
|
||||
if strings.Contains(str1, "\n") {
|
||||
t.Errorf("Actual:\n~~~~~~~~~~~~~~~~\n%v\n~~~~~~~~~~~~~~~~\n\n", expected)
|
||||
} else {
|
||||
t.Errorf("Actual := \"%v\"\n", expected)
|
||||
}
|
||||
|
||||
if strings.Contains(str2, "\n") {
|
||||
t.Errorf("Not Expected:\n~~~~~~~~~~~~~~~~\n%v\n~~~~~~~~~~~~~~~~\n\n", actual)
|
||||
} else {
|
||||
t.Errorf("Not Expected := \"%v\"\n", actual)
|
||||
}
|
||||
|
||||
t.Error(string(debug.Stack()))
|
||||
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func AssertStrRepEqual(t *testing.T, key string, expected any, actual any) {
|
||||
strExp := fmt.Sprintf("%v", unpointer(expected))
|
||||
strAct := fmt.Sprintf("%v", unpointer(actual))
|
||||
|
||||
if strAct != strExp {
|
||||
t.Errorf("Value [%s] differs (%T <-> %T):\n", key, expected, actual)
|
||||
|
||||
if strings.Contains(strAct, "\n") {
|
||||
t.Errorf("Actual:\n~~~~~~~~~~~~~~~~\n%v\n~~~~~~~~~~~~~~~~\n\n", strAct)
|
||||
} else {
|
||||
t.Errorf("Actual := \"%v\"\n", strAct)
|
||||
}
|
||||
|
||||
if strings.Contains(strExp, "\n") {
|
||||
t.Errorf("Expected:\n~~~~~~~~~~~~~~~~\n%v\n~~~~~~~~~~~~~~~~\n\n", strExp)
|
||||
} else {
|
||||
t.Errorf("Expected := \"%v\"\n", strExp)
|
||||
}
|
||||
|
||||
t.Error(string(debug.Stack()))
|
||||
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func AssertNotStrRepEqual(t *testing.T, key string, expected any, actual any) {
|
||||
strExp := fmt.Sprintf("%v", unpointer(expected))
|
||||
strAct := fmt.Sprintf("%v", unpointer(actual))
|
||||
|
||||
if strAct == strExp {
|
||||
t.Errorf("Value [%s] does not differ (%T <-> %T):\n", key, expected, actual)
|
||||
|
||||
if strings.Contains(strAct, "\n") {
|
||||
t.Errorf("Actual:\n~~~~~~~~~~~~~~~~\n%v\n~~~~~~~~~~~~~~~~\n\n", strAct)
|
||||
} else {
|
||||
t.Errorf("Actual := \"%v\"\n", strAct)
|
||||
}
|
||||
|
||||
if strings.Contains(strExp, "\n") {
|
||||
t.Errorf("Expected:\n~~~~~~~~~~~~~~~~\n%v\n~~~~~~~~~~~~~~~~\n\n", strExp)
|
||||
} else {
|
||||
t.Errorf("Expected := \"%v\"\n", strExp)
|
||||
}
|
||||
|
||||
t.Error(string(debug.Stack()))
|
||||
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestFail(t *testing.T, msg string) {
|
||||
t.Error(msg)
|
||||
t.Error(string(debug.Stack()))
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
func TestFailFmt(t *testing.T, format string, args ...any) {
|
||||
t.Errorf(format, args...)
|
||||
t.Error(string(debug.Stack()))
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
func TestFailErr(t *testing.T, e error) {
|
||||
t.Error(fmt.Sprintf("Failed with error:\n%s\n\nError:\n%+v\n\nTrace:\n%s", e.Error(), e, string(debug.Stack())))
|
||||
t.Error(string(debug.Stack()))
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
func TestFailIfErr(t *testing.T, e error) {
|
||||
if e != nil {
|
||||
TestFailErr(t, e)
|
||||
}
|
||||
}
|
||||
|
||||
func AssertArrAny[T any](t *testing.T, key string, arr []T, fn func(T) bool) {
|
||||
if !langext.ArrAny(arr, fn) {
|
||||
t.Errorf("AssertArrAny(%s) failed", key)
|
||||
t.Error(string(debug.Stack()))
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func unpointer(v any) any {
|
||||
if v == nil {
|
||||
return v
|
||||
}
|
||||
|
||||
val := reflect.ValueOf(v)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
if val.IsNil() {
|
||||
return v
|
||||
}
|
||||
val = val.Elem()
|
||||
return unpointer(val.Interface())
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func AssertMultiNonEmpty(t *testing.T, key string, args ...any) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
|
||||
reflval := reflect.ValueOf(args[i])
|
||||
|
||||
if args[i] == nil || reflval.IsZero() {
|
||||
t.Errorf("Value %s[%d] is empty (AssertMultiNonEmpty)", key, i)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
}
|
378
scnserver/test/util/factory.go
Normal file
378
scnserver/test/util/factory.go
Normal file
@@ -0,0 +1,378 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
|
||||
"gopkg.in/loremipsum.v1"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// # Generated by https://chat.openai.com/chat
|
||||
// ===========================================
|
||||
//
|
||||
// Create me a list of 32 example notification messages that I can use in unit tests.
|
||||
// Every notification message contains a title and a content.
|
||||
// Do not to repeat the same words in every message.
|
||||
// Vary the length of the content from short sentences to multiple sentences.
|
||||
//
|
||||
// Create me a list of 8 creative and realistic usernames.
|
||||
//
|
||||
// Create me a list of 8 imaginary phone models
|
||||
//
|
||||
// Create me a list of 8 names for your phone
|
||||
//
|
||||
// Create me a list of 8 discord channel names
|
||||
//
|
||||
|
||||
type msgex struct {
|
||||
User int
|
||||
Channel string
|
||||
SenderName string
|
||||
Priority int
|
||||
Key int
|
||||
Title string
|
||||
Content string
|
||||
TSOffset time.Duration
|
||||
}
|
||||
|
||||
type userex struct {
|
||||
Idx int64
|
||||
WithClient bool
|
||||
Username string
|
||||
AgentModel string
|
||||
AgentVersion string
|
||||
ClientType string
|
||||
FCMTok string
|
||||
ProTok string
|
||||
}
|
||||
|
||||
type clientex struct {
|
||||
User int
|
||||
AgentModel string
|
||||
AgentVersion string
|
||||
ClientType string
|
||||
FCMTok string
|
||||
}
|
||||
|
||||
type Userdat struct {
|
||||
UID int64
|
||||
SendKey string
|
||||
AdminKey string
|
||||
ReadKey string
|
||||
}
|
||||
|
||||
const PX = -1
|
||||
const P0 = 0
|
||||
const P1 = 1
|
||||
const P2 = 2
|
||||
|
||||
const AKEY = 0
|
||||
const SKEY = 1
|
||||
|
||||
var userExamples = []userex{
|
||||
{0, true, "", "Starfire", "2.0", "IOS", "FCM_TOK_EX_001", ""},
|
||||
{1, true, "", "Galaxy Quest", "2022", "ANDROID", "FCM_TOK_EX_002", ""},
|
||||
{2, true, "Dreamer23", "Ocean Explorer", "737edc01", "IOS", "FCM_TOK_EX_003", ""},
|
||||
{3, true, "CreativeGenius", "Snow Leopard", "1.0.1.99~3", "ANDROID", "FCM_TOK_EX_004", "ANDROID|v2|PURCHASED:PRO_TOK_001"},
|
||||
{4, true, "WanderingSoul", "Ocean Explorer", "737edc01", "IOS", "FCM_TOK_EX_005", ""},
|
||||
{5, true, "", "Ocean Explorer", "737edc01", "IOS", "FCM_TOK_EX_006", ""},
|
||||
{6, true, "BoldExplorer", "Cyber Nova", "Cyber 4", "IOS", "FCM_TOK_EX_007", ""},
|
||||
{7, true, "ImaginationKing", "Galaxy Quest", "2023.1", "ANDROID", "FCM_TOK_EX_008", ""},
|
||||
{8, true, "", "Galaxy Quest", "2023.1", "ANDROID", "FCM_TOK_EX_009", ""},
|
||||
{9, true, "UniqueUnicorn", "Galaxy Quest", "2023.1", "ANDROID", "FCM_TOK_EX_010", ""},
|
||||
{10, false, "", "", "", "", "", ""},
|
||||
{11, false, "", "", "", "", "", "ANDROID|v2|PURCHASED:PRO_TOK_002"},
|
||||
}
|
||||
|
||||
var clientExamples = []clientex{
|
||||
{2, "GalaxySurfer", "Triple-XXX", "IOS", "FCM_TOK_EX2_001"},
|
||||
{2, "GalaxySurfer", "Triple-XXX", "IOS", "FCM_TOK_EX2_002"},
|
||||
{4, "Thunder-Bolt-4$", "#12", "ANDROID", "FCM_TOK_EX_005"}, // overwrites FCM from first client
|
||||
{6, "GalaxySurfer", "Triple-XXX", "IOS", "FCM_TOK_EX2_002"}, // overwrites FCM from user 2 - client 3 (second extra)
|
||||
{9, "GalaxySurfer", "Triple-XXX", "IOS", "FCM_TOK_EX2_004"},
|
||||
{9, "DreamWeaver", "Triple-XXX", "IOS", "FCM_TOK_EX2_005"},
|
||||
{9, "Galaxy Quest", "2023.1", "ANDROID", "FCM_TOK_EX2_006"},
|
||||
{9, "Galaxy Quest", "2023.2", "ANDROID", "FCM_TOK_EX2_006"}, // overwrites FCM from (previous) client 4 (3rd extra)
|
||||
}
|
||||
|
||||
var messageExamples = []msgex{
|
||||
{0, "Chatting Chamber", "Mobile Mate", P1, AKEY, "New message from John Doe", "", 0},
|
||||
{0, "Chatting Chamber", "", P2, SKEY, "Upcoming event", "Don't forget to attend the staff meeting tomorrow at 9:00am", timeext.FromHours(-10.28)},
|
||||
{0, "Unicôdé Häll \U0001f92a", "Mobile Mate", P0, SKEY, "System update", "We will be performing maintenance on the server tonight at 11:00pm. The system may be unavailable for up to an hour.", 0},
|
||||
{0, "", "Pocket Pal", P2, SKEY, "Reminder", "Your payment is due by the end of the week", 0},
|
||||
{0, "", "", P0, SKEY, "New feature available", "We've added a new feature that allows you to save your favorite items for later", 0},
|
||||
{0, "Promotions", "", PX, AKEY, "Security alert", "We've detected unusual activity on your account. Please reset your password as soon as possible", 0},
|
||||
{0, "Reminders", "", P1, AKEY, "Important notice", "The office will be closed on Friday for a company-wide event", timeext.FromHours(2.72)},
|
||||
{0, "Reminders", "Mobile Mate", PX, AKEY, "Urgent", "There has been a power outage in the building. Please evacuate the premises immediately", 0},
|
||||
{0, "Reminders", "", PX, AKEY, "Weather alert", "A severe storm is expected to hit the area tonight. Please take necessary precautions", 0},
|
||||
{0, "", "", P0, SKEY, "Congratulations", "You have been selected as Employee of the Month. Please come to the front desk to pick up your prize", 0},
|
||||
{0, "", "", PX, AKEY, "Attention", "The water cooler is empty. Could someone please refill it?", timeext.FromHours(-11.29)},
|
||||
{0, "Chatting Chamber", "Mobile Mate", P2, SKEY, "Important", "All employees are required to complete a safety training course by the end of the month", 0},
|
||||
{0, "", "", P1, AKEY, "FAQ Update", Lipsum(10001, 1), 0},
|
||||
{0, "", "", PX, AKEY, "Notice", "There will be a fire drill at 10:00am tomorrow. Please follow the instructions of the fire marshal", 0},
|
||||
{0, "", "Cellular Confidant", P2, SKEY, "Invitation", "You are invited to a celebration in honor of our 10-year anniversary. The party will be held on Friday at 7:00pm", 0},
|
||||
{0, "", "", P0, SKEY, "Deadline reminder", "Please remember to submit your project proposal by the end of the day \U0001f638", 0},
|
||||
{0, "Reminders", "", PX, AKEY, "Attention - The copier is out of toner", "", 0},
|
||||
{0, "Reminders", "Cellular Confidant", P2, SKEY, "Reminder", "Don't forget to clock in before starting your shift", timeext.FromHours(0.40)},
|
||||
{0, "Reminders", "Cellular Confidant", P1, AKEY, "Important", "There will be a company-wide meeting on Monday at 9:00am in the conference room", timeext.FromHours(23.15)},
|
||||
{0, "", "", P2, SKEY, "System update", "We will be performing maintenance on the server tonight at 11:00pm. The system may be unavailable for up to an hour. Please save any unsaved work before then", 0},
|
||||
{0, "Promotions", "Pocket Pal", P0, SKEY, "Attention - The first aid kit is running low on supplies.", "", 0},
|
||||
{0, "Promotions", "Pocket Pal", PX, AKEY, "Urgent", "We have received a complaint about a safety hazard in the workplace. Please address the issue immediately", 0},
|
||||
|
||||
{1, "", "", P1, AKEY, "New message from Jane Doe", "Hey, what's up?", 0},
|
||||
{1, "", "", P2, SKEY, "Reminder: Meeting at 3 PM", "Don't forget to join the meeting in the conference room.", 0},
|
||||
{1, "", "", P0, SKEY, "Urgent: Action required", "Please review and respond to this important email as soon as possible.", timeext.FromHours(-1.62)},
|
||||
{1, "", "", P2, SKEY, "Congratulations!", "You have successfully completed the first step in our onboarding process.", 0},
|
||||
{1, "", "", P0, SKEY, "Notice: Maintenance scheduled", "The server will be down for maintenance tonight from 10 PM to 2 AM. Please save your work and log off before then.", 0},
|
||||
{1, "private", "", PX, AKEY, "Security alert", "We have detected suspicious activity on your account. Please reset your password immediately to protect your information.", 0},
|
||||
{1, "", "", P1, AKEY, "New follower on Twitter", "You have a new follower on Twitter! Check out their profile and see if you want to follow them back.", 0},
|
||||
{1, "private", "", PX, AKEY, "Weather alert", "A severe storm is expected to hit the area tonight. Please take shelter and stay safe.", timeext.FromHours(-5.18)},
|
||||
{1, "private", "", PX, AKEY, "Free trial ending", "Your free trial of our service is ending in three days. Upgrade now to continue using it.", 0},
|
||||
{1, "", "", P0, SKEY, "Task assigned", "You have been assigned a new task: complete the report by Friday at 5 PM.", 0},
|
||||
{1, "", "", PX, AKEY, "Event reminder", "Don't forget to join us for the company picnic this Saturday from 12 PM to 3 PM at the park.", 0},
|
||||
{1, "private", "", P2, SKEY, "Shipping update", "Your order has shipped and is on its way. Track your package and expect delivery within the next three days.", 0},
|
||||
|
||||
{2, "", "", P1, AKEY, "New feature available", "We have added a new feature to our app! Check it out and let us know what you think.", 0},
|
||||
{2, "", "", PX, AKEY, "Payment overdue", "Your payment is overdue. Please make the payment as soon as possible to avoid late fees.", 0},
|
||||
{2, "Ü", "", P2, SKEY, "Account suspended", "Your account has been suspended for violating our terms of service. Please contact us to resolve this issue.", 0},
|
||||
{2, "Ö", "", P0, SKEY, "Survey invitation", "We would like to invite you to participate in a survey about your experience with our product. Your feedback is valuable to us.", timeext.FromHours(4.66)},
|
||||
{2, "", "", PX, AKEY, "Contest winner", "Congratulations! You are the winner of our latest contest. Please contact us to claim your prize.", 0},
|
||||
{2, "", "", P2, SKEY, "Appointment confirmation", "This is a confirmation of your upcoming appointment on Friday at 9 AM. Please reply to confirm or reschedule.", 0},
|
||||
{2, "Ö", "", P1, AKEY, "Referral program", "Refer a friend to our service and earn a $20 credit. Share your referral code with them and have them sign up using it.", 0},
|
||||
{2, "", "", P2, SKEY, "Price change", "We have changed our pricing for our product. Check out the updated pricing and let us know if you have any questions.", 0},
|
||||
{2, "Ä", "", P0, SKEY, "New blog post", "We have published a new blog post on our website. Check it out to learn more about our latest product release.", 0},
|
||||
{2, "Ä", "", PX, AKEY, "Support ticket update", "We have received your support ticket and are working on a solution. Please expect a response within the next 24 hours.", 0},
|
||||
{2, "Ä", "", P2, SKEY, "Order confirmation", "Thank you for your order! Your order number is 12345. We will send a confirmation email when your order has shipped.", 0},
|
||||
{2, "Ü", "", P0, SKEY, "Feedback request", "We value your opinion. Please take a few minutes to complete our survey and let us know how we can improve our service.", 0},
|
||||
{2, "", "", P1, AKEY, "New product launch", "We are excited to announce the launch of our newest product! Check it out and let us know what you think.", 0},
|
||||
{2, "", "", P0, SKEY, "Free shipping", "Enjoy free shipping on your", 0},
|
||||
|
||||
{3, "\U0001f5ff", "", PX, AKEY, "New message received", "You have a new message from John Doe.", 0},
|
||||
{3, "", "", P1, AKEY, "Meeting reminder", "Don't forget, your meeting with the sales team is at 10 AM tomorrow.", 0},
|
||||
{3, "", "", PX, AKEY, "Payment confirmation", "Your payment of $100 has been successfully processed. Thank you for your business.", 0},
|
||||
{3, "", "", P2, SKEY, "Task completed", "Your task \"Update website content\" has been completed and is ready for review.", 0},
|
||||
{3, "Innovations", "", PX, AKEY, "Invitation to join a group", "You have been invited to join the \"Marketing Team\" group on our collaboration platform.", 0},
|
||||
{3, "", "", P2, SKEY, "Password reset", Lipsum(10002, 1), 0},
|
||||
{3, "", "", P2, SKEY, "Low battery alert", Lipsum(10003, 2), 0},
|
||||
{3, "Innovations", "", P2, SKEY, "System update available", Lipsum(10004, 5), 0},
|
||||
{3, "", "", P2, SKEY, "Appointment confirmation", "Your appointment for a physical exam on Monday, March 15th at 10 AM has been confirmed.", 0},
|
||||
{3, "\U0001f5ff", "", P2, SKEY, "Order shipped", "Your order #123456 has been shipped and is on its way to your address.", 0},
|
||||
{3, "", "", P2, SKEY, "Order cancelled", "Your order #123456 has been cancelled. We apologize for any inconvenience this may have caused.", 0},
|
||||
{3, "", "", P2, SKEY, "Event reminder", "Don't forget, the company holiday party is tomorrow at 6 PM. We hope to see you there!", 0},
|
||||
{3, "Reminders", "", PX, AKEY, "Account verification", "", timeext.FromHours(1.15)},
|
||||
{3, "Reminders", "", PX, AKEY, "Overdue payment", "", 0},
|
||||
{3, "Reminders", "", P2, SKEY, "Security alert", "We have detected suspicious activity on your account. Please take the necessary steps to secure your account.", timeext.FromHours(0.80)},
|
||||
{3, "Reminders", "", PX, AKEY, "Product back in stock", Lipsum(10001, 6), 0},
|
||||
{3, "", "", PX, AKEY, "Connection lost", "Your device has lost its connection to the internet. Please check your network settings and try again.", 0},
|
||||
{3, "", "", P2, SKEY, "Subscription renewal", "Your subscription is set to renew in one week. Please update your payment information to avoid any interruption in service.", 0},
|
||||
{3, "", "", PX, AKEY, "Work order assigned", "You have been assigned a new work order #123456. Please review the details and complete the task as soon as possible.", 0},
|
||||
{3, "Innovations", "", P2, SKEY, "Scheduled maintenance", "", 0},
|
||||
{3, "Innovations", "", P2, SKEY, "Payment declined", "Your payment for invoice #123456 has been declined. Please update your payment information and try again.", 0},
|
||||
{3, "Innovations", "", P1, AKEY, "New follower", "You have a new follower on our platform. Welcome them with a message and start building your network.", 0},
|
||||
{3, "", "", P1, AKEY, "Account suspended", "", 0},
|
||||
{3, "\U0001f5ff", "", P0, SKEY, "Request for feedback", "We value your feedback and would love to hear your thoughts on your recent experience with our platform.", 0},
|
||||
{3, "\U0001f5ff", "", P0, SKEY, "Task deadline approaching", "Your task \"Write blog post\" is due in three days. Please make sure to complete it on time", 0},
|
||||
|
||||
{4, "", "", P0, SKEY, "Server maintenance", "The server will be offline for maintenance on Tuesday, January 5th at 10pm EST", 0},
|
||||
{4, "", "", P0, SKEY, "New feature update", "A new feature has been added to the server. Please check the changelog for details", 0},
|
||||
{4, "", "Server0", PX, AKEY, "Security alert", "There has been a security breach on the server. Please change your password immediately", timeext.FromHours(-4.90)},
|
||||
{4, "", "Server0", P0, SKEY, "Server upgrade", "The server has been upgraded with improved performance and security features", timeext.FromHours(6.29)},
|
||||
{4, "", "", P2, SKEY, "Scheduled downtime", "The server will be offline for scheduled downtime on Friday, January 8th at 8am EST", 0},
|
||||
{4, "", "Server0", P0, SKEY, "Server outage", "There is currently a server outage. We are working to resolve the issue as soon as possible", 0},
|
||||
{4, "", "", P0, SKEY, "User account update", "Your user account has been updated with new features and improved security measures", 0},
|
||||
{4, "", "", P1, AKEY, "Server status update", "The server is currently experiencing higher than normal traffic. We apologize for any inconvenience", 0},
|
||||
|
||||
{5, "", "localhost", P1, AKEY, "New server release", "A new version of the server has been released. Please update to the latest version to ensure optimal performance", 0},
|
||||
{5, "Test1", "localhost", P1, AKEY, "Server maintenance schedule", "The server will be undergoing regular maintenance every Tuesday at 10pm EST", timeext.FromHours(12.45)},
|
||||
{5, "Test2", "example.com", P0, SKEY, "Server outage notification", "We apologize for the inconvenience, but the server is currently experiencing an outage", 0},
|
||||
{5, "Test3", "example.com", PX, AKEY, "Server performance update", "The server is currently experiencing improved performance thanks to recent upgrades", timeext.FromHours(-0.18)},
|
||||
{5, "Test4", "example.org", P1, AKEY, "Server security patch", "A security patch has been applied to the server to improve its security measures", 0},
|
||||
{5, "Test5", "example.org", PX, AKEY, "Server downtime schedule", "The server will be offline for scheduled downtime on the first Friday of every month at 8am EST", 0},
|
||||
|
||||
{6, "", "server2", P0, SKEY, "Server outage resolution", "The server outage has been resolved and the server is now back online", 0},
|
||||
{6, "", "server1", P0, SKEY, "New server features", "The server has been updated with new features and improved functionality", 0},
|
||||
{6, "", "server2", P2, SKEY, "Server traffic update", "The server is currently experiencing high traffic levels. We apologize for any delays", 0},
|
||||
{6, "Security", "server2", P0, SKEY, "Server security breach", "There has been a security breach on the server. Please change your password and update your security settings", 0},
|
||||
{6, "", "server1", P1, AKEY, "Server maintenance notification", "The server will be offline for maintenance on Wednesday, January 6th at 10pm EST", 0},
|
||||
{6, "", "server1", P0, SKEY, "Server outage warning", "We are experiencing server issues and may need to take the server offline for maintenance", timeext.FromHours(6.65)},
|
||||
{6, "", "server1", P2, SKEY, "Server performance improvement", "Thanks to recent upgrades, the server is now performing better than ever", 0},
|
||||
{6, "", "server1", PX, AKEY, "Server security update", "The server has been updated with the latest security patches and enhancements", 0},
|
||||
{6, "", "server1", P1, AKEY, "Server downtime schedule change", "The server downtime schedule has been changed to every other Friday at 8am EST", 0},
|
||||
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20001, 1), 0},
|
||||
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20002, 1), 0},
|
||||
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20003, 1), 0},
|
||||
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20004, 1), 0},
|
||||
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20005, 1), 0},
|
||||
{6, "Lipsum", "", P1, AKEY, "Lorem Ipsum", Lipsum(20006, 1), 0},
|
||||
{6, "Lipsum", "", P1, AKEY, "Lorem Ipsum", Lipsum(20007, 1), timeext.FromHours(-3.39)},
|
||||
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20008, 1), 0},
|
||||
{6, "Lipsum", "", PX, AKEY, "Lorem Ipsum", Lipsum(20009, 1), 0},
|
||||
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20010, 1), 0},
|
||||
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20011, 1), 0},
|
||||
{6, "Lipsum", "", PX, AKEY, "Lorem Ipsum", Lipsum(20012, 1), 0},
|
||||
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20013, 1), 0},
|
||||
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20014, 1), timeext.FromHours(-2.33)},
|
||||
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20015, 1), 0},
|
||||
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20016, 1), 0},
|
||||
|
||||
{7, "", "localhost", P2, SKEY, "Server outage resolution update", "We are still working on resolving the server outage and will provide updates as soon as possible", 0},
|
||||
{7, "", "localhost", P0, SKEY, "New server release update", "A new update for the server has been released. Please update to the latest version for optimal performance", 0},
|
||||
{7, "", "localhost", P2, SKEY, "Server traffic warning", "The server is experiencing high traffic levels and may be slow. We apologize for any inconvenience", 0},
|
||||
{7, "", "localhost", P1, AKEY, "Server security alert", "There has been a potential security breach on the server. Please update your password and security settings immediately", 0},
|
||||
{7, "", "localhost", PX, AKEY, "Server maintenance reminder", "Don't forget, the server will be offline for maintenance on Thursday, January 7th at 10pm EST", 0},
|
||||
{7, "", "localhost", P1, AKEY, "Server outage status", "The server outage is ongoing and we are working to resolve the...", 0},
|
||||
|
||||
{8, "", "", PX, AKEY, "Get your free trial now!", "Sign up for our exclusive offer and get access to our premium features for a limited time!", 0},
|
||||
{8, "", "", PX, AKEY, "Limited time offer", "Hurry and take advantage of our discounted rates before they expire!", 0},
|
||||
{8, "", "", PX, AKEY, "Unbeatable deals", "Get the best prices on the hottest products today! Act fast, these deals won't last long.", 0},
|
||||
{8, "", "", P2, SKEY, "One-click signup", "Join our mailing list and get instant access to exclusive offers and discounts.", 0},
|
||||
{8, "", "", P0, SKEY, "Don't miss out", "Join now and get access to our members-only perks and benefits.", 0},
|
||||
{8, "", "", P2, SKEY, "Sign up and save", "Get instant savings when you join our email list and be the first to hear about our special deals and promotions.", 0},
|
||||
{8, "", "", P0, SKEY, "Exclusive offer", "Sign up now and get a free gift with your first purchase!", timeext.FromHours(10.81)},
|
||||
|
||||
{9, "", "Max", P0, SKEY, "Special discount", "", 0},
|
||||
{9, "", "Tim", P1, AKEY, "Huge savings", "", 0},
|
||||
{9, "", "Vincent", P0, SKEY, "Insider access", "", 0},
|
||||
|
||||
{10, "", "", PX, AKEY, "Join the club", "Become a member today and get access to exclusive perks and benefits, plus get a free gift with your first purchase.", 0},
|
||||
{10, "", "", P2, SKEY, "Join now and save", "Sign up for our email list and get instant access to exclusive offers and discounts on your favorite products.", 0},
|
||||
{10, "", "", P1, AKEY, "Hurry, limited time offer", "Sign up now and get a free trial of our premium services before the offer expires!", 0},
|
||||
{10, "", "", P2, SKEY, "Limited time only", "Sign up now and get a free gift with your first purchase, plus get access to exclusive deals and discounts.", 0},
|
||||
{10, "", "", PX, AKEY, "Sign up and save big", "Join our email list and get instant access to special offers and discounts on top brands and products.", 0},
|
||||
{10, "", "", P0, SKEY, "Exclusive membership", "Join now and get access to our members-only perks and benefits, plus get a free gift with your first purchase.", 0},
|
||||
{10, "", "", P2, SKEY, "Don't miss out on savings", "Sign up for our email list and get instant access to exclusive offers and discounts on your favorite products.", 0},
|
||||
{10, "", "", P1, AKEY, "Sign up and get a free gift", "Join now and get a free gift with your first purchase, plus get access to exclusive deals and discounts.", 0},
|
||||
{10, "", "", P2, SKEY, "Limited time offer", "Sign up now and get a free trial of our premium services before the offer expires!", 0},
|
||||
{10, "", "", P0, SKEY, "Join now and save", "Sign up for our email list and get instant access to exclusive offers and discounts on top brands and products.", 0},
|
||||
{10, "", "", PX, AKEY, "Exclusive offer", "Join now and get access to our members-only perks and benefits, plus get a free gift with your first purchase.", 0},
|
||||
|
||||
{11, "Promotions", "localhost", P2, SKEY, "New Product Launch: Introducing Our Latest Innovation", "We are excited to announce the release of our newest product, designed to revolutionize the industry. Don't miss out on this game-changing technology.", timeext.FromHours(-12.21)},
|
||||
{11, "Promotions", "#S0", P0, SKEY, "Limited Time Offer: Get 50% Off Your Next Purchase", "For a limited time, take advantage of our special offer and get half off your next purchase. Don't miss out on this amazing deal.", 0},
|
||||
{11, "Promotions", "#S0", P2, SKEY, "Customer Appreciation Sale: Save Up to 75% on Your Favorite Products", "", 0},
|
||||
{11, "Promotions", "", P0, SKEY, "Sign Up for Our Newsletter and Save 10% on Your Next Order", "", 0},
|
||||
{11, "Promotions", "", PX, AKEY, "New Arrivals: Check Out Our Latest Collection", "We've just added new items to our collection and we think you'll love them. Take a look and see what's new in fashion, home decor, and more.", 0},
|
||||
{11, "Promotions", "", PX, AKEY, "Join Our Rewards Program and Earn Points on Every Purchase", "Sign up for our rewards program and earn points on every purchase you make. Redeem your points for discounts, free products, and more.", 0},
|
||||
{11, "Promotions", "#S0", P0, SKEY, "Seasonal Special: Save on Your Favorite Fall Products", "As the leaves change color and the air gets cooler, we have the perfect products to help you enjoy the season. Take advantage of our special offers and save on your favorite fall products.", 0},
|
||||
{11, "Promotions", "192.168.0.1", P1, AKEY, "Refer a Friend and Save on Your Next Order", "Share the love and refer a friend to our store. When they make a purchase, you'll receive a discount on your next order. It's a win-win for both of you.", 0},
|
||||
{11, "Promotions", "", P2, SKEY, "Free Shipping on All Orders Over $50", "", 0},
|
||||
{11, "Promotions", "", PX, AKEY, "Buy One, Get One 50% Off: Mix and Match Your Favorite Products", "", 0},
|
||||
{11, "Promotions", "192.168.0.1", P1, AKEY, "New Customer Coupon: Save $10 on Your First Order", "Welcome to our store! As a new customer, we want to offer you a special discount on your first order. Use the coupon code NEW10 at checkout and save $10 on your purchase.", 0},
|
||||
{11, "Promotions", "192.168.0.1", P1, AKEY, "Announcing Our Annual Black Friday Sale", "Mark your calendars and get ready for the biggest sale of the year. Our annual Black Friday sale is coming soon and you won't want to miss out on the amazing deals and discounts.", 0},
|
||||
{11, "Promotions", "", PX, AKEY, "Join Our VIP Club and Enjoy Exclusive Benefits", "Sign up for our VIP club and enjoy exclusive benefits like early access to sales, special offers, and personalized service. Don't miss out on this exclusive opportunity.", timeext.FromHours(2.32)},
|
||||
{11, "Promotions", "", P2, SKEY, "Summer Clearance: Save Up to 75% on Your Favorite Products", "It's time for our annual summer clearance sale! Save up to 75% on your favorite products, from clothing and accessories to home decor and more.", timeext.FromHours(1.87)},
|
||||
}
|
||||
|
||||
type DefData struct {
|
||||
User []Userdat
|
||||
}
|
||||
|
||||
func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
|
||||
|
||||
// set logger to buffer, only output if error occured
|
||||
success := false
|
||||
SetBufLogger()
|
||||
defer func() {
|
||||
ClearBufLogger(!success)
|
||||
if success {
|
||||
log.Info().Msgf("Succesfully initialized default data (%d messages, %d users)", len(messageExamples), len(userExamples))
|
||||
}
|
||||
}()
|
||||
|
||||
baseUrl := "http://127.0.0.1:" + ws.Port
|
||||
|
||||
users := make([]Userdat, 0, len(userExamples))
|
||||
|
||||
for _, uex := range userExamples {
|
||||
body := gin.H{}
|
||||
if uex.WithClient {
|
||||
body["agent_model"] = uex.AgentModel
|
||||
body["agent_version"] = uex.AgentVersion
|
||||
body["client_type"] = uex.ClientType
|
||||
body["fcm_token"] = uex.FCMTok
|
||||
} else {
|
||||
body["no_client"] = true
|
||||
}
|
||||
if uex.Username != "" {
|
||||
body["username"] = uex.Username
|
||||
}
|
||||
if uex.ProTok != "" {
|
||||
body["pro_token"] = uex.ProTok
|
||||
}
|
||||
|
||||
user0 := RequestPost[gin.H](t, baseUrl, "/api/users", body)
|
||||
uid0 := int64(user0["user_id"].(float64))
|
||||
readtok0 := user0["read_key"].(string)
|
||||
sendtok0 := user0["send_key"].(string)
|
||||
admintok0 := user0["admin_key"].(string)
|
||||
AssertMultiNonEmpty(t, "user0", uid0, readtok0, sendtok0, admintok0)
|
||||
|
||||
users = append(users, Userdat{
|
||||
UID: uid0,
|
||||
SendKey: sendtok0,
|
||||
AdminKey: admintok0,
|
||||
ReadKey: readtok0,
|
||||
})
|
||||
}
|
||||
|
||||
for _, cex := range clientExamples {
|
||||
body := gin.H{}
|
||||
body["agent_model"] = cex.AgentModel
|
||||
body["agent_version"] = cex.AgentVersion
|
||||
body["client_type"] = cex.ClientType
|
||||
body["fcm_token"] = cex.FCMTok
|
||||
RequestAuthPost[gin.H](t, users[cex.User].AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/clients", users[cex.User].UID), body)
|
||||
}
|
||||
|
||||
for _, mex := range messageExamples {
|
||||
//User int
|
||||
//Channel string
|
||||
//SenderName string
|
||||
//Priority int
|
||||
//Key int
|
||||
//Title string
|
||||
//Content string
|
||||
//TSOffset time.Duration
|
||||
body := gin.H{}
|
||||
body["title"] = mex.Title
|
||||
body["user_id"] = users[mex.User].UID
|
||||
switch mex.Key {
|
||||
case AKEY:
|
||||
body["user_key"] = users[mex.User].AdminKey
|
||||
case SKEY:
|
||||
body["user_key"] = users[mex.User].SendKey
|
||||
}
|
||||
if mex.Content != "" {
|
||||
body["content"] = mex.Content
|
||||
}
|
||||
if mex.SenderName != "" {
|
||||
body["sender_name"] = mex.SenderName
|
||||
}
|
||||
if mex.Channel != "" {
|
||||
body["channel"] = mex.Channel
|
||||
}
|
||||
if mex.Priority != PX {
|
||||
body["priority"] = mex.Priority
|
||||
}
|
||||
if mex.TSOffset != 0 {
|
||||
body["timestamp"] = (time.Now().Add(mex.TSOffset)).Unix()
|
||||
}
|
||||
|
||||
RequestPost[gin.H](t, baseUrl, "/", body)
|
||||
}
|
||||
|
||||
success = true
|
||||
|
||||
return DefData{User: users}
|
||||
}
|
||||
|
||||
func Lipsum(seed int64, paracount int) string {
|
||||
return loremipsum.NewWithSeed(seed).Paragraphs(paracount)
|
||||
}
|
||||
|
||||
func ShortLipsum0(wcount int) string {
|
||||
return loremipsum.NewWithSeed(0).Words(wcount)
|
||||
}
|
3
scnserver/test/util/formData.go
Normal file
3
scnserver/test/util/formData.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package util
|
||||
|
||||
type FormData map[string]string
|
35
scnserver/test/util/init.go
Normal file
35
scnserver/test/util/init.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func InitTests() {
|
||||
log.Logger = createLogger(createConsoleWriter())
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
}
|
||||
|
||||
func createConsoleWriter() *zerolog.ConsoleWriter {
|
||||
return &zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "2006-01-02 15:04:05.000 Z07:00",
|
||||
}
|
||||
}
|
||||
|
||||
func createLogger(cw io.Writer) zerolog.Logger {
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
|
||||
|
||||
multi := zerolog.MultiLevelWriter(cw)
|
||||
logger := zerolog.New(multi).With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger()
|
||||
|
||||
return logger
|
||||
}
|
47
scnserver/test/util/log.go
Normal file
47
scnserver/test/util/log.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginext"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var buflogger *BufferWriter = nil
|
||||
|
||||
func SetBufLogger() {
|
||||
buflogger = &BufferWriter{cw: createConsoleWriter()}
|
||||
log.Logger = createLogger(buflogger)
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
ginext.SuppressGinLogs = true
|
||||
}
|
||||
|
||||
func ClearBufLogger(dump bool) {
|
||||
size := len(buflogger.buffer)
|
||||
if dump {
|
||||
buflogger.Dump()
|
||||
}
|
||||
log.Logger = createLogger(createConsoleWriter())
|
||||
buflogger = nil
|
||||
gin.SetMode(gin.TestMode)
|
||||
ginext.SuppressGinLogs = false
|
||||
if !dump {
|
||||
log.Info().Msgf("Suppressed %d logmessages / printf-statements", size)
|
||||
}
|
||||
}
|
||||
|
||||
func TPrintf(format string, a ...any) {
|
||||
if buflogger != nil {
|
||||
buflogger.Printf(format, a...)
|
||||
} else {
|
||||
fmt.Printf(format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
func TPrintln(a ...any) {
|
||||
if buflogger != nil {
|
||||
buflogger.Println(a...)
|
||||
} else {
|
||||
fmt.Println(a...)
|
||||
}
|
||||
}
|
38
scnserver/test/util/logbuffer.go
Normal file
38
scnserver/test/util/logbuffer.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type BufferWriter struct {
|
||||
cw *zerolog.ConsoleWriter
|
||||
|
||||
buffer []func(cw *zerolog.ConsoleWriter)
|
||||
}
|
||||
|
||||
func (b *BufferWriter) Write(p []byte) (n int, err error) {
|
||||
b.buffer = append(b.buffer, func(cw *zerolog.ConsoleWriter) {
|
||||
_, _ = cw.Write(p)
|
||||
})
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *BufferWriter) Dump() {
|
||||
for _, v := range b.buffer {
|
||||
v(b.cw)
|
||||
}
|
||||
b.buffer = nil
|
||||
}
|
||||
|
||||
func (b *BufferWriter) Println(a ...any) {
|
||||
b.buffer = append(b.buffer, func(cw *zerolog.ConsoleWriter) {
|
||||
fmt.Println(a...)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BufferWriter) Printf(format string, a ...any) {
|
||||
b.buffer = append(b.buffer, func(cw *zerolog.ConsoleWriter) {
|
||||
fmt.Printf(format, a...)
|
||||
})
|
||||
}
|
281
scnserver/test/util/requests.go
Normal file
281
scnserver/test/util/requests.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func RequestGet[TResult any](t *testing.T, baseURL string, urlSuffix string) TResult {
|
||||
return RequestAny[TResult](t, "", "GET", baseURL, urlSuffix, nil)
|
||||
}
|
||||
|
||||
func RequestAuthGet[TResult any](t *testing.T, akey string, baseURL string, urlSuffix string) TResult {
|
||||
return RequestAny[TResult](t, akey, "GET", baseURL, urlSuffix, nil)
|
||||
}
|
||||
|
||||
func RequestPost[TResult any](t *testing.T, baseURL string, urlSuffix string, body any) TResult {
|
||||
return RequestAny[TResult](t, "", "POST", baseURL, urlSuffix, body)
|
||||
}
|
||||
|
||||
func RequestAuthPost[TResult any](t *testing.T, akey string, baseURL string, urlSuffix string, body any) TResult {
|
||||
return RequestAny[TResult](t, akey, "POST", baseURL, urlSuffix, body)
|
||||
}
|
||||
|
||||
func RequestPut[TResult any](t *testing.T, baseURL string, urlSuffix string, body any) TResult {
|
||||
return RequestAny[TResult](t, "", "PUT", baseURL, urlSuffix, body)
|
||||
}
|
||||
|
||||
func RequestAuthPUT[TResult any](t *testing.T, akey string, baseURL string, urlSuffix string, body any) TResult {
|
||||
return RequestAny[TResult](t, akey, "PUT", baseURL, urlSuffix, body)
|
||||
}
|
||||
|
||||
func RequestPatch[TResult any](t *testing.T, baseURL string, urlSuffix string, body any) TResult {
|
||||
return RequestAny[TResult](t, "", "PATCH", baseURL, urlSuffix, body)
|
||||
}
|
||||
|
||||
func RequestAuthPatch[TResult any](t *testing.T, akey string, baseURL string, urlSuffix string, body any) TResult {
|
||||
return RequestAny[TResult](t, akey, "PATCH", baseURL, urlSuffix, body)
|
||||
}
|
||||
|
||||
func RequestDelete[TResult any](t *testing.T, baseURL string, urlSuffix string, body any) TResult {
|
||||
return RequestAny[TResult](t, "", "DELETE", baseURL, urlSuffix, body)
|
||||
}
|
||||
|
||||
func RequestAuthDelete[TResult any](t *testing.T, akey string, baseURL string, urlSuffix string, body any) TResult {
|
||||
return RequestAny[TResult](t, akey, "DELETE", baseURL, urlSuffix, body)
|
||||
}
|
||||
|
||||
func RequestGetShouldFail(t *testing.T, baseURL string, urlSuffix string, statusCode int, errcode apierr.APIError) {
|
||||
RequestAuthAnyShouldFail(t, "", "GET", baseURL, urlSuffix, nil, statusCode, errcode)
|
||||
}
|
||||
|
||||
func RequestPostShouldFail(t *testing.T, baseURL string, urlSuffix string, body any, statusCode int, errcode apierr.APIError) {
|
||||
RequestAuthAnyShouldFail(t, "", "POST", baseURL, urlSuffix, body, statusCode, errcode)
|
||||
}
|
||||
|
||||
func RequestPatchShouldFail(t *testing.T, baseURL string, urlSuffix string, body any, statusCode int, errcode apierr.APIError) {
|
||||
RequestAuthAnyShouldFail(t, "", "PATCH", baseURL, urlSuffix, body, statusCode, errcode)
|
||||
}
|
||||
|
||||
func RequestDeleteShouldFail(t *testing.T, baseURL string, urlSuffix string, body any, statusCode int, errcode apierr.APIError) {
|
||||
RequestAuthAnyShouldFail(t, "", "DELETE", baseURL, urlSuffix, body, statusCode, errcode)
|
||||
}
|
||||
|
||||
func RequestAuthGetShouldFail(t *testing.T, akey string, baseURL string, urlSuffix string, statusCode int, errcode apierr.APIError) {
|
||||
RequestAuthAnyShouldFail(t, akey, "GET", baseURL, urlSuffix, nil, statusCode, errcode)
|
||||
}
|
||||
|
||||
func RequestAuthPostShouldFail(t *testing.T, akey string, baseURL string, urlSuffix string, body any, statusCode int, errcode apierr.APIError) {
|
||||
RequestAuthAnyShouldFail(t, akey, "POST", baseURL, urlSuffix, body, statusCode, errcode)
|
||||
}
|
||||
|
||||
func RequestAuthPatchShouldFail(t *testing.T, akey string, baseURL string, urlSuffix string, body any, statusCode int, errcode apierr.APIError) {
|
||||
RequestAuthAnyShouldFail(t, akey, "PATCH", baseURL, urlSuffix, body, statusCode, errcode)
|
||||
}
|
||||
|
||||
func RequestAuthDeleteShouldFail(t *testing.T, akey string, baseURL string, urlSuffix string, body any, statusCode int, errcode apierr.APIError) {
|
||||
RequestAuthAnyShouldFail(t, akey, "DELETE", baseURL, urlSuffix, body, statusCode, errcode)
|
||||
}
|
||||
|
||||
func RequestAny[TResult any](t *testing.T, akey string, method string, baseURL string, urlSuffix string, body any) TResult {
|
||||
client := http.Client{}
|
||||
|
||||
TPrintf("[-> REQUEST] (%s) %s%s [%s] [%s]\n", method, baseURL, urlSuffix, langext.Conditional(akey == "", "NO AUTH", "AUTH"), langext.Conditional(body == nil, "NO BODY", "BODY"))
|
||||
|
||||
bytesbody := make([]byte, 0)
|
||||
contentType := ""
|
||||
if body != nil {
|
||||
switch bd := body.(type) {
|
||||
case FormData:
|
||||
bodybuffer := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(bodybuffer)
|
||||
for bdk, bdv := range bd {
|
||||
err := writer.WriteField(bdk, bdv)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
}
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
bytesbody = bodybuffer.Bytes()
|
||||
contentType = writer.FormDataContentType()
|
||||
default:
|
||||
bjson, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
bytesbody = bjson
|
||||
contentType = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, baseURL+urlSuffix, bytes.NewReader(bytesbody))
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
if akey != "" {
|
||||
req.Header.Set("Authorization", "SCN "+akey)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBodyBin, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
TPrintln("")
|
||||
TPrintf("---------------- RESPONSE (%d) ----------------\n", resp.StatusCode)
|
||||
TPrintln(langext.TryPrettyPrintJson(string(respBodyBin)))
|
||||
TryPrintTraceObj("---------------- -------- ----------------", respBodyBin, "")
|
||||
TPrintln("---------------- -------- ----------------")
|
||||
TPrintln("")
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
TestFail(t, "Statuscode != 200")
|
||||
}
|
||||
|
||||
var data TResult
|
||||
if err := json.Unmarshal(respBodyBin, &data); err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func RequestAuthAnyShouldFail(t *testing.T, akey string, method string, baseURL string, urlSuffix string, body any, statusCode int, errcode apierr.APIError) {
|
||||
client := http.Client{}
|
||||
|
||||
TPrintf("[-> REQUEST] (%s) %s%s [%s] (should-fail with %d/%d)\n", method, baseURL, urlSuffix, langext.Conditional(akey == "", "NO AUTH", "AUTH"), statusCode, errcode)
|
||||
|
||||
bytesbody := make([]byte, 0)
|
||||
contentType := ""
|
||||
if body != nil {
|
||||
switch bd := body.(type) {
|
||||
case FormData:
|
||||
bodybuffer := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(bodybuffer)
|
||||
for bdk, bdv := range bd {
|
||||
err := writer.WriteField(bdk, bdv)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
}
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
bytesbody = bodybuffer.Bytes()
|
||||
contentType = writer.FormDataContentType()
|
||||
default:
|
||||
bjson, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
bytesbody = bjson
|
||||
contentType = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, baseURL+urlSuffix, bytes.NewReader(bytesbody))
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
if akey != "" {
|
||||
req.Header.Set("Authorization", "SCN "+akey)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBodyBin, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
TPrintln("")
|
||||
TPrintf("---------------- RESPONSE (%d) ----------------\n", resp.StatusCode)
|
||||
TPrintln(langext.TryPrettyPrintJson(string(respBodyBin)))
|
||||
if (statusCode != 0 && resp.StatusCode != statusCode) || (statusCode == 0 && resp.StatusCode == 200) {
|
||||
TryPrintTraceObj("---------------- -------- ----------------", respBodyBin, "")
|
||||
}
|
||||
TPrintln("---------------- -------- ----------------")
|
||||
TPrintln("")
|
||||
|
||||
if statusCode != 0 && resp.StatusCode != statusCode {
|
||||
TestFailFmt(t, "Statuscode != %d (expected failure)", statusCode)
|
||||
}
|
||||
if statusCode == 0 && resp.StatusCode == 200 {
|
||||
TestFailFmt(t, "Statuscode == %d (expected failure)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data gin.H
|
||||
if err := json.Unmarshal(respBodyBin, &data); err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
if v, ok := data["success"]; ok {
|
||||
if v.(bool) {
|
||||
TestFail(t, "Success == true (expected failure)")
|
||||
}
|
||||
} else {
|
||||
TestFail(t, "missing response['success']")
|
||||
}
|
||||
|
||||
if errcode != 0 {
|
||||
if v, ok := data["error"]; ok {
|
||||
if fmt.Sprintf("%v", v) != fmt.Sprintf("%v", errcode) {
|
||||
TestFailFmt(t, "wrong errorcode (expected: %d), (actual: %v)", errcode, v)
|
||||
}
|
||||
} else {
|
||||
TestFail(t, "missing response['error']")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TryPrintTraceObj(prefix string, body []byte, suffix string) {
|
||||
v1 := gin.H{}
|
||||
if err := json.Unmarshal(body, &v1); err == nil {
|
||||
if v2, ok := v1["traceObj"]; ok {
|
||||
if v3, ok := v2.(string); ok {
|
||||
if prefix != "" {
|
||||
TPrintln(prefix)
|
||||
}
|
||||
|
||||
TPrintln(strings.TrimSpace(v3))
|
||||
|
||||
if suffix != "" {
|
||||
TPrintln(suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
109
scnserver/test/util/webserver.go
Normal file
109
scnserver/test/util/webserver.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
scn "blackforestbytes.com/simplecloudnotifier"
|
||||
"blackforestbytes.com/simplecloudnotifier/api"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginext"
|
||||
"blackforestbytes.com/simplecloudnotifier/db"
|
||||
"blackforestbytes.com/simplecloudnotifier/google"
|
||||
"blackforestbytes.com/simplecloudnotifier/jobs"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/push"
|
||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Void = struct{}
|
||||
|
||||
func StartSimpleWebserver(t *testing.T) (*logic.Application, string, func()) {
|
||||
InitTests()
|
||||
|
||||
uuid2, _ := langext.NewHexUUID()
|
||||
dbdir := t.TempDir()
|
||||
dbfile := filepath.Join(dbdir, uuid2+".sqlite3")
|
||||
|
||||
err := os.MkdirAll(dbdir, os.ModePerm)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
f, err := os.Create(dbfile)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
err = f.Close()
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
err = os.Chmod(dbfile, 0777)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
TPrintln("DatabaseFile: " + dbfile)
|
||||
|
||||
conf := scn.Config{
|
||||
Namespace: "test",
|
||||
GinDebug: true,
|
||||
ServerIP: "0.0.0.0",
|
||||
ServerPort: "0", // simply choose a free port
|
||||
DBFile: dbfile,
|
||||
DBJournal: "WAL",
|
||||
DBTimeout: 500 * time.Millisecond,
|
||||
DBMaxOpenConns: 2,
|
||||
DBMaxIdleConns: 2,
|
||||
DBConnMaxLifetime: 1 * time.Second,
|
||||
DBConnMaxIdleTime: 1 * time.Second,
|
||||
RequestTimeout: 30 * time.Second,
|
||||
RequestMaxRetry: 32,
|
||||
RequestRetrySleep: 100 * time.Millisecond,
|
||||
ReturnRawErrors: true,
|
||||
DummyFirebase: true,
|
||||
DBSingleConn: false,
|
||||
}
|
||||
|
||||
scn.Conf = conf
|
||||
|
||||
sqlite, err := db.NewDatabase(conf)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
app := logic.NewApp(sqlite)
|
||||
|
||||
if err := app.Migrate(); err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
ginengine := ginext.NewEngine(conf)
|
||||
|
||||
router := api.NewRouter(app)
|
||||
|
||||
nc := push.NewTestSink()
|
||||
|
||||
apc := google.NewDummy()
|
||||
|
||||
jobRetry := jobs.NewDeliveryRetryJob(app)
|
||||
app.Init(conf, ginengine, nc, apc, []logic.Job{jobRetry})
|
||||
|
||||
router.Init(ginengine)
|
||||
|
||||
stop := func() {
|
||||
app.Stop()
|
||||
_ = os.Remove(dbfile)
|
||||
_ = app.IsRunning.WaitWithTimeout(400*time.Millisecond, false)
|
||||
}
|
||||
|
||||
go func() { app.Run() }()
|
||||
|
||||
err = app.IsRunning.WaitWithTimeout(100*time.Millisecond, true)
|
||||
if err != nil {
|
||||
TestFailErr(t, err)
|
||||
}
|
||||
|
||||
return app, "http://127.0.0.1:" + app.Port, stop
|
||||
}
|
39
scnserver/test/webserver_test.go
Normal file
39
scnserver/test/webserver_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
tt "blackforestbytes.com/simplecloudnotifier/test/util"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebserver(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
fmt.Printf("URL := %s\n", baseUrl)
|
||||
}
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
_ = tt.RequestGet[tt.Void](t, baseUrl, "/api/ping")
|
||||
_ = tt.RequestPut[tt.Void](t, baseUrl, "/api/ping", nil)
|
||||
_ = tt.RequestPost[tt.Void](t, baseUrl, "/api/ping", nil)
|
||||
_ = tt.RequestPatch[tt.Void](t, baseUrl, "/api/ping", nil)
|
||||
_ = tt.RequestDelete[tt.Void](t, baseUrl, "/api/ping", nil)
|
||||
}
|
||||
|
||||
func TestMongo(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
_ = tt.RequestPost[tt.Void](t, baseUrl, "/api/db-test", nil)
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
_, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
_ = tt.RequestGet[tt.Void](t, baseUrl, "/api/health")
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user