From dbc014f819286b042fbfd32d94d81baf07302394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Wed, 21 Dec 2022 18:14:13 +0100 Subject: [PATCH] Added a SQL-Preprocessor - this way we can unmarshal recursive structures (LEFT JOIN etc) --- scnserver/Makefile | 4 +- scnserver/README.md | 2 - scnserver/api/handler/api.go | 165 ++++++++++++++----- scnserver/db/channels.go | 57 ++++--- scnserver/db/database.go | 38 +---- scnserver/db/dbtools/logger.go | 90 ++++++++++ scnserver/db/dbtools/preprocessor.go | 238 +++++++++++++++++++++++++++ scnserver/db/subscriptions.go | 37 ++++- scnserver/db/users.go | 2 +- scnserver/db/utils.go | 12 -- scnserver/go.mod | 2 +- scnserver/go.sum | 8 + scnserver/models/channel.go | 60 +++++++ scnserver/models/messagefilter.go | 4 +- scnserver/swagger/swagger.json | 43 +++-- scnserver/swagger/swagger.yaml | 41 +++-- scnserver/test/channel_test.go | 45 +++-- scnserver/test/util/common.go | 20 +++ scnserver/test/util/factory.go | 34 +++- 19 files changed, 740 insertions(+), 162 deletions(-) create mode 100644 scnserver/db/dbtools/logger.go create mode 100644 scnserver/db/dbtools/preprocessor.go diff --git a/scnserver/Makefile b/scnserver/Makefile index 7fa2471..f13c20d 100644 --- a/scnserver/Makefile +++ b/scnserver/Makefile @@ -5,6 +5,8 @@ PORT=9090 NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD) HASH=$(shell git rev-parse HEAD) +.PHONY: test swagger + build: swagger fmt mkdir -p _build rm -f ./_build/scn_backend @@ -29,7 +31,6 @@ docker: build -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" @@ -67,7 +68,6 @@ fmt: go fmt ./... swag fmt -.PHONY: test test: go test ./test/... diff --git a/scnserver/README.md b/scnserver/README.md index 2ba0c76..fb327c6 100644 --- a/scnserver/README.md +++ b/scnserver/README.md @@ -23,8 +23,6 @@ ------------------------------------------------------------------------------------------------------------------------------- - - (?) return subscribtions in list-channels (?) - - (?) ack/read deliveries && return ack-count (? or not, how to query?) - (?) "login" on website and list/search/filter messages diff --git a/scnserver/api/handler/api.go b/scnserver/api/handler/api.go index 8309c23..2622e48 100644 --- a/scnserver/api/handler/api.go +++ b/scnserver/api/handler/api.go @@ -490,13 +490,14 @@ func (h APIHandler) DeleteClient(g *gin.Context) ginresp.HTTPResponse { // ListChannels swaggerdoc // -// @Summary List channels of a user (subscribed/owned) +// @Summary List channels of a user (subscribed/owned/all) // @Description The possible values for 'selector' are: -// @Description - "owned" Return all channels of the user -// @Description - "subscribed" Return all channels that the user is subscribing to -// @Description - "all" Return channels that the user owns or is subscribing +// @Description - "owned" Return all channels of the user +// @Description - "subscribed" Return all channels that the user is subscribing to +// @Description - "all" Return channels that the user owns or is subscribing // @Description - "subscribed_any" Return all channels that the user is subscribing to (even unconfirmed) -// @Description - "all_any" Return channels that the user owns or is subscribing (even unconfirmed) +// @Description - "all_any" Return channels that the user owns or is subscribing (even unconfirmed) +// // @ID api-channels-list // @Tags API-v2 // @@ -517,7 +518,7 @@ func (h APIHandler) ListChannels(g *gin.Context) ginresp.HTTPResponse { Selector *string `json:"selector" form:"selector" enums:"owned,subscribed_any,all_any,subscribed,all"` } type response struct { - Channels []models.ChannelJSON `json:"channels"` + Channels []models.ChannelWithSubscriptionJSON `json:"channels"` } var u uri @@ -534,40 +535,52 @@ func (h APIHandler) ListChannels(g *gin.Context) ginresp.HTTPResponse { sel := strings.ToLower(langext.Coalesce(q.Selector, "owned")) - var res []models.ChannelJSON + var res []models.ChannelWithSubscriptionJSON if sel == "owned" { - channels, err := h.database.ListChannelsByOwner(ctx, u.UserID) + + channels, err := h.database.ListChannelsByOwner(ctx, u.UserID, u.UserID) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) } - res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(true) }) + res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(true) }) + } else if sel == "subscribed_any" { - channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, false) + + channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, nil) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) } - res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(false) }) + res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) }) + } else if sel == "all_any" { - channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, false) + + channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, nil) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) } - res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(false) }) + res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) }) + } else if sel == "subscribed" { - channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, true) + + channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, langext.Ptr(true)) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) } - res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(false) }) + res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) }) + } else if sel == "all" { - channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, true) + + channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, langext.Ptr(true)) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) } - res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(false) }) + res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) }) + } else { + return ginresp.APIError(g, 400, apierr.INVALID_ENUM_VALUE, "Invalid value for the [selector] parameter", nil) + } return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Channels: res})) @@ -575,14 +588,14 @@ func (h APIHandler) ListChannels(g *gin.Context) ginresp.HTTPResponse { // GetChannel swaggerdoc // -// @Summary List all channels of a user +// @Summary Get a single channel // @ID api-channels-get // @Tags API-v2 // // @Param uid path int true "UserID" // @Param cid path int true "ChannelID" // -// @Success 200 {object} models.ChannelJSON +// @Success 200 {object} models.ChannelWithSubscriptionJSON // @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" // @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" // @Failure 404 {object} ginresp.apiError "channel not found" @@ -626,19 +639,20 @@ func (h APIHandler) GetChannel(g *gin.Context) ginresp.HTTPResponse { // @Param uid path int true "UserID" // @Param post_body body handler.CreateChannel.body false " " // -// @Success 200 {object} models.ChannelJSON +// @Success 200 {object} models.ChannelWithSubscriptionJSON // @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" // @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" // @Failure 409 {object} ginresp.apiError "channel already exists" // @Failure 500 {object} ginresp.apiError "internal server error" // -// @Router /api/users/{uid}/channels/ [POST] +// @Router /api/users/{uid}/channels [POST] func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse { type uri struct { UserID models.UserID `uri:"uid"` } type body struct { - Name string `json:"name"` + Name string `json:"name"` + Subscribe *bool `json:"subscribe"` } var u uri @@ -684,7 +698,21 @@ func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create channel", err) } - return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.JSON(true))) + if langext.Coalesce(b.Subscribe, true) { + + sub, err := h.database.CreateSubscription(ctx, u.UserID, channel, true) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create subscription", err) + } + + return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.WithSubscription(langext.Ptr(sub)).JSON(true))) + + } else { + + return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.WithSubscription(nil).JSON(true))) + + } + } // UpdateChannel swaggerdoc @@ -699,7 +727,7 @@ func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse { // @Param subscribe_key body string false "Send `true` to create a new subscribe_key" // @Param send_key body string false "Send `true` to create a new send_key" // -// @Success 200 {object} models.ChannelJSON +// @Success 200 {object} models.ChannelWithSubscriptionJSON // @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" // @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" // @Failure 404 {object} ginresp.apiError "channel not found" @@ -754,12 +782,12 @@ func (h APIHandler) UpdateChannel(g *gin.Context) ginresp.HTTPResponse { } } - user, err := h.database.GetChannel(ctx, u.UserID, u.ChannelID) + channel, err := h.database.GetChannel(ctx, u.UserID, u.ChannelID) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query (updated) user", err) } - return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, user.JSON(true))) + return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.JSON(true))) } // ListChannelMessages swaggerdoc @@ -865,28 +893,41 @@ func (h APIHandler) ListChannelMessages(g *gin.Context) ginresp.HTTPResponse { // ListUserSubscriptions swaggerdoc // -// @Summary List all channels of a user +// @Summary List all subscriptions of a user (incoming/owned) +// // @Description The possible values for 'selector' are: +// // @Description - "owner_all" All subscriptions (confirmed/unconfirmed) with the user as owner (= subscriptions he can use to read channels) +// // @Description - "owner_confirmed" Confirmed subscriptions with the user as owner +// // @Description - "owner_unconfirmed" Unconfirmed (Pending) subscriptions with the user as owner +// // @Description - "incoming_all" All subscriptions (confirmed/unconfirmed) from other users to channels of this user (= incoming subscriptions and subscription requests) +// // @Description - "incoming_confirmed" Confirmed subscriptions from other users to channels of this user +// // @Description - "incoming_unconfirmed" Unconfirmed subscriptions from other users to channels of this user (= requests) +// // // @ID api-user-subscriptions-list // @Tags API-v2 // -// @Param uid path int true "UserID" +// @Param uid path int true "UserID" +// @Param selector query string true "Filter subscribptions (default: owner_all)" Enums(owner_all, owner_confirmed, owner_unconfirmed, incoming_all, incoming_confirmed, incoming_unconfirmed) // -// @Success 200 {object} handler.ListUserSubscriptions.response -// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" -// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" -// @Failure 500 {object} ginresp.apiError "internal server error" +// @Success 200 {object} handler.ListUserSubscriptions.response +// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" +// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" +// @Failure 500 {object} ginresp.apiError "internal server error" // // @Router /api/users/{uid}/subscriptions [GET] func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse { type uri struct { UserID models.UserID `uri:"uid"` } + type query struct { + Selector *string `json:"selector" form:"selector" enums:"owner_all,owner_confirmed,owner_unconfirmed,incoming_all,incoming_confirmed,incoming_unconfirmed"` + } type response struct { Subscriptions []models.SubscriptionJSON `json:"subscriptions"` } var u uri - ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil) + var q query + ctx, errResp := h.app.StartRequest(g, &u, &q, nil, nil) if errResp != nil { return *errResp } @@ -896,14 +937,62 @@ func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse { return *permResp } - clients, err := h.database.ListSubscriptionsByOwner(ctx, u.UserID) - if err != nil { - return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) + sel := strings.ToLower(langext.Coalesce(q.Selector, "owner_all")) + + var res []models.Subscription + var err error + + if sel == "owner_all" { + + res, err = h.database.ListSubscriptionsByOwner(ctx, u.UserID, nil) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) + } + + } else if sel == "owner_confirmed" { + + res, err = h.database.ListSubscriptionsByOwner(ctx, u.UserID, langext.Ptr(true)) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) + } + + } else if sel == "owner_unconfirmed" { + + res, err = h.database.ListSubscriptionsByOwner(ctx, u.UserID, langext.Ptr(false)) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) + } + + } else if sel == "incoming_all" { + + res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, nil) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) + } + + } else if sel == "incoming_confirmed" { + + res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, langext.Ptr(true)) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) + } + + } else if sel == "incoming_unconfirmed" { + + res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, langext.Ptr(false)) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) + } + + } else { + + return ginresp.APIError(g, 400, apierr.INVALID_ENUM_VALUE, "Invalid value for the [selector] parameter", nil) + } - res := langext.ArrMap(clients, func(v models.Subscription) models.SubscriptionJSON { return v.JSON() }) + jsonres := langext.ArrMap(res, func(v models.Subscription) models.SubscriptionJSON { return v.JSON() }) - return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Subscriptions: res})) + return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Subscriptions: jsonres})) } // ListChannelSubscriptions swaggerdoc diff --git a/scnserver/db/channels.go b/scnserver/db/channels.go index d6a252f..7f6270d 100644 --- a/scnserver/db/channels.go +++ b/scnserver/db/channels.go @@ -93,18 +93,21 @@ func (db *Database) CreateChannel(ctx TxContext, userid models.UserID, name stri }, nil } -func (db *Database) ListChannelsByOwner(ctx TxContext, userid models.UserID) ([]models.Channel, error) { +func (db *Database) ListChannelsByOwner(ctx TxContext, userid models.UserID, subUserID models.UserID) ([]models.ChannelWithSubscription, 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}) + rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub ON channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE owner_user_id = :ouid", sq.PP{ + "ouid": userid, + "subuid": subUserID, + }) if err != nil { return nil, err } - data, err := models.DecodeChannels(rows) + data, err := models.DecodeChannelsWithSubscription(rows) if err != nil { return nil, err } @@ -112,25 +115,27 @@ func (db *Database) ListChannelsByOwner(ctx TxContext, userid models.UserID) ([] return data, nil } -func (db *Database) ListChannelsBySubscriber(ctx TxContext, userid models.UserID, confirmed bool) ([]models.Channel, error) { +func (db *Database) ListChannelsBySubscriber(ctx TxContext, userid models.UserID, confirmed *bool) ([]models.ChannelWithSubscription, error) { tx, err := ctx.GetOrCreateTransaction(db) if err != nil { return nil, err } confCond := "" - if confirmed { + if confirmed != nil && *confirmed { confCond = " AND sub.confirmed = 1" + } else if confirmed != nil && !*confirmed { + confCond = " AND sub.confirmed = 0" } - 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, + rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub on channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE sub.subscription_id IS NOT NULL "+confCond, sq.PP{ + "subuid": userid, }) if err != nil { return nil, err } - data, err := models.DecodeChannels(rows) + data, err := models.DecodeChannelsWithSubscription(rows) if err != nil { return nil, err } @@ -138,25 +143,28 @@ func (db *Database) ListChannelsBySubscriber(ctx TxContext, userid models.UserID return data, nil } -func (db *Database) ListChannelsByAccess(ctx TxContext, userid models.UserID, confirmed bool) ([]models.Channel, error) { +func (db *Database) ListChannelsByAccess(ctx TxContext, userid models.UserID, confirmed *bool) ([]models.ChannelWithSubscription, 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)" + confCond := "" + if confirmed != nil && *confirmed { + confCond = "OR sub.confirmed = 1" + } else if confirmed != nil && !*confirmed { + confCond = "OR sub.confirmed = 0" } - 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, + rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub on channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE owner_user_id = :ouid "+confCond, sq.PP{ + "ouid": userid, + "subuid": userid, }) if err != nil { return nil, err } - data, err := models.DecodeChannels(rows) + data, err := models.DecodeChannelsWithSubscription(rows) if err != nil { return nil, err } @@ -164,26 +172,27 @@ func (db *Database) ListChannelsByAccess(ctx TxContext, userid models.UserID, co return data, nil } -func (db *Database) GetChannel(ctx TxContext, userid models.UserID, channelid models.ChannelID) (models.Channel, error) { +func (db *Database) GetChannel(ctx TxContext, userid models.UserID, channelid models.ChannelID) (models.ChannelWithSubscription, error) { tx, err := ctx.GetOrCreateTransaction(db) if err != nil { - return models.Channel{}, err + return models.ChannelWithSubscription{}, 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, + rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub on channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE owner_user_id = :ouid AND channels.channel_id = :cid LIMIT 1", sq.PP{ + "ouid": userid, + "cid": channelid, + "subuid": userid, }) if err != nil { - return models.Channel{}, err + return models.ChannelWithSubscription{}, err } - client, err := models.DecodeChannel(rows) + channel, err := models.DecodeChannelWithSubscription(rows) if err != nil { - return models.Channel{}, err + return models.ChannelWithSubscription{}, err } - return client, nil + return channel, nil } func (db *Database) IncChannelMessageCounter(ctx TxContext, channel models.Channel) error { diff --git a/scnserver/db/database.go b/scnserver/db/database.go index 65817c8..506a615 100644 --- a/scnserver/db/database.go +++ b/scnserver/db/database.go @@ -2,6 +2,7 @@ package db import ( server "blackforestbytes.com/simplecloudnotifier" + "blackforestbytes.com/simplecloudnotifier/db/dbtools" "blackforestbytes.com/simplecloudnotifier/db/schema" "context" "database/sql" @@ -9,7 +10,6 @@ import ( "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" @@ -40,7 +40,9 @@ func NewDatabase(conf server.Config) (*Database, error) { scndb := &Database{qqdb} - qqdb.SetListener(scndb) + qqdb.AddListener(dbtools.DBLogger{}) + + qqdb.AddListener(dbtools.NewDBPreprocessor(scndb.db)) return scndb, nil } @@ -83,35 +85,3 @@ func (db *Database) Ping(ctx context.Context) error { 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)) -} diff --git a/scnserver/db/dbtools/logger.go b/scnserver/db/dbtools/logger.go new file mode 100644 index 0000000..02d5853 --- /dev/null +++ b/scnserver/db/dbtools/logger.go @@ -0,0 +1,90 @@ +package dbtools + +import ( + "context" + "fmt" + "github.com/rs/zerolog/log" + "gogs.mikescher.com/BlackForestBytes/goext/sq" + "strings" +) + +type DBLogger struct{} + +func (l DBLogger) PrePing(ctx context.Context) error { + log.Debug().Msg("[SQL-PING]") + + return nil +} + +func (l DBLogger) PreTxBegin(ctx context.Context, txid uint16) error { + log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-START]", txid)) + + return nil +} + +func (l DBLogger) PreTxCommit(txid uint16) error { + log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-COMMIT]", txid)) + + return nil +} + +func (l DBLogger) PreTxRollback(txid uint16) error { + log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-ROLLBACK]", txid)) + + return nil +} + +func (l DBLogger) PreQuery(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error { + 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))) + } + + return nil +} + +func (l DBLogger) PreExec(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error { + 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))) + } + + return nil +} + +func (l DBLogger) PostPing(result error) { + // +} + +func (l DBLogger) PostTxBegin(txid uint16, result error) { + // +} + +func (l DBLogger) PostTxCommit(txid uint16, result error) { + // +} + +func (l DBLogger) PostTxRollback(txid uint16, result error) { + // +} + +func (l DBLogger) PostQuery(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) { + // +} + +func (l DBLogger) PostExec(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) { + // +} + +func fmtSQLPrint(sql string) string { + if strings.Contains(sql, ";") { + return "(...multi...)" + } + + sql = strings.ReplaceAll(sql, "\r", "") + sql = strings.ReplaceAll(sql, "\n", " ") + + return sql +} diff --git a/scnserver/db/dbtools/preprocessor.go b/scnserver/db/dbtools/preprocessor.go new file mode 100644 index 0000000..b138fdf --- /dev/null +++ b/scnserver/db/dbtools/preprocessor.go @@ -0,0 +1,238 @@ +package dbtools + +import ( + "context" + "errors" + "fmt" + "github.com/rs/zerolog/log" + "gogs.mikescher.com/BlackForestBytes/goext/langext" + "gogs.mikescher.com/BlackForestBytes/goext/sq" + "regexp" + "strings" + "sync" +) + +// +// This is..., not good... +// +// for sq.ScanAll to work with (left-)joined tables _need_ to get column names aka "alias.column" +// But sqlite (and all other db server) only return "column" if we don't manually specify `alias.column as "alias.columnname"` +// But always specifying all columns (and their alias) would be __very__ cumbersome... +// +// The "solution" is this preprocessor, which translates queries of the form `SELECT tab1.*, tab2.* From tab1` into `SELECT tab1.col1 AS "tab1.col1", tab1.col2 AS "tab1.col2" ....` +// +// Prerequisites: +// - all aliased tables must be written as `tablename AS alias` (the variant without the AS keyword is invalid) +// - a star only expands to the (single) table in FROM. Use *, table2.* if there exists a second (joined) table +// - No weird SQL syntax, this "parser" is not very robust... +// + +type DBPreprocessor struct { + db sq.DB + + lock sync.Mutex + cacheColumns map[string][]string + cacheQuery map[string]string +} + +var regexAlias = regexp.MustCompile("([A-Za-z_\\-0-9]+)\\s+AS\\s+([A-Za-z_\\-0-9]+)") + +func NewDBPreprocessor(db sq.DB) *DBPreprocessor { + return &DBPreprocessor{ + db: db, + lock: sync.Mutex{}, + cacheColumns: make(map[string][]string), + cacheQuery: make(map[string]string), + } +} + +func (pp *DBPreprocessor) PrePing(ctx context.Context) error { + return nil +} + +func (pp *DBPreprocessor) PreTxBegin(ctx context.Context, txid uint16) error { + return nil +} + +func (pp *DBPreprocessor) PreTxCommit(txid uint16) error { + return nil +} + +func (pp *DBPreprocessor) PreTxRollback(txid uint16) error { + return nil +} + +func (pp *DBPreprocessor) PreQuery(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error { + sqlOriginal := *sql + + pp.lock.Lock() + v, ok := pp.cacheQuery[sqlOriginal] + pp.lock.Unlock() + + if ok { + *sql = v + return nil + } + + if !strings.HasPrefix(sqlOriginal, "SELECT ") { + return nil + } + + idxFrom := strings.Index(sqlOriginal, " FROM ") + if idxFrom < 0 { + return nil + } + + fromTableName := strings.Split(strings.TrimSpace(sqlOriginal[idxFrom+len(" FROM"):]), " ")[0] + + sels := strings.TrimSpace(sqlOriginal[len("SELECT "):idxFrom]) + + split := strings.Split(sels, ",") + + newsel := make([]string, 0) + + aliasMap := make(map[string]string) + for _, v := range regexAlias.FindAllStringSubmatch(sqlOriginal, idxFrom+len(" FROM")) { + aliasMap[strings.TrimSpace(v[2])] = strings.TrimSpace(v[1]) + } + + for _, expr := range split { + + expr = strings.TrimSpace(expr) + + if expr == "*" { + + columns, err := pp.getTableColumns(ctx, fromTableName) + if err != nil { + return err + } + + for _, colname := range columns { + newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s\"", fromTableName, colname, colname)) + } + + } else if strings.HasSuffix(expr, ".*") { + + tableName := expr[0 : len(expr)-2] + + if tableRealName, ok := aliasMap[tableName]; ok { + + columns, err := pp.getTableColumns(ctx, tableRealName) + if err != nil { + return err + } + + for _, colname := range columns { + newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s.%s\"", tableName, colname, tableName, colname)) + } + + } else if tableName == fromTableName { + + columns, err := pp.getTableColumns(ctx, tableName) + if err != nil { + return err + } + + for _, colname := range columns { + newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s\"", tableName, colname, colname)) + } + + } else { + + columns, err := pp.getTableColumns(ctx, tableName) + if err != nil { + return err + } + + for _, colname := range columns { + newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s.%s\"", tableName, colname, tableName, colname)) + } + + } + + } else { + return nil + } + + } + + newSQL := "SELECT " + strings.Join(newsel, ", ") + sqlOriginal[idxFrom:] + + pp.lock.Lock() + pp.cacheQuery[sqlOriginal] = newSQL + pp.lock.Unlock() + + log.Debug().Msgf("Preprocessed SQL statement from '%s' --to--> '%s'", sqlOriginal, newSQL) + + *sql = newSQL + return nil +} + +func (pp *DBPreprocessor) PreExec(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error { + return nil +} + +func (pp *DBPreprocessor) PostPing(result error) { + // +} + +func (pp *DBPreprocessor) PostTxBegin(txid uint16, result error) { + // +} + +func (pp *DBPreprocessor) PostTxCommit(txid uint16, result error) { + // +} + +func (pp *DBPreprocessor) PostTxRollback(txid uint16, result error) { + // +} + +func (pp *DBPreprocessor) PostQuery(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) { + // +} + +func (pp *DBPreprocessor) PostExec(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) { + // +} + +func (pp *DBPreprocessor) getTableColumns(ctx context.Context, tablename string) ([]string, error) { + pp.lock.Lock() + v, ok := pp.cacheColumns[tablename] + pp.lock.Unlock() + + if ok { + return v, nil + } + + type res struct { + CID int64 `db:"cid"` + Name string `db:"name"` + Type string `db:"type"` + NotNull int `db:"notnull"` + DFLT *string `db:"dflt_value"` + PK int `db:"pk"` + } + + rows, err := pp.db.Query(ctx, "PRAGMA table_info('"+tablename+"');", sq.PP{}) + if err != nil { + return nil, err + } + + resrows, err := sq.ScanAll[res](rows, true) + if err != nil { + return nil, err + } + + columns := langext.ArrMap(resrows, func(v res) string { return v.Name }) + + if len(columns) == 0 { + return nil, errors.New("no columns in table '" + tablename + "' (table does not exist?)") + } + + pp.lock.Lock() + pp.cacheColumns[tablename] = columns + pp.lock.Unlock() + + return columns, nil +} diff --git a/scnserver/db/subscriptions.go b/scnserver/db/subscriptions.go index 93aaeb8..ce9c64f 100644 --- a/scnserver/db/subscriptions.go +++ b/scnserver/db/subscriptions.go @@ -62,13 +62,46 @@ func (db *Database) ListSubscriptionsByChannel(ctx TxContext, channelID models.C return data, nil } -func (db *Database) ListSubscriptionsByOwner(ctx TxContext, ownerUserID models.UserID) ([]models.Subscription, error) { +func (db *Database) ListSubscriptionsByOwner(ctx TxContext, ownerUserID models.UserID, confirmed *bool) ([]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}) + cond := "" + if confirmed != nil && *confirmed { + cond = " AND confirmed = 1" + } else if confirmed != nil && !*confirmed { + cond = " AND confirmed = 0" + } + + rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE channel_owner_user_id = :ouid"+cond, 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) ListSubscriptionsBySubscriber(ctx TxContext, subscriberUserID models.UserID, confirmed *bool) ([]models.Subscription, error) { + tx, err := ctx.GetOrCreateTransaction(db) + if err != nil { + return nil, err + } + + cond := "" + if confirmed != nil && *confirmed { + cond = " AND confirmed = 1" + } else if confirmed != nil && !*confirmed { + cond = " AND confirmed = 0" + } + + rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE subscriber_user_id = :suid"+cond, sq.PP{"suid": subscriberUserID}) if err != nil { return nil, err } diff --git a/scnserver/db/users.go b/scnserver/db/users.go index b01e649..8ca19a4 100644 --- a/scnserver/db/users.go +++ b/scnserver/db/users.go @@ -186,7 +186,7 @@ func (db *Database) UpdateUserKeys(ctx TxContext, userid models.UserID, sendKey return err } - _, err = tx.Exec(ctx, "UPDATE users SET send_key = :sk, read_key = :rk, admin_key = :ak WHERE user_id = ?", sq.PP{ + _, err = tx.Exec(ctx, "UPDATE users SET send_key = :sk, read_key = :rk, admin_key = :ak WHERE user_id = :uid", sq.PP{ "sk": sendKey, "rk": readKey, "ak": adminKey, diff --git a/scnserver/db/utils.go b/scnserver/db/utils.go index 83f0024..2890b63 100644 --- a/scnserver/db/utils.go +++ b/scnserver/db/utils.go @@ -2,7 +2,6 @@ package db import ( "gogs.mikescher.com/BlackForestBytes/goext/langext" - "strings" "time" ) @@ -24,14 +23,3 @@ func time2DBOpt(t *time.Time) *int64 { } 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 -} diff --git a/scnserver/go.mod b/scnserver/go.mod index 1d7391b..dc9c5a5 100644 --- a/scnserver/go.mod +++ b/scnserver/go.mod @@ -8,7 +8,7 @@ require ( 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 + gogs.mikescher.com/BlackForestBytes/goext v0.0.41 ) require ( diff --git a/scnserver/go.sum b/scnserver/go.sum index 179dc3f..76937c9 100644 --- a/scnserver/go.sum +++ b/scnserver/go.sum @@ -112,6 +112,14 @@ gogs.mikescher.com/BlackForestBytes/goext v0.0.36 h1:iOUYz2NEiObCCdBnkt8DPi1N8gH 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= +gogs.mikescher.com/BlackForestBytes/goext v0.0.38 h1:P9tf0tHexcH6Q3u9pRwi4iQsOb65Lc80JvzD49f/EIY= +gogs.mikescher.com/BlackForestBytes/goext v0.0.38/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g= +gogs.mikescher.com/BlackForestBytes/goext v0.0.39 h1:96QUjPMoCzcZpA2SYUU4XXytWJq3dcsnfska88tMikU= +gogs.mikescher.com/BlackForestBytes/goext v0.0.39/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g= +gogs.mikescher.com/BlackForestBytes/goext v0.0.40 h1:wh5+IRmcMAbwJ8cK6JZvg71IiMUWuJoaKAR5dbt7FLU= +gogs.mikescher.com/BlackForestBytes/goext v0.0.40/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g= +gogs.mikescher.com/BlackForestBytes/goext v0.0.41 h1:3p/MtkHZ2gulSdizXql3VnFf2v7WpeOBCmTi0rQYCQw= +gogs.mikescher.com/BlackForestBytes/goext v0.0.41/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= diff --git a/scnserver/models/channel.go b/scnserver/models/channel.go index af19dde..712de04 100644 --- a/scnserver/models/channel.go +++ b/scnserver/models/channel.go @@ -31,6 +31,29 @@ func (c Channel) JSON(includeKey bool) ChannelJSON { } } +func (c Channel) WithSubscription(sub *Subscription) ChannelWithSubscription { + return ChannelWithSubscription{ + Channel: c, + Subscription: sub, + } +} + +type ChannelWithSubscription struct { + Channel + Subscription *Subscription +} + +func (c ChannelWithSubscription) JSON(includeChannelKey bool) ChannelWithSubscriptionJSON { + var sub *SubscriptionJSON = nil + if c.Subscription != nil { + sub = langext.Ptr(c.Subscription.JSON()) + } + return ChannelWithSubscriptionJSON{ + ChannelJSON: c.Channel.JSON(includeChannelKey), + Subscription: sub, + } +} + type ChannelJSON struct { ChannelID ChannelID `json:"channel_id"` OwnerUserID UserID `json:"owner_user_id"` @@ -42,6 +65,11 @@ type ChannelJSON struct { MessagesSent int `json:"messages_sent"` } +type ChannelWithSubscriptionJSON struct { + ChannelJSON + Subscription *SubscriptionJSON `json:"subscription"` +} + type ChannelDB struct { ChannelID ChannelID `db:"channel_id"` OwnerUserID UserID `db:"owner_user_id"` @@ -67,6 +95,22 @@ func (c ChannelDB) Model() Channel { } } +type ChannelWithSubscriptionDB struct { + ChannelDB + Subscription *SubscriptionDB `db:"sub"` +} + +func (c ChannelWithSubscriptionDB) Model() ChannelWithSubscription { + var sub *Subscription = nil + if c.Subscription != nil { + sub = langext.Ptr(c.Subscription.Model()) + } + return ChannelWithSubscription{ + Channel: c.ChannelDB.Model(), + Subscription: sub, + } +} + func DecodeChannel(r *sqlx.Rows) (Channel, error) { data, err := sq.ScanSingle[ChannelDB](r, true) if err != nil { @@ -82,3 +126,19 @@ func DecodeChannels(r *sqlx.Rows) ([]Channel, error) { } return langext.ArrMap(data, func(v ChannelDB) Channel { return v.Model() }), nil } + +func DecodeChannelWithSubscription(r *sqlx.Rows) (ChannelWithSubscription, error) { + data, err := sq.ScanSingle[ChannelWithSubscriptionDB](r, true) + if err != nil { + return ChannelWithSubscription{}, err + } + return data.Model(), nil +} + +func DecodeChannelsWithSubscription(r *sqlx.Rows) ([]ChannelWithSubscription, error) { + data, err := sq.ScanAll[ChannelWithSubscriptionDB](r, true) + if err != nil { + return nil, err + } + return langext.ArrMap(data, func(v ChannelWithSubscriptionDB) ChannelWithSubscription { return v.Model() }), nil +} diff --git a/scnserver/models/messagefilter.go b/scnserver/models/messagefilter.go index 863e714..654f26b 100644 --- a/scnserver/models/messagefilter.go +++ b/scnserver/models/messagefilter.go @@ -45,10 +45,10 @@ 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 " + joinClause += " LEFT JOIN subscriptions AS subs on messages.channel_id = subs.channel_id " } if f.SearchString != nil { - joinClause += " JOIN messages_fts mfts on (mfts.rowid = messages.scn_message_id) " + joinClause += " JOIN messages_fts AS mfts on (mfts.rowid = messages.scn_message_id) " } sqlClauses := make([]string, 0) diff --git a/scnserver/swagger/swagger.json b/scnserver/swagger/swagger.json index c4a6f6e..b15f02b 100644 --- a/scnserver/swagger/swagger.json +++ b/scnserver/swagger/swagger.json @@ -1151,11 +1151,11 @@ }, "/api/users/{uid}/channels": { "get": { - "description": "The possible values for 'selector' are:\n- \"owned\" Return all channels of the user\n- \"subscribed\" Return all channels that the user is subscribing to\n- \"all\" Return channels that the user owns or is subscribing\n- \"subscribed_any\" Return all channels that the user is subscribing to (even unconfirmed)\n- \"all_any\" Return channels that the user owns or is subscribing (even unconfirmed)", + "description": "The possible values for 'selector' are:\n- \"owned\" Return all channels of the user\n- \"subscribed\" Return all channels that the user is subscribing to\n- \"all\" Return channels that the user owns or is subscribing\n- \"subscribed_any\" Return all channels that the user is subscribing to (even unconfirmed)\n- \"all_any\" Return channels that the user owns or is subscribing (even unconfirmed)", "tags": [ "API-v2" ], - "summary": "List channels of a user (subscribed/owned)", + "summary": "List channels of a user (subscribed/owned/all)", "operationId": "api-channels-list", "parameters": [ { @@ -1206,9 +1206,7 @@ } } } - } - }, - "/api/users/{uid}/channels/": { + }, "post": { "tags": [ "API-v2" @@ -1236,7 +1234,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/models.ChannelJSON" + "$ref": "#/definitions/models.ChannelWithSubscriptionJSON" } }, "400": { @@ -1271,7 +1269,7 @@ "tags": [ "API-v2" ], - "summary": "List all channels of a user", + "summary": "Get a single channel", "operationId": "api-channels-get", "parameters": [ { @@ -1293,7 +1291,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/models.ChannelJSON" + "$ref": "#/definitions/models.ChannelWithSubscriptionJSON" } }, "400": { @@ -1364,7 +1362,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/models.ChannelJSON" + "$ref": "#/definitions/models.ChannelWithSubscriptionJSON" } }, "400": { @@ -1740,7 +1738,7 @@ "tags": [ "API-v2" ], - "summary": "List all channels of a user", + "summary": "List all subscriptions of a user (incoming/owned)", "operationId": "api-user-subscriptions-list", "parameters": [ { @@ -1749,6 +1747,21 @@ "name": "uid", "in": "path", "required": true + }, + { + "enum": [ + "owner_all", + "owner_confirmed", + "owner_unconfirmed", + "incoming_all", + "incoming_confirmed", + "incoming_unconfirmed" + ], + "type": "string", + "description": "Filter subscribptions (default: owner_all)", + "name": "selector", + "in": "query", + "required": true } ], "responses": { @@ -2380,6 +2393,9 @@ "properties": { "name": { "type": "string" + }, + "subscribe": { + "type": "boolean" } } }, @@ -2529,7 +2545,7 @@ "channels": { "type": "array", "items": { - "$ref": "#/definitions/models.ChannelJSON" + "$ref": "#/definitions/models.ChannelWithSubscriptionJSON" } } } @@ -2804,7 +2820,7 @@ } } }, - "models.ChannelJSON": { + "models.ChannelWithSubscriptionJSON": { "type": "object", "properties": { "channel_id": { @@ -2827,6 +2843,9 @@ "description": "can be nil, depending on endpoint", "type": "string" }, + "subscription": { + "$ref": "#/definitions/models.SubscriptionJSON" + }, "timestamp_created": { "type": "string" }, diff --git a/scnserver/swagger/swagger.yaml b/scnserver/swagger/swagger.yaml index 1697a0d..d7664ec 100644 --- a/scnserver/swagger/swagger.yaml +++ b/scnserver/swagger/swagger.yaml @@ -55,6 +55,8 @@ definitions: properties: name: type: string + subscribe: + type: boolean type: object handler.CreateSubscription.body: properties: @@ -151,7 +153,7 @@ definitions: properties: channels: items: - $ref: '#/definitions/models.ChannelJSON' + $ref: '#/definitions/models.ChannelWithSubscriptionJSON' type: array type: object handler.ListClients.response: @@ -334,7 +336,7 @@ definitions: suppress_send: type: boolean type: object - models.ChannelJSON: + models.ChannelWithSubscriptionJSON: properties: channel_id: type: integer @@ -350,6 +352,8 @@ definitions: subscribe_key: description: can be nil, depending on endpoint type: string + subscription: + $ref: '#/definitions/models.SubscriptionJSON' timestamp_created: type: string timestamp_lastsent: @@ -1288,11 +1292,11 @@ paths: get: description: |- The possible values for 'selector' are: - - "owned" Return all channels of the user - - "subscribed" Return all channels that the user is subscribing to - - "all" Return channels that the user owns or is subscribing + - "owned" Return all channels of the user + - "subscribed" Return all channels that the user is subscribing to + - "all" Return channels that the user owns or is subscribing - "subscribed_any" Return all channels that the user is subscribing to (even unconfirmed) - - "all_any" Return channels that the user owns or is subscribing (even unconfirmed) + - "all_any" Return channels that the user owns or is subscribing (even unconfirmed) operationId: api-channels-list parameters: - description: UserID @@ -1328,10 +1332,9 @@ paths: description: internal server error schema: $ref: '#/definitions/ginresp.apiError' - summary: List channels of a user (subscribed/owned) + summary: List channels of a user (subscribed/owned/all) tags: - API-v2 - /api/users/{uid}/channels/: post: operationId: api-channels-create parameters: @@ -1349,7 +1352,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/models.ChannelJSON' + $ref: '#/definitions/models.ChannelWithSubscriptionJSON' "400": description: supplied values/parameters cannot be parsed / are invalid schema: @@ -1387,7 +1390,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/models.ChannelJSON' + $ref: '#/definitions/models.ChannelWithSubscriptionJSON' "400": description: supplied values/parameters cannot be parsed / are invalid schema: @@ -1404,7 +1407,7 @@ paths: description: internal server error schema: $ref: '#/definitions/ginresp.apiError' - summary: List all channels of a user + summary: Get a single channel tags: - API-v2 patch: @@ -1434,7 +1437,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/models.ChannelJSON' + $ref: '#/definitions/models.ChannelWithSubscriptionJSON' "400": description: supplied values/parameters cannot be parsed / are invalid schema: @@ -1693,6 +1696,18 @@ paths: name: uid required: true type: integer + - description: 'Filter subscribptions (default: owner_all)' + enum: + - owner_all + - owner_confirmed + - owner_unconfirmed + - incoming_all + - incoming_confirmed + - incoming_unconfirmed + in: query + name: selector + required: true + type: string responses: "200": description: OK @@ -1710,7 +1725,7 @@ paths: description: internal server error schema: $ref: '#/definitions/ginresp.apiError' - summary: List all channels of a user + summary: List all subscriptions of a user (incoming/owned) tags: - API-v2 post: diff --git a/scnserver/test/channel_test.go b/scnserver/test/channel_test.go index 0b0cc83..08a9592 100644 --- a/scnserver/test/channel_test.go +++ b/scnserver/test/channel_test.go @@ -28,8 +28,8 @@ func TestCreateChannel(t *testing.T) { } { - chan0 := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid)) - tt.AssertEqual(t, "chan-count", 0, len(chan0.Channels)) + clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid)) + tt.AssertMappedSet(t, "channels", []string{}, clist.Channels, "name") } tt.RequestAuthPost[gin.H](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{ @@ -39,7 +39,7 @@ func TestCreateChannel(t *testing.T) { { 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.AssertMappedSet(t, "channels", []string{"test"}, clist.Channels, "name") } tt.RequestAuthPost[gin.H](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{ @@ -48,9 +48,7 @@ func TestCreateChannel(t *testing.T) { { 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" }) + tt.AssertMappedSet(t, "channels", []string{"asdf", "test"}, clist.Channels, "name") } } @@ -137,12 +135,37 @@ func TestChannelNameNormalization(t *testing.T) { } } -func TestListChannels(t *testing.T) { - t.SkipNow() //TODO -} - func TestListChannelsOwned(t *testing.T) { - t.SkipNow() //TODO + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitDefaultData(t, ws) + + type chanlist struct { + Channels []gin.H `json:"channels"` + } + + testdata := map[int][]string{ + 0: {"main", "chattingchamber", "unicdhll", "promotions", "reminders"}, + 1: {"promotions"}, + 2: {}, + 3: {}, + 4: {}, + 5: {}, + 6: {}, + 7: {}, + 8: {}, + 9: {}, + 10: {}, + 11: {}, + 12: {}, + 13: {}, + } + + for k, v := range testdata { + r0 := tt.RequestAuthGet[chanlist](t, data.User[k].AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/channels", data.User[k].UID)) + tt.AssertMappedSet(t, fmt.Sprintf("%d->chanlist", k), v, r0.Channels, "name") + } } func TestListChannelsSubscribedAny(t *testing.T) { diff --git a/scnserver/test/util/common.go b/scnserver/test/util/common.go index b09dc1b..4ffc8cb 100644 --- a/scnserver/test/util/common.go +++ b/scnserver/test/util/common.go @@ -2,6 +2,7 @@ package util import ( "fmt" + "github.com/gin-gonic/gin" "gogs.mikescher.com/BlackForestBytes/goext/langext" "reflect" "runtime/debug" @@ -199,3 +200,22 @@ func AssertMultiNonEmpty(t *testing.T, key string, args ...any) { } } } + +func AssertMappedSet[T langext.OrderedConstraint](t *testing.T, key string, expected []T, values []gin.H, objkey string) { + + actual := langext.ArrMap(values, func(v gin.H) T { return v[objkey].(T) }) + + langext.Sort(actual) + langext.Sort(expected) + + if !langext.ArrEqualsExact(actual, expected) { + t.Errorf("Value [%s] differs (%T <-> %T):\n", key, expected, actual) + + t.Errorf("Actual := [%v]\n", actual) + t.Errorf("Expected := [%v]\n", expected) + + t.Error(string(debug.Stack())) + + t.FailNow() + } +} diff --git a/scnserver/test/util/factory.go b/scnserver/test/util/factory.go index 8e421cf..4ed5da0 100644 --- a/scnserver/test/util/factory.go +++ b/scnserver/test/util/factory.go @@ -86,6 +86,8 @@ var userExamples = []userex{ {9, true, "UniqueUnicorn", "Galaxy Quest", "2023.1", "ANDROID", "FCM_TOK_EX_010", ""}, {10, false, "", "", "", "", "", ""}, {11, false, "", "", "", "", "", "ANDROID|v2|PURCHASED:PRO_TOK_002"}, + {12, true, "ChanTester1", "StarfireXX", "1.x", "IOS", "FCM_TOK_EX_012", ""}, + {13, true, "ChanTester2", "StarfireXX", "1.x", "IOS", "FCM_TOK_EX_013", ""}, } var clientExamples = []clientex{ @@ -264,6 +266,15 @@ var messageExamples = []msgex{ {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)}, + + {12, "", "", P0, SKEY, "New Product Launch", "We are excited to announce the launch of our new product, the XYZ widget", 0}, + {12, "chan_self_subscribed", "", P0, SKEY, "Important Update", "We have released a critical update", 0}, + {12, "chan_self_unsub", "", P0, SKEY, "Reminder: Upcoming Maintenance", "", 0}, + + {13, "", "", P0, SKEY, "New Feature Available", "ability to schedule appointments", 0}, + {13, "chan_other_nosub", "", P0, SKEY, "Account Suspended", "Please contact us", 0}, + {13, "chan_other_request", "", P0, SKEY, "Invitation to Beta Test", "", 0}, + {13, "chan_other_accepted", "", P0, SKEY, "New Blog Post", "Congratulations on your promotion! We are proud", 0}, } type DefData struct { @@ -286,6 +297,8 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData { users := make([]Userdat, 0, len(userExamples)) + // Create Users + for _, uex := range userExamples { body := gin.H{} if uex.WithClient { @@ -318,6 +331,8 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData { }) } + // Create Clients + for _, cex := range clientExamples { body := gin.H{} body["agent_model"] = cex.AgentModel @@ -327,15 +342,9 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData { RequestAuthPost[gin.H](t, users[cex.User].AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/clients", users[cex.User].UID), body) } + // Create Messages + 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 @@ -364,6 +373,15 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData { RequestPost[gin.H](t, baseUrl, "/", body) } + // Sub/Unsub for Users 12+13 + + { + //TODO User 12 unsubscribe from 12:chan_self_unsub + //TODO User 13 request-subscribe to 13:chan_other_request + //TODO User 13 request-subscribe to 13:chan_other_accepted + //TODO User 13 accept subscription from user 12 to 13:chan_other_accepted + } + success = true return DefData{User: users}