features/jwt #7

Merged
jtom38 merged 10 commits from features/jwt into main 2024-05-07 22:21:58 -07:00
26 changed files with 945 additions and 1723 deletions
Showing only changes of commit c765227932 - Show all commits

View File

@ -5,14 +5,10 @@ WORKDIR /app
# Always make sure that swagger docs are updated # Always make sure that swagger docs are updated
RUN go install github.com/swaggo/swag/cmd/swag@latest 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 build .
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest #RUN go install github.com/pressly/goose/v3/cmd/goose@latest
RUN /go/bin/sqlc generate
RUN go build .
RUN go install github.com/pressly/goose/v3/cmd/goose@latest
FROM alpine:latest as app FROM alpine:latest as app
@ -21,8 +17,7 @@ RUN apk --no-cache add libc6-compat
RUN apk --no-cache add chromium RUN apk --no-cache add chromium
RUN mkdir /app && mkdir /app/migrations RUN mkdir /app && mkdir /app/migrations
COPY --from=build /app/collector /app COPY --from=build /app/server /app
COPY --from=build /go/bin/goose /app COPY ./internal/database/migrations/ /app/migrations
COPY ./database/migrations/ /app/migrations
CMD [ "/app/collector" ] CMD [ "/app/collector" ]

View File

@ -3,7 +3,9 @@ package main
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"os"
_ "github.com/glebarez/go-sqlite" _ "github.com/glebarez/go-sqlite"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
@ -17,6 +19,10 @@ import (
// @title NewsBot collector // @title NewsBot collector
// @version 0.1 // @version 0.1
// @BasePath /api // @BasePath /api
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
func main() { func main() {
ctx := context.Background() ctx := context.Background()
@ -30,14 +36,9 @@ func main() {
panic(err) panic(err)
} }
err = goose.SetDialect("sqlite3") err = migrateDatabase(db)
if err != nil { if err != nil {
panic(err) fmt.Print(err)
}
err = goose.Up(db, "../internal/database/migrations")
if err != nil {
panic(err)
} }
c := cron.NewScheduler(ctx, db) c := cron.NewScheduler(ctx, db)
@ -51,3 +52,39 @@ func main() {
server.Router.Start(":8081") 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")
}

View File

@ -18,6 +18,11 @@ const docTemplate = `{
"paths": { "paths": {
"/v1/articles": { "/v1/articles": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -57,6 +62,11 @@ const docTemplate = `{
}, },
"/v1/articles/by/sourceid": { "/v1/articles/by/sourceid": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -103,6 +113,11 @@ const docTemplate = `{
}, },
"/v1/articles/{ID}": { "/v1/articles/{ID}": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -143,6 +158,11 @@ const docTemplate = `{
}, },
"/v1/articles/{ID}/details": { "/v1/articles/{ID}/details": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "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": { "/v1/sources": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -517,6 +502,11 @@ const docTemplate = `{
}, },
"/v1/sources/by/source": { "/v1/sources/by/source": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -563,6 +553,11 @@ const docTemplate = `{
}, },
"/v1/sources/by/sourceAndName": { "/v1/sources/by/sourceAndName": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -610,6 +605,11 @@ const docTemplate = `{
}, },
"/v1/sources/new/reddit": { "/v1/sources/new/reddit": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -654,6 +654,11 @@ const docTemplate = `{
}, },
"/v1/sources/new/rss": { "/v1/sources/new/rss": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -698,6 +703,11 @@ const docTemplate = `{
}, },
"/v1/sources/new/twitch": { "/v1/sources/new/twitch": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -716,6 +726,11 @@ const docTemplate = `{
}, },
"/v1/sources/new/youtube": { "/v1/sources/new/youtube": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -741,6 +756,11 @@ const docTemplate = `{
}, },
"/v1/sources/{id}": { "/v1/sources/{id}": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -779,6 +799,11 @@ const docTemplate = `{
} }
}, },
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -797,6 +822,11 @@ const docTemplate = `{
}, },
"/v1/sources/{id}/disable": { "/v1/sources/{id}/disable": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -834,6 +864,11 @@ const docTemplate = `{
}, },
"/v1/sources/{id}/enable": { "/v1/sources/{id}/enable": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "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": { "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": { "securityDefinitions": {
"type": "string" "Bearer": {
}, "description": "Type \"Bearer\" followed by a space and JWT token.",
"title": { "type": "apiKey",
"type": "string" "name": "Authorization",
}, "in": "header"
"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"
}
}
} }
} }
}` }`

