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..463c253 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" ], @@ -442,48 +462,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 +502,11 @@ const docTemplate = `{ }, "/v1/sources/by/source": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -563,6 +553,11 @@ const docTemplate = `{ }, "/v1/sources/by/sourceAndName": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -610,6 +605,11 @@ const docTemplate = `{ }, "/v1/sources/new/reddit": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -654,6 +654,11 @@ const docTemplate = `{ }, "/v1/sources/new/rss": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -698,6 +703,11 @@ const docTemplate = `{ }, "/v1/sources/new/twitch": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -716,6 +726,11 @@ const docTemplate = `{ }, "/v1/sources/new/youtube": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -741,6 +756,11 @@ const docTemplate = `{ }, "/v1/sources/{id}": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -779,6 +799,11 @@ const docTemplate = `{ } }, "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -797,6 +822,11 @@ const docTemplate = `{ }, "/v1/sources/{id}/disable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -834,6 +864,11 @@ const docTemplate = `{ }, "/v1/sources/{id}/enable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -868,167 +903,6 @@ 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": { - "post": { - "tags": [ - "Subscription" - ], - "summary": "Creates a new subscription to link a post from a Source to a DiscordWebHook.", - "parameters": [ - { - "type": "string", - "description": "discordWebHookId", - "name": "discordWebHookId", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "sourceId", - "name": "sourceId", - "in": "query", - "required": true - } - ], - "responses": {} - } } }, "definitions": { @@ -1185,212 +1059,14 @@ const docTemplate = `{ } } } - }, - "models.ArticleDetailsDto": { - "type": "object", - "properties": { - "authorImage": { - "type": "string" - }, - "authorName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "pubdate": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/models.SourceDto" - }, - "tags": { - "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": { - "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..e9cb4ca 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" ], @@ -433,48 +453,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 +493,11 @@ }, "/v1/sources/by/source": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -554,6 +544,11 @@ }, "/v1/sources/by/sourceAndName": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -601,6 +596,11 @@ }, "/v1/sources/new/reddit": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -645,6 +645,11 @@ }, "/v1/sources/new/rss": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -689,6 +694,11 @@ }, "/v1/sources/new/twitch": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -707,6 +717,11 @@ }, "/v1/sources/new/youtube": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -732,6 +747,11 @@ }, "/v1/sources/{id}": { "get": { + "security": [ + { + "Bearer": [] + } + ], "produces": [ "application/json" ], @@ -770,6 +790,11 @@ } }, "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -788,6 +813,11 @@ }, "/v1/sources/{id}/disable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -825,6 +855,11 @@ }, "/v1/sources/{id}/enable": { "post": { + "security": [ + { + "Bearer": [] + } + ], "tags": [ "Source" ], @@ -859,167 +894,6 @@ } } } - }, - "/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": { - "post": { - "tags": [ - "Subscription" - ], - "summary": "Creates a new subscription to link a post from a Source to a DiscordWebHook.", - "parameters": [ - { - "type": "string", - "description": "discordWebHookId", - "name": "discordWebHookId", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "sourceId", - "name": "sourceId", - "in": "query", - "required": true - } - ], - "responses": {} - } } }, "definitions": { @@ -1176,212 +1050,14 @@ } } } - }, - "models.ArticleDetailsDto": { - "type": "object", - "properties": { - "authorImage": { - "type": "string" - }, - "authorName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "pubdate": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/models.SourceDto" - }, - "tags": { - "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": { - "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..ad8429d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -102,140 +102,6 @@ definitions: $ref: '#/definitions/domain.SourceDto' type: array type: object - models.ArticleDetailsDto: - properties: - authorImage: - type: string - authorName: - type: string - description: - type: string - id: - type: string - pubdate: - type: string - source: - $ref: '#/definitions/models.SourceDto' - tags: - items: - type: string - type: array - thumbnail: - 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 - type: object info: contact: {} title: NewsBot collector @@ -263,6 +129,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 +157,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 +185,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 +217,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: @@ -520,32 +394,6 @@ paths: 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 +412,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 +440,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Returns a single entity by ID tags: - Source @@ -601,6 +453,8 @@ paths: required: true type: string responses: {} + security: + - Bearer: [] summary: Marks a source as deleted based on its ID value. tags: - Source @@ -625,6 +479,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Disables a source from processing. tags: - Source @@ -649,6 +505,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Enables a source to continue processing. tags: - Source @@ -679,6 +537,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 +570,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.BaseResponse' + security: + - Bearer: [] summary: Returns a single entity by ID tags: - Source @@ -739,6 +601,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 +632,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 +646,8 @@ paths: required: true type: string responses: {} + security: + - Bearer: [] summary: Creates a new twitch source to monitor. tags: - Source @@ -797,112 +665,15 @@ 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: - post: - parameters: - - description: discordWebHookId - in: query - name: discordWebHookId - required: true - type: string - - description: sourceId - in: query - name: sourceId - required: true - type: string - responses: {} - summary: Creates a new subscription to link a post from a Source to a DiscordWebHook. - tags: - - Subscription +securityDefinitions: + Bearer: + description: Type "Bearer" followed by a space and JWT token. + in: header + name: Authorization + type: apiKey swagger: "2.0" diff --git a/internal/database/migrations/20240425083756_init.sql b/internal/database/migrations/20240425083756_init.sql index 8ab36dd..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,18 +61,6 @@ CREATE Table Sources ( Tags TEXT NOT NULL ); -/* -CREATE TABLE Subscriptions ( - ID INTEGER PRIMARY KEY AUTOINCREMENT, - CreatedAt DATETIME NOT NULL, - UpdatedAt DATETIME NOT NULL, - DeletedAt DATETIME NOT NULL, - DiscordWebHookID NUMBER NOT NULL, - SourceID NUMBER NOT NULL, - UserID NUMBER NOT NULL -); -*/ - CREATE TABLE UserSourceSubscriptions ( ID INTEGER PRIMARY KEY AUTOINCREMENT, CreatedAt DATETIME NOT NULL, @@ -107,7 +84,7 @@ 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 @@ -117,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 ); @@ -127,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/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..4be9e81 100644 --- a/internal/domain/requests.go +++ b/internal/domain/requests.go @@ -11,3 +11,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/handler/v1/articles.go b/internal/handler/v1/articles.go index 0febc5d..21ff796 100644 --- a/internal/handler/v1/articles.go +++ b/internal/handler/v1/articles.go @@ -10,14 +10,15 @@ 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 { resp := domain.ArticleResponse{ BaseResponse: domain.BaseResponse{ @@ -40,14 +41,15 @@ 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 { p := domain.ArticleResponse{ BaseResponse: domain.BaseResponse{ @@ -74,14 +76,15 @@ 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 { p := domain.ArticleDetailedResponse{ BaseResponse: domain.BaseResponse{ @@ -114,15 +117,16 @@ 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 { p := domain.ArticleResponse{ BaseResponse: domain.BaseResponse{ diff --git a/internal/handler/v1/auth.go b/internal/handler/v1/auth.go new file mode 100644 index 0000000..7847f56 --- /dev/null +++ b/internal/handler/v1/auth.go @@ -0,0 +1,207 @@ +package v1 + +import ( + "net/http" + "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" +) + +// Register +// @Summary Creates a new user +// @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.ScopeRead) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusCreated, domain.BaseResponse{ + Message: "OK", + }) +} + +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.validateAdminToken(c, password) + } + + // check if the user exists + err := h.repo.Users.DoesUserExist(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) + + jwt, err := h.generateJwtWithExp(username, h.config.ServerAddress, 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) validateAdminToken(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) + } + + token, err := h.generateJwt("admin", h.config.ServerAddress) + if err != nil { + return h.InternalServerErrorResponse(c, err.Error()) + } + + return c.JSON(http.StatusOK, token) +} + +// This will take collect some information about the requested refresh, validate and then return a new jwt token if approved. +func (h *Handler) RefreshJwtToken(c echo.Context) error { + // 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()) + } + + jwt, err := h.generateJwtWithExp(request.Username, h.config.ServerAddress, 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, + }) +} + +func (h *Handler) AddScopes(c echo.Context) error { + token, err := h.getJwtToken(c) + if err != nil { + return h.UnauthorizedResponse(c, err.Error()) + } + + err = token.IsValid(domain.ScopeAll) + if err != nil { + return h.UnauthorizedResponse(c, err.Error()) + } + + request := domain.UpdateScopesRequest{} + err = (&echo.DefaultBinder{}).BindBody(c, &request) + if err != nil { + 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", + }) +} + +func (h *Handler) RemoveScopes(c echo.Context) error { + token, err := h.getJwtToken(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 275a256..65fd3ee 100644 --- a/internal/handler/v1/discordwebhooks.go +++ b/internal/handler/v1/discordwebhooks.go @@ -143,7 +143,7 @@ func (s *Handler) NewDiscordWebHook(c echo.Context) error { }) } - user, err := s.repo.Users.GetByName(token.UserName) + user, err := s.repo.Users.GetUser(c.Request().Context(), token.UserName) if err != nil { s.WriteMessage(c, ErrUserUnknown, http.StatusBadRequest) } diff --git a/internal/handler/v1/handler.go b/internal/handler/v1/handler.go index f39ad3d..2bc9d9e 100644 --- a/internal/handler/v1/handler.go +++ b/internal/handler/v1/handler.go @@ -3,7 +3,7 @@ package v1 import ( "context" "database/sql" - "errors" + "net/http" "github.com/golang-jwt/jwt/v5" echojwt "github.com/labstack/echo-jwt/v4" @@ -100,15 +100,6 @@ 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.Use(echojwt.WithConfig(jwtConfig)) - 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) - s.Router = router return s } @@ -134,18 +125,14 @@ func (s *Handler) WriteMessage(c echo.Context, msg string, HttpStatusCode int) e }) } -func (h *Handler) getJwtToken(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 +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, + }) } diff --git a/internal/handler/v1/jwt.go b/internal/handler/v1/jwt.go index b7f1222..92f77be 100644 --- a/internal/handler/v1/jwt.go +++ b/internal/handler/v1/jwt.go @@ -7,6 +7,7 @@ import ( "git.jamestombleson.com/jtom38/newsbot-api/internal/domain" "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" ) const ( @@ -97,3 +98,19 @@ func (h *Handler) generateJwtWithExp(username, issuer string, expiresAt time.Tim return tokenString, nil } + +func (h *Handler) getJwtToken(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/sources.go b/internal/handler/v1/sources.go index 496be4c..7379f2c 100644 --- a/internal/handler/v1/sources.go +++ b/internal/handler/v1/sources.go @@ -16,13 +16,14 @@ import ( ) // 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{ BaseResponse: domain.BaseResponse{ @@ -46,15 +47,16 @@ 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 { resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ @@ -85,14 +87,15 @@ 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 { resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ @@ -119,15 +122,16 @@ 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 { resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ @@ -155,14 +159,15 @@ 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 { resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ @@ -211,11 +216,12 @@ 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) @@ -275,10 +281,11 @@ func (s *Handler) newYoutubeSource(c echo.Context) error { } // 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 { var param domain.NewSourceParamRequest err := c.Bind(¶m) @@ -322,14 +329,15 @@ func (s *Handler) newTwitchSource(c echo.Context) error { } // 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 { resp := domain.SourcesResponse{ BaseResponse: domain.BaseResponse{ @@ -373,10 +381,11 @@ 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 string true "id" +// @Tags Source +// @Router /v1/sources/{id} [POST] +// @Security Bearer func (s *Handler) deleteSources(c echo.Context) error { id := c.Param("ID") uuid, err := uuid.Parse(id) @@ -418,13 +427,14 @@ func (s *Handler) deleteSources(c echo.Context) error { } // 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{ BaseResponse: domain.BaseResponse{ @@ -460,13 +470,14 @@ 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{ BaseResponse: domain.BaseResponse{ 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/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/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/respositoryServices/refreshTokens.go b/internal/respositoryServices/refreshTokens.go new file mode 100644 index 0000000..ddd6033 --- /dev/null +++ b/internal/respositoryServices/refreshTokens.go @@ -0,0 +1,87 @@ +package respositoryservices + +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/respositoryServices/userService.go b/internal/respositoryServices/userService.go new file mode 100644 index 0000000..e187ca0 --- /dev/null +++ b/internal/respositoryServices/userService.go @@ -0,0 +1,171 @@ +package respositoryservices + +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.ScopeRead) + 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 8a923a9..6b34302 100644 --- a/internal/services/config.go +++ b/internal/services/config.go @@ -35,6 +35,7 @@ const ( type Configs struct { ServerAddress string JwtSecret string + AdminSecret string RedditEnabled bool RedditPullTop bool @@ -65,7 +66,7 @@ func NewConfig() ConfigClient { func GetEnvConfig() Configs { return Configs{ ServerAddress: os.Getenv(ServerAddress), - JwtSecret: os.Getenv("JwtSecret"), + JwtSecret: os.Getenv("JwtSecret"), 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 d5db9cd..900c1a0 100644 --- a/internal/services/database.go +++ b/internal/services/database.go @@ -4,15 +4,16 @@ import ( "database/sql" "git.jamestombleson.com/jtom38/newsbot-api/internal/repository" + repositoryservice "git.jamestombleson.com/jtom38/newsbot-api/internal/respositoryServices" ) type RepositoryService struct { AlertDiscord repository.AlertDiscordRepo Articles repository.ArticlesRepo DiscordWebHooks repository.DiscordWebHookRepo - RefreshTokens repository.RefreshToken + RefreshTokens repositoryservice.RefreshToken Sources repository.Sources - Users repository.Users + Users repositoryservice.UserServices UserSourceSubscriptions repository.UserSourceRepo } @@ -21,9 +22,9 @@ func NewRepositoryService(conn *sql.DB) RepositoryService { AlertDiscord: repository.NewAlertDiscordRepository(conn), Articles: repository.NewArticleRepository(conn), DiscordWebHooks: repository.NewDiscordWebHookRepository(conn), - RefreshTokens: repository.NewRefreshTokenRepository(conn), + RefreshTokens: repositoryservice.NewRefreshTokenService(conn), Sources: repository.NewSourceRepository(conn), - Users: repository.NewUserRepository(conn), + Users: repositoryservice.NewUserService(conn), UserSourceSubscriptions: repository.NewUserSourceRepository(conn), } }