diff --git a/.drone.yaml b/.drone.yaml new file mode 100644 index 0000000..3a51629 --- /dev/null +++ b/.drone.yaml @@ -0,0 +1,60 @@ +--- +kind: pipeline +type: docker +name: buildLatestImage + +steps: + - name: buildLatestImage + image: plugins/docker + settings: + repo: jtom38/newsbot-collector + username: jtom38 + password: + from_secret: DockerPushPat +trigger: + branch: + include: + - main + + event: + exclude: + - pull_request +--- +kind: pipeline +type: docker +name: buildReleaseImage + +steps: + - name: buildReleaseImage + image: plugins/docker + settings: + repo: jtom38/newsbot-collector + username: jtom38 + password: + from_secret: DockerPushPat +trigger: + branch: + include: + - releases/* + ref: + include: + - refs/tags/** + event: + exclude: + - pull_request + + +--- +kind: pipeline +type: docker +name: PullRequestCompileTest +steps: + - name: Compile project + image: golang:1.22 + commands: + - go test ./internal/repository + - go build ./cmd/server.go + - +trigger: + event: + - pull_request \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index f944eb2..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Launch Package", - "type": "go", - "request": "launch", - "mode": "auto", - "program": "." - } - ] -} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1d7645e..2be6722 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,14 +5,10 @@ WORKDIR /app # Always make sure that swagger docs are updated RUN go install github.com/swaggo/swag/cmd/swag@latest -RUN /go/bin/swag i +RUN /go/bin/swag init -g cmd/server.go -# Always build the latest sql queries -RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest -RUN /go/bin/sqlc generate - -RUN go build . -RUN go install github.com/pressly/goose/v3/cmd/goose@latest +#RUN go build . +#RUN go install github.com/pressly/goose/v3/cmd/goose@latest FROM alpine:latest as app @@ -21,8 +17,7 @@ RUN apk --no-cache add libc6-compat RUN apk --no-cache add chromium RUN mkdir /app && mkdir /app/migrations -COPY --from=build /app/collector /app -COPY --from=build /go/bin/goose /app -COPY ./database/migrations/ /app/migrations +COPY --from=build /app/server /app +COPY ./internal/database/migrations/ /app/migrations CMD [ "/app/collector" ] \ No newline at end of file diff --git a/cmd/server.go b/cmd/server.go index 48f4826..dad9a4b 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -3,7 +3,9 @@ package main import ( "context" "database/sql" + "errors" "fmt" + "os" _ "github.com/glebarez/go-sqlite" "github.com/pressly/goose/v3" @@ -14,9 +16,13 @@ import ( "git.jamestombleson.com/jtom38/newsbot-api/internal/services/cron" ) -// @title NewsBot collector -// @version 0.1 -// @BasePath /api +// @title NewsBot collector +// @version 0.1 +// @BasePath /api +// @securityDefinitions.apikey Bearer +// @in header +// @name Authorization +// @description Type "Bearer" followed by a space and JWT token. func main() { ctx := context.Background() @@ -30,14 +36,9 @@ func main() { panic(err) } - err = goose.SetDialect("sqlite3") + err = migrateDatabase(db) if err != nil { - panic(err) - } - - err = goose.Up(db, "../internal/database/migrations") - if err != nil { - panic(err) + fmt.Print(err) } c := cron.NewScheduler(ctx, db) @@ -51,3 +52,39 @@ func main() { server.Router.Start(":8081") } + +func migrateDatabase(db *sql.DB) error { + err := goose.SetDialect("sqlite3") + if err != nil { + panic(err) + } + + err = goose.Up(db, "../internal/database/migrations") + if err != nil { + panic(err) + } + + _, err = os.Stat("./migrations") + if err == nil { + + err = goose.Up(db, "../internal/database/migrations") + if err != nil { + panic(err) + } + + return nil + } + + _, err = os.Stat("../internal/database/migrations") + if err == nil { + + err = goose.Up(db, "../internal/database/migrations") + if err != nil { + panic(err) + } + + return nil + } + + return errors.New("failed to find the migration files") +} diff --git a/docs/docs.go b/docs/docs.go index 4dfaa2c..f7ce7b5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -18,6 +18,11 @@ const docTemplate = `{ "paths": { "/v1/articles": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -57,6 +62,11 @@ const docTemplate = `{ }, "/v1/articles/by/sourceid": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -103,6 +113,11 @@ const docTemplate = `{ }, "/v1/articles/{ID}": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -143,6 +158,11 @@ const docTemplate = `{ }, "/v1/articles/{ID}/details": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -183,6 +203,11 @@ const docTemplate = `{ }, "/v1/discord/webhooks": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -214,6 +239,11 @@ const docTemplate = `{ }, "/v1/discord/webhooks/by/serverAndChannel": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -261,6 +291,11 @@ const docTemplate = `{ }, "/v1/discord/webhooks/new": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "DiscordWebhook" ], @@ -349,6 +384,11 @@ const docTemplate = `{ }, "/v1/discord/webhooks/{ID}/disable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "DiscordWebhook" ], @@ -386,6 +426,11 @@ const docTemplate = `{ }, "/v1/discord/webhooks/{ID}/enable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "DiscordWebhook" ], @@ -404,6 +449,11 @@ const docTemplate = `{ }, "/v1/discord/webhooks/{id}": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -442,48 +492,13 @@ const docTemplate = `{ } } }, - "/v1/queue/discord/webhooks": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Queue" - ], - "summary": "Returns the top 100 entries from the queue to be processed.", - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListDiscordWebHooksQueueResults" - } - } - } - } - }, - "/v1/settings/{key}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Settings" - ], - "summary": "Returns a object based on the Key that was given.", - "parameters": [ - { - "type": "string", - "description": "Settings Key value", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": {} - } - }, "/v1/sources": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -517,6 +532,11 @@ const docTemplate = `{ }, "/v1/sources/by/source": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -563,6 +583,11 @@ const docTemplate = `{ }, "/v1/sources/by/sourceAndName": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -610,6 +635,11 @@ const docTemplate = `{ }, "/v1/sources/new/reddit": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -654,6 +684,11 @@ const docTemplate = `{ }, "/v1/sources/new/rss": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -698,6 +733,11 @@ const docTemplate = `{ }, "/v1/sources/new/twitch": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -716,6 +756,11 @@ const docTemplate = `{ }, "/v1/sources/new/youtube": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -741,6 +786,11 @@ const docTemplate = `{ }, "/v1/sources/{id}": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -779,24 +829,53 @@ const docTemplate = `{ } }, "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], "summary": "Marks a source as deleted based on its ID value.", "parameters": [ { - "type": "string", + "type": "integer", "description": "id", "name": "id", "in": "path", "required": true } ], - "responses": {} + "responses": { + "200": { + "description": "ok", + "schema": { + "$ref": "#/definitions/domain.SourcesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } } }, "/v1/sources/{id}/disable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -834,6 +913,11 @@ const docTemplate = `{ }, "/v1/sources/{id}/enable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -869,165 +953,234 @@ const docTemplate = `{ } } }, - "/v1/subscriptions": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Returns the top 100 entries from the queue to be processed.", - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListSubscriptions" - } - }, - "400": { - "description": "Unable to reach SQL.", - "schema": { - "$ref": "#/definitions/v1.ApiError" - } - }, - "500": { - "description": "Failed to process data from SQL.", - "schema": { - "$ref": "#/definitions/v1.ApiError" - } - } - } - } - }, - "/v1/subscriptions/by/SourceId": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Returns the top 100 entries from the queue to be processed.", - "parameters": [ - { - "type": "string", - "description": "id", - "name": "id", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListSubscriptions" - } - } - } - } - }, - "/v1/subscriptions/by/discordId": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Returns the top 100 entries from the queue to be processed.", - "parameters": [ - { - "type": "string", - "description": "id", - "name": "id", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListSubscriptions" - } - }, - "400": { - "description": "Unable to reach SQL or Data problems", - "schema": { - "$ref": "#/definitions/v1.ApiError" - } - }, - "500": { - "description": "Data problems", - "schema": { - "$ref": "#/definitions/v1.ApiError" - } - } - } - } - }, - "/v1/subscriptions/details": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Returns the top 50 entries with full deatils on the source and output.", - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListSubscriptionDetails" - } - } - } - } - }, - "/v1/subscriptions/discord/webhook/delete": { - "delete": { - "tags": [ - "Subscription" - ], - "summary": "Removes a Discord WebHook Subscription based on the Subscription ID.", - "parameters": [ - { - "type": "string", - "description": "id", - "name": "id", - "in": "query", - "required": true - } - ], - "responses": {} - } - }, - "/v1/subscriptions/discord/webhook/new": { + "/v1/users/login": { "post": { - "tags": [ - "Subscription" + "produces": [ + "application/json" ], - "summary": "Creates a new subscription to link a post from a Source to a DiscordWebHook.", + "tags": [ + "Users" + ], + "summary": "Logs into the API and returns a bearer token if successful", "parameters": [ { "type": "string", - "description": "discordWebHookId", - "name": "discordWebHookId", - "in": "query", - "required": true + "name": "password", + "in": "formData" }, { "type": "string", - "description": "sourceId", - "name": "sourceId", - "in": "query", - "required": true + "name": "username", + "in": "formData" } ], - "responses": {} + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.LoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } + } + }, + "/v1/users/refreshToken": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "tags": [ + "Users" + ], + "summary": "Generates a new token", + "parameters": [ + { + "description": "body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.RefreshTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.LoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } + } + }, + "/v1/users/register": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Creates a new user", + "parameters": [ + { + "type": "string", + "name": "password", + "in": "formData" + }, + { + "type": "string", + "name": "username", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } + } + }, + "/v1/users/scopes/add": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Adds a new scope to a user account", + "parameters": [ + { + "description": "body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateScopesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } + } + }, + "/v1/users/scopes/remove": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Adds a new scope to a user account", + "parameters": [ + { + "description": "body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateScopesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } } } }, @@ -1149,6 +1302,34 @@ const docTemplate = `{ } } }, + "domain.LoginResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "domain.RefreshTokenRequest": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "domain.SourceDto": { "type": "object", "properties": { @@ -1186,211 +1367,30 @@ const docTemplate = `{ } } }, - "models.ArticleDetailsDto": { + "domain.UpdateScopesRequest": { "type": "object", + "required": [ + "scopes" + ], "properties": { - "authorImage": { - "type": "string" - }, - "authorName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "pubdate": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/models.SourceDto" - }, - "tags": { + "scopes": { "type": "array", "items": { "type": "string" } }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - }, - "url": { - "type": "string" - }, - "video": { - "type": "string" - }, - "videoHeight": { - "type": "integer" - }, - "videoWidth": { - "type": "integer" - } - } - }, - "models.DiscordQueueDetailsDto": { - "type": "object", - "properties": { - "article": { - "$ref": "#/definitions/models.ArticleDetailsDto" - }, - "id": { + "username": { "type": "string" } } - }, - "models.DiscordWebHooksDto": { - "type": "object", - "properties": { - "ID": { - "type": "string" - }, - "channel": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "server": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "models.SourceDto": { - "type": "object", - "properties": { - "deleted": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "site": { - "type": "string" - }, - "source": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "models.SubscriptionDetailsDto": { - "type": "object", - "properties": { - "discordwebhook": { - "$ref": "#/definitions/models.DiscordWebHooksDto" - }, - "id": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/models.SourceDto" - } - } - }, - "models.SubscriptionDto": { - "type": "object", - "properties": { - "discordwebhookid": { - "type": "string" - }, - "id": { - "type": "string" - }, - "sourceid": { - "type": "string" - } - } - }, - "v1.ApiError": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - } - }, - "v1.ListDiscordWebHooksQueueResults": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "payload": { - "type": "array", - "items": { - "$ref": "#/definitions/models.DiscordQueueDetailsDto" - } - }, - "status": { - "type": "integer" - } - } - }, - "v1.ListSubscriptionDetails": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "payload": { - "type": "array", - "items": { - "$ref": "#/definitions/models.SubscriptionDetailsDto" - } - }, - "status": { - "type": "integer" - } - } - }, - "v1.ListSubscriptions": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "payload": { - "type": "array", - "items": { - "$ref": "#/definitions/models.SubscriptionDto" - } - }, - "status": { - "type": "integer" - } - } + } + }, + "securityDefinitions": { + "Bearer": { + "description": "Type \"Bearer\" followed by a space and JWT token.", + "type": "apiKey", + "name": "Authorization", + "in": "header" } } }` diff --git a/docs/swagger.json b/docs/swagger.json index 1457fe6..89172d1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -9,6 +9,11 @@ "paths": { "/v1/articles": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -48,6 +53,11 @@ }, "/v1/articles/by/sourceid": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -94,6 +104,11 @@ }, "/v1/articles/{ID}": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -134,6 +149,11 @@ }, "/v1/articles/{ID}/details": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -174,6 +194,11 @@ }, "/v1/discord/webhooks": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -205,6 +230,11 @@ }, "/v1/discord/webhooks/by/serverAndChannel": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -252,6 +282,11 @@ }, "/v1/discord/webhooks/new": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "DiscordWebhook" ], @@ -340,6 +375,11 @@ }, "/v1/discord/webhooks/{ID}/disable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "DiscordWebhook" ], @@ -377,6 +417,11 @@ }, "/v1/discord/webhooks/{ID}/enable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "DiscordWebhook" ], @@ -395,6 +440,11 @@ }, "/v1/discord/webhooks/{id}": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -433,48 +483,13 @@ } } }, - "/v1/queue/discord/webhooks": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Queue" - ], - "summary": "Returns the top 100 entries from the queue to be processed.", - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListDiscordWebHooksQueueResults" - } - } - } - } - }, - "/v1/settings/{key}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Settings" - ], - "summary": "Returns a object based on the Key that was given.", - "parameters": [ - { - "type": "string", - "description": "Settings Key value", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": {} - } - }, "/v1/sources": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -508,6 +523,11 @@ }, "/v1/sources/by/source": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -554,6 +574,11 @@ }, "/v1/sources/by/sourceAndName": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -601,6 +626,11 @@ }, "/v1/sources/new/reddit": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -645,6 +675,11 @@ }, "/v1/sources/new/rss": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -689,6 +724,11 @@ }, "/v1/sources/new/twitch": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -707,6 +747,11 @@ }, "/v1/sources/new/youtube": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -732,6 +777,11 @@ }, "/v1/sources/{id}": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -770,24 +820,53 @@ } }, "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], "summary": "Marks a source as deleted based on its ID value.", "parameters": [ { - "type": "string", + "type": "integer", "description": "id", "name": "id", "in": "path", "required": true } ], - "responses": {} + "responses": { + "200": { + "description": "ok", + "schema": { + "$ref": "#/definitions/domain.SourcesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } } }, "/v1/sources/{id}/disable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -825,6 +904,11 @@ }, "/v1/sources/{id}/enable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -860,165 +944,234 @@ } } }, - "/v1/subscriptions": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Returns the top 100 entries from the queue to be processed.", - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListSubscriptions" - } - }, - "400": { - "description": "Unable to reach SQL.", - "schema": { - "$ref": "#/definitions/v1.ApiError" - } - }, - "500": { - "description": "Failed to process data from SQL.", - "schema": { - "$ref": "#/definitions/v1.ApiError" - } - } - } - } - }, - "/v1/subscriptions/by/SourceId": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Returns the top 100 entries from the queue to be processed.", - "parameters": [ - { - "type": "string", - "description": "id", - "name": "id", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListSubscriptions" - } - } - } - } - }, - "/v1/subscriptions/by/discordId": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Returns the top 100 entries from the queue to be processed.", - "parameters": [ - { - "type": "string", - "description": "id", - "name": "id", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListSubscriptions" - } - }, - "400": { - "description": "Unable to reach SQL or Data problems", - "schema": { - "$ref": "#/definitions/v1.ApiError" - } - }, - "500": { - "description": "Data problems", - "schema": { - "$ref": "#/definitions/v1.ApiError" - } - } - } - } - }, - "/v1/subscriptions/details": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subscription" - ], - "summary": "Returns the top 50 entries with full deatils on the source and output.", - "responses": { - "200": { - "description": "ok", - "schema": { - "$ref": "#/definitions/v1.ListSubscriptionDetails" - } - } - } - } - }, - "/v1/subscriptions/discord/webhook/delete": { - "delete": { - "tags": [ - "Subscription" - ], - "summary": "Removes a Discord WebHook Subscription based on the Subscription ID.", - "parameters": [ - { - "type": "string", - "description": "id", - "name": "id", - "in": "query", - "required": true - } - ], - "responses": {} - } - }, - "/v1/subscriptions/discord/webhook/new": { + "/v1/users/login": { "post": { - "tags": [ - "Subscription" + "produces": [ + "application/json" ], - "summary": "Creates a new subscription to link a post from a Source to a DiscordWebHook.", + "tags": [ + "Users" + ], + "summary": "Logs into the API and returns a bearer token if successful", "parameters": [ { "type": "string", - "description": "discordWebHookId", - "name": "discordWebHookId", - "in": "query", - "required": true + "name": "password", + "in": "formData" }, { "type": "string", - "description": "sourceId", - "name": "sourceId", - "in": "query", - "required": true + "name": "username", + "in": "formData" } ], - "responses": {} + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.LoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } + } + }, + "/v1/users/refreshToken": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "tags": [ + "Users" + ], + "summary": "Generates a new token", + "parameters": [ + { + "description": "body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.RefreshTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.LoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } + } + }, + "/v1/users/register": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Creates a new user", + "parameters": [ + { + "type": "string", + "name": "password", + "in": "formData" + }, + { + "type": "string", + "name": "username", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } + } + }, + "/v1/users/scopes/add": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Adds a new scope to a user account", + "parameters": [ + { + "description": "body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateScopesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } + } + }, + "/v1/users/scopes/remove": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Adds a new scope to a user account", + "parameters": [ + { + "description": "body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateScopesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.BaseResponse" + } + } + } } } }, @@ -1140,6 +1293,34 @@ } } }, + "domain.LoginResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "domain.RefreshTokenRequest": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "domain.SourceDto": { "type": "object", "properties": { @@ -1177,211 +1358,30 @@ } } }, - "models.ArticleDetailsDto": { + "domain.UpdateScopesRequest": { "type": "object", + "required": [ + "scopes" + ], "properties": { - "authorImage": { - "type": "string" - }, - "authorName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "pubdate": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/models.SourceDto" - }, - "tags": { + "scopes": { "type": "array", "items": { "type": "string" } }, - "thumbnail": { + "username": { "type": "string" - }, - "title": { - "type": "string" - }, - "url": { - "type": "string" - }, - "video": { - "type": "string" - }, - "videoHeight": { - "type": "integer" - }, - "videoWidth": { - "type": "integer" - } - } - }, - "models.DiscordQueueDetailsDto": { - "type": "object", - "properties": { - "article": { - "$ref": "#/definitions/models.ArticleDetailsDto" - }, - "id": { - "type": "string" - } - } - }, - "models.DiscordWebHooksDto": { - "type": "object", - "properties": { - "ID": { - "type": "string" - }, - "channel": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "server": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "models.SourceDto": { - "type": "object", - "properties": { - "deleted": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "site": { - "type": "string" - }, - "source": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "models.SubscriptionDetailsDto": { - "type": "object", - "properties": { - "discordwebhook": { - "$ref": "#/definitions/models.DiscordWebHooksDto" - }, - "id": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/models.SourceDto" - } - } - }, - "models.SubscriptionDto": { - "type": "object", - "properties": { - "discordwebhookid": { - "type": "string" - }, - "id": { - "type": "string" - }, - "sourceid": { - "type": "string" - } - } - }, - "v1.ApiError": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - } - }, - "v1.ListDiscordWebHooksQueueResults": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "payload": { - "type": "array", - "items": { - "$ref": "#/definitions/models.DiscordQueueDetailsDto" - } - }, - "status": { - "type": "integer" - } - } - }, - "v1.ListSubscriptionDetails": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "payload": { - "type": "array", - "items": { - "$ref": "#/definitions/models.SubscriptionDetailsDto" - } - }, - "status": { - "type": "integer" - } - } - }, - "v1.ListSubscriptions": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "payload": { - "type": "array", - "items": { - "$ref": "#/definitions/models.SubscriptionDto" - } - }, - "status": { - "type": "integer" } } } + }, + "securityDefinitions": { + "Bearer": { + "description": "Type \"Bearer\" followed by a space and JWT token.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a77fcff..9eddc06 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -78,6 +78,24 @@ definitions: $ref: '#/definitions/domain.DiscordWebHookDto' type: array type: object + domain.LoginResponse: + properties: + message: + type: string + refreshToken: + type: string + token: + type: string + type: + type: string + type: object + domain.RefreshTokenRequest: + properties: + refreshToken: + type: string + username: + type: string + type: object domain.SourceDto: properties: enabled: @@ -102,139 +120,16 @@ definitions: $ref: '#/definitions/domain.SourceDto' type: array type: object - models.ArticleDetailsDto: + domain.UpdateScopesRequest: properties: - authorImage: - type: string - authorName: - type: string - description: - type: string - id: - type: string - pubdate: - type: string - source: - $ref: '#/definitions/models.SourceDto' - tags: + scopes: items: type: string type: array - thumbnail: + username: type: string - title: - type: string - url: - type: string - video: - type: string - videoHeight: - type: integer - videoWidth: - type: integer - type: object - models.DiscordQueueDetailsDto: - properties: - article: - $ref: '#/definitions/models.ArticleDetailsDto' - id: - type: string - type: object - models.DiscordWebHooksDto: - properties: - ID: - type: string - channel: - type: string - enabled: - type: boolean - server: - type: string - url: - type: string - type: object - models.SourceDto: - properties: - deleted: - type: boolean - enabled: - type: boolean - id: - type: string - name: - type: string - site: - type: string - source: - type: string - tags: - items: - type: string - type: array - type: - type: string - url: - type: string - value: - type: string - type: object - models.SubscriptionDetailsDto: - properties: - discordwebhook: - $ref: '#/definitions/models.DiscordWebHooksDto' - id: - type: string - source: - $ref: '#/definitions/models.SourceDto' - type: object - models.SubscriptionDto: - properties: - discordwebhookid: - type: string - id: - type: string - sourceid: - type: string - type: object - v1.ApiError: - properties: - message: - type: string - status: - type: integer - type: object - v1.ListDiscordWebHooksQueueResults: - properties: - message: - type: string - payload: - items: - $ref: '#/definitions/models.DiscordQueueDetailsDto' - type: array - status: - type: integer - type: object - v1.ListSubscriptionDetails: - properties: - message: - type: string - payload: - items: - $ref: '#/definitions/models.SubscriptionDetailsDto' - type: array - status: - type: integer - type: object - v1.ListSubscriptions: - properties: - message: - type: string - payload: - items: - $ref: '#/definitions/models.SubscriptionDto' - type: array - status: - type: integer + required: + - scopes type: object info: contact: {} @@ -263,6 +158,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Lists the top 25 records ordering from newest to oldest. tags: - Articles @@ -289,6 +186,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Returns an article based on defined ID. tags: - Articles @@ -315,6 +214,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Returns an article and source based on defined ID. tags: - Articles @@ -345,6 +246,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Finds the articles based on the SourceID provided. Returns the top 25. tags: @@ -366,6 +269,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Returns the top 100 tags: - DiscordWebhook @@ -414,6 +319,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Disables a Webhook from being used. tags: - DiscordWebhook @@ -426,6 +333,8 @@ paths: required: true type: integer responses: {} + security: + - Bearer: [] summary: Enables a source to continue processing. tags: - DiscordWebhook @@ -452,6 +361,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Returns the top 100 entries from the queue to be processed. tags: - DiscordWebhook @@ -483,6 +394,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Returns all the known web hooks based on the Server and Channel given. tags: - DiscordWebhook @@ -517,35 +430,11 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Creates a new record for a discord web hook to post data to. tags: - DiscordWebhook - /v1/queue/discord/webhooks: - get: - produces: - - application/json - responses: - "200": - description: ok - schema: - $ref: '#/definitions/v1.ListDiscordWebHooksQueueResults' - summary: Returns the top 100 entries from the queue to be processed. - tags: - - Queue - /v1/settings/{key}: - get: - parameters: - - description: Settings Key value - in: path - name: key - required: true - type: string - produces: - - application/json - responses: {} - summary: Returns a object based on the Key that was given. - tags: - - Settings /v1/sources: get: parameters: @@ -564,6 +453,8 @@ paths: description: Unable to reach SQL or Data problems schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Lists the top 50 records tags: - Source @@ -590,6 +481,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Returns a single entity by ID tags: - Source @@ -599,8 +492,22 @@ paths: in: path name: id required: true - type: string - responses: {} + type: integer + responses: + "200": + description: ok + schema: + $ref: '#/definitions/domain.SourcesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.BaseResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Marks a source as deleted based on its ID value. tags: - Source @@ -625,6 +532,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Disables a source from processing. tags: - Source @@ -649,6 +558,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Enables a source to continue processing. tags: - Source @@ -679,6 +590,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: 'Lists the top 50 records based on the name given. Example: reddit' tags: - Source @@ -710,6 +623,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Returns a single entity by ID tags: - Source @@ -739,6 +654,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Creates a new reddit source to monitor. tags: - Source @@ -768,6 +685,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Creates a new rss source to monitor. tags: - Source @@ -780,6 +699,8 @@ paths: required: true type: string responses: {} + security: + - Bearer: [] summary: Creates a new twitch source to monitor. tags: - Source @@ -797,112 +718,158 @@ paths: required: true type: string responses: {} + security: + - Bearer: [] summary: Creates a new youtube source to monitor. tags: - Source - /v1/subscriptions: - get: - produces: - - application/json - responses: - "200": - description: ok - schema: - $ref: '#/definitions/v1.ListSubscriptions' - "400": - description: Unable to reach SQL. - schema: - $ref: '#/definitions/v1.ApiError' - "500": - description: Failed to process data from SQL. - schema: - $ref: '#/definitions/v1.ApiError' - summary: Returns the top 100 entries from the queue to be processed. - tags: - - Subscription - /v1/subscriptions/by/SourceId: - get: - parameters: - - description: id - in: query - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: ok - schema: - $ref: '#/definitions/v1.ListSubscriptions' - summary: Returns the top 100 entries from the queue to be processed. - tags: - - Subscription - /v1/subscriptions/by/discordId: - get: - parameters: - - description: id - in: query - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: ok - schema: - $ref: '#/definitions/v1.ListSubscriptions' - "400": - description: Unable to reach SQL or Data problems - schema: - $ref: '#/definitions/v1.ApiError' - "500": - description: Data problems - schema: - $ref: '#/definitions/v1.ApiError' - summary: Returns the top 100 entries from the queue to be processed. - tags: - - Subscription - /v1/subscriptions/details: - get: - produces: - - application/json - responses: - "200": - description: ok - schema: - $ref: '#/definitions/v1.ListSubscriptionDetails' - summary: Returns the top 50 entries with full deatils on the source and output. - tags: - - Subscription - /v1/subscriptions/discord/webhook/delete: - delete: - parameters: - - description: id - in: query - name: id - required: true - type: string - responses: {} - summary: Removes a Discord WebHook Subscription based on the Subscription ID. - tags: - - Subscription - /v1/subscriptions/discord/webhook/new: + /v1/users/login: post: parameters: - - description: discordWebHookId - in: query - name: discordWebHookId - required: true + - in: formData + name: password type: string - - description: sourceId - in: query - name: sourceId - required: true + - in: formData + name: username type: string - responses: {} - summary: Creates a new subscription to link a post from a Source to a DiscordWebHook. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.LoginResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.BaseResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.BaseResponse' + summary: Logs into the API and returns a bearer token if successful tags: - - Subscription + - Users + /v1/users/refreshToken: + post: + parameters: + - description: body + in: body + name: request + required: true + schema: + $ref: '#/definitions/domain.RefreshTokenRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.LoginResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.BaseResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] + summary: Generates a new token + tags: + - Users + /v1/users/register: + post: + parameters: + - in: formData + name: password + type: string + - in: formData + name: username + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.BaseResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.BaseResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.BaseResponse' + summary: Creates a new user + tags: + - Users + /v1/users/scopes/add: + post: + consumes: + - application/json + parameters: + - description: body + in: body + name: request + required: true + schema: + $ref: '#/definitions/domain.UpdateScopesRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.BaseResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.BaseResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] + summary: Adds a new scope to a user account + tags: + - Users + /v1/users/scopes/remove: + post: + consumes: + - application/json + parameters: + - description: body + in: body + name: request + required: true + schema: + $ref: '#/definitions/domain.UpdateScopesRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.BaseResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.BaseResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] + summary: Adds a new scope to a user account + tags: + - Users +securityDefinitions: + Bearer: + description: Type "Bearer" followed by a space and JWT token. + in: header + name: Authorization + type: apiKey swagger: "2.0" diff --git a/go.mod b/go.mod index f4bf926..8cc9542 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,11 @@ require ( github.com/PuerkitoBio/goquery v1.8.0 github.com/glebarez/go-sqlite v1.22.0 github.com/go-rod/rod v0.107.1 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/huandu/go-sqlbuilder v1.27.1 github.com/joho/godotenv v1.4.0 + github.com/labstack/echo-jwt/v4 v4.2.0 github.com/labstack/echo/v4 v4.12.0 github.com/mmcdole/gofeed v1.1.3 github.com/nicklaw5/helix/v2 v2.4.0 diff --git a/go.sum b/go.sum index a92e69f..0711bf5 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= @@ -60,6 +62,8 @@ 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/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c= +github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU= github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= diff --git a/internal/database/migrations/20240425083756_init.sql b/internal/database/migrations/20240425083756_init.sql index 96e82ae..fa53a97 100644 --- a/internal/database/migrations/20240425083756_init.sql +++ b/internal/database/migrations/20240425083756_init.sql @@ -18,23 +18,12 @@ CREATE TABLE Articles ( AuthorImageUrl TEXT NOT NULL ); -CREATE Table DiscordQueue ( - ID INTEGER PRIMARY KEY AUTOINCREMENT, - CreatedAt DATETIME NOT NULL, - UpdatedAt DATETIME NOT NULL, - DeletedAt DATETIME, - ArticleId NUMBER NOT NULL, - SourceId NUMBER NOT NULL -); - CREATE Table DiscordWebHooks ( ID INTEGER PRIMARY KEY AUTOINCREMENT, CreatedAt DATETIME NOT NULL, UpdatedAt DATETIME NOT NULL, DeletedAt DATETIME NOT NULL, UserID INTEGER NOT NULL, - --Name TEXT NOT NULL, -- Defines webhook purpose - --Key TEXT, Url TEXT NOT NULL, -- Webhook Url Server TEXT NOT NULL, -- Defines the server its bound it. Used for reference Channel TEXT NOT NULL, -- Defines the channel its bound to. Used for reference @@ -72,21 +61,30 @@ CREATE Table Sources ( Tags TEXT NOT NULL ); -CREATE TABLE Subscriptions ( +CREATE TABLE UserSourceSubscriptions ( ID INTEGER PRIMARY KEY AUTOINCREMENT, CreatedAt DATETIME NOT NULL, UpdatedAt DATETIME NOT NULL, - DeletedAt DATETIME, - DiscordWebHookID NUMBER NOT NULL, + DeletedAt DATETIME NOT NULL, + UserID NUMBER NOT NULL, + SourceID NUMBER NOT NULL +); + +CREATE TABLE AlertDiscord ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + CreatedAt DATETIME NOT NULL, + UpdatedAt DATETIME NOT NULL, + DeletedAt DATETIME NOT NULL, + UserID NUMBER NOT NULL, SourceID NUMBER NOT NULL, - UserID NUMBER NOT NULL + DiscordWebHookID NUMBER NOT NULL ); CREATE TABLE Users ( ID INTEGER PRIMARY KEY AUTOINCREMENT, CreatedAt DATETIME NOT NULL, UpdatedAt DATETIME NOT NULL, - DeletedAt DATETIME, + DeletedAt DATETIME NOT NULL, Name TEXT NOT NULL, Hash TEXT NOT NULL, Scopes TEXT NOT NULL @@ -96,7 +94,7 @@ CREATE TABLE RefreshTokens ( ID INTEGER PRIMARY KEY AUTOINCREMENT, CreatedAt DATETIME NOT NULL, UpdatedAt DATETIME NOT NULL, - DeletedAt DATETIME, + DeletedAt DATETIME NOT NULL, Username TEXT NOT NULL, Token TEXT NOT NULL ); @@ -106,13 +104,12 @@ CREATE TABLE RefreshTokens ( -- +goose Down -- +goose StatementBegin +DROP TABLE AlertDiscord; Drop Table Articles; -Drop Table DiscordQueue; Drop Table DiscordWebHooks; Drop Table Icons; -Drop Table Settings; -Drop Table Sources; -DROP TABLE Subscriptions; -DROP TABLE Users; DROP TABLE RefreshTokens; +Drop Table Sources; +DROP TABLE Users; +DROP TABLE UserSourceSubscriptions; -- +goose StatementEnd diff --git a/internal/domain/entity.go b/internal/domain/entity.go index bc56a9b..6983fb5 100644 --- a/internal/domain/entity.go +++ b/internal/domain/entity.go @@ -4,6 +4,18 @@ import ( "time" ) +// This links a source to a discord webhook. +// It is owned by a user so they can remove the link +type AlertDiscordEntity struct { + ID int64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt time.Time + UserID int64 + SourceID int64 + DiscordWebHookId int64 +} + type ArticleEntity struct { ID int64 CreatedAt time.Time @@ -35,10 +47,11 @@ type DiscordWebHookEntity struct { CreatedAt time.Time UpdatedAt time.Time DeletedAt time.Time - Url string - Server string - Channel string - Enabled bool + UserID int64 + Url string + Server string + Channel string + Enabled bool } type IconEntity struct { @@ -61,38 +74,50 @@ type SettingEntity struct { } type SourceEntity struct { - ID int64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt time.Time + ID int64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt time.Time - // Who will collect from it. Used + // Who will collect from it. Used // domain.SourceCollector... - Source string + Source string // Human Readable value to state what is getting collected DisplayName string // Tells the parser where to look for data - Url string - + Url string + // Static tags for this defined record - Tags string + Tags string // If the record is disabled, then it will be skipped on processing - Enabled bool + Enabled bool } -type SubscriptionEntity struct { - ID int64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt time.Time - SourceID int64 - SourceType string - SourceName string - DiscordID int64 - DiscordName string +//type SubscriptionEntity struct { +// ID int64 +// CreatedAt time.Time +// UpdatedAt time.Time +// DeletedAt time.Time +// UserID int64 +// SourceID int64 +// //SourceType string +// //SourceName string +// DiscordID int64 +// //DiscordName string +//} + +// This defines what sources a user wants to follow. +// These will show up for the user as a front page +type UserSourceSubscriptionEntity struct { + ID int64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt time.Time + UserID int64 + SourceID int64 } type UserEntity struct { diff --git a/internal/domain/interfaces/source.go b/internal/domain/interfaces/source.go deleted file mode 100644 index 59bcc64..0000000 --- a/internal/domain/interfaces/source.go +++ /dev/null @@ -1,22 +0,0 @@ -package interfaces - -import ( - "github.com/go-rod/rod" - "github.com/mmcdole/gofeed" -) - -type Sources interface { - CheckSource() error - PullFeed() (*gofeed.Feed, error) - - GetBrowser() *rod.Browser - GetPage(parser *rod.Browser, url string) *rod.Page - - ExtractThumbnail(page *rod.Page) (string, error) - ExtractPubDate(page *rod.Page) (string, error) - ExtractDescription(page *rod.Page) (string, error) - ExtractAuthor(page *rod.Page) (string, error) - ExtractAuthorImage(page *rod.Page) (string, error) - ExtractTags(page *rod.Page) (string, error) - ExtractTitle(page *rod.Page) (string, error) -} diff --git a/internal/domain/models/dto.go b/internal/domain/models/dto.go deleted file mode 100644 index 4fc48e5..0000000 --- a/internal/domain/models/dto.go +++ /dev/null @@ -1,129 +0,0 @@ -package models - -import ( - "strings" - "time" - - "github.com/google/uuid" - - "git.jamestombleson.com/jtom38/newsbot-api/internal/database" -) - -type ArticleDto struct { - ID uuid.UUID `json:"id"` - Source uuid.UUID `json:"sourceid"` - Tags []string `json:"tags"` - Title string `json:"title"` - Url string `json:"url"` - Pubdate time.Time `json:"pubdate"` - Video string `json:"video"` - Videoheight int32 `json:"videoHeight"` - Videowidth int32 `json:"videoWidth"` - Thumbnail string `json:"thumbnail"` - Description string `json:"description"` - Authorname string `json:"authorName"` - Authorimage string `json:"authorImage"` -} - -type ArticleDetailsDto struct { - ID uuid.UUID `json:"id"` - Source SourceDto `json:"source"` - Tags []string `json:"tags"` - Title string `json:"title"` - Url string `json:"url"` - Pubdate time.Time `json:"pubdate"` - Video string `json:"video"` - Videoheight int32 `json:"videoHeight"` - Videowidth int32 `json:"videoWidth"` - Thumbnail string `json:"thumbnail"` - Description string `json:"description"` - Authorname string `json:"authorName"` - Authorimage string `json:"authorImage"` -} - -type DiscordWebHooksDto struct { - ID uuid.UUID `json:"ID"` - Url string `json:"url"` - Server string `json:"server"` - Channel string `json:"channel"` - Enabled bool `json:"enabled"` -} - -func ConvertToDiscordWebhookDto(i database.Discordwebhook) DiscordWebHooksDto { - return DiscordWebHooksDto{ - ID: i.ID, - Url: i.Url, - Server: i.Server, - Channel: i.Channel, - Enabled: i.Enabled, - } -} - -type SourceDto struct { - ID uuid.UUID `json:"id"` - Site string `json:"site"` - Name string `json:"name"` - Source string `json:"source"` - Type string `json:"type"` - Value string `json:"value"` - Enabled bool `json:"enabled"` - Url string `json:"url"` - Tags []string `json:"tags"` - Deleted bool `json:"deleted"` -} - -func ConvertToSourceDto(i database.Source) SourceDto { - var deleted bool - if !i.Deleted.Valid { - deleted = true - } - - return SourceDto{ - ID: i.ID, - Site: i.Site, - Name: i.Name, - Source: i.Source, - Type: i.Type, - Value: i.Value.String, - Enabled: i.Enabled, - Url: i.Url, - Tags: splitTags(i.Tags), - Deleted: deleted, - } -} - -type DiscordQueueDto struct { - ID uuid.UUID `json:"id"` - Articleid uuid.UUID `json:"articleId"` -} - -type DiscordQueueDetailsDto struct { - ID uuid.UUID `json:"id"` - Article ArticleDetailsDto `json:"article"` -} - -type SubscriptionDto struct { - ID uuid.UUID `json:"id"` - DiscordWebhookId uuid.UUID `json:"discordwebhookid"` - SourceId uuid.UUID `json:"sourceid"` -} - -func ConvertToSubscriptionDto(i database.Subscription) SubscriptionDto { - c := SubscriptionDto{ - ID: i.ID, - DiscordWebhookId: i.Discordwebhookid, - SourceId: i.Sourceid, - } - return c -} - -type SubscriptionDetailsDto struct { - ID uuid.UUID `json:"id"` - Source SourceDto `json:"source"` - DiscordWebHook DiscordWebHooksDto `json:"discordwebhook"` -} - -func splitTags(t string) []string { - items := strings.Split(t, ", ") - return items -} diff --git a/internal/domain/requests.go b/internal/domain/requests.go index b7bd1ad..557e3b5 100644 --- a/internal/domain/requests.go +++ b/internal/domain/requests.go @@ -1,5 +1,10 @@ package domain +type LoginFormRequest struct { + Username string `form:"username"` + Password string `form:"password"` +} + type GetSourceBySourceAndNameParamRequest struct { Name string `query:"name"` Source string `query:"source"` @@ -11,3 +16,12 @@ type NewSourceParamRequest struct { Tags string `query:"tags"` } +type RefreshTokenRequest struct { + Username string `json:"username"` + RefreshToken string `json:"refreshToken"` +} + +type UpdateScopesRequest struct { + Username string `json:"username"` + Scopes []string `json:"scopes" validate:"required"` +} \ No newline at end of file diff --git a/internal/domain/responses.go b/internal/domain/responses.go index f6ca7ef..1ee7a22 100644 --- a/internal/domain/responses.go +++ b/internal/domain/responses.go @@ -5,6 +5,13 @@ type BaseResponse struct { Message string `json:"message"` } +type LoginResponse struct { + BaseResponse + Token string `json:"token"` + Type string `json:"type"` + RefreshToken string `json:"refreshToken"` +} + type ArticleResponse struct { BaseResponse Payload []ArticleDto `json:"payload"` diff --git a/internal/domain/scopes.go b/internal/domain/scopes.go new file mode 100644 index 0000000..15e9dc8 --- /dev/null +++ b/internal/domain/scopes.go @@ -0,0 +1,14 @@ +package domain + +const ( + ScopeAll = "newsbot:all" + + ScopeArticleRead = "newsbot:article:read" + ScopeArticleDisable = "newsbot:article:disable" + + ScopeSourceRead = "newsbot:source:read" + ScopeSourceCreate = "newsbot:source:create" + + ScopeDiscordWebHookCreate = "newsbot:discordwebhook:create" + ScopeDiscordWebhookRead = "newsbot:discordwebhook:read" +) diff --git a/internal/handler/v1/articles.go b/internal/handler/v1/articles.go index 0febc5d..b77f4d9 100644 --- a/internal/handler/v1/articles.go +++ b/internal/handler/v1/articles.go @@ -10,15 +10,21 @@ import ( ) // ListArticles -// @Summary Lists the top 25 records ordering from newest to oldest. -// @Produce application/json -// @Param page query string false "page number" -// @Tags Articles -// @Router /v1/articles [get] -// @Success 200 {object} domain.ArticleResponse -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Lists the top 25 records ordering from newest to oldest. +// @Produce application/json +// @Param page query string false "page number" +// @Tags Articles +// @Router /v1/articles [get] +// @Success 200 {object} domain.ArticleResponse +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) listArticles(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeArticleRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + resp := domain.ArticleResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -32,7 +38,7 @@ func (s *Handler) listArticles(c echo.Context) error { res, err := s.repo.Articles.ListByPage(c.Request().Context(), page, 25) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } resp.Payload = services.ArticlesToDto(res) @@ -40,15 +46,21 @@ func (s *Handler) listArticles(c echo.Context) error { } // GetArticle -// @Summary Returns an article based on defined ID. -// @Param ID path string true "int" -// @Produce application/json -// @Tags Articles -// @Router /v1/articles/{ID} [get] -// @Success 200 {object} domain.ArticleResponse "OK" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Returns an article based on defined ID. +// @Param ID path string true "int" +// @Produce application/json +// @Tags Articles +// @Router /v1/articles/{ID} [get] +// @Success 200 {object} domain.ArticleResponse "OK" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) getArticle(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeArticleRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + p := domain.ArticleResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -58,7 +70,7 @@ func (s *Handler) getArticle(c echo.Context) error { id := c.Param("ID") idNumber, err := strconv.Atoi(id) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) } item, err := s.repo.Articles.GetById(c.Request().Context(), int64(idNumber)) @@ -74,37 +86,41 @@ func (s *Handler) getArticle(c echo.Context) error { } // GetArticleDetails -// @Summary Returns an article and source based on defined ID. -// @Param ID path string true "int" -// @Produce application/json -// @Tags Articles -// @Router /v1/articles/{ID}/details [get] -// @Success 200 {object} domain.ArticleDetailedResponse "OK" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Returns an article and source based on defined ID. +// @Param ID path string true "int" +// @Produce application/json +// @Tags Articles +// @Router /v1/articles/{ID}/details [get] +// @Success 200 {object} domain.ArticleDetailedResponse "OK" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) getArticleDetails(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeArticleRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + p := domain.ArticleDetailedResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, }, - Payload: domain.ArticleAndSourceModel{ - - }, + Payload: domain.ArticleAndSourceModel{}, } id, err := strconv.Atoi(c.Param("ID")) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) } article, err := s.repo.Articles.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } source, err := s.repo.Sources.GetById(c.Request().Context(), article.SourceID) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } p.Payload.Article = services.ArticleToDto(article) @@ -114,16 +130,22 @@ func (s *Handler) getArticleDetails(c echo.Context) error { } // ListArticlesBySourceID -// @Summary Finds the articles based on the SourceID provided. Returns the top 25. -// @Param id query string true "source id" -// @Param page query int false "Page to query" -// @Produce application/json -// @Tags Articles -// @Router /v1/articles/by/sourceid [get] -// @Success 200 {object} domain.ArticleResponse "OK" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Finds the articles based on the SourceID provided. Returns the top 25. +// @Param id query string true "source id" +// @Param page query int false "Page to query" +// @Produce application/json +// @Tags Articles +// @Router /v1/articles/by/sourceid [get] +// @Success 200 {object} domain.ArticleResponse "OK" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) ListArticlesBySourceId(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeArticleRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + p := domain.ArticleResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -132,7 +154,7 @@ func (s *Handler) ListArticlesBySourceId(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) } // if the page number is missing, default to 0 diff --git a/internal/handler/v1/auth.go b/internal/handler/v1/auth.go new file mode 100644 index 0000000..f73f57e --- /dev/null +++ b/internal/handler/v1/auth.go @@ -0,0 +1,264 @@ +package v1 + +import ( + "net/http" + "strings" + "time" + + "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" + "git.jamestombleson.com/jtom38/newsbot-api/internal/repository" + + "github.com/labstack/echo/v4" +) + +const ( + ErrUserNotFound = "requested user does not exist" + ErrUsernameAlreadyExists = "the requested username already exists" +) + +// @Summary Creates a new user +// @Router /v1/users/register [post] +// @Param request formData domain.LoginFormRequest true "form" +// @Accepts x-www-form-urlencoded +// @Produce json +// @Tags Users +// @Success 200 {object} domain.BaseResponse +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +func (h *Handler) AuthRegister(c echo.Context) error { + username := c.FormValue("username") + password := c.FormValue("password") + + //username := c.QueryParam("username") + exists, err := h.repo.Users.GetUser(c.Request().Context(), username) + if err != nil { + // if we have an err, validate that if its not user not found. + // if the user is not found, we can use that name + if err.Error() != repository.ErrUserNotFound { + return h.WriteError(c, err, http.StatusBadRequest) + } + } + if exists.Username == username { + return h.InternalServerErrorResponse(c, ErrUsernameAlreadyExists) + } + + //password := c.QueryParam("password") + err = h.repo.Users.CheckPasswordForRequirements(password) + if err != nil { + return h.WriteError(c, err, http.StatusInternalServerError) + } + + _, err = h.repo.Users.Create(c.Request().Context(), username, password, domain.ScopeArticleRead) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusCreated, domain.BaseResponse{ + Message: "OK", + }) +} + +// @Summary Logs into the API and returns a bearer token if successful +// @Router /v1/users/login [post] +// @Param request formData domain.LoginFormRequest true "form" +// @Accepts x-www-form-urlencoded +// @Produce json +// @Tags Users +// @Success 200 {object} domain.LoginResponse +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +func (h *Handler) AuthLogin(c echo.Context) error { + username := c.FormValue("username") + password := c.FormValue("password") + + // Check to see if they are trying to login with the admin token + if username == "" { + return h.createAdminToken(c, password) + } + + // check if the user exists + user, err := h.repo.Users.GetUser(c.Request().Context(), username) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + // make sure the hash matches + err = h.repo.Users.DoesPasswordMatchHash(c.Request().Context(), username, password) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + // TODO think about moving this down some? + expiresAt := time.Now().Add(time.Hour * 48) + userScopes := strings.Split(user.Scopes, ",") + + jwt, err := h.generateJwtWithExp(username, h.config.ServerAddress, userScopes, user.ID, expiresAt) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + refresh, err := h.repo.RefreshTokens.Create(c.Request().Context(), username) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, domain.LoginResponse{ + BaseResponse: domain.BaseResponse{ + Message: "OK", + }, + Token: jwt, + Type: "Bearer", + RefreshToken: refresh, + }) +} + +func (h *Handler) createAdminToken(c echo.Context, password string) error { + // if the admin token is blank, then the admin wanted this disabled. + // this will fail right away and not progress. + if h.config.AdminSecret == "" { + return h.InternalServerErrorResponse(c, ErrUserNotFound) + } + + if h.config.AdminSecret != password { + return h.UnauthorizedResponse(c, ErrUserNotFound) + } + var userScopes []string + userScopes = append(userScopes, domain.ScopeAll) + + token, err := h.generateJwt("admin", h.config.ServerAddress, userScopes, -1) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, domain.LoginResponse{ + BaseResponse: domain.BaseResponse{ + Message: "OK", + }, + Token: token, + Type: "Bearer", + }) +} + +// This will take collect some information about the requested refresh, validate and then return a new jwt token if approved. +// Register +// @Summary Generates a new token +// @Router /v1/users/refreshToken [post] +// @Param request body domain.RefreshTokenRequest true "body" +// @Tags Users +// @Success 200 {object} domain.LoginResponse +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer +func (h *Handler) RefreshJwtToken(c echo.Context) error { + _, err := h.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate) + if err != nil { + return h.WriteError(c, err, http.StatusBadRequest) + } + + // Check the context for the refresh token + var request domain.RefreshTokenRequest + err = (&echo.DefaultBinder{}).BindBody(c, &request) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + err = h.repo.RefreshTokens.IsRequestValid(c.Request().Context(), request.Username, request.RefreshToken) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + user, err := h.repo.Users.GetUser(c.Request().Context(), request.Username) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + userScopes := strings.Split(user.Scopes, ",") + + jwt, err := h.generateJwtWithExp(request.Username, h.config.ServerAddress, userScopes, user.ID, time.Now().Add(time.Hour*48)) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + newRefreshToken, err := h.repo.RefreshTokens.Create(c.Request().Context(), request.Username) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, domain.LoginResponse{ + BaseResponse: domain.BaseResponse{ + Message: "OK", + }, + Token: jwt, + Type: "Bearer", + RefreshToken: newRefreshToken, + }) +} + +// @Summary Adds a new scope to a user account +// @Router /v1/users/scopes/add [post] +// @Param request body domain.UpdateScopesRequest true "body" +// @Tags Users +// @Accept json +// @Produce json +// @Success 200 {object} domain.BaseResponse +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer +func (h *Handler) AddScopes(c echo.Context) error { + _, err := h.ValidateJwtToken(c, domain.ScopeAll) + if err != nil { + return h.WriteError(c, err, http.StatusBadRequest) + } + + request := domain.UpdateScopesRequest{} + err = (&echo.DefaultBinder{}).BindBody(c, &request) + if err != nil { + return h.WriteError(c, err, http.StatusBadRequest) + } + + err = h.repo.Users.AddScopes(c.Request().Context(), request.Username, request.Scopes) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, domain.BaseResponse{ + Message: "OK", + }) +} + +// @Summary Adds a new scope to a user account +// @Router /v1/users/scopes/remove [post] +// @Param request body domain.UpdateScopesRequest true "body" +// @Tags Users +// @Accept json +// @Produce json +// @Success 200 {object} domain.BaseResponse +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer +func (h *Handler) RemoveScopes(c echo.Context) error { + token, err := h.getJwtTokenFromContext(c) + if err != nil { + return h.WriteError(c, err, http.StatusUnauthorized) + } + + err = token.IsValid(domain.ScopeAll) + if err != nil { + return h.WriteError(c, err, http.StatusUnauthorized) + } + + request := domain.UpdateScopesRequest{} + err = (&echo.DefaultBinder{}).BindBody(c, &request) + if err != nil { + h.WriteError(c, err, http.StatusBadRequest) + + } + + err = h.repo.Users.RemoveScopes(c.Request().Context(), request.Username, request.Scopes) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, domain.BaseResponse{ + Message: "OK", + }) +} diff --git a/internal/handler/v1/discordwebhooks.go b/internal/handler/v1/discordwebhooks.go index 328d179..4328904 100644 --- a/internal/handler/v1/discordwebhooks.go +++ b/internal/handler/v1/discordwebhooks.go @@ -11,14 +11,20 @@ import ( ) // ListDiscordWebhooks -// @Summary Returns the top 100 -// @Produce application/json -// @Tags DiscordWebhook -// @Router /v1/discord/webhooks [get] -// @Success 200 {object} domain.DiscordWebhookResponse -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Returns the top 100 +// @Produce application/json +// @Tags DiscordWebhook +// @Router /v1/discord/webhooks [get] +// @Success 200 {object} domain.DiscordWebhookResponse +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) ListDiscordWebHooks(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebhookRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + p := domain.DiscordWebhookResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -34,15 +40,21 @@ func (s *Handler) ListDiscordWebHooks(c echo.Context) error { } // GetDiscordWebHook -// @Summary Returns the top 100 entries from the queue to be processed. -// @Produce application/json -// @Param id path int true "id" -// @Tags DiscordWebhook -// @Router /v1/discord/webhooks/{id} [get] -// @Success 200 {object} domain.DiscordWebhookResponse "OK" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Returns the top 100 entries from the queue to be processed. +// @Produce application/json +// @Param id path int true "id" +// @Tags DiscordWebhook +// @Router /v1/discord/webhooks/{id} [get] +// @Success 200 {object} domain.DiscordWebhookResponse "OK" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) GetDiscordWebHooksById(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebhookRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + p := domain.DiscordWebhookResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -51,12 +63,12 @@ func (s *Handler) GetDiscordWebHooksById(c echo.Context) error { id, err := strconv.Atoi(c.Param("ID")) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) } res, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dtos []domain.DiscordWebHookDto dtos = append(dtos, services.DiscordWebhookToDto(res)) @@ -65,16 +77,22 @@ func (s *Handler) GetDiscordWebHooksById(c echo.Context) error { } // GetDiscordWebHookByServerAndChannel -// @Summary Returns all the known web hooks based on the Server and Channel given. -// @Produce application/json -// @Param server query string true "Fancy Server" -// @Param channel query string true "memes" -// @Tags DiscordWebhook -// @Router /v1/discord/webhooks/by/serverAndChannel [get] -// @Success 200 {object} domain.DiscordWebhookResponse "OK" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Returns all the known web hooks based on the Server and Channel given. +// @Produce application/json +// @Param server query string true "Fancy Server" +// @Param channel query string true "memes" +// @Tags DiscordWebhook +// @Router /v1/discord/webhooks/by/serverAndChannel [get] +// @Success 200 {object} domain.DiscordWebhookResponse "OK" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) GetDiscordWebHooksByServerAndChannel(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebhookRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + p := domain.DiscordWebhookResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -83,17 +101,17 @@ func (s *Handler) GetDiscordWebHooksByServerAndChannel(c echo.Context) error { _server := c.QueryParam("server") if _server == "" { - s.WriteMessage(c, "server was not defined", http.StatusBadRequest) + return s.WriteMessage(c, "server was not defined", http.StatusBadRequest) } _channel := c.QueryParam("channel") if _channel == "" { - s.WriteMessage(c, "channel was not defined", http.StatusBadRequest) + return s.WriteMessage(c, "channel was not defined", http.StatusBadRequest) } res, err := s.repo.DiscordWebHooks.ListByServerAndChannel(c.Request().Context(), _server, _channel) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } p.Payload = services.DiscordWebhooksToDto(res) @@ -101,16 +119,22 @@ func (s *Handler) GetDiscordWebHooksByServerAndChannel(c echo.Context) error { } // NewDiscordWebHook -// @Summary Creates a new record for a discord web hook to post data to. -// @Param url query string true "url" -// @Param server query string true "Server name" -// @Param channel query string true "Channel name" -// @Tags DiscordWebhook -// @Router /v1/discord/webhooks/new [post] -// @Success 200 {object} domain.DiscordWebhookResponse "OK" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Creates a new record for a discord web hook to post data to. +// @Param url query string true "url" +// @Param server query string true "Server name" +// @Param channel query string true "Channel name" +// @Tags DiscordWebhook +// @Router /v1/discord/webhooks/new [post] +// @Success 200 {object} domain.DiscordWebhookResponse "OK" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) NewDiscordWebHook(c echo.Context) error { + token, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + _url := c.QueryParam("url") _server := c.QueryParam("server") _channel := c.QueryParam("channel") @@ -136,18 +160,23 @@ func (s *Handler) NewDiscordWebHook(c echo.Context) error { }) } - rows, err := s.repo.DiscordWebHooks.Create(c.Request().Context(), _url, _server, _channel, true) + user, err := s.repo.Users.GetUser(c.Request().Context(), token.UserName) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteMessage(c, ErrUserUnknown, http.StatusBadRequest) + } + + rows, err := s.repo.DiscordWebHooks.Create(c.Request().Context(), user.ID, _url, _server, _channel, true) + if err != nil { + return s.WriteError(c, err, http.StatusInternalServerError) } if rows != 1 { - s.WriteMessage(c, "data was not written to database", http.StatusInternalServerError) + return s.WriteMessage(c, "data was not written to database", http.StatusInternalServerError) } item, err := s.repo.DiscordWebHooks.GetByUrl(c.Request().Context(), _url) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dtos []domain.DiscordWebHookDto @@ -162,14 +191,20 @@ func (s *Handler) NewDiscordWebHook(c echo.Context) error { } // DisableDiscordWebHooks -// @Summary Disables a Webhook from being used. -// @Param id path int true "id" -// @Tags DiscordWebhook -// @Router /v1/discord/webhooks/{ID}/disable [post] -// @Success 200 {object} domain.DiscordWebhookResponse "OK" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Disables a Webhook from being used. +// @Param id path int true "id" +// @Tags DiscordWebhook +// @Router /v1/discord/webhooks/{ID}/disable [post] +// @Success 200 {object} domain.DiscordWebhookResponse "OK" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) disableDiscordWebHook(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + id, err := strconv.Atoi(c.Param("ID")) if err != nil { return c.JSON(http.StatusBadRequest, domain.BaseResponse{ @@ -178,25 +213,29 @@ func (s *Handler) disableDiscordWebHook(c echo.Context) error { } // Check to make sure we can find the record - _, err = s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) + record, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) + } + + if record.UserID != s.GetUserIdFromJwtToken(c) { + return s.WriteMessage(c, ErrYouDontOwnTheRecord, http.StatusBadRequest) } // flip the it updated, err := s.repo.DiscordWebHooks.Disable(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } // make sure we got a row updated if updated != 1 { - s.WriteMessage(c, "unexpected number of updates found", http.StatusInternalServerError) + return s.WriteMessage(c, "unexpected number of updates found", http.StatusInternalServerError) } item, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dtos []domain.DiscordWebHookDto @@ -210,34 +249,44 @@ func (s *Handler) disableDiscordWebHook(c echo.Context) error { } // EnableDiscordWebHook -// @Summary Enables a source to continue processing. -// @Param id path int true "id" -// @Tags DiscordWebhook -// @Router /v1/discord/webhooks/{ID}/enable [post] +// @Summary Enables a source to continue processing. +// @Param id path int true "id" +// @Tags DiscordWebhook +// @Router /v1/discord/webhooks/{ID}/enable [post] +// @Security Bearer func (s *Handler) enableDiscordWebHook(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + id, err := strconv.Atoi(c.Param("ID")) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) } // Check to make sure we can find the record - _, err = s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) + record, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) + } + + if record.UserID != s.GetUserIdFromJwtToken(c) { + return s.WriteMessage(c, ErrYouDontOwnTheRecord, http.StatusBadRequest) } updated, err := s.repo.DiscordWebHooks.Enable(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } if updated != 1 { - s.WriteMessage(c, "unexpected number of updates found", http.StatusInternalServerError) + return s.WriteMessage(c, ErrFailedToUpdateRecord, http.StatusInternalServerError) } item, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dtos []domain.DiscordWebHookDto @@ -259,17 +308,26 @@ func (s *Handler) enableDiscordWebHook(c echo.Context) error { // @Failure 400 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse func (s *Handler) deleteDiscordWebHook(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + id, err := strconv.Atoi(c.Param("ID")) if err != nil { return c.JSON(http.StatusBadRequest, err.Error()) } // Check to make sure we can find the record - _, err = s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) + record, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) if err != nil { return c.JSON(http.StatusInternalServerError, err.Error()) } + if record.UserID != s.GetUserIdFromJwtToken(c) { + return s.WriteMessage(c, ErrYouDontOwnTheRecord, http.StatusBadRequest) + } + // Soft delete the record updated, err := s.repo.DiscordWebHooks.SoftDelete(c.Request().Context(), int64(id)) if err != nil { @@ -277,12 +335,12 @@ func (s *Handler) deleteDiscordWebHook(c echo.Context) error { } if updated != 1 { - s.WriteMessage(c, "unexpected number of updates found", http.StatusInternalServerError) + return s.WriteMessage(c, ErrFailedToUpdateRecord, http.StatusInternalServerError) } item, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dtos []domain.DiscordWebHookDto diff --git a/internal/handler/v1/handler.go b/internal/handler/v1/handler.go index 149de1f..d3e76ad 100644 --- a/internal/handler/v1/handler.go +++ b/internal/handler/v1/handler.go @@ -3,31 +3,38 @@ package v1 import ( "context" "database/sql" + "errors" + "net/http" + "github.com/golang-jwt/jwt/v5" + echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" swagger "github.com/swaggo/echo-swagger" _ "git.jamestombleson.com/jtom38/newsbot-api/docs" - "git.jamestombleson.com/jtom38/newsbot-api/internal/database" "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" "git.jamestombleson.com/jtom38/newsbot-api/internal/services" ) type Handler struct { Router *echo.Echo - Db *database.Queries - //dto *dto.DtoClient + //Db *database.Queries config services.Configs repo services.RepositoryService } const ( - ErrParameterIdMissing = "The requested parameter ID was not found." - ErrParameterMissing = "The requested parameter was not found found:" - ErrUnableToParseId = "Unable to parse the requested ID" + ErrParameterIdMissing = "The requested parameter ID was not found." + ErrParameterMissing = "The requested parameter was not found found:" + ErrUnableToParseId = "Unable to parse the requested ID" + ErrRecordMissing = "The requested record was not found" ErrFailedToCreateRecord = "The record was not created due to a database problem" + ErrFailedToUpdateRecord = "The requested record was not updated due to a database problem" + + ErrUserUnknown = "User is unknown" + ErrYouDontOwnTheRecord = "The record requested does not belong to you" ResponseMessageSuccess = "Success" ) @@ -47,6 +54,13 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han repo: services.NewRepositoryService(conn), } + jwtConfig := echojwt.Config{ + NewClaimsFunc: func(c echo.Context) jwt.Claims { + return new(JwtToken) + }, + SigningKey: []byte(configs.JwtSecret), + } + router := echo.New() router.Pre(middleware.RemoveTrailingSlash()) router.Pre(middleware.Logger()) @@ -55,6 +69,7 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han v1 := router.Group("/api/v1") articles := v1.Group("/articles") + articles.Use(echojwt.WithConfig(jwtConfig)) articles.GET("", s.listArticles) articles.GET(":id", s.getArticle) articles.GET(":id/details", s.getArticleDetails) @@ -76,6 +91,7 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han //settings.GET("/", s.getSettings) sources := v1.Group("/sources") + sources.Use(echojwt.WithConfig(jwtConfig)) sources.GET("", s.listSources) sources.GET("/by/source", s.listSourcesBySource) sources.GET("/by/sourceAndName", s.GetSourceBySourceAndName) @@ -88,26 +104,26 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han sources.POST("/:ID/disable", s.disableSource) sources.POST("/:ID/enable", s.enableSource) - subs := v1.Group("/subscriptions") - subs.GET("/", s.ListSubscriptions) - subs.GET("/details", s.ListSubscriptionDetails) - subs.GET("/by/discordId", s.GetSubscriptionsByDiscordId) - subs.GET("/by/sourceId", s.GetSubscriptionsBySourceId) - subs.POST("/discord/webhook/new", s.newDiscordWebHookSubscription) - subs.DELETE("/discord/webhook/delete", s.DeleteDiscordWebHookSubscription) + users := v1.Group("/users") + users.POST("/login", s.AuthLogin) + users.POST("/register", s.AuthRegister) + users.Use(echojwt.WithConfig(jwtConfig)) + users.POST("/scopes/add", s.AddScopes) + users.POST("/scopes/remove", s.RemoveScopes) + users.POST("/refreshToken", s.RefreshJwtToken) s.Router = router return s } -type ApiStatusModel struct { - StatusCode int `json:"status"` - Message string `json:"message"` -} +//type ApiStatusModel struct { +// StatusCode int `json:"status"` +// Message string `json:"message"` +//} -type ApiError struct { - *ApiStatusModel -} +//type ApiError struct { +// *ApiStatusModel +//} func (s *Handler) WriteError(c echo.Context, errMessage error, HttpStatusCode int) error { return c.JSON(HttpStatusCode, domain.BaseResponse{ @@ -120,3 +136,53 @@ func (s *Handler) WriteMessage(c echo.Context, msg string, HttpStatusCode int) e Message: msg, }) } + +func (s *Handler) InternalServerErrorResponse(c echo.Context, msg string) error { + return c.JSON(http.StatusInternalServerError, domain.BaseResponse{ + Message: msg, + }) +} + +func (s *Handler) UnauthorizedResponse(c echo.Context, msg string) error { + return c.JSON(http.StatusUnauthorized, domain.BaseResponse{ + Message: msg, + }) +} + +// If the token is not valid then an json error will be returned. +// If the token has the wrong scope, a json error will be returned. +// If the token passes all the checks, it is valid and is returned back to the caller. +func (s *Handler) ValidateJwtToken(c echo.Context, requiredScope string) (JwtToken, error) { + token, err := s.getJwtTokenFromContext(c) + if err != nil { + s.WriteMessage(c, ErrJwtMissing, http.StatusUnauthorized) + } + + err = token.hasExpired() + if err != nil { + return JwtToken{}, errors.New(ErrJwtExpired) + //s.WriteMessage(c, ErrJwtExpired, http.StatusUnauthorized) + } + + err = token.hasScope(requiredScope) + if err != nil { + return JwtToken{}, errors.New(ErrJwtScopeMissing) + //s.WriteMessage(c, ErrJwtScopeMissing, http.StatusUnauthorized) + } + + if token.Iss != s.config.ServerAddress { + return JwtToken{}, errors.New(ErrJwtInvalidIssuer) + //s.WriteMessage(c, ErrJwtInvalidIssuer, http.StatusUnauthorized) + } + + return token, nil +} + +func (s *Handler) GetUserIdFromJwtToken(c echo.Context) int64 { + token, err := s.getJwtTokenFromContext(c) + if err != nil { + s.WriteMessage(c, ErrJwtMissing, http.StatusUnauthorized) + } + + return token.GetUserId() +} diff --git a/internal/handler/v1/jwt.go b/internal/handler/v1/jwt.go new file mode 100644 index 0000000..1a8d565 --- /dev/null +++ b/internal/handler/v1/jwt.go @@ -0,0 +1,128 @@ +package v1 + +import ( + "errors" + "strings" + "time" + + "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" +) + +const ( + ErrJwtMissing = "auth token is missing" + ErrJwtClaimsMissing = "claims missing on token" + ErrJwtExpired = "auth token has expired" + ErrJwtScopeMissing = "required scope is missing" + ErrJwtInvalidIssuer = "incorrect server issued the token" +) + +type JwtToken struct { + Exp time.Time `json:"exp"` + Iss string `json:"iss"` + Authorized bool `json:"authorized"` + UserName string `json:"username"` + UserId int64 `json:"userId"` + Scopes []string `json:"scopes"` + jwt.RegisteredClaims +} + +func (j JwtToken) IsValid(scope string) error { + err := j.hasExpired() + if err != nil { + return err + } + + // Check to see if they have the scope to do anything + // if they do, let them pass + err = j.hasScope(domain.ScopeAll) + if err == nil { + return nil + } + + err = j.hasScope(scope) + if err != nil { + return err + } + + return nil +} + +func (j JwtToken) GetUsername() string { + return j.UserName +} + +func (j JwtToken) GetUserId() int64 { + return j.UserId +} + +func (j JwtToken) hasExpired() error { + // Check to see if the token has expired + //hasExpired := j.Exp.Compare(time.Now()) + hasExpired := time.Now().Compare(j.Exp) + if hasExpired == 1 { + return errors.New(ErrJwtExpired) + } + return nil +} + +// This will check the users token to make sure they have the correct scope to access the handler. +// It will evaluate if you have the admin scope or the required scope for the handler. +func (j JwtToken) hasScope(scope string) error { + // they have the scope to access everything, so let them pass. + userScopes := strings.Join(j.Scopes, "") + if strings.Contains(domain.ScopeAll, userScopes) { + return nil + } + + if strings.Contains(userScopes, scope) { + return nil + } + + return errors.New(ErrJwtScopeMissing) +} + +func (h *Handler) generateJwt(username, issuer string, userScopes []string, userId int64) (string, error) { + return h.generateJwtWithExp(username, issuer, userScopes, userId, time.Now().Add(10*time.Minute)) +} + +func (h *Handler) generateJwtWithExp(username, issuer string, userScopes []string, userId int64, expiresAt time.Time) (string, error) { + secret := []byte(h.config.JwtSecret) + + // Anyone who wants to decrypt the key needs to use the same method + token := jwt.New(jwt.SigningMethodHS256) + claims := token.Claims.(jwt.MapClaims) + claims["exp"] = expiresAt + claims["authorized"] = true + claims["username"] = username + claims["iss"] = issuer + claims["userId"] = userId + + var scopes []string + scopes = append(scopes, userScopes...) + claims["scopes"] = scopes + + tokenString, err := token.SignedString(secret) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func (h *Handler) getJwtTokenFromContext(c echo.Context) (JwtToken, error) { + // Make sure that the request came with a jwtToken + token, ok := c.Get("user").(*jwt.Token) + if !ok { + return JwtToken{}, errors.New(ErrJwtMissing) + } + + // Generate the claims from the token + claims, ok := token.Claims.(*JwtToken) + if !ok { + return JwtToken{}, errors.New(ErrJwtClaimsMissing) + } + + return *claims, nil +} diff --git a/internal/handler/v1/queue.go b/internal/handler/v1/queue.go deleted file mode 100644 index 92878ae..0000000 --- a/internal/handler/v1/queue.go +++ /dev/null @@ -1,39 +0,0 @@ -package v1 - -import ( - "net/http" - - "git.jamestombleson.com/jtom38/newsbot-api/internal/domain/models" - "github.com/labstack/echo/v4" -) - -type ListDiscordWebHooksQueueResults struct { - ApiStatusModel - Payload []models.DiscordQueueDetailsDto `json:"payload"` -} - -// GetDiscordQueue -// @Summary Returns the top 100 entries from the queue to be processed. -// @Produce application/json -// @Tags Queue -// @Router /v1/queue/discord/webhooks [get] -// @Success 200 {object} ListDiscordWebHooksQueueResults "ok" -func (s *Handler) ListDiscordWebhookQueue(c echo.Context) error { - p := ListDiscordWebHooksQueueResults{ - ApiStatusModel: ApiStatusModel{ - Message: "OK", - StatusCode: http.StatusOK, - }, - } - - // Get the raw resp from sql - //res, err := s.dto.ListDiscordWebhookQueueDetails(c.Request().Context(), 50) - //if err != nil { - // return c.JSON(http.StatusInternalServerError, domain.BaseResponse{ - // Message: err.Error(), - // }) - //} - - //p.Payload = res - return c.JSON(http.StatusOK, p) -} diff --git a/internal/handler/v1/settings.go b/internal/handler/v1/settings.go deleted file mode 100644 index 26367af..0000000 --- a/internal/handler/v1/settings.go +++ /dev/null @@ -1,39 +0,0 @@ -package v1 - -import ( - "encoding/json" - "net/http" - - "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" - "github.com/google/uuid" - "github.com/labstack/echo/v4" -) - -// GetSettings -// @Summary Returns a object based on the Key that was given. -// @Param key path string true "Settings Key value" -// @Produce application/json -// @Tags Settings -// @Router /v1/settings/{key} [get] -func (s *Handler) getSettings(c echo.Context) error { - id := c.Param("ID") - - uuid, err := uuid.Parse(id) - if err != nil { - return c.JSON(http.StatusBadRequest, domain.BaseResponse{ - Message: err.Error(), - }) - } - - res, err := s.Db.GetSourceByID(c.Request().Context(), uuid) - if err != nil { - return c.JSON(http.StatusInternalServerError, err.Error()) - } - - bResult, err := json.Marshal(res) - if err != nil { - return c.JSON(http.StatusInternalServerError, err.Error()) - } - - return c.JSON(http.StatusOK, bResult) -} diff --git a/internal/handler/v1/sources.go b/internal/handler/v1/sources.go index 2297858..000cfde 100644 --- a/internal/handler/v1/sources.go +++ b/internal/handler/v1/sources.go @@ -1,41 +1,32 @@ package v1 import ( - "context" - "encoding/json" "fmt" "net/http" "strconv" "strings" - "git.jamestombleson.com/jtom38/newsbot-api/internal/database" "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" - "git.jamestombleson.com/jtom38/newsbot-api/internal/domain/models" "git.jamestombleson.com/jtom38/newsbot-api/internal/services" - "github.com/google/uuid" "github.com/labstack/echo/v4" ) -type ListSources struct { - ApiStatusModel - Payload []models.SourceDto `json:"payload"` -} - -type GetSource struct { - ApiStatusModel - Payload models.SourceDto `json:"payload"` -} - // ListSources -// @Summary Lists the top 50 records -// @Param page query string false "page number" -// @Produce application/json -// @Tags Source -// @Router /v1/sources [get] -// @Success 200 {object} domain.SourcesResponse "ok" -// @Failure 400 {object} domain.BaseResponse "Unable to reach SQL or Data problems" +// @Summary Lists the top 50 records +// @Param page query string false "page number" +// @Produce application/json +// @Tags Source +// @Router /v1/sources [get] +// @Success 200 {object} domain.SourcesResponse "ok" +// @Failure 400 {object} domain.BaseResponse "Unable to reach SQL or Data problems" +// @Security Bearer func (s *Handler) listSources(c echo.Context) error { - resp := domain.SourcesResponse { + _, err := s.ValidateJwtToken(c, domain.ScopeSourceRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + + resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, }, @@ -49,7 +40,7 @@ func (s *Handler) listSources(c echo.Context) error { // Default way of showing all sources items, err := s.repo.Sources.List(c.Request().Context(), page, 25) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } resp.Payload = services.SourcesToDto(items) @@ -57,16 +48,22 @@ func (s *Handler) listSources(c echo.Context) error { } // ListSourcesBySource -// @Summary Lists the top 50 records based on the name given. Example: reddit -// @Param source query string true "Source Name" -// @Param page query string false "page number" -// @Produce application/json -// @Tags Source -// @Router /v1/sources/by/source [get] -// @Success 200 {object} domain.SourcesResponse "ok" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Lists the top 50 records based on the name given. Example: reddit +// @Param source query string true "Source Name" +// @Param page query string false "page number" +// @Produce application/json +// @Tags Source +// @Router /v1/sources/by/source [get] +// @Success 200 {object} domain.SourcesResponse "ok" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) listSourcesBySource(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeSourceRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -75,7 +72,7 @@ func (s *Handler) listSourcesBySource(c echo.Context) error { source := c.QueryParam("source") if source == "" { - s.WriteMessage(c, fmt.Sprintf("%s source", ErrParameterMissing), http.StatusBadRequest) + return s.WriteMessage(c, fmt.Sprintf("%s source", ErrParameterMissing), http.StatusBadRequest) } page, err := strconv.Atoi(c.QueryParam("page")) @@ -96,15 +93,21 @@ func (s *Handler) listSourcesBySource(c echo.Context) error { } // GetSource -// @Summary Returns a single entity by ID -// @Param id path int true "uuid" -// @Produce application/json -// @Tags Source -// @Router /v1/sources/{id} [get] -// @Success 200 {object} domain.SourcesResponse "ok" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Returns a single entity by ID +// @Param id path int true "uuid" +// @Produce application/json +// @Tags Source +// @Router /v1/sources/{id} [get] +// @Success 200 {object} domain.SourcesResponse "ok" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) getSource(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeSourceRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -120,7 +123,7 @@ func (s *Handler) getSource(c echo.Context) error { item, err := s.repo.Sources.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dto []domain.SourceDto @@ -130,16 +133,22 @@ func (s *Handler) getSource(c echo.Context) error { } // GetSourceByNameAndSource -// @Summary Returns a single entity by ID -// @Param name query string true "dadjokes" -// @Param source query string true "reddit" -// @Produce application/json -// @Tags Source -// @Router /v1/sources/by/sourceAndName [get] -// @Success 200 {object} domain.SourcesResponse "ok" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Returns a single entity by ID +// @Param name query string true "dadjokes" +// @Param source query string true "reddit" +// @Produce application/json +// @Tags Source +// @Router /v1/sources/by/sourceAndName [get] +// @Success 200 {object} domain.SourcesResponse "ok" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) GetSourceBySourceAndName(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeSourceRead) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -147,7 +156,7 @@ func (s *Handler) GetSourceBySourceAndName(c echo.Context) error { } var param domain.GetSourceBySourceAndNameParamRequest - err := c.Bind(¶m) + err = c.Bind(¶m) if err != nil { return c.JSON(http.StatusBadRequest, domain.BaseResponse{ Message: err.Error(), @@ -166,15 +175,21 @@ func (s *Handler) GetSourceBySourceAndName(c echo.Context) error { } // NewRedditSource -// @Summary Creates a new reddit source to monitor. -// @Param name query string true "name" -// @Param url query string true "url" -// @Tags Source -// @Router /v1/sources/new/reddit [post] -// @Success 200 {object} domain.SourcesResponse "ok" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Creates a new reddit source to monitor. +// @Param name query string true "name" +// @Param url query string true "url" +// @Tags Source +// @Router /v1/sources/new/reddit [post] +// @Success 200 {object} domain.SourcesResponse "ok" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) newRedditSource(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeSourceCreate) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -182,37 +197,30 @@ func (s *Handler) newRedditSource(c echo.Context) error { } var param domain.NewSourceParamRequest - err := c.Bind(¶m) + err = c.Bind(¶m) if err != nil { - return c.JSON(http.StatusBadRequest, domain.BaseResponse{ - Message: err.Error(), - }) + return s.WriteError(c, err, http.StatusBadRequest) } - if param.Url == "" { - return c.JSON(http.StatusBadRequest, domain.BaseResponse{ - Message: "Url is missing a value", - }) + return s.WriteMessage(c, "url is missing", http.StatusBadRequest) } if !strings.Contains(param.Url, "reddit.com") { - return c.JSON(http.StatusBadRequest, domain.BaseResponse{ - Message: "Invalid URL given", - }) + return s.WriteMessage(c, "invalid url", http.StatusBadRequest) } tags := fmt.Sprintf("twitch, %v, %s", param.Name, param.Tags) rows, err := s.repo.Sources.Create(c.Request().Context(), domain.SourceCollectorReddit, param.Name, param.Url, tags, true) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } if rows != 1 { - s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError) + return s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError) } item, err := s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorReddit, param.Name) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dto []domain.SourceDto @@ -222,126 +230,139 @@ func (s *Handler) newRedditSource(c echo.Context) error { } // NewYoutubeSource -// @Summary Creates a new youtube source to monitor. -// @Param name query string true "name" -// @Param url query string true "url" -// @Tags Source -// @Router /v1/sources/new/youtube [post] +// @Summary Creates a new youtube source to monitor. +// @Param name query string true "name" +// @Param url query string true "url" +// @Tags Source +// @Router /v1/sources/new/youtube [post] +// @Security Bearer func (s *Handler) newYoutubeSource(c echo.Context) error { - var param domain.NewSourceParamRequest - err := c.Bind(¶m) + // Validate the jwt + _, err := s.ValidateJwtToken(c, domain.ScopeSourceCreate) if err != nil { - return c.JSON(http.StatusBadRequest, domain.BaseResponse{ - Message: err.Error(), - }) + return s.WriteError(c, err, http.StatusBadRequest) } - //query := r.URL.Query() - //_name := query["name"][0] - //_url := query["url"][0] - ////_tags := query["tags"][0] - + var param domain.NewSourceParamRequest + err = c.Bind(¶m) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } if param.Url == "" { - return c.JSON(http.StatusBadRequest, domain.BaseResponse{ - Message: "url is missing a value", - }) + return s.WriteMessage(c, "url is missing a value", http.StatusBadRequest) } if !strings.Contains(param.Url, "youtube.com") { - return c.JSON(http.StatusBadRequest, domain.BaseResponse{ - Message: "Invalid URL", - }) + return s.WriteMessage(c, "invalid url", http.StatusBadRequest) + } + + resp := domain.SourcesResponse{ + BaseResponse: domain.BaseResponse{ + Message: ResponseMessageSuccess, + }, + } + + item, err := s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorYoutube, param.Name) + if err == nil { + var dto []domain.SourceDto + dto = append(dto, services.SourceToDto(item)) + resp.Payload = dto + return c.JSON(http.StatusOK, resp) } - /* - if _tags == "" { - tags = fmt.Sprintf("twitch, %v", _name) - } else { - } - */ tags := fmt.Sprintf("twitch, %v", param.Name) - - params := database.CreateSourceParams{ - ID: uuid.New(), - Site: "youtube", - Name: param.Name, - Source: "youtube", - Type: "feed", - Enabled: true, - Url: param.Url, - Tags: tags, - } - err = s.Db.CreateSource(context.Background(), params) + rows, err := s.repo.Sources.Create(c.Request().Context(), domain.SourceCollectorYoutube, param.Name, param.Url, tags, true) if err != nil { return c.JSON(http.StatusInternalServerError, err.Error()) } - bJson, err := json.Marshal(¶ms) - if err != nil { - return c.JSON(http.StatusInternalServerError, domain.BaseResponse{ - Message: err.Error(), - }) + if rows != 1 { + return s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError) } - return c.JSON(http.StatusOK, bJson) + item, err = s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorYoutube, param.Name) + if err == nil { + var dto []domain.SourceDto + dto = append(dto, services.SourceToDto(item)) + resp.Payload = dto + return c.JSON(http.StatusOK, resp) + } + + return c.JSON(http.StatusOK, resp) } // NewTwitchSource -// @Summary Creates a new twitch source to monitor. -// @Param name query string true "name" -// @Tags Source -// @Router /v1/sources/new/twitch [post] +// @Summary Creates a new twitch source to monitor. +// @Param name query string true "name" +// @Tags Source +// @Router /v1/sources/new/twitch [post] +// @Security Bearer func (s *Handler) newTwitchSource(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeSourceCreate) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + var param domain.NewSourceParamRequest - err := c.Bind(¶m) + err = c.Bind(¶m) if err != nil { return c.JSON(http.StatusBadRequest, domain.BaseResponse{ Message: err.Error(), }) } - //query := r.URL.Query() - //_name := query["name"][0] + resp := domain.SourcesResponse{ + BaseResponse: domain.BaseResponse{ + Message: ResponseMessageSuccess, + }, + } tags := fmt.Sprintf("twitch, %v", param.Name) - _url := fmt.Sprintf("https://twitch.tv/%v", param.Name) + url := fmt.Sprintf("https://twitch.tv/%v", param.Name) - params := database.CreateSourceParams{ - ID: uuid.New(), - Site: "twitch", - Name: param.Name, - Source: "twitch", - Type: "api", - Enabled: true, - Url: _url, - Tags: tags, + // Check if the record already exists + item, err := s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorTwitch, param.Name) + if err == nil { + var dto []domain.SourceDto + dto = append(dto, services.SourceToDto(item)) + resp.Payload = dto + return c.JSON(http.StatusOK, resp) } - err = s.Db.CreateSource(c.Request().Context(), params) + + rows, err := s.repo.Sources.Create(c.Request().Context(), domain.SourceCollectorTwitch, param.Name, url, tags, true) if err != nil { return c.JSON(http.StatusInternalServerError, domain.BaseResponse{ Message: err.Error(), }) } - bJson, err := json.Marshal(¶ms) - if err != nil { - return c.JSON(http.StatusInternalServerError, domain.BaseResponse{ - Message: err.Error(), - }) + if rows != 1 { + return s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError) } - return c.JSON(http.StatusOK, bJson) + item, _ = s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorTwitch, param.Name) + var dto []domain.SourceDto + dto = append(dto, services.SourceToDto(item)) + resp.Payload = dto + + return c.JSON(http.StatusOK, resp) } // NewRssSource -// @Summary Creates a new rss source to monitor. -// @Param name query string true "Site Name" -// @Param url query string true "RSS Url" -// @Tags Source -// @Router /v1/sources/new/rss [post] -// @Success 200 {object} domain.SourcesResponse "ok" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Creates a new rss source to monitor. +// @Param name query string true "Site Name" +// @Param url query string true "RSS Url" +// @Tags Source +// @Router /v1/sources/new/rss [post] +// @Success 200 {object} domain.SourcesResponse "ok" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) newRssSource(c echo.Context) error { + _, err := s.ValidateJwtToken(c, domain.ScopeSourceCreate) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, @@ -349,7 +370,7 @@ func (s *Handler) newRssSource(c echo.Context) error { } var param domain.NewSourceParamRequest - err := c.Bind(¶m) + err = c.Bind(¶m) if err != nil { return c.JSON(http.StatusBadRequest, domain.BaseResponse{ Message: err.Error(), @@ -365,16 +386,16 @@ func (s *Handler) newRssSource(c echo.Context) error { tags := fmt.Sprintf("rss, %v, %s", param.Name, param.Tags) rows, err := s.repo.Sources.Create(c.Request().Context(), domain.SourceCollectorRss, param.Name, param.Url, tags, true) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } if rows != 1 { - s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError) + return s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError) } item, err := s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorRss, param.Name) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dto []domain.SourceDto @@ -384,60 +405,73 @@ func (s *Handler) newRssSource(c echo.Context) error { } // DeleteSource -// @Summary Marks a source as deleted based on its ID value. -// @Param id path string true "id" -// @Tags Source -// @Router /v1/sources/{id} [POST] +// @Summary Marks a source as deleted based on its ID value. +// @Param id path int true "id" +// @Tags Source +// @Router /v1/sources/{id} [POST] +// @Success 200 {object} domain.SourcesResponse "ok" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) deleteSources(c echo.Context) error { - id := c.Param("ID") - uuid, err := uuid.Parse(id) + _, err := s.ValidateJwtToken(c, domain.ScopeAll) if err != nil { - return c.JSON(http.StatusBadRequest, domain.BaseResponse{ - Message: err.Error(), - }) + return s.WriteError(c, err, http.StatusBadRequest) + } + + id, err := strconv.Atoi(c.Param("ID")) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) } // Check to make sure we can find the record - _, err = s.Db.GetSourceByID(c.Request().Context(), uuid) + _, err = s.repo.Sources.GetById(c.Request().Context(), int64(id)) if err != nil { - return c.JSON(http.StatusInternalServerError, domain.BaseResponse{ - Message: err.Error(), - }) + return s.WriteError(c, err, http.StatusInternalServerError) } // Delete the record - err = s.Db.DeleteSource(c.Request().Context(), uuid) + rows, err := s.repo.Sources.SoftDelete(c.Request().Context(), int64(id)) if err != nil { - return c.JSON(http.StatusInternalServerError, domain.BaseResponse{ - Message: err.Error(), - }) + return s.WriteError(c, err, http.StatusInternalServerError) + } + if rows != 1 { + return s.WriteMessage(c, ErrFailedToUpdateRecord, http.StatusInternalServerError) } - p := ApiStatusModel{ - Message: "OK", - StatusCode: http.StatusOK, - } - - b, err := json.Marshal(p) + // pull the record with its updated value + item, err := s.repo.Sources.GetById(c.Request().Context(), int64(id)) if err != nil { - return c.JSON(http.StatusInternalServerError, domain.BaseResponse{ - Message: err.Error(), - }) + return s.WriteError(c, err, http.StatusInternalServerError) } - return c.JSON(http.StatusOK, b) + var items []domain.SourceDto + items = append(items, services.SourceToDto(item)) + + return c.JSON(http.StatusOK, domain.SourcesResponse{ + BaseResponse: domain.BaseResponse{ + Message: "OK", + }, + Payload: items, + }) } // DisableSource -// @Summary Disables a source from processing. -// @Param id path int true "id" -// @Tags Source -// @Router /v1/sources/{id}/disable [post] -// @Success 200 {object} domain.SourcesResponse "ok" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Disables a source from processing. +// @Param id path int true "id" +// @Tags Source +// @Router /v1/sources/{id}/disable [post] +// @Success 200 {object} domain.SourcesResponse "ok" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) disableSource(c echo.Context) error { - resp := domain.SourcesResponse { + _, err := s.ValidateJwtToken(c, domain.ScopeAll) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + + resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, }, @@ -445,23 +479,23 @@ func (s *Handler) disableSource(c echo.Context) error { id, err := strconv.Atoi(c.Param("ID")) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) } // Check to make sure we can find the record _, err = s.repo.Sources.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) } _, err = s.repo.Sources.Disable(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } item, err := s.repo.Sources.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dto []domain.SourceDto @@ -471,15 +505,21 @@ func (s *Handler) disableSource(c echo.Context) error { } // EnableSource -// @Summary Enables a source to continue processing. -// @Param id path string true "id" -// @Tags Source -// @Router /v1/sources/{id}/enable [post] -// @Success 200 {object} domain.SourcesResponse "ok" -// @Failure 400 {object} domain.BaseResponse -// @Failure 500 {object} domain.BaseResponse +// @Summary Enables a source to continue processing. +// @Param id path string true "id" +// @Tags Source +// @Router /v1/sources/{id}/enable [post] +// @Success 200 {object} domain.SourcesResponse "ok" +// @Failure 400 {object} domain.BaseResponse +// @Failure 500 {object} domain.BaseResponse +// @Security Bearer func (s *Handler) enableSource(c echo.Context) error { - resp := domain.SourcesResponse { + _, err := s.ValidateJwtToken(c, domain.ScopeAll) + if err != nil { + return s.WriteError(c, err, http.StatusBadRequest) + } + + resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ Message: ResponseMessageSuccess, }, @@ -487,23 +527,23 @@ func (s *Handler) enableSource(c echo.Context) error { id, err := strconv.Atoi(c.Param("ID")) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) } // Check to make sure we can find the record _, err = s.repo.Sources.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusBadRequest) + return s.WriteError(c, err, http.StatusBadRequest) } _, err = s.repo.Sources.Enable(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } item, err := s.repo.Sources.GetById(c.Request().Context(), int64(id)) if err != nil { - s.WriteError(c, err, http.StatusInternalServerError) + return s.WriteError(c, err, http.StatusInternalServerError) } var dto []domain.SourceDto diff --git a/internal/handler/v1/subscriptions.go b/internal/handler/v1/subscriptions.go deleted file mode 100644 index dcb7440..0000000 --- a/internal/handler/v1/subscriptions.go +++ /dev/null @@ -1,225 +0,0 @@ -package v1 - -import ( - "context" - "encoding/json" - "errors" - "net/http" - - "git.jamestombleson.com/jtom38/newsbot-api/internal/database" - "git.jamestombleson.com/jtom38/newsbot-api/internal/domain/models" - "github.com/google/uuid" - "github.com/labstack/echo/v4" -) - -type ListSubscriptions struct { - ApiStatusModel - Payload []models.SubscriptionDto `json:"payload"` -} - -type GetSubscription struct { - ApiStatusModel - Payload models.SubscriptionDto `json:"payload"` -} - -type ListSubscriptionDetails struct { - ApiStatusModel - Payload []models.SubscriptionDetailsDto `json:"payload"` -} - -// GetSubscriptions -// @Summary Returns the top 100 entries from the queue to be processed. -// @Produce application/json -// @Tags Subscription -// @Router /v1/subscriptions [get] -// @Success 200 {object} ListSubscriptions "ok" -// @Failure 400 {object} ApiError "Unable to reach SQL." -// @Failure 500 {object} ApiError "Failed to process data from SQL." -func (s *Handler) ListSubscriptions(c echo.Context) error { - payload := ListSubscriptions{ - ApiStatusModel: ApiStatusModel{ - StatusCode: http.StatusOK, - Message: "OK", - }, - } - - //res, err := s.dto.ListSubscriptions(c.Request().Context(), 50) - //if err != nil { - // return s.WriteError(c, err, http.StatusBadRequest) - //} - //payload.Payload = res - return c.JSON(http.StatusOK, payload) -} - -// ListSubscriptionDetails -// @Summary Returns the top 50 entries with full deatils on the source and output. -// @Produce application/json -// @Tags Subscription -// @Router /v1/subscriptions/details [get] -// @Success 200 {object} ListSubscriptionDetails "ok" -func (s *Handler) ListSubscriptionDetails(c echo.Context) error { - payload := ListSubscriptionDetails{ - ApiStatusModel: ApiStatusModel{ - StatusCode: http.StatusOK, - Message: "OK", - }, - } - - //res, err := s.dto.ListSubscriptionDetails(c.Request().Context(), 50) - //if err != nil { - // return s.WriteError(c, err, http.StatusInternalServerError) - //} - //payload.Payload = res - return c.JSON(http.StatusOK, payload) -} - -// GetSubscriptionsByDiscordId -// @Summary Returns the top 100 entries from the queue to be processed. -// @Produce application/json -// @Param id query string true "id" -// @Tags Subscription -// @Router /v1/subscriptions/by/discordId [get] -// @Success 200 {object} ListSubscriptions "ok" -// @Failure 400 {object} ApiError "Unable to reach SQL or Data problems" -// @Failure 500 {object} ApiError "Data problems" -func (s *Handler) GetSubscriptionsByDiscordId(c echo.Context) error { - p := ListSubscriptions{ - ApiStatusModel: ApiStatusModel{ - StatusCode: http.StatusOK, - Message: "OK", - }, - } - - id := c.QueryParam("id") - if id == "" { - return s.WriteError(c, errors.New(ErrIdValueMissing), http.StatusBadRequest) - } - - //uuid, err := uuid.Parse(id) - //if err != nil { - // return s.WriteError(c, errors.New(ErrValueNotUuid), http.StatusBadRequest) - //} - - //res, err := s.dto.ListSubscriptionsByDiscordWebhookId(context.Background(), uuid) - //if err != nil { - // return s.WriteError(c, err, http.StatusNoContent) - //} - //p.Payload = res - return c.JSON(http.StatusOK, p) -} - -// GetSubscriptionsBySourceId -// @Summary Returns the top 100 entries from the queue to be processed. -// @Produce application/json -// @Param id query string true "id" -// @Tags Subscription -// @Router /v1/subscriptions/by/SourceId [get] -// @Success 200 {object} ListSubscriptions "ok" -func (s *Handler) GetSubscriptionsBySourceId(c echo.Context) error { - p := ListSubscriptions{ - ApiStatusModel: ApiStatusModel{ - StatusCode: http.StatusOK, - Message: "OK", - }, - } - - _id := c.QueryParam("id") - if _id == "" { - return s.WriteError(c, errors.New(ErrIdValueMissing), http.StatusBadRequest) - } - - //uuid, err := uuid.Parse(_id) - //if err != nil { - // return s.WriteError(c, err, http.StatusBadRequest) - //} - - //res, err := s.dto.ListSubscriptionsBySourceId(context.Background(), uuid) - //if err != nil { - // return s.WriteError(c, err, http.StatusNoContent) - //} - //p.Payload = res - return c.JSON(http.StatusOK, p) -} - -// NewDiscordWebHookSubscription -// @Summary Creates a new subscription to link a post from a Source to a DiscordWebHook. -// @Param discordWebHookId query string true "discordWebHookId" -// @Param sourceId query string true "sourceId" -// @Tags Subscription -// @Router /v1/subscriptions/discord/webhook/new [post] -func (s *Handler) newDiscordWebHookSubscription(c echo.Context) error { - // Extract the values given - discordWebHookId := c.QueryParam("discordWebHookId") - sourceId := c.QueryParam("sourceId") - - // Check to make we didn't get a null - if discordWebHookId == "" { - return s.WriteError(c, errors.New("invalid discordWebHooksId given"), http.StatusBadRequest) - } - if sourceId == "" { - return s.WriteError(c, errors.New("invalid sourceID given"), http.StatusBadRequest) - } - - // Validate they are UUID values - uHook, err := uuid.Parse(discordWebHookId) - if err != nil { - return s.WriteError(c, err, http.StatusBadRequest) - } - uSource, err := uuid.Parse(sourceId) - if err != nil { - return s.WriteError(c, err, http.StatusBadRequest) - } - - // Check if the sub already exists - _, err = s.Db.QuerySubscriptions(c.Request().Context(), database.QuerySubscriptionsParams{ - Discordwebhookid: uHook, - Sourceid: uSource, - }) - if err == nil { - return s.WriteError(c, errors.New("a subscription already exists between these two entities"), http.StatusBadRequest) - } - - // Does not exist, so make it. - params := database.CreateSubscriptionParams{ - ID: uuid.New(), - Discordwebhookid: uHook, - Sourceid: uSource, - } - err = s.Db.CreateSubscription(context.Background(), params) - if err != nil { - return s.WriteError(c, err, http.StatusInternalServerError) - } - - bJson, err := json.Marshal(¶ms) - if err != nil { - return s.WriteError(c, err, http.StatusInternalServerError) - } - - return c.JSON(http.StatusOK, bJson) -} - -// DeleteDiscordWebHookSubscription -// @Summary Removes a Discord WebHook Subscription based on the Subscription ID. -// @Param id query string true "id" -// @Tags Subscription -// @Router /v1/subscriptions/discord/webhook/delete [delete] -func (s *Handler) DeleteDiscordWebHookSubscription(c echo.Context) error { - var ErrMissingSubscriptionID string = "the request was missing a 'Id'" - - id := c.QueryParam("id") - if id == "" { - return s.WriteError(c, errors.New(ErrMissingSubscriptionID), http.StatusBadRequest) - } - - uid, err := uuid.Parse(id) - if err != nil { - return s.WriteError(c, err, http.StatusBadRequest) - } - - err = s.Db.DeleteSubscription(context.Background(), uid) - if err != nil { - return s.WriteError(c, err, http.StatusInternalServerError) - } - - return c.JSON(http.StatusOK, nil) -} diff --git a/internal/repository/alertDiscord.go b/internal/repository/alertDiscord.go new file mode 100644 index 0000000..e6ef6de --- /dev/null +++ b/internal/repository/alertDiscord.go @@ -0,0 +1,122 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" + "github.com/huandu/go-sqlbuilder" +) + +type AlertDiscordRepo interface { + Create(ctx context.Context, userId, sourceId, webhookId int64) (int64, error) + SoftDelete(ctx context.Context, id int64) (int64, error) + Restore(ctx context.Context, id int64) (int64, error) + Delete(ctx context.Context, id int64) (int64, error) + ListByUser(ctx context.Context, page, limit int, userId int64) ([]domain.AlertDiscordEntity, error) +} + +type alertDiscordRepository struct { + conn *sql.DB + defaultLimit int + defaultOffset int +} + +func NewAlertDiscordRepository(conn *sql.DB) alertDiscordRepository { + return alertDiscordRepository{ + conn: conn, + defaultLimit: 50, + defaultOffset: 50, + } +} + +func (r alertDiscordRepository) Create(ctx context.Context, userId, sourceId, webhookId int64) (int64, error) { + dt := time.Now() + queryBuilder := sqlbuilder.NewInsertBuilder() + queryBuilder.InsertInto("AlertDiscord") + queryBuilder.Cols("UpdatedAt", "CreatedAt", "DeletedAt", "UserID", "SourceID", "DiscordWebHookID") + queryBuilder.Values(dt, dt, timeZero, userId, sourceId, webhookId) + query, args := queryBuilder.Build() + + _, err := r.conn.ExecContext(ctx, query, args...) + if err != nil { + return 0, err + } + + return 1, nil +} + +func (r alertDiscordRepository) SoftDelete(ctx context.Context, id int64) (int64, error) { + return softDeleteRow(ctx, r.conn, "AlertDiscord", id) +} + +func (r alertDiscordRepository) Restore(ctx context.Context, id int64) (int64, error) { + return restoreRow(ctx, r.conn, "AlertDiscord", id) +} + +func (r alertDiscordRepository) Delete(ctx context.Context, id int64) (int64, error) { + return deleteFromTable(ctx, r.conn, "AlertDiscord", id) +} + +func (r alertDiscordRepository) ListByUser(ctx context.Context, page, limit int, userId int64) ([]domain.AlertDiscordEntity, error) { + builder := sqlbuilder.NewSelectBuilder() + builder.Select("*") + builder.From("AlertDiscord") + builder.Where( + builder.Equal("UserID", userId), + ) + builder.Offset(page * limit) + builder.Limit(limit) + + query, args := builder.Build() + rows, err := r.conn.QueryContext(ctx, query, args...) + if err != nil { + return []domain.AlertDiscordEntity{}, err + } + + data := r.processRows(rows) + if len(data) == 0 { + return []domain.AlertDiscordEntity{}, errors.New(ErrUserNotFound) + } + + return data, nil +} + +func (ur alertDiscordRepository) processRows(rows *sql.Rows) []domain.AlertDiscordEntity { + items := []domain.AlertDiscordEntity{} + + for rows.Next() { + var id int64 + var createdAt time.Time + var updatedAt time.Time + var deletedAt time.Time + var userId int64 + var sourceId int64 + var webhookId int64 + + err := rows.Scan( + &id, &createdAt, &updatedAt, &deletedAt, + &userId, &sourceId, &webhookId, + ) + if err != nil { + fmt.Println(err) + } + + item := domain.AlertDiscordEntity{ + ID: id, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + DeletedAt: deletedAt, + UserID: userId, + SourceID: sourceId, + DiscordWebHookId: webhookId, + } + + items = append(items, item) + } + + return items +} diff --git a/internal/repository/alertDiscord_test.go b/internal/repository/alertDiscord_test.go new file mode 100644 index 0000000..e9cadcb --- /dev/null +++ b/internal/repository/alertDiscord_test.go @@ -0,0 +1,63 @@ +package repository_test + +import ( + "context" + "testing" + "time" + + "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" + "git.jamestombleson.com/jtom38/newsbot-api/internal/repository" +) + +func TestAlertDiscordCreate(t *testing.T) { + t.Log(time.Time{}) + db, err := setupInMemoryDb() + if err != nil { + t.Log(err) + t.FailNow() + } + defer db.Close() + + r := repository.NewAlertDiscordRepository(db) + created, err := r.Create(context.Background(), 1, 1, 1) + if err != nil { + t.Log(err) + t.FailNow() + } + + if created != 1 { + t.Log("failed to create the record") + t.FailNow() + } +} + +func TestAlertDiscordCreateAndValidate(t *testing.T) { + t.Log(time.Time{}) + db, err := setupInMemoryDb() + if err != nil { + t.Log(err) + t.FailNow() + } + defer db.Close() + + source := repository.NewSourceRepository(db) + source.Create(context.Background(), domain.SourceCollectorRss, "Unit Testing", "www.fake.com", "testing,units", true) + sourceRecord, _ := source.GetBySourceAndName(context.Background(), domain.SourceCollectorRss, "Unit Testing") + + webhookRepo := repository.NewDiscordWebHookRepository(db) + webhookRepo.Create(context.Background(), 999, "discord.com", "Unit Testing", "memes", true) + webhook, _ := webhookRepo.GetByUrl(context.Background(), "discord.com") + + r := repository.NewAlertDiscordRepository(db) + r.Create(context.Background(), 999, sourceRecord.ID, webhook.ID) + alert, err := r.ListByUser(context.Background(), 0, 10, 999) + if err != nil { + t.Error(err) + t.FailNow() + } + + if len(alert) != 1 { + t.Error("got the incorrect number of rows back") + t.FailNow() + } +} diff --git a/internal/repository/discordWebHooks.go b/internal/repository/discordWebHooks.go index e549897..b87ef86 100644 --- a/internal/repository/discordWebHooks.go +++ b/internal/repository/discordWebHooks.go @@ -9,8 +9,8 @@ import ( "github.com/huandu/go-sqlbuilder" ) -type DiscordWebHookRepo interface{ - Create(ctx context.Context, url, server, channel string, enabled bool) (int64, error) +type DiscordWebHookRepo interface { + Create(ctx context.Context, userId int64, url, server, channel string, enabled bool) (int64, error) Enable(ctx context.Context, id int64) (int64, error) Disable(ctx context.Context, id int64) (int64, error) SoftDelete(ctx context.Context, id int64) (int64, error) @@ -32,12 +32,12 @@ func NewDiscordWebHookRepository(conn *sql.DB) discordWebHookRepository { } } -func (r discordWebHookRepository) Create(ctx context.Context, url, server, channel string, enabled bool) (int64, error) { +func (r discordWebHookRepository) Create(ctx context.Context, userId int64, url, server, channel string, enabled bool) (int64, error) { dt := time.Now() queryBuilder := sqlbuilder.NewInsertBuilder() queryBuilder.InsertInto("DiscordWebHooks") - queryBuilder.Cols("UpdatedAt", "CreatedAt", "DeletedAt", "Url", "Server", "Channel", "Enabled") - queryBuilder.Values(dt, dt, timeZero, url, server, channel, enabled) + queryBuilder.Cols("UpdatedAt", "CreatedAt", "DeletedAt", "UserID", "Url", "Server", "Channel", "Enabled") + queryBuilder.Values(dt, dt, timeZero, userId, url, server, channel, enabled) query, args := queryBuilder.Build() _, err := r.conn.ExecContext(ctx, query, args...) @@ -195,13 +195,14 @@ func (r discordWebHookRepository) processRows(rows *sql.Rows) ([]domain.DiscordW var createdAt time.Time var updatedAt time.Time var deletedAt time.Time + var userId int64 var url string var server string var channel string var enabled bool err := rows.Scan( &id, &createdAt, &updatedAt, - &deletedAt, &url, &server, + &deletedAt, &userId, &url, &server, &channel, &enabled, ) if err != nil { @@ -213,6 +214,7 @@ func (r discordWebHookRepository) processRows(rows *sql.Rows) ([]domain.DiscordW CreatedAt: createdAt, UpdatedAt: updatedAt, DeletedAt: deletedAt, + UserID: userId, Url: url, Server: server, Channel: channel, diff --git a/internal/repository/discordWebHooks_test.go b/internal/repository/discordWebHooks_test.go index 122299d..b79dd63 100644 --- a/internal/repository/discordWebHooks_test.go +++ b/internal/repository/discordWebHooks_test.go @@ -17,7 +17,7 @@ func TestCreateDiscordWebHookRecord(t *testing.T) { defer db.Close() r := repository.NewDiscordWebHookRepository(db) - created, err := r.Create(context.Background(), "www.discord.com/bad/webhook", "Unit Testing", "memes", true) + created, err := r.Create(context.Background(), 999, "www.discord.com/bad/webhook", "Unit Testing", "memes", true) if err != nil { t.Log(err) t.FailNow() @@ -38,7 +38,7 @@ func TestDiscordWebHookGetById(t *testing.T) { defer db.Close() ctx := context.Background() r := repository.NewDiscordWebHookRepository(db) - created, err := r.Create(ctx, "www.discord.com/bad/webhook", "Unit Testing", "memes", true) + created, err := r.Create(ctx, 999, "www.discord.com/bad/webhook", "Unit Testing", "memes", true) if err != nil { t.Log(err) t.FailNow() @@ -71,7 +71,7 @@ func TestDiscordWebHookGetByUrl(t *testing.T) { ctx := context.Background() r := repository.NewDiscordWebHookRepository(db) - _, _ = r.Create(ctx, "www.discord.com/bad/webhook", "Unit Testing", "memes", true) + _, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", "Unit Testing", "memes", true) item, err := r.GetByUrl(ctx, "www.discord.com/bad/webhook") if err != nil { t.Log(err) @@ -95,7 +95,7 @@ func TestDiscordWebHookListByServerName(t *testing.T) { ctx := context.Background() serverName := "Unit Testing" r := repository.NewDiscordWebHookRepository(db) - _, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, "memes", true) + _, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, "memes", true) item, err := r.ListByServerName(ctx, serverName) if err != nil { @@ -121,7 +121,7 @@ func TestDiscordWebHookListByServerAndChannel(t *testing.T) { serverName := "Unit Testing" channel := "memes" r := repository.NewDiscordWebHookRepository(db) - _, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, true) + _, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, true) item, err := r.ListByServerAndChannel(ctx, serverName, channel) if err != nil { @@ -152,7 +152,7 @@ func TestDiscordWebHookEnableRecord(t *testing.T) { serverName := "Unit Testing" channel := "memes" r := repository.NewDiscordWebHookRepository(db) - _, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, false) + _, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, false) item, err := r.GetById(ctx, 1) if err != nil { @@ -195,7 +195,7 @@ func TestDiscordWebHookDisableRecord(t *testing.T) { serverName := "Unit Testing" channel := "memes" r := repository.NewDiscordWebHookRepository(db) - _, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, true) + _, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, true) item, err := r.GetById(ctx, 1) if err != nil { @@ -238,7 +238,7 @@ func TestDiscordWebHookSoftDelete(t *testing.T) { serverName := "Unit Testing" channel := "memes" r := repository.NewDiscordWebHookRepository(db) - _, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, true) + _, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, true) _, err = r.SoftDelete(ctx, 1) if err != nil { t.Log(err) @@ -263,7 +263,7 @@ func TestDiscordWebHookRestore(t *testing.T) { timeZero := time.Time{} r := repository.NewDiscordWebHookRepository(db) - _, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, true) + _, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, true) item, _ := r.GetById(ctx, 1) if item.DeletedAt != timeZero { t.Log("DeletedAt was not zero") diff --git a/internal/repository/refreshTokens.go b/internal/repository/refreshTokens.go index fc6ce83..ec7fc6f 100644 --- a/internal/repository/refreshTokens.go +++ b/internal/repository/refreshTokens.go @@ -1,6 +1,7 @@ package repository import ( + "context" "database/sql" "errors" "fmt" @@ -15,9 +16,9 @@ const ( ) type RefreshToken interface { - Create(username string, token string) (int64, error) - GetByUsername(name string) (domain.RefreshTokenEntity, error) - DeleteById(id int64) (int64, error) + Create(ctx context.Context, username string, token string) (int64, error) + GetByUsername(ctx context.Context, name string) (domain.RefreshTokenEntity, error) + DeleteById(ctx context.Context, id int64) (int64, error) } type RefreshTokenRepository struct { @@ -30,15 +31,15 @@ func NewRefreshTokenRepository(conn *sql.DB) RefreshTokenRepository { } } -func (rt RefreshTokenRepository) Create(username string, token string) (int64, error) { +func (rt RefreshTokenRepository) Create(ctx context.Context, username string, token string) (int64, error) { dt := time.Now() builder := sqlbuilder.NewInsertBuilder() builder.InsertInto(refreshTokenTableName) - builder.Cols("Username", "Token", "CreatedAt", "UpdatedAt") - builder.Values(username, token, dt, dt) + builder.Cols("Username", "Token", "CreatedAt", "UpdatedAt", "DeletedAt") + builder.Values(username, token, dt, dt, time.Time{}) query, args := builder.Build() - _, err := rt.connection.Exec(query, args...) + _, err := rt.connection.ExecContext(ctx, query, args...) if err != nil { return 0, err } @@ -46,14 +47,14 @@ func (rt RefreshTokenRepository) Create(username string, token string) (int64, e return 1, nil } -func (rt RefreshTokenRepository) GetByUsername(name string) (domain.RefreshTokenEntity, error) { +func (rt RefreshTokenRepository) GetByUsername(ctx context.Context, name string) (domain.RefreshTokenEntity, error) { builder := sqlbuilder.NewSelectBuilder() builder.Select("*").From(refreshTokenTableName).Where( builder.E("Username", name), ) query, args := builder.Build() - rows, err := rt.connection.Query(query, args...) + rows, err := rt.connection.QueryContext(ctx, query, args...) if err != nil { return domain.RefreshTokenEntity{}, err } @@ -66,7 +67,7 @@ func (rt RefreshTokenRepository) GetByUsername(name string) (domain.RefreshToken return data[0], nil } -func (rt RefreshTokenRepository) DeleteById(id int64) (int64, error) { +func (rt RefreshTokenRepository) DeleteById(ctx context.Context, id int64) (int64, error) { builder := sqlbuilder.NewDeleteBuilder() builder.DeleteFrom(refreshTokenTableName) builder.Where( @@ -74,7 +75,7 @@ func (rt RefreshTokenRepository) DeleteById(id int64) (int64, error) { ) query, args := builder.Build() - rows, err := rt.connection.Exec(query, args...) + rows, err := rt.connection.ExecContext(ctx, query, args...) if err != nil { return -1, err } diff --git a/internal/repository/refreshTokens_test.go b/internal/repository/refreshTokens_test.go index 2e44ef3..c182b63 100644 --- a/internal/repository/refreshTokens_test.go +++ b/internal/repository/refreshTokens_test.go @@ -1,6 +1,7 @@ package repository_test import ( + "context" "testing" "git.jamestombleson.com/jtom38/newsbot-api/internal/repository" @@ -14,7 +15,7 @@ func TestRefreshTokenCreate(t *testing.T) { } client := repository.NewRefreshTokenRepository(conn) - rows, err := client.Create("tester", "BadTokenDontUse") + rows, err := client.Create(context.Background(), "tester", "BadTokenDontUse") if err != nil { t.Log(err) t.FailNow() @@ -33,7 +34,7 @@ func TestRefreshTokenGetByUsername(t *testing.T) { } client := repository.NewRefreshTokenRepository(conn) - rows, err := client.Create("tester", "BadTokenDoNotUse") + rows, err := client.Create(context.Background(), "tester", "BadTokenDoNotUse") if err != nil { t.Log(err) t.FailNow() @@ -44,7 +45,7 @@ func TestRefreshTokenGetByUsername(t *testing.T) { t.FailNow() } - model, err := client.GetByUsername("tester") + model, err := client.GetByUsername(context.Background(), "tester") if err != nil { t.Log(err) t.FailNow() @@ -64,7 +65,7 @@ func TestRefreshTokenDeleteById(t *testing.T) { } client := repository.NewRefreshTokenRepository(conn) - created, err := client.Create("tester", "BadTokenDoNotUse") + created, err := client.Create(context.Background(), "tester", "BadTokenDoNotUse") if err != nil { t.Log(err) t.FailNow() @@ -73,13 +74,13 @@ func TestRefreshTokenDeleteById(t *testing.T) { t.Log("Unexpected number back for rows created") } - model, err := client.GetByUsername("tester") + model, err := client.GetByUsername(context.Background(), "tester") if err != nil { t.Log(err) t.FailNow() } - updated, err := client.DeleteById(model.ID) + updated, err := client.DeleteById(context.Background(), model.ID) if err != nil { t.Log(err) t.FailNow() diff --git a/internal/repository/userSourceSubscription.go b/internal/repository/userSourceSubscription.go new file mode 100644 index 0000000..7a9a5c2 --- /dev/null +++ b/internal/repository/userSourceSubscription.go @@ -0,0 +1,120 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" + "github.com/huandu/go-sqlbuilder" +) + +type UserSourceRepo interface { + Create(ctx context.Context, userId, sourceId int64) (int64, error) + SoftDelete(ctx context.Context, id int64) (int64, error) + Restore(ctx context.Context, id int64) (int64, error) + Delete(ctx context.Context, id int64) (int64, error) + ListByUser(ctx context.Context, page, limit int, userId int64) ([]domain.UserSourceSubscriptionEntity, error) +} + +type userSourceRepository struct { + conn *sql.DB + defaultLimit int + defaultOffset int +} + +func NewUserSourceRepository(conn *sql.DB) userSourceRepository { + return userSourceRepository{ + conn: conn, + defaultLimit: 50, + defaultOffset: 50, + } +} + +func (r userSourceRepository) Create(ctx context.Context, userId, sourceId int64) (int64, error) { + dt := time.Now() + queryBuilder := sqlbuilder.NewInsertBuilder() + queryBuilder.InsertInto("UserSourceSubscriptions") + queryBuilder.Cols("UpdatedAt", "CreatedAt", "DeletedAt", "UserID", "SourceID") + queryBuilder.Values(dt, dt, timeZero, userId, sourceId) + query, args := queryBuilder.Build() + + _, err := r.conn.ExecContext(ctx, query, args...) + if err != nil { + return 0, err + } + + return 1, nil +} + +func (r userSourceRepository) SoftDelete(ctx context.Context, id int64) (int64, error) { + return softDeleteRow(ctx, r.conn, "UserSourceSubscriptions", id) +} + +func (r userSourceRepository) Restore(ctx context.Context, id int64) (int64, error) { + return restoreRow(ctx, r.conn, "UserSourceSubscriptions", id) +} + +func (r userSourceRepository) Delete(ctx context.Context, id int64) (int64, error) { + return deleteFromTable(ctx, r.conn, "UserSourceSubscriptions", id) +} + +func (r userSourceRepository) ListByUser(ctx context.Context, page, limit int, userId int64) ([]domain.UserSourceSubscriptionEntity, error) { + builder := sqlbuilder.NewSelectBuilder() + builder.Select("*") + builder.From("UserSourceSubscriptions") + builder.Where( + builder.Equal("UserID", userId), + ) + builder.Offset(page * limit) + builder.Limit(limit) + + query, args := builder.Build() + rows, err := r.conn.QueryContext(ctx, query, args...) + if err != nil { + return []domain.UserSourceSubscriptionEntity{}, err + } + + data := r.processRows(rows) + if len(data) == 0 { + return []domain.UserSourceSubscriptionEntity{}, errors.New(ErrUserNotFound) + } + + return data, nil +} + +func (ur userSourceRepository) processRows(rows *sql.Rows) []domain.UserSourceSubscriptionEntity { + items := []domain.UserSourceSubscriptionEntity{} + + for rows.Next() { + var id int64 + var createdAt time.Time + var updatedAt time.Time + var deletedAt time.Time + var userId int64 + var sourceId int64 + + err := rows.Scan( + &id, &createdAt, &updatedAt, &deletedAt, + &userId, &sourceId, + ) + if err != nil { + fmt.Println(err) + } + + item := domain.UserSourceSubscriptionEntity{ + ID: id, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + DeletedAt: deletedAt, + UserID: userId, + SourceID: sourceId, + } + + items = append(items, item) + } + + return items +} diff --git a/internal/repository/users.go b/internal/repository/users.go index 209c6f5..c6a4d1e 100644 --- a/internal/repository/users.go +++ b/internal/repository/users.go @@ -1,6 +1,7 @@ package repository import ( + "context" "database/sql" "errors" "fmt" @@ -18,12 +19,12 @@ const ( ) type Users interface { - GetByName(name string) (domain.UserEntity, error) - Create(name, password, scope string) (int64, error) - Update(id int, entity domain.UserEntity) error - UpdatePassword(name, password string) error - CheckUserHash(name, password string) error - UpdateScopes(name, scope string) error + GetByName(ctx context.Context, name string) (domain.UserEntity, error) + Create(ctx context.Context, name, password, scope string) (int64, error) + Update(ctx context.Context, id int, entity domain.UserEntity) error + UpdatePassword(ctx context.Context, name, password string) error + CheckUserHash(ctx context.Context, name, password string) error + UpdateScopes(ctx context.Context, name, scope string) error } // Creates a new instance of UserRepository with the bound sql @@ -37,14 +38,14 @@ type userRepository struct { connection *sql.DB } -func (ur userRepository) GetByName(name string) (domain.UserEntity, error) { +func (ur userRepository) GetByName(ctx context.Context, name string) (domain.UserEntity, error) { builder := sqlbuilder.NewSelectBuilder() builder.Select("*").From("users").Where( builder.E("Name", name), ) query, args := builder.Build() - rows, err := ur.connection.Query(query, args...) + rows, err := ur.connection.QueryContext(ctx, query, args...) if err != nil { return domain.UserEntity{}, err } @@ -57,7 +58,7 @@ func (ur userRepository) GetByName(name string) (domain.UserEntity, error) { return data[0], nil } -func (ur userRepository) Create(name, password, scope string) (int64, error) { +func (ur userRepository) Create(ctx context.Context,name, password, scope string) (int64, error) { passwordBytes := []byte(password) hash, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) if err != nil { @@ -67,11 +68,11 @@ func (ur userRepository) Create(name, password, scope string) (int64, error) { dt := time.Now() queryBuilder := sqlbuilder.NewInsertBuilder() queryBuilder.InsertInto("users") - queryBuilder.Cols("Name", "Hash", "UpdatedAt", "CreatedAt", "Scopes") - queryBuilder.Values(name, string(hash), dt, dt, scope) + queryBuilder.Cols("Name", "Hash", "UpdatedAt", "CreatedAt", "DeletedAt", "Scopes") + queryBuilder.Values(name, string(hash), dt, dt, time.Time{}, scope) query, args := queryBuilder.Build() - _, err = ur.connection.Exec(query, args...) + _, err = ur.connection.ExecContext(ctx, query, args...) if err != nil { return 0, err } @@ -79,12 +80,12 @@ func (ur userRepository) Create(name, password, scope string) (int64, error) { return 1, nil } -func (ur userRepository) Update(id int, entity domain.UserEntity) error { +func (ur userRepository) Update(ctx context.Context, id int, entity domain.UserEntity) error { return errors.New("not implemented") } -func (ur userRepository) UpdatePassword(name, password string) error { - _, err := ur.GetByName(name) +func (ur userRepository) UpdatePassword(ctx context.Context, name, password string) error { + _, err := ur.GetByName(ctx, name) if err != nil { return nil } @@ -97,8 +98,8 @@ func (ur userRepository) UpdatePassword(name, password string) error { // If the hash matches what we have in the database, an error will not be returned. // If the user does not exist or the hash does not match, an error will be returned -func (ur userRepository) CheckUserHash(name, password string) error { - record, err := ur.GetByName(name) +func (ur userRepository) CheckUserHash(ctx context.Context,name, password string) error { + record, err := ur.GetByName(ctx, name) if err != nil { return err } @@ -111,7 +112,7 @@ func (ur userRepository) CheckUserHash(name, password string) error { return nil } -func (ur userRepository) UpdateScopes(name, scope string) error { +func (ur userRepository) UpdateScopes(ctx context.Context,name, scope string) error { builder := sqlbuilder.NewUpdateBuilder() builder.Update("users") builder.Set( @@ -122,7 +123,7 @@ func (ur userRepository) UpdateScopes(name, scope string) error { ) query, args := builder.Build() - _, err := ur.connection.Exec(query, args...) + _, err := ur.connection.ExecContext(ctx, query, args...) if err != nil { return err } diff --git a/internal/repository/users_test.go b/internal/repository/users_test.go index 849338e..84fe23a 100644 --- a/internal/repository/users_test.go +++ b/internal/repository/users_test.go @@ -1,6 +1,7 @@ package repository_test import ( + "context" "database/sql" "log" "testing" @@ -20,7 +21,7 @@ func TestCanCreateNewUser(t *testing.T) { defer db.Close() repo := repository.NewUserRepository(db) - updated, err := repo.Create("testing", "NotSecure", "placeholder") + updated, err := repo.Create(context.Background(), "testing", "NotSecure", "placeholder") if err != nil { log.Println(err) t.FailNow() @@ -37,7 +38,7 @@ func TestCanFindUserInTable(t *testing.T) { defer db.Close() repo := repository.NewUserRepository(db) - updated, err := repo.Create("testing", "NotSecure", "placeholder") + updated, err := repo.Create(context.Background(), "testing", "NotSecure", "placeholder") if err != nil { t.Log(err) t.FailNow() @@ -48,7 +49,7 @@ func TestCanFindUserInTable(t *testing.T) { t.FailNow() } - user, err := repo.GetByName("testing") + user, err := repo.GetByName(context.Background(), "testing") if err != nil { log.Println(err) t.FailNow() @@ -65,7 +66,7 @@ func TestCheckUserHash(t *testing.T) { defer db.Close() repo := repository.NewUserRepository(db) - repo.CheckUserHash("testing", "NotSecure") + repo.CheckUserHash(context.Background(), "testing", "NotSecure") } func setupInMemoryDb() (*sql.DB, error) { diff --git a/internal/repositoryServices/refreshTokens.go b/internal/repositoryServices/refreshTokens.go new file mode 100644 index 0000000..ca5b800 --- /dev/null +++ b/internal/repositoryServices/refreshTokens.go @@ -0,0 +1,87 @@ +package repositoryservices + +import ( + "context" + "database/sql" + "errors" + + "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" + "git.jamestombleson.com/jtom38/newsbot-api/internal/repository" + "github.com/google/uuid" +) + +const ( + ErrUnexpectedAmountOfRowsUpdated = "got a unexpected of rows updated" +) + +type RefreshToken interface { + Create(ctx context.Context, username string) (string, error) + GetByName(ctx context.Context, name string) (domain.RefreshTokenEntity, error) + Delete(ctx context.Context, id int64) (int64, error) + IsRequestValid(ctx context.Context, username, refreshToken string) error +} + +// A new jwt token can be made if the user has the correct refresh token for the user. +// It will also require the old JWT token so the expire time is pulled and part of the validation +type RefreshTokenService struct { + table repository.RefreshTokenRepository +} + +func NewRefreshTokenService(conn *sql.DB) RefreshTokenService { + return RefreshTokenService{ + table: repository.NewRefreshTokenRepository(conn), + } +} + +func (rt RefreshTokenService) Create(ctx context.Context, username string) (string, error) { + //if a refresh token already exists for a user, reuse + existingToken, err := rt.GetByName(ctx, username) + if err == nil { + rowsRemoved, err := rt.Delete(ctx, existingToken.ID) + if err != nil { + return "", err + } + + if rowsRemoved != 1 { + return "", errors.New(ErrUnexpectedAmountOfRowsUpdated) + } + } + + token, err := uuid.NewV7() + if err != nil { + return "", err + } + + rows, err := rt.table.Create(ctx, username, token.String()) + if err != nil { + return "", err + } + + if rows != 1 { + return "", errors.New("expected one row but got none") + } + return token.String(), nil +} + +// Find the saved refresh token for a user and return it if it exists +func (rt RefreshTokenService) GetByName(ctx context.Context, name string) (domain.RefreshTokenEntity, error) { + return rt.table.GetByUsername(ctx, name) +} + +// This will request that a object is removed from the database +func (rt RefreshTokenService) Delete(ctx context.Context, id int64) (int64, error) { + return rt.table.DeleteById(ctx, id) +} + +func (rt RefreshTokenService) IsRequestValid(ctx context.Context, username, refreshToken string) error { + token, err := rt.GetByName(ctx, username) + if err != nil { + return err + } + + if token.Token != refreshToken { + return errors.New("the refresh token given does not match") + } + + return nil +} diff --git a/internal/repositoryServices/userService.go b/internal/repositoryServices/userService.go new file mode 100644 index 0000000..7a4bed2 --- /dev/null +++ b/internal/repositoryServices/userService.go @@ -0,0 +1,171 @@ +package repositoryservices + +import ( + "context" + "database/sql" + "errors" + "strings" + + "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" + "git.jamestombleson.com/jtom38/newsbot-api/internal/repository" + + "golang.org/x/crypto/bcrypt" +) + +const ( + ErrPasswordNotLongEnough = "password needs to be 8 character or longer" + ErrPasswordMissingSpecialCharacter = "password needs to contain one of the following: !, @, #" + ErrInvalidPassword = "invalid password" +) + +type UserServices interface { + DoesUserExist(ctx context.Context, username string) error + DoesPasswordMatchHash(ctx context.Context, username, password string) error + GetUser(ctx context.Context, username string) (domain.UserEntity, error) + AddScopes(ctx context.Context, username string, scopes []string) error + RemoveScopes(ctx context.Context, username string, scopes []string) error + Create(ctx context.Context, name, password, scope string) (domain.UserEntity, error) + CheckPasswordForRequirements(password string) error +} + +// This will handle operations that are user related, but one layer higher then the repository +type UserService struct { + repo repository.Users +} + +// This is a layer on top of the Users Repository. +// Use this over directly talking to the table when ever possible. +func NewUserService(conn *sql.DB) UserService { + return UserService{ + repo: repository.NewUserRepository(conn), + } +} + +func (us UserService) DoesUserExist(ctx context.Context, username string) error { + _, err := us.repo.GetByName(ctx, username) + if err != nil { + return err + } + return nil +} + +func (us UserService) DoesPasswordMatchHash(ctx context.Context, username, password string) error { + model, err := us.GetUser(ctx, username) + if err != nil { + return err + } + + err = bcrypt.CompareHashAndPassword([]byte(model.Hash), []byte(password)) + if err != nil { + return errors.New(ErrInvalidPassword) + } + + return nil +} + +func (us UserService) GetUser(ctx context.Context, username string) (domain.UserEntity, error) { + return us.repo.GetByName(ctx, username) +} + +func (us UserService) AddScopes(ctx context.Context, username string, scopes []string) error { + usr, err := us.repo.GetByName(ctx, username) + if err != nil { + return err + } + + if usr.Username != username { + return errors.New(repository.ErrUserNotFound) + } + + currentScopes := strings.Split(usr.Scopes, ",") + + // check the current scopes + for _, item := range scopes { + if !strings.Contains(usr.Scopes, item) { + currentScopes = append(currentScopes, item) + } + } + return us.repo.UpdateScopes(ctx, username, strings.Join(currentScopes, ",")) +} + +func (us UserService) RemoveScopes(ctx context.Context, username string, scopes []string) error { + usr, err := us.repo.GetByName(ctx, username) + if err != nil { + return err + } + + if usr.Username != username { + return errors.New(repository.ErrUserNotFound) + } + + var newScopes []string + + // check all the scopes that are currently assigned + for _, item := range strings.Split(usr.Scopes, ",") { + + // check the scopes given, if one matches skip it + if us.doesScopeExist(scopes, item) { + continue + } + + // did not match, add it + newScopes = append(newScopes, item) + } + + return us.repo.UpdateScopes(ctx, username, strings.Join(newScopes, ",")) +} + +func (us UserService) doesScopeExist(scopes []string, target string) bool { + for _, item := range scopes { + if item == target { + return true + } + } + return false +} + +func (us UserService) Create(ctx context.Context, name, password, scope string) (domain.UserEntity, error) { + err := us.CheckPasswordForRequirements(password) + if err != nil { + return domain.UserEntity{}, err + } + + us.repo.Create(ctx, name, password, domain.ScopeArticleRead) + return domain.UserEntity{}, nil +} + +func (us UserService) CheckPasswordForRequirements(password string) error { + err := us.checkPasswordLength(password) + if err != nil { + return err + } + + err = us.checkPasswordForSpecialCharacters(password) + if err != nil { + return err + } + + return nil +} + +func (us UserService) checkPasswordLength(password string) error { + if len(password) < 8 { + return errors.New(ErrPasswordNotLongEnough) + } + return nil +} + +func (us UserService) checkPasswordForSpecialCharacters(password string) error { + var chars []string + chars = append(chars, "!") + chars = append(chars, "@") + chars = append(chars, "#") + + for _, char := range chars { + if strings.Contains(password, char) { + return nil + } + } + + return errors.New(ErrPasswordMissingSpecialCharacter) +} diff --git a/internal/services/config.go b/internal/services/config.go index b1acbf6..f181aa1 100644 --- a/internal/services/config.go +++ b/internal/services/config.go @@ -34,6 +34,8 @@ const ( type Configs struct { ServerAddress string + JwtSecret string + AdminSecret string RedditEnabled bool RedditPullTop bool @@ -64,6 +66,8 @@ func NewConfig() ConfigClient { func GetEnvConfig() Configs { return Configs{ ServerAddress: os.Getenv(ServerAddress), + JwtSecret: os.Getenv("JwtSecret"), + AdminSecret: os.Getenv("AdminSecret"), RedditEnabled: processBoolConfig(os.Getenv(FEATURE_ENABLE_REDDIT_BACKEND)), RedditPullTop: processBoolConfig(os.Getenv(REDDIT_PULL_TOP)), diff --git a/internal/services/database.go b/internal/services/database.go index 9d11878..94a1506 100644 --- a/internal/services/database.go +++ b/internal/services/database.go @@ -4,22 +4,27 @@ import ( "database/sql" "git.jamestombleson.com/jtom38/newsbot-api/internal/repository" + repositoryservices "git.jamestombleson.com/jtom38/newsbot-api/internal/repositoryServices" ) type RepositoryService struct { - Articles repository.ArticlesRepo - DiscordWebHooks repository.DiscordWebHookRepo - Sources repository.Sources - Users repository.Users - RefreshTokens repository.RefreshToken + AlertDiscord repository.AlertDiscordRepo + Articles repository.ArticlesRepo + DiscordWebHooks repository.DiscordWebHookRepo + RefreshTokens repositoryservices.RefreshToken + Sources repository.Sources + Users repositoryservices.UserServices + UserSourceSubscriptions repository.UserSourceRepo } func NewRepositoryService(conn *sql.DB) RepositoryService { return RepositoryService{ - Articles: repository.NewArticleRepository(conn), - DiscordWebHooks: repository.NewDiscordWebHookRepository(conn), - Sources: repository.NewSourceRepository(conn), - Users: repository.NewUserRepository(conn), - RefreshTokens: repository.NewRefreshTokenRepository(conn), + AlertDiscord: repository.NewAlertDiscordRepository(conn), + Articles: repository.NewArticleRepository(conn), + DiscordWebHooks: repository.NewDiscordWebHookRepository(conn), + RefreshTokens: repositoryservices.NewRefreshTokenService(conn), + Sources: repository.NewSourceRepository(conn), + Users: repositoryservices.NewUserService(conn), + UserSourceSubscriptions: repository.NewUserSourceRepository(conn), } } diff --git a/makefile b/makefile index b2d1ed4..ad19d77 100644 --- a/makefile +++ b/makefile @@ -6,6 +6,7 @@ build: ## builds the application with the current go runtime ~/go/bin/swag f ~/go/bin/swag init -g cmd/server.go go build cmd/server.go + ls -lh server docker-build: ## Generates the docker image docker build -t "newsbot.collector.api" .