View File

@ -9,6 +9,11 @@
"paths": { "paths": {
"/v1/articles": { "/v1/articles": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -48,6 +53,11 @@
}, },
"/v1/articles/by/sourceid": { "/v1/articles/by/sourceid": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -94,6 +104,11 @@
}, },
"/v1/articles/{ID}": { "/v1/articles/{ID}": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -134,6 +149,11 @@
}, },
"/v1/articles/{ID}/details": { "/v1/articles/{ID}/details": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "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": { "/v1/sources": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -508,6 +493,11 @@
}, },
"/v1/sources/by/source": { "/v1/sources/by/source": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -554,6 +544,11 @@
}, },
"/v1/sources/by/sourceAndName": { "/v1/sources/by/sourceAndName": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -601,6 +596,11 @@
}, },
"/v1/sources/new/reddit": { "/v1/sources/new/reddit": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -645,6 +645,11 @@
}, },
"/v1/sources/new/rss": { "/v1/sources/new/rss": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -689,6 +694,11 @@
}, },
"/v1/sources/new/twitch": { "/v1/sources/new/twitch": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -707,6 +717,11 @@
}, },
"/v1/sources/new/youtube": { "/v1/sources/new/youtube": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -732,6 +747,11 @@
}, },
"/v1/sources/{id}": { "/v1/sources/{id}": {
"get": { "get": {
"security": [
{
"Bearer": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -770,6 +790,11 @@
} }
}, },
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -788,6 +813,11 @@
}, },
"/v1/sources/{id}/disable": { "/v1/sources/{id}/disable": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "Source"
], ],
@ -825,6 +855,11 @@
}, },
"/v1/sources/{id}/enable": { "/v1/sources/{id}/enable": {
"post": { "post": {
"security": [
{
"Bearer": []
}
],
"tags": [ "tags": [
"Source" "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": { "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": { "securityDefinitions": {
"type": "string" "Bearer": {
}, "description": "Type \"Bearer\" followed by a space and JWT token.",
"title": { "type": "apiKey",
"type": "string" "name": "Authorization",
}, "in": "header"
"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"
}
}
} }
} }
} }

View File

@ -102,140 +102,6 @@ definitions:
$ref: '#/definitions/domain.SourceDto' $ref: '#/definitions/domain.SourceDto'
type: array type: array
type: object 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: info:
contact: {} contact: {}
title: NewsBot collector title: NewsBot collector
@ -263,6 +129,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Lists the top 25 records ordering from newest to oldest. summary: Lists the top 25 records ordering from newest to oldest.
tags: tags:
- Articles - Articles
@ -289,6 +157,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns an article based on defined ID. summary: Returns an article based on defined ID.
tags: tags:
- Articles - Articles
@ -315,6 +185,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns an article and source based on defined ID. summary: Returns an article and source based on defined ID.
tags: tags:
- Articles - Articles
@ -345,6 +217,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Finds the articles based on the SourceID provided. Returns the top summary: Finds the articles based on the SourceID provided. Returns the top
25. 25.
tags: tags:
@ -520,32 +394,6 @@ paths:
summary: Creates a new record for a discord web hook to post data to. summary: Creates a new record for a discord web hook to post data to.
tags: tags:
- DiscordWebhook - 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: /v1/sources:
get: get:
parameters: parameters:
@ -564,6 +412,8 @@ paths:
description: Unable to reach SQL or Data problems description: Unable to reach SQL or Data problems
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Lists the top 50 records summary: Lists the top 50 records
tags: tags:
- Source - Source
@ -590,6 +440,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns a single entity by ID summary: Returns a single entity by ID
tags: tags:
- Source - Source
@ -601,6 +453,8 @@ paths:
required: true required: true
type: string type: string
responses: {} responses: {}
security:
- Bearer: []
summary: Marks a source as deleted based on its ID value. summary: Marks a source as deleted based on its ID value.
tags: tags:
- Source - Source
@ -625,6 +479,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Disables a source from processing. summary: Disables a source from processing.
tags: tags:
- Source - Source
@ -649,6 +505,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Enables a source to continue processing. summary: Enables a source to continue processing.
tags: tags:
- Source - Source
@ -679,6 +537,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: 'Lists the top 50 records based on the name given. Example: reddit' summary: 'Lists the top 50 records based on the name given. Example: reddit'
tags: tags:
- Source - Source
@ -710,6 +570,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns a single entity by ID summary: Returns a single entity by ID
tags: tags:
- Source - Source
@ -739,6 +601,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Creates a new reddit source to monitor. summary: Creates a new reddit source to monitor.
tags: tags:
- Source - Source
@ -768,6 +632,8 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/domain.BaseResponse' $ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Creates a new rss source to monitor. summary: Creates a new rss source to monitor.
tags: tags:
- Source - Source
@ -780,6 +646,8 @@ paths:
required: true required: true
type: string type: string
responses: {} responses: {}
security:
- Bearer: []
summary: Creates a new twitch source to monitor. summary: Creates a new twitch source to monitor.
tags: tags:
- Source - Source
@ -797,112 +665,15 @@ paths:
required: true required: true
type: string type: string
responses: {} responses: {}
security:
- Bearer: []
summary: Creates a new youtube source to monitor. summary: Creates a new youtube source to monitor.
tags: tags:
- Source - Source
/v1/subscriptions: securityDefinitions:
get: Bearer:
produces: description: Type "Bearer" followed by a space and JWT token.
- application/json in: header
responses: name: Authorization
"200": type: apiKey
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
swagger: "2.0" swagger: "2.0"

View File

@ -18,23 +18,12 @@ CREATE TABLE Articles (
AuthorImageUrl TEXT NOT NULL 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 ( CREATE Table DiscordWebHooks (
ID INTEGER PRIMARY KEY AUTOINCREMENT, ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL, CreatedAt DATETIME NOT NULL,
UpdatedAt DATETIME NOT NULL, UpdatedAt DATETIME NOT NULL,
DeletedAt DATETIME NOT NULL, DeletedAt DATETIME NOT NULL,
UserID INTEGER NOT NULL, UserID INTEGER NOT NULL,
--Name TEXT NOT NULL, -- Defines webhook purpose
--Key TEXT,
Url TEXT NOT NULL, -- Webhook Url Url TEXT NOT NULL, -- Webhook Url
Server TEXT NOT NULL, -- Defines the server its bound it. Used for reference 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 Channel TEXT NOT NULL, -- Defines the channel its bound to. Used for reference
@ -72,18 +61,6 @@ CREATE Table Sources (
Tags TEXT NOT NULL 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 ( CREATE TABLE UserSourceSubscriptions (
ID INTEGER PRIMARY KEY AUTOINCREMENT, ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL, CreatedAt DATETIME NOT NULL,
@ -107,7 +84,7 @@ CREATE TABLE Users (
ID INTEGER PRIMARY KEY AUTOINCREMENT, ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL, CreatedAt DATETIME NOT NULL,
UpdatedAt DATETIME NOT NULL, UpdatedAt DATETIME NOT NULL,
DeletedAt DATETIME, DeletedAt DATETIME NOT NULL,
Name TEXT NOT NULL, Name TEXT NOT NULL,
Hash TEXT NOT NULL, Hash TEXT NOT NULL,
Scopes TEXT NOT NULL Scopes TEXT NOT NULL
@ -117,7 +94,7 @@ CREATE TABLE RefreshTokens (
ID INTEGER PRIMARY KEY AUTOINCREMENT, ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL, CreatedAt DATETIME NOT NULL,
UpdatedAt DATETIME NOT NULL, UpdatedAt DATETIME NOT NULL,
DeletedAt DATETIME, DeletedAt DATETIME NOT NULL,
Username TEXT NOT NULL, Username TEXT NOT NULL,
Token TEXT NOT NULL Token TEXT NOT NULL
); );
@ -127,13 +104,12 @@ CREATE TABLE RefreshTokens (
-- +goose Down -- +goose Down
-- +goose StatementBegin -- +goose StatementBegin
DROP TABLE AlertDiscord;
Drop Table Articles; Drop Table Articles;
Drop Table DiscordQueue;
Drop Table DiscordWebHooks; Drop Table DiscordWebHooks;
Drop Table Icons; Drop Table Icons;
Drop Table Settings;
Drop Table Sources;
DROP TABLE Subscriptions;
DROP TABLE Users;
DROP TABLE RefreshTokens; DROP TABLE RefreshTokens;
Drop Table Sources;
DROP TABLE Users;
DROP TABLE UserSourceSubscriptions;
-- +goose StatementEnd -- +goose StatementEnd

View File

@ -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)
}

View File

@ -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
}

View File

@ -11,3 +11,12 @@ type NewSourceParamRequest struct {
Tags string `query:"tags"` 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"`
}

View File

@ -5,6 +5,13 @@ type BaseResponse struct {
Message string `json:"message"` Message string `json:"message"`
} }
type LoginResponse struct {
BaseResponse
Token string `json:"token"`
Type string `json:"type"`
RefreshToken string `json:"refreshToken"`
}
type ArticleResponse struct { type ArticleResponse struct {
BaseResponse BaseResponse
Payload []ArticleDto `json:"payload"` Payload []ArticleDto `json:"payload"`

View File

@ -18,6 +18,7 @@ import (
// @Success 200 {object} domain.ArticleResponse // @Success 200 {object} domain.ArticleResponse
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) listArticles(c echo.Context) error { func (s *Handler) listArticles(c echo.Context) error {
resp := domain.ArticleResponse{ resp := domain.ArticleResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -48,6 +49,7 @@ func (s *Handler) listArticles(c echo.Context) error {
// @Success 200 {object} domain.ArticleResponse "OK" // @Success 200 {object} domain.ArticleResponse "OK"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) getArticle(c echo.Context) error { func (s *Handler) getArticle(c echo.Context) error {
p := domain.ArticleResponse{ p := domain.ArticleResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -82,6 +84,7 @@ func (s *Handler) getArticle(c echo.Context) error {
// @Success 200 {object} domain.ArticleDetailedResponse "OK" // @Success 200 {object} domain.ArticleDetailedResponse "OK"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) getArticleDetails(c echo.Context) error { func (s *Handler) getArticleDetails(c echo.Context) error {
p := domain.ArticleDetailedResponse{ p := domain.ArticleDetailedResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -123,6 +126,7 @@ func (s *Handler) getArticleDetails(c echo.Context) error {
// @Success 200 {object} domain.ArticleResponse "OK" // @Success 200 {object} domain.ArticleResponse "OK"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) ListArticlesBySourceId(c echo.Context) error { func (s *Handler) ListArticlesBySourceId(c echo.Context) error {
p := domain.ArticleResponse{ p := domain.ArticleResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{

207
internal/handler/v1/auth.go Normal file
View File

@ -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",
})
}

View File

@ -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 { if err != nil {
s.WriteMessage(c, ErrUserUnknown, http.StatusBadRequest) s.WriteMessage(c, ErrUserUnknown, http.StatusBadRequest)
} }

View File

@ -3,7 +3,7 @@ package v1
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors" "net/http"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4" 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/disable", s.disableSource)
sources.POST("/:ID/enable", s.enableSource) 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 s.Router = router
return s 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) { func (s *Handler) InternalServerErrorResponse(c echo.Context, msg string) error {
// Make sure that the request came with a jwtToken return c.JSON(http.StatusInternalServerError, domain.BaseResponse{
token, ok := c.Get("user").(*jwt.Token) Message: msg,
if !ok { })
return JwtToken{}, errors.New(ErrJwtMissing) }
}
func (s *Handler) UnauthorizedResponse(c echo.Context, msg string) error {
// Generate the claims from the token return c.JSON(http.StatusUnauthorized, domain.BaseResponse{
claims, ok := token.Claims.(*JwtToken) Message: msg,
if !ok { })
return JwtToken{}, errors.New(ErrJwtClaimsMissing)
}
return *claims, nil
} }

View File

@ -7,6 +7,7 @@ import (
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain" "git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
) )
const ( const (
@ -97,3 +98,19 @@ func (h *Handler) generateJwtWithExp(username, issuer string, expiresAt time.Tim
return tokenString, nil 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
}

View File

@ -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)
}

View File

@ -23,6 +23,7 @@ import (
// @Router /v1/sources [get] // @Router /v1/sources [get]
// @Success 200 {object} domain.SourcesResponse "ok" // @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse "Unable to reach SQL or Data problems" // @Failure 400 {object} domain.BaseResponse "Unable to reach SQL or Data problems"
// @Security Bearer
func (s *Handler) listSources(c echo.Context) error { func (s *Handler) listSources(c echo.Context) error {
resp := domain.SourcesResponse{ resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -55,6 +56,7 @@ func (s *Handler) listSources(c echo.Context) error {
// @Success 200 {object} domain.SourcesResponse "ok" // @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) listSourcesBySource(c echo.Context) error { func (s *Handler) listSourcesBySource(c echo.Context) error {
resp := domain.SourcesResponse{ resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -93,6 +95,7 @@ func (s *Handler) listSourcesBySource(c echo.Context) error {
// @Success 200 {object} domain.SourcesResponse "ok" // @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) getSource(c echo.Context) error { func (s *Handler) getSource(c echo.Context) error {
resp := domain.SourcesResponse{ resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -128,6 +131,7 @@ func (s *Handler) getSource(c echo.Context) error {
// @Success 200 {object} domain.SourcesResponse "ok" // @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) GetSourceBySourceAndName(c echo.Context) error { func (s *Handler) GetSourceBySourceAndName(c echo.Context) error {
resp := domain.SourcesResponse{ resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -163,6 +167,7 @@ func (s *Handler) GetSourceBySourceAndName(c echo.Context) error {
// @Success 200 {object} domain.SourcesResponse "ok" // @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) newRedditSource(c echo.Context) error { func (s *Handler) newRedditSource(c echo.Context) error {
resp := domain.SourcesResponse{ resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -216,6 +221,7 @@ func (s *Handler) newRedditSource(c echo.Context) error {
// @Param url query string true "url" // @Param url query string true "url"
// @Tags Source // @Tags Source
// @Router /v1/sources/new/youtube [post] // @Router /v1/sources/new/youtube [post]
// @Security Bearer
func (s *Handler) newYoutubeSource(c echo.Context) error { func (s *Handler) newYoutubeSource(c echo.Context) error {
var param domain.NewSourceParamRequest var param domain.NewSourceParamRequest
err := c.Bind(&param) err := c.Bind(&param)
@ -279,6 +285,7 @@ func (s *Handler) newYoutubeSource(c echo.Context) error {
// @Param name query string true "name" // @Param name query string true "name"
// @Tags Source // @Tags Source
// @Router /v1/sources/new/twitch [post] // @Router /v1/sources/new/twitch [post]
// @Security Bearer
func (s *Handler) newTwitchSource(c echo.Context) error { func (s *Handler) newTwitchSource(c echo.Context) error {
var param domain.NewSourceParamRequest var param domain.NewSourceParamRequest
err := c.Bind(&param) err := c.Bind(&param)
@ -330,6 +337,7 @@ func (s *Handler) newTwitchSource(c echo.Context) error {
// @Success 200 {object} domain.SourcesResponse "ok" // @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) newRssSource(c echo.Context) error { func (s *Handler) newRssSource(c echo.Context) error {
resp := domain.SourcesResponse{ resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -377,6 +385,7 @@ func (s *Handler) newRssSource(c echo.Context) error {
// @Param id path string true "id" // @Param id path string true "id"
// @Tags Source // @Tags Source
// @Router /v1/sources/{id} [POST] // @Router /v1/sources/{id} [POST]
// @Security Bearer
func (s *Handler) deleteSources(c echo.Context) error { func (s *Handler) deleteSources(c echo.Context) error {
id := c.Param("ID") id := c.Param("ID")
uuid, err := uuid.Parse(id) uuid, err := uuid.Parse(id)
@ -425,6 +434,7 @@ func (s *Handler) deleteSources(c echo.Context) error {
// @Success 200 {object} domain.SourcesResponse "ok" // @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) disableSource(c echo.Context) error { func (s *Handler) disableSource(c echo.Context) error {
resp := domain.SourcesResponse{ resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{
@ -467,6 +477,7 @@ func (s *Handler) disableSource(c echo.Context) error {
// @Success 200 {object} domain.SourcesResponse "ok" // @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse // @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse // @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) enableSource(c echo.Context) error { func (s *Handler) enableSource(c echo.Context) error {
resp := domain.SourcesResponse{ resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{ BaseResponse: domain.BaseResponse{

View File

@ -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(&params)
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)
}

View File

@ -1,6 +1,7 @@
package repository package repository
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@ -15,9 +16,9 @@ const (
) )
type RefreshToken interface { type RefreshToken interface {
Create(username string, token string) (int64, error) Create(ctx context.Context, username string, token string) (int64, error)
GetByUsername(name string) (domain.RefreshTokenEntity, error) GetByUsername(ctx context.Context, name string) (domain.RefreshTokenEntity, error)
DeleteById(id int64) (int64, error) DeleteById(ctx context.Context, id int64) (int64, error)
} }
type RefreshTokenRepository struct { 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() dt := time.Now()
builder := sqlbuilder.NewInsertBuilder() builder := sqlbuilder.NewInsertBuilder()
builder.InsertInto(refreshTokenTableName) builder.InsertInto(refreshTokenTableName)
builder.Cols("Username", "Token", "CreatedAt", "UpdatedAt") builder.Cols("Username", "Token", "CreatedAt", "UpdatedAt", "DeletedAt")
builder.Values(username, token, dt, dt) builder.Values(username, token, dt, dt, time.Time{})
query, args := builder.Build() query, args := builder.Build()
_, err := rt.connection.Exec(query, args...) _, err := rt.connection.ExecContext(ctx, query, args...)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@ -46,14 +47,14 @@ func (rt RefreshTokenRepository) Create(username string, token string) (int64, e
return 1, nil 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 := sqlbuilder.NewSelectBuilder()
builder.Select("*").From(refreshTokenTableName).Where( builder.Select("*").From(refreshTokenTableName).Where(
builder.E("Username", name), builder.E("Username", name),
) )
query, args := builder.Build() query, args := builder.Build()
rows, err := rt.connection.Query(query, args...) rows, err := rt.connection.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return domain.RefreshTokenEntity{}, err return domain.RefreshTokenEntity{}, err
} }
@ -66,7 +67,7 @@ func (rt RefreshTokenRepository) GetByUsername(name string) (domain.RefreshToken
return data[0], nil 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 := sqlbuilder.NewDeleteBuilder()
builder.DeleteFrom(refreshTokenTableName) builder.DeleteFrom(refreshTokenTableName)
builder.Where( builder.Where(
@ -74,7 +75,7 @@ func (rt RefreshTokenRepository) DeleteById(id int64) (int64, error) {
) )
query, args := builder.Build() query, args := builder.Build()
rows, err := rt.connection.Exec(query, args...) rows, err := rt.connection.ExecContext(ctx, query, args...)
if err != nil { if err != nil {
return -1, err return -1, err
} }

View File

@ -1,6 +1,7 @@
package repository_test package repository_test
import ( import (
"context"
"testing" "testing"
"git.jamestombleson.com/jtom38/newsbot-api/internal/repository" "git.jamestombleson.com/jtom38/newsbot-api/internal/repository"
@ -14,7 +15,7 @@ func TestRefreshTokenCreate(t *testing.T) {
} }
client := repository.NewRefreshTokenRepository(conn) client := repository.NewRefreshTokenRepository(conn)
rows, err := client.Create("tester", "BadTokenDontUse") rows, err := client.Create(context.Background(), "tester", "BadTokenDontUse")
if err != nil { if err != nil {
t.Log(err) t.Log(err)
t.FailNow() t.FailNow()
@ -33,7 +34,7 @@ func TestRefreshTokenGetByUsername(t *testing.T) {
} }
client := repository.NewRefreshTokenRepository(conn) client := repository.NewRefreshTokenRepository(conn)
rows, err := client.Create("tester", "BadTokenDoNotUse") rows, err := client.Create(context.Background(), "tester", "BadTokenDoNotUse")
if err != nil { if err != nil {
t.Log(err) t.Log(err)
t.FailNow() t.FailNow()
@ -44,7 +45,7 @@ func TestRefreshTokenGetByUsername(t *testing.T) {
t.FailNow() t.FailNow()
} }
model, err := client.GetByUsername("tester") model, err := client.GetByUsername(context.Background(), "tester")
if err != nil { if err != nil {
t.Log(err) t.Log(err)
t.FailNow() t.FailNow()
@ -64,7 +65,7 @@ func TestRefreshTokenDeleteById(t *testing.T) {
} }
client := repository.NewRefreshTokenRepository(conn) client := repository.NewRefreshTokenRepository(conn)
created, err := client.Create("tester", "BadTokenDoNotUse") created, err := client.Create(context.Background(), "tester", "BadTokenDoNotUse")
if err != nil { if err != nil {
t.Log(err) t.Log(err)
t.FailNow() t.FailNow()
@ -73,13 +74,13 @@ func TestRefreshTokenDeleteById(t *testing.T) {
t.Log("Unexpected number back for rows created") t.Log("Unexpected number back for rows created")
} }
model, err := client.GetByUsername("tester") model, err := client.GetByUsername(context.Background(), "tester")
if err != nil { if err != nil {
t.Log(err) t.Log(err)
t.FailNow() t.FailNow()
} }
updated, err := client.DeleteById(model.ID) updated, err := client.DeleteById(context.Background(), model.ID)
if err != nil { if err != nil {
t.Log(err) t.Log(err)
t.FailNow() t.FailNow()

View File

@ -1,6 +1,7 @@
package repository package repository
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@ -18,12 +19,12 @@ const (
) )
type Users interface { type Users interface {
GetByName(name string) (domain.UserEntity, error) GetByName(ctx context.Context, name string) (domain.UserEntity, error)
Create(name, password, scope string) (int64, error) Create(ctx context.Context, name, password, scope string) (int64, error)
Update(id int, entity domain.UserEntity) error Update(ctx context.Context, id int, entity domain.UserEntity) error
UpdatePassword(name, password string) error UpdatePassword(ctx context.Context, name, password string) error
CheckUserHash(name, password string) error CheckUserHash(ctx context.Context, name, password string) error
UpdateScopes(name, scope string) error UpdateScopes(ctx context.Context, name, scope string) error
} }
// Creates a new instance of UserRepository with the bound sql // Creates a new instance of UserRepository with the bound sql
@ -37,14 +38,14 @@ type userRepository struct {
connection *sql.DB 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 := sqlbuilder.NewSelectBuilder()
builder.Select("*").From("users").Where( builder.Select("*").From("users").Where(
builder.E("Name", name), builder.E("Name", name),
) )
query, args := builder.Build() query, args := builder.Build()
rows, err := ur.connection.Query(query, args...) rows, err := ur.connection.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return domain.UserEntity{}, err return domain.UserEntity{}, err
} }
@ -57,7 +58,7 @@ func (ur userRepository) GetByName(name string) (domain.UserEntity, error) {
return data[0], nil 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) passwordBytes := []byte(password)
hash, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
if err != nil { if err != nil {
@ -67,11 +68,11 @@ func (ur userRepository) Create(name, password, scope string) (int64, error) {
dt := time.Now() dt := time.Now()
queryBuilder := sqlbuilder.NewInsertBuilder() queryBuilder := sqlbuilder.NewInsertBuilder()
queryBuilder.InsertInto("users") queryBuilder.InsertInto("users")
queryBuilder.Cols("Name", "Hash", "UpdatedAt", "CreatedAt", "Scopes") queryBuilder.Cols("Name", "Hash", "UpdatedAt", "CreatedAt", "DeletedAt", "Scopes")
queryBuilder.Values(name, string(hash), dt, dt, scope) queryBuilder.Values(name, string(hash), dt, dt, time.Time{}, scope)
query, args := queryBuilder.Build() query, args := queryBuilder.Build()
_, err = ur.connection.Exec(query, args...) _, err = ur.connection.ExecContext(ctx, query, args...)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@ -79,12 +80,12 @@ func (ur userRepository) Create(name, password, scope string) (int64, error) {
return 1, nil 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") return errors.New("not implemented")
} }
func (ur userRepository) UpdatePassword(name, password string) error { func (ur userRepository) UpdatePassword(ctx context.Context, name, password string) error {
_, err := ur.GetByName(name) _, err := ur.GetByName(ctx, name)
if err != nil { if err != nil {
return 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 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 // 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 { func (ur userRepository) CheckUserHash(ctx context.Context,name, password string) error {
record, err := ur.GetByName(name) record, err := ur.GetByName(ctx, name)
if err != nil { if err != nil {
return err return err
} }
@ -111,7 +112,7 @@ func (ur userRepository) CheckUserHash(name, password string) error {
return nil 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 := sqlbuilder.NewUpdateBuilder()
builder.Update("users") builder.Update("users")
builder.Set( builder.Set(
@ -122,7 +123,7 @@ func (ur userRepository) UpdateScopes(name, scope string) error {
) )
query, args := builder.Build() query, args := builder.Build()
_, err := ur.connection.Exec(query, args...) _, err := ur.connection.ExecContext(ctx, query, args...)
if err != nil { if err != nil {
return err return err
} }

View File

@ -1,6 +1,7 @@
package repository_test package repository_test
import ( import (
"context"
"database/sql" "database/sql"
"log" "log"
"testing" "testing"
@ -20,7 +21,7 @@ func TestCanCreateNewUser(t *testing.T) {
defer db.Close() defer db.Close()
repo := repository.NewUserRepository(db) repo := repository.NewUserRepository(db)
updated, err := repo.Create("testing", "NotSecure", "placeholder") updated, err := repo.Create(context.Background(), "testing", "NotSecure", "placeholder")
if err != nil { if err != nil {
log.Println(err) log.Println(err)
t.FailNow() t.FailNow()
@ -37,7 +38,7 @@ func TestCanFindUserInTable(t *testing.T) {
defer db.Close() defer db.Close()
repo := repository.NewUserRepository(db) repo := repository.NewUserRepository(db)
updated, err := repo.Create("testing", "NotSecure", "placeholder") updated, err := repo.Create(context.Background(), "testing", "NotSecure", "placeholder")
if err != nil { if err != nil {
t.Log(err) t.Log(err)
t.FailNow() t.FailNow()
@ -48,7 +49,7 @@ func TestCanFindUserInTable(t *testing.T) {
t.FailNow() t.FailNow()
} }
user, err := repo.GetByName("testing") user, err := repo.GetByName(context.Background(), "testing")
if err != nil { if err != nil {
log.Println(err) log.Println(err)
t.FailNow() t.FailNow()
@ -65,7 +66,7 @@ func TestCheckUserHash(t *testing.T) {
defer db.Close() defer db.Close()
repo := repository.NewUserRepository(db) repo := repository.NewUserRepository(db)
repo.CheckUserHash("testing", "NotSecure") repo.CheckUserHash(context.Background(), "testing", "NotSecure")
} }
func setupInMemoryDb() (*sql.DB, error) { func setupInMemoryDb() (*sql.DB, error) {

View File

@ -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
}

View File

@ -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)
}

View File

@ -35,6 +35,7 @@ const (
type Configs struct { type Configs struct {
ServerAddress string ServerAddress string
JwtSecret string JwtSecret string
AdminSecret string
RedditEnabled bool RedditEnabled bool
RedditPullTop bool RedditPullTop bool

View File

@ -4,15 +4,16 @@ import (
"database/sql" "database/sql"
"git.jamestombleson.com/jtom38/newsbot-api/internal/repository" "git.jamestombleson.com/jtom38/newsbot-api/internal/repository"
repositoryservice "git.jamestombleson.com/jtom38/newsbot-api/internal/respositoryServices"
) )
type RepositoryService struct { type RepositoryService struct {
AlertDiscord repository.AlertDiscordRepo AlertDiscord repository.AlertDiscordRepo
Articles repository.ArticlesRepo Articles repository.ArticlesRepo
DiscordWebHooks repository.DiscordWebHookRepo DiscordWebHooks repository.DiscordWebHookRepo
RefreshTokens repository.RefreshToken RefreshTokens repositoryservice.RefreshToken
Sources repository.Sources Sources repository.Sources
Users repository.Users Users repositoryservice.UserServices
UserSourceSubscriptions repository.UserSourceRepo UserSourceSubscriptions repository.UserSourceRepo
} }
@ -21,9 +22,9 @@ func NewRepositoryService(conn *sql.DB) RepositoryService {
AlertDiscord: repository.NewAlertDiscordRepository(conn), AlertDiscord: repository.NewAlertDiscordRepository(conn),
Articles: repository.NewArticleRepository(conn), Articles: repository.NewArticleRepository(conn),
DiscordWebHooks: repository.NewDiscordWebHookRepository(conn), DiscordWebHooks: repository.NewDiscordWebHookRepository(conn),
RefreshTokens: repository.NewRefreshTokenRepository(conn), RefreshTokens: repositoryservice.NewRefreshTokenService(conn),
Sources: repository.NewSourceRepository(conn), Sources: repository.NewSourceRepository(conn),
Users: repository.NewUserRepository(conn), Users: repositoryservice.NewUserService(conn),
UserSourceSubscriptions: repository.NewUserSourceRepository(conn), UserSourceSubscriptions: repository.NewUserSourceRepository(conn),
} }
} }