Compare commits

...

11 Commits

39 changed files with 2774 additions and 1964 deletions

60
.drone.yaml Normal file
View File

@ -0,0 +1,60 @@
---
kind: pipeline
type: docker
name: buildLatestImage
steps:
- name: buildLatestImage
image: plugins/docker
settings:
repo: jtom38/newsbot-collector
username: jtom38
password:
from_secret: DockerPushPat
trigger:
branch:
include:
- main
event:
exclude:
- pull_request
---
kind: pipeline
type: docker
name: buildReleaseImage
steps:
- name: buildReleaseImage
image: plugins/docker
settings:
repo: jtom38/newsbot-collector
username: jtom38
password:
from_secret: DockerPushPat
trigger:
branch:
include:
- releases/*
ref:
include:
- refs/tags/**
event:
exclude:
- pull_request
---
kind: pipeline
type: docker
name: PullRequestCompileTest
steps:
- name: Compile project
image: golang:1.22
commands:
- go test ./internal/repository
- go build ./cmd/server.go
-
trigger:
event:
- pull_request

15
.vscode/launch.json vendored
View File

@ -1,15 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "."
}
]
}

View File

@ -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" ]

View File

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

View File

@ -18,6 +18,11 @@ const docTemplate = `{
"paths": {
"/v1/articles": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -57,6 +62,11 @@ const docTemplate = `{
},
"/v1/articles/by/sourceid": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -103,6 +113,11 @@ const docTemplate = `{
},
"/v1/articles/{ID}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -143,6 +158,11 @@ const docTemplate = `{
},
"/v1/articles/{ID}/details": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -183,6 +203,11 @@ const docTemplate = `{
},
"/v1/discord/webhooks": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -214,6 +239,11 @@ const docTemplate = `{
},
"/v1/discord/webhooks/by/serverAndChannel": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -261,6 +291,11 @@ const docTemplate = `{
},
"/v1/discord/webhooks/new": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"DiscordWebhook"
],
@ -349,6 +384,11 @@ const docTemplate = `{
},
"/v1/discord/webhooks/{ID}/disable": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"DiscordWebhook"
],
@ -386,6 +426,11 @@ const docTemplate = `{
},
"/v1/discord/webhooks/{ID}/enable": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"DiscordWebhook"
],
@ -404,6 +449,11 @@ const docTemplate = `{
},
"/v1/discord/webhooks/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -442,48 +492,13 @@ const docTemplate = `{
}
}
},
"/v1/queue/discord/webhooks": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Queue"
],
"summary": "Returns the top 100 entries from the queue to be processed.",
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListDiscordWebHooksQueueResults"
}
}
}
}
},
"/v1/settings/{key}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Settings"
],
"summary": "Returns a object based on the Key that was given.",
"parameters": [
{
"type": "string",
"description": "Settings Key value",
"name": "key",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/v1/sources": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -517,6 +532,11 @@ const docTemplate = `{
},
"/v1/sources/by/source": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -563,6 +583,11 @@ const docTemplate = `{
},
"/v1/sources/by/sourceAndName": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -610,6 +635,11 @@ const docTemplate = `{
},
"/v1/sources/new/reddit": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -654,6 +684,11 @@ const docTemplate = `{
},
"/v1/sources/new/rss": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -698,6 +733,11 @@ const docTemplate = `{
},
"/v1/sources/new/twitch": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -716,6 +756,11 @@ const docTemplate = `{
},
"/v1/sources/new/youtube": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -741,6 +786,11 @@ const docTemplate = `{
},
"/v1/sources/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -779,24 +829,53 @@ const docTemplate = `{
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
"summary": "Marks a source as deleted based on its ID value.",
"parameters": [
{
"type": "string",
"type": "integer",
"description": "id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/domain.SourcesResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/sources/{id}/disable": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -834,6 +913,11 @@ const docTemplate = `{
},
"/v1/sources/{id}/enable": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -869,165 +953,234 @@ const docTemplate = `{
}
}
},
"/v1/subscriptions": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Subscription"
],
"summary": "Returns the top 100 entries from the queue to be processed.",
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListSubscriptions"
}
},
"400": {
"description": "Unable to reach SQL.",
"schema": {
"$ref": "#/definitions/v1.ApiError"
}
},
"500": {
"description": "Failed to process data from SQL.",
"schema": {
"$ref": "#/definitions/v1.ApiError"
}
}
}
}
},
"/v1/subscriptions/by/SourceId": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Subscription"
],
"summary": "Returns the top 100 entries from the queue to be processed.",
"parameters": [
{
"type": "string",
"description": "id",
"name": "id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListSubscriptions"
}
}
}
}
},
"/v1/subscriptions/by/discordId": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Subscription"
],
"summary": "Returns the top 100 entries from the queue to be processed.",
"parameters": [
{
"type": "string",
"description": "id",
"name": "id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListSubscriptions"
}
},
"400": {
"description": "Unable to reach SQL or Data problems",
"schema": {
"$ref": "#/definitions/v1.ApiError"
}
},
"500": {
"description": "Data problems",
"schema": {
"$ref": "#/definitions/v1.ApiError"
}
}
}
}
},
"/v1/subscriptions/details": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Subscription"
],
"summary": "Returns the top 50 entries with full deatils on the source and output.",
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListSubscriptionDetails"
}
}
}
}
},
"/v1/subscriptions/discord/webhook/delete": {
"delete": {
"tags": [
"Subscription"
],
"summary": "Removes a Discord WebHook Subscription based on the Subscription ID.",
"parameters": [
{
"type": "string",
"description": "id",
"name": "id",
"in": "query",
"required": true
}
],
"responses": {}
}
},
"/v1/subscriptions/discord/webhook/new": {
"/v1/users/login": {
"post": {
"tags": [
"Subscription"
"produces": [
"application/json"
],
"summary": "Creates a new subscription to link a post from a Source to a DiscordWebHook.",
"tags": [
"Users"
],
"summary": "Logs into the API and returns a bearer token if successful",
"parameters": [
{
"type": "string",
"description": "discordWebHookId",
"name": "discordWebHookId",
"in": "query",
"required": true
"name": "password",
"in": "formData"
},
{
"type": "string",
"description": "sourceId",
"name": "sourceId",
"in": "query",
"required": true
"name": "username",
"in": "formData"
}
],
"responses": {}
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/refreshToken": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Users"
],
"summary": "Generates a new token",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.RefreshTokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/register": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Creates a new user",
"parameters": [
{
"type": "string",
"name": "password",
"in": "formData"
},
{
"type": "string",
"name": "username",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/scopes/add": {
"post": {
"security": [
{
"Bearer": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Adds a new scope to a user account",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateScopesRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/scopes/remove": {
"post": {
"security": [
{
"Bearer": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Adds a new scope to a user account",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateScopesRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
}
},
@ -1149,6 +1302,34 @@ const docTemplate = `{
}
}
},
"domain.LoginResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"refreshToken": {
"type": "string"
},
"token": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"domain.RefreshTokenRequest": {
"type": "object",
"properties": {
"refreshToken": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"domain.SourceDto": {
"type": "object",
"properties": {
@ -1186,211 +1367,30 @@ const docTemplate = `{
}
}
},
"models.ArticleDetailsDto": {
"domain.UpdateScopesRequest": {
"type": "object",
"required": [
"scopes"
],
"properties": {
"authorImage": {
"type": "string"
},
"authorName": {
"type": "string"
},
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"pubdate": {
"type": "string"
},
"source": {
"$ref": "#/definitions/models.SourceDto"
},
"tags": {
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"thumbnail": {
"type": "string"
},
"title": {
"type": "string"
},
"url": {
"type": "string"
},
"video": {
"type": "string"
},
"videoHeight": {
"type": "integer"
},
"videoWidth": {
"type": "integer"
}
}
},
"models.DiscordQueueDetailsDto": {
"type": "object",
"properties": {
"article": {
"$ref": "#/definitions/models.ArticleDetailsDto"
},
"id": {
"username": {
"type": "string"
}
}
},
"models.DiscordWebHooksDto": {
"type": "object",
"properties": {
"ID": {
"type": "string"
},
"channel": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"server": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"models.SourceDto": {
"type": "object",
"properties": {
"deleted": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"site": {
"type": "string"
},
"source": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"type": {
"type": "string"
},
"url": {
"type": "string"
},
"value": {
"type": "string"
}
}
},
"models.SubscriptionDetailsDto": {
"type": "object",
"properties": {
"discordwebhook": {
"$ref": "#/definitions/models.DiscordWebHooksDto"
},
"id": {
"type": "string"
},
"source": {
"$ref": "#/definitions/models.SourceDto"
}
}
},
"models.SubscriptionDto": {
"type": "object",
"properties": {
"discordwebhookid": {
"type": "string"
},
"id": {
"type": "string"
},
"sourceid": {
"type": "string"
}
}
},
"v1.ApiError": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"status": {
"type": "integer"
}
}
},
"v1.ListDiscordWebHooksQueueResults": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/definitions/models.DiscordQueueDetailsDto"
}
},
"status": {
"type": "integer"
}
}
},
"v1.ListSubscriptionDetails": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SubscriptionDetailsDto"
}
},
"status": {
"type": "integer"
}
}
},
"v1.ListSubscriptions": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SubscriptionDto"
}
},
"status": {
"type": "integer"
}
}
}
},
"securityDefinitions": {
"Bearer": {
"description": "Type \"Bearer\" followed by a space and JWT token.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}`

View File

@ -9,6 +9,11 @@
"paths": {
"/v1/articles": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -48,6 +53,11 @@
},
"/v1/articles/by/sourceid": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -94,6 +104,11 @@
},
"/v1/articles/{ID}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -134,6 +149,11 @@
},
"/v1/articles/{ID}/details": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -174,6 +194,11 @@
},
"/v1/discord/webhooks": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -205,6 +230,11 @@
},
"/v1/discord/webhooks/by/serverAndChannel": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -252,6 +282,11 @@
},
"/v1/discord/webhooks/new": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"DiscordWebhook"
],
@ -340,6 +375,11 @@
},
"/v1/discord/webhooks/{ID}/disable": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"DiscordWebhook"
],
@ -377,6 +417,11 @@
},
"/v1/discord/webhooks/{ID}/enable": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"DiscordWebhook"
],
@ -395,6 +440,11 @@
},
"/v1/discord/webhooks/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -433,48 +483,13 @@
}
}
},
"/v1/queue/discord/webhooks": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Queue"
],
"summary": "Returns the top 100 entries from the queue to be processed.",
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListDiscordWebHooksQueueResults"
}
}
}
}
},
"/v1/settings/{key}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Settings"
],
"summary": "Returns a object based on the Key that was given.",
"parameters": [
{
"type": "string",
"description": "Settings Key value",
"name": "key",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/v1/sources": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -508,6 +523,11 @@
},
"/v1/sources/by/source": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -554,6 +574,11 @@
},
"/v1/sources/by/sourceAndName": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -601,6 +626,11 @@
},
"/v1/sources/new/reddit": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -645,6 +675,11 @@
},
"/v1/sources/new/rss": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -689,6 +724,11 @@
},
"/v1/sources/new/twitch": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -707,6 +747,11 @@
},
"/v1/sources/new/youtube": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -732,6 +777,11 @@
},
"/v1/sources/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
@ -770,24 +820,53 @@
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
"summary": "Marks a source as deleted based on its ID value.",
"parameters": [
{
"type": "string",
"type": "integer",
"description": "id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/domain.SourcesResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/sources/{id}/disable": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -825,6 +904,11 @@
},
"/v1/sources/{id}/enable": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Source"
],
@ -860,165 +944,234 @@
}
}
},
"/v1/subscriptions": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Subscription"
],
"summary": "Returns the top 100 entries from the queue to be processed.",
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListSubscriptions"
}
},
"400": {
"description": "Unable to reach SQL.",
"schema": {
"$ref": "#/definitions/v1.ApiError"
}
},
"500": {
"description": "Failed to process data from SQL.",
"schema": {
"$ref": "#/definitions/v1.ApiError"
}
}
}
}
},
"/v1/subscriptions/by/SourceId": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Subscription"
],
"summary": "Returns the top 100 entries from the queue to be processed.",
"parameters": [
{
"type": "string",
"description": "id",
"name": "id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListSubscriptions"
}
}
}
}
},
"/v1/subscriptions/by/discordId": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Subscription"
],
"summary": "Returns the top 100 entries from the queue to be processed.",
"parameters": [
{
"type": "string",
"description": "id",
"name": "id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListSubscriptions"
}
},
"400": {
"description": "Unable to reach SQL or Data problems",
"schema": {
"$ref": "#/definitions/v1.ApiError"
}
},
"500": {
"description": "Data problems",
"schema": {
"$ref": "#/definitions/v1.ApiError"
}
}
}
}
},
"/v1/subscriptions/details": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Subscription"
],
"summary": "Returns the top 50 entries with full deatils on the source and output.",
"responses": {
"200": {
"description": "ok",
"schema": {
"$ref": "#/definitions/v1.ListSubscriptionDetails"
}
}
}
}
},
"/v1/subscriptions/discord/webhook/delete": {
"delete": {
"tags": [
"Subscription"
],
"summary": "Removes a Discord WebHook Subscription based on the Subscription ID.",
"parameters": [
{
"type": "string",
"description": "id",
"name": "id",
"in": "query",
"required": true
}
],
"responses": {}
}
},
"/v1/subscriptions/discord/webhook/new": {
"/v1/users/login": {
"post": {
"tags": [
"Subscription"
"produces": [
"application/json"
],
"summary": "Creates a new subscription to link a post from a Source to a DiscordWebHook.",
"tags": [
"Users"
],
"summary": "Logs into the API and returns a bearer token if successful",
"parameters": [
{
"type": "string",
"description": "discordWebHookId",
"name": "discordWebHookId",
"in": "query",
"required": true
"name": "password",
"in": "formData"
},
{
"type": "string",
"description": "sourceId",
"name": "sourceId",
"in": "query",
"required": true
"name": "username",
"in": "formData"
}
],
"responses": {}
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/refreshToken": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Users"
],
"summary": "Generates a new token",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.RefreshTokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/register": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Creates a new user",
"parameters": [
{
"type": "string",
"name": "password",
"in": "formData"
},
{
"type": "string",
"name": "username",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/scopes/add": {
"post": {
"security": [
{
"Bearer": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Adds a new scope to a user account",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateScopesRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/scopes/remove": {
"post": {
"security": [
{
"Bearer": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Adds a new scope to a user account",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateScopesRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
}
},
@ -1140,6 +1293,34 @@
}
}
},
"domain.LoginResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"refreshToken": {
"type": "string"
},
"token": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"domain.RefreshTokenRequest": {
"type": "object",
"properties": {
"refreshToken": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"domain.SourceDto": {
"type": "object",
"properties": {
@ -1177,211 +1358,30 @@
}
}
},
"models.ArticleDetailsDto": {
"domain.UpdateScopesRequest": {
"type": "object",
"required": [
"scopes"
],
"properties": {
"authorImage": {
"type": "string"
},
"authorName": {
"type": "string"
},
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"pubdate": {
"type": "string"
},
"source": {
"$ref": "#/definitions/models.SourceDto"
},
"tags": {
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"thumbnail": {
"username": {
"type": "string"
},
"title": {
"type": "string"
},
"url": {
"type": "string"
},
"video": {
"type": "string"
},
"videoHeight": {
"type": "integer"
},
"videoWidth": {
"type": "integer"
}
}
},
"models.DiscordQueueDetailsDto": {
"type": "object",
"properties": {
"article": {
"$ref": "#/definitions/models.ArticleDetailsDto"
},
"id": {
"type": "string"
}
}
},
"models.DiscordWebHooksDto": {
"type": "object",
"properties": {
"ID": {
"type": "string"
},
"channel": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"server": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"models.SourceDto": {
"type": "object",
"properties": {
"deleted": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"site": {
"type": "string"
},
"source": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"type": {
"type": "string"
},
"url": {
"type": "string"
},
"value": {
"type": "string"
}
}
},
"models.SubscriptionDetailsDto": {
"type": "object",
"properties": {
"discordwebhook": {
"$ref": "#/definitions/models.DiscordWebHooksDto"
},
"id": {
"type": "string"
},
"source": {
"$ref": "#/definitions/models.SourceDto"
}
}
},
"models.SubscriptionDto": {
"type": "object",
"properties": {
"discordwebhookid": {
"type": "string"
},
"id": {
"type": "string"
},
"sourceid": {
"type": "string"
}
}
},
"v1.ApiError": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"status": {
"type": "integer"
}
}
},
"v1.ListDiscordWebHooksQueueResults": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/definitions/models.DiscordQueueDetailsDto"
}
},
"status": {
"type": "integer"
}
}
},
"v1.ListSubscriptionDetails": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SubscriptionDetailsDto"
}
},
"status": {
"type": "integer"
}
}
},
"v1.ListSubscriptions": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SubscriptionDto"
}
},
"status": {
"type": "integer"
}
}
}
},
"securityDefinitions": {
"Bearer": {
"description": "Type \"Bearer\" followed by a space and JWT token.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}

View File

@ -78,6 +78,24 @@ definitions:
$ref: '#/definitions/domain.DiscordWebHookDto'
type: array
type: object
domain.LoginResponse:
properties:
message:
type: string
refreshToken:
type: string
token:
type: string
type:
type: string
type: object
domain.RefreshTokenRequest:
properties:
refreshToken:
type: string
username:
type: string
type: object
domain.SourceDto:
properties:
enabled:
@ -102,139 +120,16 @@ definitions:
$ref: '#/definitions/domain.SourceDto'
type: array
type: object
models.ArticleDetailsDto:
domain.UpdateScopesRequest:
properties:
authorImage:
type: string
authorName:
type: string
description:
type: string
id:
type: string
pubdate:
type: string
source:
$ref: '#/definitions/models.SourceDto'
tags:
scopes:
items:
type: string
type: array
thumbnail:
username:
type: string
title:
type: string
url:
type: string
video:
type: string
videoHeight:
type: integer
videoWidth:
type: integer
type: object
models.DiscordQueueDetailsDto:
properties:
article:
$ref: '#/definitions/models.ArticleDetailsDto'
id:
type: string
type: object
models.DiscordWebHooksDto:
properties:
ID:
type: string
channel:
type: string
enabled:
type: boolean
server:
type: string
url:
type: string
type: object
models.SourceDto:
properties:
deleted:
type: boolean
enabled:
type: boolean
id:
type: string
name:
type: string
site:
type: string
source:
type: string
tags:
items:
type: string
type: array
type:
type: string
url:
type: string
value:
type: string
type: object
models.SubscriptionDetailsDto:
properties:
discordwebhook:
$ref: '#/definitions/models.DiscordWebHooksDto'
id:
type: string
source:
$ref: '#/definitions/models.SourceDto'
type: object
models.SubscriptionDto:
properties:
discordwebhookid:
type: string
id:
type: string
sourceid:
type: string
type: object
v1.ApiError:
properties:
message:
type: string
status:
type: integer
type: object
v1.ListDiscordWebHooksQueueResults:
properties:
message:
type: string
payload:
items:
$ref: '#/definitions/models.DiscordQueueDetailsDto'
type: array
status:
type: integer
type: object
v1.ListSubscriptionDetails:
properties:
message:
type: string
payload:
items:
$ref: '#/definitions/models.SubscriptionDetailsDto'
type: array
status:
type: integer
type: object
v1.ListSubscriptions:
properties:
message:
type: string
payload:
items:
$ref: '#/definitions/models.SubscriptionDto'
type: array
status:
type: integer
required:
- scopes
type: object
info:
contact: {}
@ -263,6 +158,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Lists the top 25 records ordering from newest to oldest.
tags:
- Articles
@ -289,6 +186,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns an article based on defined ID.
tags:
- Articles
@ -315,6 +214,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns an article and source based on defined ID.
tags:
- Articles
@ -345,6 +246,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Finds the articles based on the SourceID provided. Returns the top
25.
tags:
@ -366,6 +269,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns the top 100
tags:
- DiscordWebhook
@ -414,6 +319,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Disables a Webhook from being used.
tags:
- DiscordWebhook
@ -426,6 +333,8 @@ paths:
required: true
type: integer
responses: {}
security:
- Bearer: []
summary: Enables a source to continue processing.
tags:
- DiscordWebhook
@ -452,6 +361,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns the top 100 entries from the queue to be processed.
tags:
- DiscordWebhook
@ -483,6 +394,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns all the known web hooks based on the Server and Channel given.
tags:
- DiscordWebhook
@ -517,35 +430,11 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Creates a new record for a discord web hook to post data to.
tags:
- DiscordWebhook
/v1/queue/discord/webhooks:
get:
produces:
- application/json
responses:
"200":
description: ok
schema:
$ref: '#/definitions/v1.ListDiscordWebHooksQueueResults'
summary: Returns the top 100 entries from the queue to be processed.
tags:
- Queue
/v1/settings/{key}:
get:
parameters:
- description: Settings Key value
in: path
name: key
required: true
type: string
produces:
- application/json
responses: {}
summary: Returns a object based on the Key that was given.
tags:
- Settings
/v1/sources:
get:
parameters:
@ -564,6 +453,8 @@ paths:
description: Unable to reach SQL or Data problems
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Lists the top 50 records
tags:
- Source
@ -590,6 +481,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns a single entity by ID
tags:
- Source
@ -599,8 +492,22 @@ paths:
in: path
name: id
required: true
type: string
responses: {}
type: integer
responses:
"200":
description: ok
schema:
$ref: '#/definitions/domain.SourcesResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Marks a source as deleted based on its ID value.
tags:
- Source
@ -625,6 +532,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Disables a source from processing.
tags:
- Source
@ -649,6 +558,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Enables a source to continue processing.
tags:
- Source
@ -679,6 +590,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: 'Lists the top 50 records based on the name given. Example: reddit'
tags:
- Source
@ -710,6 +623,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Returns a single entity by ID
tags:
- Source
@ -739,6 +654,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Creates a new reddit source to monitor.
tags:
- Source
@ -768,6 +685,8 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Creates a new rss source to monitor.
tags:
- Source
@ -780,6 +699,8 @@ paths:
required: true
type: string
responses: {}
security:
- Bearer: []
summary: Creates a new twitch source to monitor.
tags:
- Source
@ -797,112 +718,158 @@ paths:
required: true
type: string
responses: {}
security:
- Bearer: []
summary: Creates a new youtube source to monitor.
tags:
- Source
/v1/subscriptions:
get:
produces:
- application/json
responses:
"200":
description: ok
schema:
$ref: '#/definitions/v1.ListSubscriptions'
"400":
description: Unable to reach SQL.
schema:
$ref: '#/definitions/v1.ApiError'
"500":
description: Failed to process data from SQL.
schema:
$ref: '#/definitions/v1.ApiError'
summary: Returns the top 100 entries from the queue to be processed.
tags:
- Subscription
/v1/subscriptions/by/SourceId:
get:
parameters:
- description: id
in: query
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: ok
schema:
$ref: '#/definitions/v1.ListSubscriptions'
summary: Returns the top 100 entries from the queue to be processed.
tags:
- Subscription
/v1/subscriptions/by/discordId:
get:
parameters:
- description: id
in: query
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: ok
schema:
$ref: '#/definitions/v1.ListSubscriptions'
"400":
description: Unable to reach SQL or Data problems
schema:
$ref: '#/definitions/v1.ApiError'
"500":
description: Data problems
schema:
$ref: '#/definitions/v1.ApiError'
summary: Returns the top 100 entries from the queue to be processed.
tags:
- Subscription
/v1/subscriptions/details:
get:
produces:
- application/json
responses:
"200":
description: ok
schema:
$ref: '#/definitions/v1.ListSubscriptionDetails'
summary: Returns the top 50 entries with full deatils on the source and output.
tags:
- Subscription
/v1/subscriptions/discord/webhook/delete:
delete:
parameters:
- description: id
in: query
name: id
required: true
type: string
responses: {}
summary: Removes a Discord WebHook Subscription based on the Subscription ID.
tags:
- Subscription
/v1/subscriptions/discord/webhook/new:
/v1/users/login:
post:
parameters:
- description: discordWebHookId
in: query
name: discordWebHookId
required: true
- in: formData
name: password
type: string
- description: sourceId
in: query
name: sourceId
required: true
- in: formData
name: username
type: string
responses: {}
summary: Creates a new subscription to link a post from a Source to a DiscordWebHook.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.LoginResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
summary: Logs into the API and returns a bearer token if successful
tags:
- Subscription
- Users
/v1/users/refreshToken:
post:
parameters:
- description: body
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.RefreshTokenRequest'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.LoginResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Generates a new token
tags:
- Users
/v1/users/register:
post:
parameters:
- in: formData
name: password
type: string
- in: formData
name: username
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.BaseResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
summary: Creates a new user
tags:
- Users
/v1/users/scopes/add:
post:
consumes:
- application/json
parameters:
- description: body
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.UpdateScopesRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.BaseResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Adds a new scope to a user account
tags:
- Users
/v1/users/scopes/remove:
post:
consumes:
- application/json
parameters:
- description: body
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.UpdateScopesRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.BaseResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Adds a new scope to a user account
tags:
- Users
securityDefinitions:
Bearer:
description: Type "Bearer" followed by a space and JWT token.
in: header
name: Authorization
type: apiKey
swagger: "2.0"

2
go.mod
View File

@ -6,9 +6,11 @@ require (
github.com/PuerkitoBio/goquery v1.8.0
github.com/glebarez/go-sqlite v1.22.0
github.com/go-rod/rod v0.107.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/huandu/go-sqlbuilder v1.27.1
github.com/joho/godotenv v1.4.0
github.com/labstack/echo-jwt/v4 v4.2.0
github.com/labstack/echo/v4 v4.12.0
github.com/mmcdole/gofeed v1.1.3
github.com/nicklaw5/helix/v2 v2.4.0

4
go.sum
View File

@ -35,6 +35,8 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
@ -60,6 +62,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c=
github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=

View File

@ -18,23 +18,12 @@ CREATE TABLE Articles (
AuthorImageUrl TEXT NOT NULL
);
CREATE Table DiscordQueue (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL,
UpdatedAt DATETIME NOT NULL,
DeletedAt DATETIME,
ArticleId NUMBER NOT NULL,
SourceId NUMBER NOT NULL
);
CREATE Table DiscordWebHooks (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL,
UpdatedAt DATETIME NOT NULL,
DeletedAt DATETIME NOT NULL,
UserID INTEGER NOT NULL,
--Name TEXT NOT NULL, -- Defines webhook purpose
--Key TEXT,
Url TEXT NOT NULL, -- Webhook Url
Server TEXT NOT NULL, -- Defines the server its bound it. Used for reference
Channel TEXT NOT NULL, -- Defines the channel its bound to. Used for reference
@ -72,21 +61,30 @@ CREATE Table Sources (
Tags TEXT NOT NULL
);
CREATE TABLE Subscriptions (
CREATE TABLE UserSourceSubscriptions (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL,
UpdatedAt DATETIME NOT NULL,
DeletedAt DATETIME,
DiscordWebHookID NUMBER NOT NULL,
DeletedAt DATETIME NOT NULL,
UserID NUMBER NOT NULL,
SourceID NUMBER NOT NULL
);
CREATE TABLE AlertDiscord (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL,
UpdatedAt DATETIME NOT NULL,
DeletedAt DATETIME NOT NULL,
UserID NUMBER NOT NULL,
SourceID NUMBER NOT NULL,
UserID NUMBER NOT NULL
DiscordWebHookID NUMBER NOT NULL
);
CREATE TABLE Users (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL,
UpdatedAt DATETIME NOT NULL,
DeletedAt DATETIME,
DeletedAt DATETIME NOT NULL,
Name TEXT NOT NULL,
Hash TEXT NOT NULL,
Scopes TEXT NOT NULL
@ -96,7 +94,7 @@ CREATE TABLE RefreshTokens (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
CreatedAt DATETIME NOT NULL,
UpdatedAt DATETIME NOT NULL,
DeletedAt DATETIME,
DeletedAt DATETIME NOT NULL,
Username TEXT NOT NULL,
Token TEXT NOT NULL
);
@ -106,13 +104,12 @@ CREATE TABLE RefreshTokens (
-- +goose Down
-- +goose StatementBegin
DROP TABLE AlertDiscord;
Drop Table Articles;
Drop Table DiscordQueue;
Drop Table DiscordWebHooks;
Drop Table Icons;
Drop Table Settings;
Drop Table Sources;
DROP TABLE Subscriptions;
DROP TABLE Users;
DROP TABLE RefreshTokens;
Drop Table Sources;
DROP TABLE Users;
DROP TABLE UserSourceSubscriptions;
-- +goose StatementEnd

View File

@ -4,6 +4,18 @@ import (
"time"
)
// This links a source to a discord webhook.
// It is owned by a user so they can remove the link
type AlertDiscordEntity struct {
ID int64
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
UserID int64
SourceID int64
DiscordWebHookId int64
}
type ArticleEntity struct {
ID int64
CreatedAt time.Time
@ -35,10 +47,11 @@ type DiscordWebHookEntity struct {
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
Url string
Server string
Channel string
Enabled bool
UserID int64
Url string
Server string
Channel string
Enabled bool
}
type IconEntity struct {
@ -61,38 +74,50 @@ type SettingEntity struct {
}
type SourceEntity struct {
ID int64
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
ID int64
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
// Who will collect from it. Used
// Who will collect from it. Used
// domain.SourceCollector...
Source string
Source string
// Human Readable value to state what is getting collected
DisplayName string
// Tells the parser where to look for data
Url string
Url string
// Static tags for this defined record
Tags string
Tags string
// If the record is disabled, then it will be skipped on processing
Enabled bool
Enabled bool
}
type SubscriptionEntity struct {
ID int64
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
SourceID int64
SourceType string
SourceName string
DiscordID int64
DiscordName string
//type SubscriptionEntity struct {
// ID int64
// CreatedAt time.Time
// UpdatedAt time.Time
// DeletedAt time.Time
// UserID int64
// SourceID int64
// //SourceType string
// //SourceName string
// DiscordID int64
// //DiscordName string
//}
// This defines what sources a user wants to follow.
// These will show up for the user as a front page
type UserSourceSubscriptionEntity struct {
ID int64
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
UserID int64
SourceID int64
}
type UserEntity struct {

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

@ -1,5 +1,10 @@
package domain
type LoginFormRequest struct {
Username string `form:"username"`
Password string `form:"password"`
}
type GetSourceBySourceAndNameParamRequest struct {
Name string `query:"name"`
Source string `query:"source"`
@ -11,3 +16,12 @@ type NewSourceParamRequest struct {
Tags string `query:"tags"`
}
type RefreshTokenRequest struct {
Username string `json:"username"`
RefreshToken string `json:"refreshToken"`
}
type UpdateScopesRequest struct {
Username string `json:"username"`
Scopes []string `json:"scopes" validate:"required"`
}

View File

@ -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"`

14
internal/domain/scopes.go Normal file
View File

@ -0,0 +1,14 @@
package domain
const (
ScopeAll = "newsbot:all"
ScopeArticleRead = "newsbot:article:read"
ScopeArticleDisable = "newsbot:article:disable"
ScopeSourceRead = "newsbot:source:read"
ScopeSourceCreate = "newsbot:source:create"
ScopeDiscordWebHookCreate = "newsbot:discordwebhook:create"
ScopeDiscordWebhookRead = "newsbot:discordwebhook:read"
)

View File

@ -10,15 +10,21 @@ import (
)
// ListArticles
// @Summary Lists the top 25 records ordering from newest to oldest.
// @Produce application/json
// @Param page query string false "page number"
// @Tags Articles
// @Router /v1/articles [get]
// @Success 200 {object} domain.ArticleResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Lists the top 25 records ordering from newest to oldest.
// @Produce application/json
// @Param page query string false "page number"
// @Tags Articles
// @Router /v1/articles [get]
// @Success 200 {object} domain.ArticleResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) listArticles(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeArticleRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
resp := domain.ArticleResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -32,7 +38,7 @@ func (s *Handler) listArticles(c echo.Context) error {
res, err := s.repo.Articles.ListByPage(c.Request().Context(), page, 25)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
resp.Payload = services.ArticlesToDto(res)
@ -40,15 +46,21 @@ func (s *Handler) listArticles(c echo.Context) error {
}
// GetArticle
// @Summary Returns an article based on defined ID.
// @Param ID path string true "int"
// @Produce application/json
// @Tags Articles
// @Router /v1/articles/{ID} [get]
// @Success 200 {object} domain.ArticleResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Returns an article based on defined ID.
// @Param ID path string true "int"
// @Produce application/json
// @Tags Articles
// @Router /v1/articles/{ID} [get]
// @Success 200 {object} domain.ArticleResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) getArticle(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeArticleRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
p := domain.ArticleResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -58,7 +70,7 @@ func (s *Handler) getArticle(c echo.Context) error {
id := c.Param("ID")
idNumber, err := strconv.Atoi(id)
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
item, err := s.repo.Articles.GetById(c.Request().Context(), int64(idNumber))
@ -74,37 +86,41 @@ func (s *Handler) getArticle(c echo.Context) error {
}
// GetArticleDetails
// @Summary Returns an article and source based on defined ID.
// @Param ID path string true "int"
// @Produce application/json
// @Tags Articles
// @Router /v1/articles/{ID}/details [get]
// @Success 200 {object} domain.ArticleDetailedResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Returns an article and source based on defined ID.
// @Param ID path string true "int"
// @Produce application/json
// @Tags Articles
// @Router /v1/articles/{ID}/details [get]
// @Success 200 {object} domain.ArticleDetailedResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) getArticleDetails(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeArticleRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
p := domain.ArticleDetailedResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
},
Payload: domain.ArticleAndSourceModel{
},
Payload: domain.ArticleAndSourceModel{},
}
id, err := strconv.Atoi(c.Param("ID"))
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
article, err := s.repo.Articles.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
source, err := s.repo.Sources.GetById(c.Request().Context(), article.SourceID)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
p.Payload.Article = services.ArticleToDto(article)
@ -114,16 +130,22 @@ func (s *Handler) getArticleDetails(c echo.Context) error {
}
// ListArticlesBySourceID
// @Summary Finds the articles based on the SourceID provided. Returns the top 25.
// @Param id query string true "source id"
// @Param page query int false "Page to query"
// @Produce application/json
// @Tags Articles
// @Router /v1/articles/by/sourceid [get]
// @Success 200 {object} domain.ArticleResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Finds the articles based on the SourceID provided. Returns the top 25.
// @Param id query string true "source id"
// @Param page query int false "Page to query"
// @Produce application/json
// @Tags Articles
// @Router /v1/articles/by/sourceid [get]
// @Success 200 {object} domain.ArticleResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) ListArticlesBySourceId(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeArticleRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
p := domain.ArticleResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -132,7 +154,7 @@ func (s *Handler) ListArticlesBySourceId(c echo.Context) error {
id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
// if the page number is missing, default to 0

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

@ -0,0 +1,264 @@
package v1
import (
"net/http"
"strings"
"time"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"git.jamestombleson.com/jtom38/newsbot-api/internal/repository"
"github.com/labstack/echo/v4"
)
const (
ErrUserNotFound = "requested user does not exist"
ErrUsernameAlreadyExists = "the requested username already exists"
)
// @Summary Creates a new user
// @Router /v1/users/register [post]
// @Param request formData domain.LoginFormRequest true "form"
// @Accepts x-www-form-urlencoded
// @Produce json
// @Tags Users
// @Success 200 {object} domain.BaseResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
func (h *Handler) AuthRegister(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")
//username := c.QueryParam("username")
exists, err := h.repo.Users.GetUser(c.Request().Context(), username)
if err != nil {
// if we have an err, validate that if its not user not found.
// if the user is not found, we can use that name
if err.Error() != repository.ErrUserNotFound {
return h.WriteError(c, err, http.StatusBadRequest)
}
}
if exists.Username == username {
return h.InternalServerErrorResponse(c, ErrUsernameAlreadyExists)
}
//password := c.QueryParam("password")
err = h.repo.Users.CheckPasswordForRequirements(password)
if err != nil {
return h.WriteError(c, err, http.StatusInternalServerError)
}
_, err = h.repo.Users.Create(c.Request().Context(), username, password, domain.ScopeArticleRead)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
return c.JSON(http.StatusCreated, domain.BaseResponse{
Message: "OK",
})
}
// @Summary Logs into the API and returns a bearer token if successful
// @Router /v1/users/login [post]
// @Param request formData domain.LoginFormRequest true "form"
// @Accepts x-www-form-urlencoded
// @Produce json
// @Tags Users
// @Success 200 {object} domain.LoginResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
func (h *Handler) AuthLogin(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")
// Check to see if they are trying to login with the admin token
if username == "" {
return h.createAdminToken(c, password)
}
// check if the user exists
user, err := h.repo.Users.GetUser(c.Request().Context(), username)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
// make sure the hash matches
err = h.repo.Users.DoesPasswordMatchHash(c.Request().Context(), username, password)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
// TODO think about moving this down some?
expiresAt := time.Now().Add(time.Hour * 48)
userScopes := strings.Split(user.Scopes, ",")
jwt, err := h.generateJwtWithExp(username, h.config.ServerAddress, userScopes, user.ID, expiresAt)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
refresh, err := h.repo.RefreshTokens.Create(c.Request().Context(), username)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
return c.JSON(http.StatusOK, domain.LoginResponse{
BaseResponse: domain.BaseResponse{
Message: "OK",
},
Token: jwt,
Type: "Bearer",
RefreshToken: refresh,
})
}
func (h *Handler) createAdminToken(c echo.Context, password string) error {
// if the admin token is blank, then the admin wanted this disabled.
// this will fail right away and not progress.
if h.config.AdminSecret == "" {
return h.InternalServerErrorResponse(c, ErrUserNotFound)
}
if h.config.AdminSecret != password {
return h.UnauthorizedResponse(c, ErrUserNotFound)
}
var userScopes []string
userScopes = append(userScopes, domain.ScopeAll)
token, err := h.generateJwt("admin", h.config.ServerAddress, userScopes, -1)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
return c.JSON(http.StatusOK, domain.LoginResponse{
BaseResponse: domain.BaseResponse{
Message: "OK",
},
Token: token,
Type: "Bearer",
})
}
// This will take collect some information about the requested refresh, validate and then return a new jwt token if approved.
// Register
// @Summary Generates a new token
// @Router /v1/users/refreshToken [post]
// @Param request body domain.RefreshTokenRequest true "body"
// @Tags Users
// @Success 200 {object} domain.LoginResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (h *Handler) RefreshJwtToken(c echo.Context) error {
_, err := h.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate)
if err != nil {
return h.WriteError(c, err, http.StatusBadRequest)
}
// Check the context for the refresh token
var request domain.RefreshTokenRequest
err = (&echo.DefaultBinder{}).BindBody(c, &request)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
err = h.repo.RefreshTokens.IsRequestValid(c.Request().Context(), request.Username, request.RefreshToken)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
user, err := h.repo.Users.GetUser(c.Request().Context(), request.Username)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
userScopes := strings.Split(user.Scopes, ",")
jwt, err := h.generateJwtWithExp(request.Username, h.config.ServerAddress, userScopes, user.ID, time.Now().Add(time.Hour*48))
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
newRefreshToken, err := h.repo.RefreshTokens.Create(c.Request().Context(), request.Username)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
return c.JSON(http.StatusOK, domain.LoginResponse{
BaseResponse: domain.BaseResponse{
Message: "OK",
},
Token: jwt,
Type: "Bearer",
RefreshToken: newRefreshToken,
})
}
// @Summary Adds a new scope to a user account
// @Router /v1/users/scopes/add [post]
// @Param request body domain.UpdateScopesRequest true "body"
// @Tags Users
// @Accept json
// @Produce json
// @Success 200 {object} domain.BaseResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (h *Handler) AddScopes(c echo.Context) error {
_, err := h.ValidateJwtToken(c, domain.ScopeAll)
if err != nil {
return h.WriteError(c, err, http.StatusBadRequest)
}
request := domain.UpdateScopesRequest{}
err = (&echo.DefaultBinder{}).BindBody(c, &request)
if err != nil {
return h.WriteError(c, err, http.StatusBadRequest)
}
err = h.repo.Users.AddScopes(c.Request().Context(), request.Username, request.Scopes)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
return c.JSON(http.StatusOK, domain.BaseResponse{
Message: "OK",
})
}
// @Summary Adds a new scope to a user account
// @Router /v1/users/scopes/remove [post]
// @Param request body domain.UpdateScopesRequest true "body"
// @Tags Users
// @Accept json
// @Produce json
// @Success 200 {object} domain.BaseResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (h *Handler) RemoveScopes(c echo.Context) error {
token, err := h.getJwtTokenFromContext(c)
if err != nil {
return h.WriteError(c, err, http.StatusUnauthorized)
}
err = token.IsValid(domain.ScopeAll)
if err != nil {
return h.WriteError(c, err, http.StatusUnauthorized)
}
request := domain.UpdateScopesRequest{}
err = (&echo.DefaultBinder{}).BindBody(c, &request)
if err != nil {
h.WriteError(c, err, http.StatusBadRequest)
}
err = h.repo.Users.RemoveScopes(c.Request().Context(), request.Username, request.Scopes)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
return c.JSON(http.StatusOK, domain.BaseResponse{
Message: "OK",
})
}

View File

@ -11,14 +11,20 @@ import (
)
// ListDiscordWebhooks
// @Summary Returns the top 100
// @Produce application/json
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks [get]
// @Success 200 {object} domain.DiscordWebhookResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Returns the top 100
// @Produce application/json
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks [get]
// @Success 200 {object} domain.DiscordWebhookResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) ListDiscordWebHooks(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebhookRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
p := domain.DiscordWebhookResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -34,15 +40,21 @@ func (s *Handler) ListDiscordWebHooks(c echo.Context) error {
}
// GetDiscordWebHook
// @Summary Returns the top 100 entries from the queue to be processed.
// @Produce application/json
// @Param id path int true "id"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/{id} [get]
// @Success 200 {object} domain.DiscordWebhookResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Returns the top 100 entries from the queue to be processed.
// @Produce application/json
// @Param id path int true "id"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/{id} [get]
// @Success 200 {object} domain.DiscordWebhookResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) GetDiscordWebHooksById(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebhookRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
p := domain.DiscordWebhookResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -51,12 +63,12 @@ func (s *Handler) GetDiscordWebHooksById(c echo.Context) error {
id, err := strconv.Atoi(c.Param("ID"))
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
res, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dtos []domain.DiscordWebHookDto
dtos = append(dtos, services.DiscordWebhookToDto(res))
@ -65,16 +77,22 @@ func (s *Handler) GetDiscordWebHooksById(c echo.Context) error {
}
// GetDiscordWebHookByServerAndChannel
// @Summary Returns all the known web hooks based on the Server and Channel given.
// @Produce application/json
// @Param server query string true "Fancy Server"
// @Param channel query string true "memes"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/by/serverAndChannel [get]
// @Success 200 {object} domain.DiscordWebhookResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Returns all the known web hooks based on the Server and Channel given.
// @Produce application/json
// @Param server query string true "Fancy Server"
// @Param channel query string true "memes"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/by/serverAndChannel [get]
// @Success 200 {object} domain.DiscordWebhookResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) GetDiscordWebHooksByServerAndChannel(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebhookRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
p := domain.DiscordWebhookResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -83,17 +101,17 @@ func (s *Handler) GetDiscordWebHooksByServerAndChannel(c echo.Context) error {
_server := c.QueryParam("server")
if _server == "" {
s.WriteMessage(c, "server was not defined", http.StatusBadRequest)
return s.WriteMessage(c, "server was not defined", http.StatusBadRequest)
}
_channel := c.QueryParam("channel")
if _channel == "" {
s.WriteMessage(c, "channel was not defined", http.StatusBadRequest)
return s.WriteMessage(c, "channel was not defined", http.StatusBadRequest)
}
res, err := s.repo.DiscordWebHooks.ListByServerAndChannel(c.Request().Context(), _server, _channel)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
p.Payload = services.DiscordWebhooksToDto(res)
@ -101,16 +119,22 @@ func (s *Handler) GetDiscordWebHooksByServerAndChannel(c echo.Context) error {
}
// NewDiscordWebHook
// @Summary Creates a new record for a discord web hook to post data to.
// @Param url query string true "url"
// @Param server query string true "Server name"
// @Param channel query string true "Channel name"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/new [post]
// @Success 200 {object} domain.DiscordWebhookResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Creates a new record for a discord web hook to post data to.
// @Param url query string true "url"
// @Param server query string true "Server name"
// @Param channel query string true "Channel name"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/new [post]
// @Success 200 {object} domain.DiscordWebhookResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) NewDiscordWebHook(c echo.Context) error {
token, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
_url := c.QueryParam("url")
_server := c.QueryParam("server")
_channel := c.QueryParam("channel")
@ -136,18 +160,23 @@ func (s *Handler) NewDiscordWebHook(c echo.Context) error {
})
}
rows, err := s.repo.DiscordWebHooks.Create(c.Request().Context(), _url, _server, _channel, true)
user, err := s.repo.Users.GetUser(c.Request().Context(), token.UserName)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteMessage(c, ErrUserUnknown, http.StatusBadRequest)
}
rows, err := s.repo.DiscordWebHooks.Create(c.Request().Context(), user.ID, _url, _server, _channel, true)
if err != nil {
return s.WriteError(c, err, http.StatusInternalServerError)
}
if rows != 1 {
s.WriteMessage(c, "data was not written to database", http.StatusInternalServerError)
return s.WriteMessage(c, "data was not written to database", http.StatusInternalServerError)
}
item, err := s.repo.DiscordWebHooks.GetByUrl(c.Request().Context(), _url)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dtos []domain.DiscordWebHookDto
@ -162,14 +191,20 @@ func (s *Handler) NewDiscordWebHook(c echo.Context) error {
}
// DisableDiscordWebHooks
// @Summary Disables a Webhook from being used.
// @Param id path int true "id"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/{ID}/disable [post]
// @Success 200 {object} domain.DiscordWebhookResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Disables a Webhook from being used.
// @Param id path int true "id"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/{ID}/disable [post]
// @Success 200 {object} domain.DiscordWebhookResponse "OK"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) disableDiscordWebHook(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
id, err := strconv.Atoi(c.Param("ID"))
if err != nil {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
@ -178,25 +213,29 @@ func (s *Handler) disableDiscordWebHook(c echo.Context) error {
}
// Check to make sure we can find the record
_, err = s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
record, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
if record.UserID != s.GetUserIdFromJwtToken(c) {
return s.WriteMessage(c, ErrYouDontOwnTheRecord, http.StatusBadRequest)
}
// flip the it
updated, err := s.repo.DiscordWebHooks.Disable(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
// make sure we got a row updated
if updated != 1 {
s.WriteMessage(c, "unexpected number of updates found", http.StatusInternalServerError)
return s.WriteMessage(c, "unexpected number of updates found", http.StatusInternalServerError)
}
item, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dtos []domain.DiscordWebHookDto
@ -210,34 +249,44 @@ func (s *Handler) disableDiscordWebHook(c echo.Context) error {
}
// EnableDiscordWebHook
// @Summary Enables a source to continue processing.
// @Param id path int true "id"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/{ID}/enable [post]
// @Summary Enables a source to continue processing.
// @Param id path int true "id"
// @Tags DiscordWebhook
// @Router /v1/discord/webhooks/{ID}/enable [post]
// @Security Bearer
func (s *Handler) enableDiscordWebHook(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
id, err := strconv.Atoi(c.Param("ID"))
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
// Check to make sure we can find the record
_, err = s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
record, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
if record.UserID != s.GetUserIdFromJwtToken(c) {
return s.WriteMessage(c, ErrYouDontOwnTheRecord, http.StatusBadRequest)
}
updated, err := s.repo.DiscordWebHooks.Enable(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
if updated != 1 {
s.WriteMessage(c, "unexpected number of updates found", http.StatusInternalServerError)
return s.WriteMessage(c, ErrFailedToUpdateRecord, http.StatusInternalServerError)
}
item, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dtos []domain.DiscordWebHookDto
@ -259,17 +308,26 @@ func (s *Handler) enableDiscordWebHook(c echo.Context) error {
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
func (s *Handler) deleteDiscordWebHook(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeDiscordWebHookCreate)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
id, err := strconv.Atoi(c.Param("ID"))
if err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
// Check to make sure we can find the record
_, err = s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
record, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
if record.UserID != s.GetUserIdFromJwtToken(c) {
return s.WriteMessage(c, ErrYouDontOwnTheRecord, http.StatusBadRequest)
}
// Soft delete the record
updated, err := s.repo.DiscordWebHooks.SoftDelete(c.Request().Context(), int64(id))
if err != nil {
@ -277,12 +335,12 @@ func (s *Handler) deleteDiscordWebHook(c echo.Context) error {
}
if updated != 1 {
s.WriteMessage(c, "unexpected number of updates found", http.StatusInternalServerError)
return s.WriteMessage(c, ErrFailedToUpdateRecord, http.StatusInternalServerError)
}
item, err := s.repo.DiscordWebHooks.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dtos []domain.DiscordWebHookDto

View File

@ -3,31 +3,38 @@ package v1
import (
"context"
"database/sql"
"errors"
"net/http"
"github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
swagger "github.com/swaggo/echo-swagger"
_ "git.jamestombleson.com/jtom38/newsbot-api/docs"
"git.jamestombleson.com/jtom38/newsbot-api/internal/database"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"git.jamestombleson.com/jtom38/newsbot-api/internal/services"
)
type Handler struct {
Router *echo.Echo
Db *database.Queries
//dto *dto.DtoClient
//Db *database.Queries
config services.Configs
repo services.RepositoryService
}
const (
ErrParameterIdMissing = "The requested parameter ID was not found."
ErrParameterMissing = "The requested parameter was not found found:"
ErrUnableToParseId = "Unable to parse the requested ID"
ErrParameterIdMissing = "The requested parameter ID was not found."
ErrParameterMissing = "The requested parameter was not found found:"
ErrUnableToParseId = "Unable to parse the requested ID"
ErrRecordMissing = "The requested record was not found"
ErrFailedToCreateRecord = "The record was not created due to a database problem"
ErrFailedToUpdateRecord = "The requested record was not updated due to a database problem"
ErrUserUnknown = "User is unknown"
ErrYouDontOwnTheRecord = "The record requested does not belong to you"
ResponseMessageSuccess = "Success"
)
@ -47,6 +54,13 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han
repo: services.NewRepositoryService(conn),
}
jwtConfig := echojwt.Config{
NewClaimsFunc: func(c echo.Context) jwt.Claims {
return new(JwtToken)
},
SigningKey: []byte(configs.JwtSecret),
}
router := echo.New()
router.Pre(middleware.RemoveTrailingSlash())
router.Pre(middleware.Logger())
@ -55,6 +69,7 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han
v1 := router.Group("/api/v1")
articles := v1.Group("/articles")
articles.Use(echojwt.WithConfig(jwtConfig))
articles.GET("", s.listArticles)
articles.GET(":id", s.getArticle)
articles.GET(":id/details", s.getArticleDetails)
@ -76,6 +91,7 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han
//settings.GET("/", s.getSettings)
sources := v1.Group("/sources")
sources.Use(echojwt.WithConfig(jwtConfig))
sources.GET("", s.listSources)
sources.GET("/by/source", s.listSourcesBySource)
sources.GET("/by/sourceAndName", s.GetSourceBySourceAndName)
@ -88,26 +104,26 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han
sources.POST("/:ID/disable", s.disableSource)
sources.POST("/:ID/enable", s.enableSource)
subs := v1.Group("/subscriptions")
subs.GET("/", s.ListSubscriptions)
subs.GET("/details", s.ListSubscriptionDetails)
subs.GET("/by/discordId", s.GetSubscriptionsByDiscordId)
subs.GET("/by/sourceId", s.GetSubscriptionsBySourceId)
subs.POST("/discord/webhook/new", s.newDiscordWebHookSubscription)
subs.DELETE("/discord/webhook/delete", s.DeleteDiscordWebHookSubscription)
users := v1.Group("/users")
users.POST("/login", s.AuthLogin)
users.POST("/register", s.AuthRegister)
users.Use(echojwt.WithConfig(jwtConfig))
users.POST("/scopes/add", s.AddScopes)
users.POST("/scopes/remove", s.RemoveScopes)
users.POST("/refreshToken", s.RefreshJwtToken)
s.Router = router
return s
}
type ApiStatusModel struct {
StatusCode int `json:"status"`
Message string `json:"message"`
}
//type ApiStatusModel struct {
// StatusCode int `json:"status"`
// Message string `json:"message"`
//}
type ApiError struct {
*ApiStatusModel
}
//type ApiError struct {
// *ApiStatusModel
//}
func (s *Handler) WriteError(c echo.Context, errMessage error, HttpStatusCode int) error {
return c.JSON(HttpStatusCode, domain.BaseResponse{
@ -120,3 +136,53 @@ func (s *Handler) WriteMessage(c echo.Context, msg string, HttpStatusCode int) e
Message: msg,
})
}
func (s *Handler) InternalServerErrorResponse(c echo.Context, msg string) error {
return c.JSON(http.StatusInternalServerError, domain.BaseResponse{
Message: msg,
})
}
func (s *Handler) UnauthorizedResponse(c echo.Context, msg string) error {
return c.JSON(http.StatusUnauthorized, domain.BaseResponse{
Message: msg,
})
}
// If the token is not valid then an json error will be returned.
// If the token has the wrong scope, a json error will be returned.
// If the token passes all the checks, it is valid and is returned back to the caller.
func (s *Handler) ValidateJwtToken(c echo.Context, requiredScope string) (JwtToken, error) {
token, err := s.getJwtTokenFromContext(c)
if err != nil {
s.WriteMessage(c, ErrJwtMissing, http.StatusUnauthorized)
}
err = token.hasExpired()
if err != nil {
return JwtToken{}, errors.New(ErrJwtExpired)
//s.WriteMessage(c, ErrJwtExpired, http.StatusUnauthorized)
}
err = token.hasScope(requiredScope)
if err != nil {
return JwtToken{}, errors.New(ErrJwtScopeMissing)
//s.WriteMessage(c, ErrJwtScopeMissing, http.StatusUnauthorized)
}
if token.Iss != s.config.ServerAddress {
return JwtToken{}, errors.New(ErrJwtInvalidIssuer)
//s.WriteMessage(c, ErrJwtInvalidIssuer, http.StatusUnauthorized)
}
return token, nil
}
func (s *Handler) GetUserIdFromJwtToken(c echo.Context) int64 {
token, err := s.getJwtTokenFromContext(c)
if err != nil {
s.WriteMessage(c, ErrJwtMissing, http.StatusUnauthorized)
}
return token.GetUserId()
}

128
internal/handler/v1/jwt.go Normal file
View File

@ -0,0 +1,128 @@
package v1
import (
"errors"
"strings"
"time"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
)
const (
ErrJwtMissing = "auth token is missing"
ErrJwtClaimsMissing = "claims missing on token"
ErrJwtExpired = "auth token has expired"
ErrJwtScopeMissing = "required scope is missing"
ErrJwtInvalidIssuer = "incorrect server issued the token"
)
type JwtToken struct {
Exp time.Time `json:"exp"`
Iss string `json:"iss"`
Authorized bool `json:"authorized"`
UserName string `json:"username"`
UserId int64 `json:"userId"`
Scopes []string `json:"scopes"`
jwt.RegisteredClaims
}
func (j JwtToken) IsValid(scope string) error {
err := j.hasExpired()
if err != nil {
return err
}
// Check to see if they have the scope to do anything
// if they do, let them pass
err = j.hasScope(domain.ScopeAll)
if err == nil {
return nil
}
err = j.hasScope(scope)
if err != nil {
return err
}
return nil
}
func (j JwtToken) GetUsername() string {
return j.UserName
}
func (j JwtToken) GetUserId() int64 {
return j.UserId
}
func (j JwtToken) hasExpired() error {
// Check to see if the token has expired
//hasExpired := j.Exp.Compare(time.Now())
hasExpired := time.Now().Compare(j.Exp)
if hasExpired == 1 {
return errors.New(ErrJwtExpired)
}
return nil
}
// This will check the users token to make sure they have the correct scope to access the handler.
// It will evaluate if you have the admin scope or the required scope for the handler.
func (j JwtToken) hasScope(scope string) error {
// they have the scope to access everything, so let them pass.
userScopes := strings.Join(j.Scopes, "")
if strings.Contains(domain.ScopeAll, userScopes) {
return nil
}
if strings.Contains(userScopes, scope) {
return nil
}
return errors.New(ErrJwtScopeMissing)
}
func (h *Handler) generateJwt(username, issuer string, userScopes []string, userId int64) (string, error) {
return h.generateJwtWithExp(username, issuer, userScopes, userId, time.Now().Add(10*time.Minute))
}
func (h *Handler) generateJwtWithExp(username, issuer string, userScopes []string, userId int64, expiresAt time.Time) (string, error) {
secret := []byte(h.config.JwtSecret)
// Anyone who wants to decrypt the key needs to use the same method
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["exp"] = expiresAt
claims["authorized"] = true
claims["username"] = username
claims["iss"] = issuer
claims["userId"] = userId
var scopes []string
scopes = append(scopes, userScopes...)
claims["scopes"] = scopes
tokenString, err := token.SignedString(secret)
if err != nil {
return "", err
}
return tokenString, nil
}
func (h *Handler) getJwtTokenFromContext(c echo.Context) (JwtToken, error) {
// Make sure that the request came with a jwtToken
token, ok := c.Get("user").(*jwt.Token)
if !ok {
return JwtToken{}, errors.New(ErrJwtMissing)
}
// Generate the claims from the token
claims, ok := token.Claims.(*JwtToken)
if !ok {
return JwtToken{}, errors.New(ErrJwtClaimsMissing)
}
return *claims, nil
}

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

@ -1,39 +0,0 @@
package v1
import (
"encoding/json"
"net/http"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
)
// GetSettings
// @Summary Returns a object based on the Key that was given.
// @Param key path string true "Settings Key value"
// @Produce application/json
// @Tags Settings
// @Router /v1/settings/{key} [get]
func (s *Handler) getSettings(c echo.Context) error {
id := c.Param("ID")
uuid, err := uuid.Parse(id)
if err != nil {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: err.Error(),
})
}
res, err := s.Db.GetSourceByID(c.Request().Context(), uuid)
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
bResult, err := json.Marshal(res)
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, bResult)
}

View File

@ -1,41 +1,32 @@
package v1
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"git.jamestombleson.com/jtom38/newsbot-api/internal/database"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain/models"
"git.jamestombleson.com/jtom38/newsbot-api/internal/services"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
)
type ListSources struct {
ApiStatusModel
Payload []models.SourceDto `json:"payload"`
}
type GetSource struct {
ApiStatusModel
Payload models.SourceDto `json:"payload"`
}
// ListSources
// @Summary Lists the top 50 records
// @Param page query string false "page number"
// @Produce application/json
// @Tags Source
// @Router /v1/sources [get]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse "Unable to reach SQL or Data problems"
// @Summary Lists the top 50 records
// @Param page query string false "page number"
// @Produce application/json
// @Tags Source
// @Router /v1/sources [get]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse "Unable to reach SQL or Data problems"
// @Security Bearer
func (s *Handler) listSources(c echo.Context) error {
resp := domain.SourcesResponse {
_, err := s.ValidateJwtToken(c, domain.ScopeSourceRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
},
@ -49,7 +40,7 @@ func (s *Handler) listSources(c echo.Context) error {
// Default way of showing all sources
items, err := s.repo.Sources.List(c.Request().Context(), page, 25)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
resp.Payload = services.SourcesToDto(items)
@ -57,16 +48,22 @@ func (s *Handler) listSources(c echo.Context) error {
}
// ListSourcesBySource
// @Summary Lists the top 50 records based on the name given. Example: reddit
// @Param source query string true "Source Name"
// @Param page query string false "page number"
// @Produce application/json
// @Tags Source
// @Router /v1/sources/by/source [get]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Lists the top 50 records based on the name given. Example: reddit
// @Param source query string true "Source Name"
// @Param page query string false "page number"
// @Produce application/json
// @Tags Source
// @Router /v1/sources/by/source [get]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) listSourcesBySource(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeSourceRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -75,7 +72,7 @@ func (s *Handler) listSourcesBySource(c echo.Context) error {
source := c.QueryParam("source")
if source == "" {
s.WriteMessage(c, fmt.Sprintf("%s source", ErrParameterMissing), http.StatusBadRequest)
return s.WriteMessage(c, fmt.Sprintf("%s source", ErrParameterMissing), http.StatusBadRequest)
}
page, err := strconv.Atoi(c.QueryParam("page"))
@ -96,15 +93,21 @@ func (s *Handler) listSourcesBySource(c echo.Context) error {
}
// GetSource
// @Summary Returns a single entity by ID
// @Param id path int true "uuid"
// @Produce application/json
// @Tags Source
// @Router /v1/sources/{id} [get]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Returns a single entity by ID
// @Param id path int true "uuid"
// @Produce application/json
// @Tags Source
// @Router /v1/sources/{id} [get]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) getSource(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeSourceRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -120,7 +123,7 @@ func (s *Handler) getSource(c echo.Context) error {
item, err := s.repo.Sources.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dto []domain.SourceDto
@ -130,16 +133,22 @@ func (s *Handler) getSource(c echo.Context) error {
}
// GetSourceByNameAndSource
// @Summary Returns a single entity by ID
// @Param name query string true "dadjokes"
// @Param source query string true "reddit"
// @Produce application/json
// @Tags Source
// @Router /v1/sources/by/sourceAndName [get]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Returns a single entity by ID
// @Param name query string true "dadjokes"
// @Param source query string true "reddit"
// @Produce application/json
// @Tags Source
// @Router /v1/sources/by/sourceAndName [get]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) GetSourceBySourceAndName(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeSourceRead)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -147,7 +156,7 @@ func (s *Handler) GetSourceBySourceAndName(c echo.Context) error {
}
var param domain.GetSourceBySourceAndNameParamRequest
err := c.Bind(&param)
err = c.Bind(&param)
if err != nil {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: err.Error(),
@ -166,15 +175,21 @@ func (s *Handler) GetSourceBySourceAndName(c echo.Context) error {
}
// NewRedditSource
// @Summary Creates a new reddit source to monitor.
// @Param name query string true "name"
// @Param url query string true "url"
// @Tags Source
// @Router /v1/sources/new/reddit [post]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Creates a new reddit source to monitor.
// @Param name query string true "name"
// @Param url query string true "url"
// @Tags Source
// @Router /v1/sources/new/reddit [post]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) newRedditSource(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeSourceCreate)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -182,37 +197,30 @@ func (s *Handler) newRedditSource(c echo.Context) error {
}
var param domain.NewSourceParamRequest
err := c.Bind(&param)
err = c.Bind(&param)
if err != nil {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: err.Error(),
})
return s.WriteError(c, err, http.StatusBadRequest)
}
if param.Url == "" {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: "Url is missing a value",
})
return s.WriteMessage(c, "url is missing", http.StatusBadRequest)
}
if !strings.Contains(param.Url, "reddit.com") {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: "Invalid URL given",
})
return s.WriteMessage(c, "invalid url", http.StatusBadRequest)
}
tags := fmt.Sprintf("twitch, %v, %s", param.Name, param.Tags)
rows, err := s.repo.Sources.Create(c.Request().Context(), domain.SourceCollectorReddit, param.Name, param.Url, tags, true)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
if rows != 1 {
s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError)
return s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError)
}
item, err := s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorReddit, param.Name)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dto []domain.SourceDto
@ -222,126 +230,139 @@ func (s *Handler) newRedditSource(c echo.Context) error {
}
// NewYoutubeSource
// @Summary Creates a new youtube source to monitor.
// @Param name query string true "name"
// @Param url query string true "url"
// @Tags Source
// @Router /v1/sources/new/youtube [post]
// @Summary Creates a new youtube source to monitor.
// @Param name query string true "name"
// @Param url query string true "url"
// @Tags Source
// @Router /v1/sources/new/youtube [post]
// @Security Bearer
func (s *Handler) newYoutubeSource(c echo.Context) error {
var param domain.NewSourceParamRequest
err := c.Bind(&param)
// Validate the jwt
_, err := s.ValidateJwtToken(c, domain.ScopeSourceCreate)
if err != nil {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: err.Error(),
})
return s.WriteError(c, err, http.StatusBadRequest)
}
//query := r.URL.Query()
//_name := query["name"][0]
//_url := query["url"][0]
////_tags := query["tags"][0]
var param domain.NewSourceParamRequest
err = c.Bind(&param)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
if param.Url == "" {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: "url is missing a value",
})
return s.WriteMessage(c, "url is missing a value", http.StatusBadRequest)
}
if !strings.Contains(param.Url, "youtube.com") {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: "Invalid URL",
})
return s.WriteMessage(c, "invalid url", http.StatusBadRequest)
}
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
},
}
item, err := s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorYoutube, param.Name)
if err == nil {
var dto []domain.SourceDto
dto = append(dto, services.SourceToDto(item))
resp.Payload = dto
return c.JSON(http.StatusOK, resp)
}
/*
if _tags == "" {
tags = fmt.Sprintf("twitch, %v", _name)
} else {
}
*/
tags := fmt.Sprintf("twitch, %v", param.Name)
params := database.CreateSourceParams{
ID: uuid.New(),
Site: "youtube",
Name: param.Name,
Source: "youtube",
Type: "feed",
Enabled: true,
Url: param.Url,
Tags: tags,
}
err = s.Db.CreateSource(context.Background(), params)
rows, err := s.repo.Sources.Create(c.Request().Context(), domain.SourceCollectorYoutube, param.Name, param.Url, tags, true)
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
bJson, err := json.Marshal(&params)
if err != nil {
return c.JSON(http.StatusInternalServerError, domain.BaseResponse{
Message: err.Error(),
})
if rows != 1 {
return s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError)
}
return c.JSON(http.StatusOK, bJson)
item, err = s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorYoutube, param.Name)
if err == nil {
var dto []domain.SourceDto
dto = append(dto, services.SourceToDto(item))
resp.Payload = dto
return c.JSON(http.StatusOK, resp)
}
return c.JSON(http.StatusOK, resp)
}
// NewTwitchSource
// @Summary Creates a new twitch source to monitor.
// @Param name query string true "name"
// @Tags Source
// @Router /v1/sources/new/twitch [post]
// @Summary Creates a new twitch source to monitor.
// @Param name query string true "name"
// @Tags Source
// @Router /v1/sources/new/twitch [post]
// @Security Bearer
func (s *Handler) newTwitchSource(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeSourceCreate)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
var param domain.NewSourceParamRequest
err := c.Bind(&param)
err = c.Bind(&param)
if err != nil {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: err.Error(),
})
}
//query := r.URL.Query()
//_name := query["name"][0]
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
},
}
tags := fmt.Sprintf("twitch, %v", param.Name)
_url := fmt.Sprintf("https://twitch.tv/%v", param.Name)
url := fmt.Sprintf("https://twitch.tv/%v", param.Name)
params := database.CreateSourceParams{
ID: uuid.New(),
Site: "twitch",
Name: param.Name,
Source: "twitch",
Type: "api",
Enabled: true,
Url: _url,
Tags: tags,
// Check if the record already exists
item, err := s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorTwitch, param.Name)
if err == nil {
var dto []domain.SourceDto
dto = append(dto, services.SourceToDto(item))
resp.Payload = dto
return c.JSON(http.StatusOK, resp)
}
err = s.Db.CreateSource(c.Request().Context(), params)
rows, err := s.repo.Sources.Create(c.Request().Context(), domain.SourceCollectorTwitch, param.Name, url, tags, true)
if err != nil {
return c.JSON(http.StatusInternalServerError, domain.BaseResponse{
Message: err.Error(),
})
}
bJson, err := json.Marshal(&params)
if err != nil {
return c.JSON(http.StatusInternalServerError, domain.BaseResponse{
Message: err.Error(),
})
if rows != 1 {
return s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError)
}
return c.JSON(http.StatusOK, bJson)
item, _ = s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorTwitch, param.Name)
var dto []domain.SourceDto
dto = append(dto, services.SourceToDto(item))
resp.Payload = dto
return c.JSON(http.StatusOK, resp)
}
// NewRssSource
// @Summary Creates a new rss source to monitor.
// @Param name query string true "Site Name"
// @Param url query string true "RSS Url"
// @Tags Source
// @Router /v1/sources/new/rss [post]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Creates a new rss source to monitor.
// @Param name query string true "Site Name"
// @Param url query string true "RSS Url"
// @Tags Source
// @Router /v1/sources/new/rss [post]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) newRssSource(c echo.Context) error {
_, err := s.ValidateJwtToken(c, domain.ScopeSourceCreate)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
@ -349,7 +370,7 @@ func (s *Handler) newRssSource(c echo.Context) error {
}
var param domain.NewSourceParamRequest
err := c.Bind(&param)
err = c.Bind(&param)
if err != nil {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: err.Error(),
@ -365,16 +386,16 @@ func (s *Handler) newRssSource(c echo.Context) error {
tags := fmt.Sprintf("rss, %v, %s", param.Name, param.Tags)
rows, err := s.repo.Sources.Create(c.Request().Context(), domain.SourceCollectorRss, param.Name, param.Url, tags, true)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
if rows != 1 {
s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError)
return s.WriteMessage(c, ErrFailedToCreateRecord, http.StatusInternalServerError)
}
item, err := s.repo.Sources.GetBySourceAndName(c.Request().Context(), domain.SourceCollectorRss, param.Name)
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dto []domain.SourceDto
@ -384,60 +405,73 @@ func (s *Handler) newRssSource(c echo.Context) error {
}
// DeleteSource
// @Summary Marks a source as deleted based on its ID value.
// @Param id path string true "id"
// @Tags Source
// @Router /v1/sources/{id} [POST]
// @Summary Marks a source as deleted based on its ID value.
// @Param id path int true "id"
// @Tags Source
// @Router /v1/sources/{id} [POST]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) deleteSources(c echo.Context) error {
id := c.Param("ID")
uuid, err := uuid.Parse(id)
_, err := s.ValidateJwtToken(c, domain.ScopeAll)
if err != nil {
return c.JSON(http.StatusBadRequest, domain.BaseResponse{
Message: err.Error(),
})
return s.WriteError(c, err, http.StatusBadRequest)
}
id, err := strconv.Atoi(c.Param("ID"))
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
// Check to make sure we can find the record
_, err = s.Db.GetSourceByID(c.Request().Context(), uuid)
_, err = s.repo.Sources.GetById(c.Request().Context(), int64(id))
if err != nil {
return c.JSON(http.StatusInternalServerError, domain.BaseResponse{
Message: err.Error(),
})
return s.WriteError(c, err, http.StatusInternalServerError)
}
// Delete the record
err = s.Db.DeleteSource(c.Request().Context(), uuid)
rows, err := s.repo.Sources.SoftDelete(c.Request().Context(), int64(id))
if err != nil {
return c.JSON(http.StatusInternalServerError, domain.BaseResponse{
Message: err.Error(),
})
return s.WriteError(c, err, http.StatusInternalServerError)
}
if rows != 1 {
return s.WriteMessage(c, ErrFailedToUpdateRecord, http.StatusInternalServerError)
}
p := ApiStatusModel{
Message: "OK",
StatusCode: http.StatusOK,
}
b, err := json.Marshal(p)
// pull the record with its updated value
item, err := s.repo.Sources.GetById(c.Request().Context(), int64(id))
if err != nil {
return c.JSON(http.StatusInternalServerError, domain.BaseResponse{
Message: err.Error(),
})
return s.WriteError(c, err, http.StatusInternalServerError)
}
return c.JSON(http.StatusOK, b)
var items []domain.SourceDto
items = append(items, services.SourceToDto(item))
return c.JSON(http.StatusOK, domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: "OK",
},
Payload: items,
})
}
// DisableSource
// @Summary Disables a source from processing.
// @Param id path int true "id"
// @Tags Source
// @Router /v1/sources/{id}/disable [post]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Disables a source from processing.
// @Param id path int true "id"
// @Tags Source
// @Router /v1/sources/{id}/disable [post]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) disableSource(c echo.Context) error {
resp := domain.SourcesResponse {
_, err := s.ValidateJwtToken(c, domain.ScopeAll)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
},
@ -445,23 +479,23 @@ func (s *Handler) disableSource(c echo.Context) error {
id, err := strconv.Atoi(c.Param("ID"))
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
// Check to make sure we can find the record
_, err = s.repo.Sources.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
_, err = s.repo.Sources.Disable(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
item, err := s.repo.Sources.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dto []domain.SourceDto
@ -471,15 +505,21 @@ func (s *Handler) disableSource(c echo.Context) error {
}
// EnableSource
// @Summary Enables a source to continue processing.
// @Param id path string true "id"
// @Tags Source
// @Router /v1/sources/{id}/enable [post]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Summary Enables a source to continue processing.
// @Param id path string true "id"
// @Tags Source
// @Router /v1/sources/{id}/enable [post]
// @Success 200 {object} domain.SourcesResponse "ok"
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (s *Handler) enableSource(c echo.Context) error {
resp := domain.SourcesResponse {
_, err := s.ValidateJwtToken(c, domain.ScopeAll)
if err != nil {
return s.WriteError(c, err, http.StatusBadRequest)
}
resp := domain.SourcesResponse{
BaseResponse: domain.BaseResponse{
Message: ResponseMessageSuccess,
},
@ -487,23 +527,23 @@ func (s *Handler) enableSource(c echo.Context) error {
id, err := strconv.Atoi(c.Param("ID"))
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
// Check to make sure we can find the record
_, err = s.repo.Sources.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusBadRequest)
return s.WriteError(c, err, http.StatusBadRequest)
}
_, err = s.repo.Sources.Enable(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
item, err := s.repo.Sources.GetById(c.Request().Context(), int64(id))
if err != nil {
s.WriteError(c, err, http.StatusInternalServerError)
return s.WriteError(c, err, http.StatusInternalServerError)
}
var dto []domain.SourceDto

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

@ -0,0 +1,122 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"github.com/huandu/go-sqlbuilder"
)
type AlertDiscordRepo interface {
Create(ctx context.Context, userId, sourceId, webhookId int64) (int64, error)
SoftDelete(ctx context.Context, id int64) (int64, error)
Restore(ctx context.Context, id int64) (int64, error)
Delete(ctx context.Context, id int64) (int64, error)
ListByUser(ctx context.Context, page, limit int, userId int64) ([]domain.AlertDiscordEntity, error)
}
type alertDiscordRepository struct {
conn *sql.DB
defaultLimit int
defaultOffset int
}
func NewAlertDiscordRepository(conn *sql.DB) alertDiscordRepository {
return alertDiscordRepository{
conn: conn,
defaultLimit: 50,
defaultOffset: 50,
}
}
func (r alertDiscordRepository) Create(ctx context.Context, userId, sourceId, webhookId int64) (int64, error) {
dt := time.Now()
queryBuilder := sqlbuilder.NewInsertBuilder()
queryBuilder.InsertInto("AlertDiscord")
queryBuilder.Cols("UpdatedAt", "CreatedAt", "DeletedAt", "UserID", "SourceID", "DiscordWebHookID")
queryBuilder.Values(dt, dt, timeZero, userId, sourceId, webhookId)
query, args := queryBuilder.Build()
_, err := r.conn.ExecContext(ctx, query, args...)
if err != nil {
return 0, err
}
return 1, nil
}
func (r alertDiscordRepository) SoftDelete(ctx context.Context, id int64) (int64, error) {
return softDeleteRow(ctx, r.conn, "AlertDiscord", id)
}
func (r alertDiscordRepository) Restore(ctx context.Context, id int64) (int64, error) {
return restoreRow(ctx, r.conn, "AlertDiscord", id)
}
func (r alertDiscordRepository) Delete(ctx context.Context, id int64) (int64, error) {
return deleteFromTable(ctx, r.conn, "AlertDiscord", id)
}
func (r alertDiscordRepository) ListByUser(ctx context.Context, page, limit int, userId int64) ([]domain.AlertDiscordEntity, error) {
builder := sqlbuilder.NewSelectBuilder()
builder.Select("*")
builder.From("AlertDiscord")
builder.Where(
builder.Equal("UserID", userId),
)
builder.Offset(page * limit)
builder.Limit(limit)
query, args := builder.Build()
rows, err := r.conn.QueryContext(ctx, query, args...)
if err != nil {
return []domain.AlertDiscordEntity{}, err
}
data := r.processRows(rows)
if len(data) == 0 {
return []domain.AlertDiscordEntity{}, errors.New(ErrUserNotFound)
}
return data, nil
}
func (ur alertDiscordRepository) processRows(rows *sql.Rows) []domain.AlertDiscordEntity {
items := []domain.AlertDiscordEntity{}
for rows.Next() {
var id int64
var createdAt time.Time
var updatedAt time.Time
var deletedAt time.Time
var userId int64
var sourceId int64
var webhookId int64
err := rows.Scan(
&id, &createdAt, &updatedAt, &deletedAt,
&userId, &sourceId, &webhookId,
)
if err != nil {
fmt.Println(err)
}
item := domain.AlertDiscordEntity{
ID: id,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
DeletedAt: deletedAt,
UserID: userId,
SourceID: sourceId,
DiscordWebHookId: webhookId,
}
items = append(items, item)
}
return items
}

View File

@ -0,0 +1,63 @@
package repository_test
import (
"context"
"testing"
"time"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"git.jamestombleson.com/jtom38/newsbot-api/internal/repository"
)
func TestAlertDiscordCreate(t *testing.T) {
t.Log(time.Time{})
db, err := setupInMemoryDb()
if err != nil {
t.Log(err)
t.FailNow()
}
defer db.Close()
r := repository.NewAlertDiscordRepository(db)
created, err := r.Create(context.Background(), 1, 1, 1)
if err != nil {
t.Log(err)
t.FailNow()
}
if created != 1 {
t.Log("failed to create the record")
t.FailNow()
}
}
func TestAlertDiscordCreateAndValidate(t *testing.T) {
t.Log(time.Time{})
db, err := setupInMemoryDb()
if err != nil {
t.Log(err)
t.FailNow()
}
defer db.Close()
source := repository.NewSourceRepository(db)
source.Create(context.Background(), domain.SourceCollectorRss, "Unit Testing", "www.fake.com", "testing,units", true)
sourceRecord, _ := source.GetBySourceAndName(context.Background(), domain.SourceCollectorRss, "Unit Testing")
webhookRepo := repository.NewDiscordWebHookRepository(db)
webhookRepo.Create(context.Background(), 999, "discord.com", "Unit Testing", "memes", true)
webhook, _ := webhookRepo.GetByUrl(context.Background(), "discord.com")
r := repository.NewAlertDiscordRepository(db)
r.Create(context.Background(), 999, sourceRecord.ID, webhook.ID)
alert, err := r.ListByUser(context.Background(), 0, 10, 999)
if err != nil {
t.Error(err)
t.FailNow()
}
if len(alert) != 1 {
t.Error("got the incorrect number of rows back")
t.FailNow()
}
}

View File

@ -9,8 +9,8 @@ import (
"github.com/huandu/go-sqlbuilder"
)
type DiscordWebHookRepo interface{
Create(ctx context.Context, url, server, channel string, enabled bool) (int64, error)
type DiscordWebHookRepo interface {
Create(ctx context.Context, userId int64, url, server, channel string, enabled bool) (int64, error)
Enable(ctx context.Context, id int64) (int64, error)
Disable(ctx context.Context, id int64) (int64, error)
SoftDelete(ctx context.Context, id int64) (int64, error)
@ -32,12 +32,12 @@ func NewDiscordWebHookRepository(conn *sql.DB) discordWebHookRepository {
}
}
func (r discordWebHookRepository) Create(ctx context.Context, url, server, channel string, enabled bool) (int64, error) {
func (r discordWebHookRepository) Create(ctx context.Context, userId int64, url, server, channel string, enabled bool) (int64, error) {
dt := time.Now()
queryBuilder := sqlbuilder.NewInsertBuilder()
queryBuilder.InsertInto("DiscordWebHooks")
queryBuilder.Cols("UpdatedAt", "CreatedAt", "DeletedAt", "Url", "Server", "Channel", "Enabled")
queryBuilder.Values(dt, dt, timeZero, url, server, channel, enabled)
queryBuilder.Cols("UpdatedAt", "CreatedAt", "DeletedAt", "UserID", "Url", "Server", "Channel", "Enabled")
queryBuilder.Values(dt, dt, timeZero, userId, url, server, channel, enabled)
query, args := queryBuilder.Build()
_, err := r.conn.ExecContext(ctx, query, args...)
@ -195,13 +195,14 @@ func (r discordWebHookRepository) processRows(rows *sql.Rows) ([]domain.DiscordW
var createdAt time.Time
var updatedAt time.Time
var deletedAt time.Time
var userId int64
var url string
var server string
var channel string
var enabled bool
err := rows.Scan(
&id, &createdAt, &updatedAt,
&deletedAt, &url, &server,
&deletedAt, &userId, &url, &server,
&channel, &enabled,
)
if err != nil {
@ -213,6 +214,7 @@ func (r discordWebHookRepository) processRows(rows *sql.Rows) ([]domain.DiscordW
CreatedAt: createdAt,
UpdatedAt: updatedAt,
DeletedAt: deletedAt,
UserID: userId,
Url: url,
Server: server,
Channel: channel,

View File

@ -17,7 +17,7 @@ func TestCreateDiscordWebHookRecord(t *testing.T) {
defer db.Close()
r := repository.NewDiscordWebHookRepository(db)
created, err := r.Create(context.Background(), "www.discord.com/bad/webhook", "Unit Testing", "memes", true)
created, err := r.Create(context.Background(), 999, "www.discord.com/bad/webhook", "Unit Testing", "memes", true)
if err != nil {
t.Log(err)
t.FailNow()
@ -38,7 +38,7 @@ func TestDiscordWebHookGetById(t *testing.T) {
defer db.Close()
ctx := context.Background()
r := repository.NewDiscordWebHookRepository(db)
created, err := r.Create(ctx, "www.discord.com/bad/webhook", "Unit Testing", "memes", true)
created, err := r.Create(ctx, 999, "www.discord.com/bad/webhook", "Unit Testing", "memes", true)
if err != nil {
t.Log(err)
t.FailNow()
@ -71,7 +71,7 @@ func TestDiscordWebHookGetByUrl(t *testing.T) {
ctx := context.Background()
r := repository.NewDiscordWebHookRepository(db)
_, _ = r.Create(ctx, "www.discord.com/bad/webhook", "Unit Testing", "memes", true)
_, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", "Unit Testing", "memes", true)
item, err := r.GetByUrl(ctx, "www.discord.com/bad/webhook")
if err != nil {
t.Log(err)
@ -95,7 +95,7 @@ func TestDiscordWebHookListByServerName(t *testing.T) {
ctx := context.Background()
serverName := "Unit Testing"
r := repository.NewDiscordWebHookRepository(db)
_, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, "memes", true)
_, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, "memes", true)
item, err := r.ListByServerName(ctx, serverName)
if err != nil {
@ -121,7 +121,7 @@ func TestDiscordWebHookListByServerAndChannel(t *testing.T) {
serverName := "Unit Testing"
channel := "memes"
r := repository.NewDiscordWebHookRepository(db)
_, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, true)
_, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, true)
item, err := r.ListByServerAndChannel(ctx, serverName, channel)
if err != nil {
@ -152,7 +152,7 @@ func TestDiscordWebHookEnableRecord(t *testing.T) {
serverName := "Unit Testing"
channel := "memes"
r := repository.NewDiscordWebHookRepository(db)
_, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, false)
_, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, false)
item, err := r.GetById(ctx, 1)
if err != nil {
@ -195,7 +195,7 @@ func TestDiscordWebHookDisableRecord(t *testing.T) {
serverName := "Unit Testing"
channel := "memes"
r := repository.NewDiscordWebHookRepository(db)
_, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, true)
_, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, true)
item, err := r.GetById(ctx, 1)
if err != nil {
@ -238,7 +238,7 @@ func TestDiscordWebHookSoftDelete(t *testing.T) {
serverName := "Unit Testing"
channel := "memes"
r := repository.NewDiscordWebHookRepository(db)
_, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, true)
_, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, true)
_, err = r.SoftDelete(ctx, 1)
if err != nil {
t.Log(err)
@ -263,7 +263,7 @@ func TestDiscordWebHookRestore(t *testing.T) {
timeZero := time.Time{}
r := repository.NewDiscordWebHookRepository(db)
_, _ = r.Create(ctx, "www.discord.com/bad/webhook", serverName, channel, true)
_, _ = r.Create(ctx, 999, "www.discord.com/bad/webhook", serverName, channel, true)
item, _ := r.GetById(ctx, 1)
if item.DeletedAt != timeZero {
t.Log("DeletedAt was not zero")

View File

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

View File

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

View File

@ -0,0 +1,120 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"github.com/huandu/go-sqlbuilder"
)
type UserSourceRepo interface {
Create(ctx context.Context, userId, sourceId int64) (int64, error)
SoftDelete(ctx context.Context, id int64) (int64, error)
Restore(ctx context.Context, id int64) (int64, error)
Delete(ctx context.Context, id int64) (int64, error)
ListByUser(ctx context.Context, page, limit int, userId int64) ([]domain.UserSourceSubscriptionEntity, error)
}
type userSourceRepository struct {
conn *sql.DB
defaultLimit int
defaultOffset int
}
func NewUserSourceRepository(conn *sql.DB) userSourceRepository {
return userSourceRepository{
conn: conn,
defaultLimit: 50,
defaultOffset: 50,
}
}
func (r userSourceRepository) Create(ctx context.Context, userId, sourceId int64) (int64, error) {
dt := time.Now()
queryBuilder := sqlbuilder.NewInsertBuilder()
queryBuilder.InsertInto("UserSourceSubscriptions")
queryBuilder.Cols("UpdatedAt", "CreatedAt", "DeletedAt", "UserID", "SourceID")
queryBuilder.Values(dt, dt, timeZero, userId, sourceId)
query, args := queryBuilder.Build()
_, err := r.conn.ExecContext(ctx, query, args...)
if err != nil {
return 0, err
}
return 1, nil
}
func (r userSourceRepository) SoftDelete(ctx context.Context, id int64) (int64, error) {
return softDeleteRow(ctx, r.conn, "UserSourceSubscriptions", id)
}
func (r userSourceRepository) Restore(ctx context.Context, id int64) (int64, error) {
return restoreRow(ctx, r.conn, "UserSourceSubscriptions", id)
}
func (r userSourceRepository) Delete(ctx context.Context, id int64) (int64, error) {
return deleteFromTable(ctx, r.conn, "UserSourceSubscriptions", id)
}
func (r userSourceRepository) ListByUser(ctx context.Context, page, limit int, userId int64) ([]domain.UserSourceSubscriptionEntity, error) {
builder := sqlbuilder.NewSelectBuilder()
builder.Select("*")
builder.From("UserSourceSubscriptions")
builder.Where(
builder.Equal("UserID", userId),
)
builder.Offset(page * limit)
builder.Limit(limit)
query, args := builder.Build()
rows, err := r.conn.QueryContext(ctx, query, args...)
if err != nil {
return []domain.UserSourceSubscriptionEntity{}, err
}
data := r.processRows(rows)
if len(data) == 0 {
return []domain.UserSourceSubscriptionEntity{}, errors.New(ErrUserNotFound)
}
return data, nil
}
func (ur userSourceRepository) processRows(rows *sql.Rows) []domain.UserSourceSubscriptionEntity {
items := []domain.UserSourceSubscriptionEntity{}
for rows.Next() {
var id int64
var createdAt time.Time
var updatedAt time.Time
var deletedAt time.Time
var userId int64
var sourceId int64
err := rows.Scan(
&id, &createdAt, &updatedAt, &deletedAt,
&userId, &sourceId,
)
if err != nil {
fmt.Println(err)
}
item := domain.UserSourceSubscriptionEntity{
ID: id,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
DeletedAt: deletedAt,
UserID: userId,
SourceID: sourceId,
}
items = append(items, item)
}
return items
}

View File

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

View File

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

View File

@ -0,0 +1,87 @@
package repositoryservices
import (
"context"
"database/sql"
"errors"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"git.jamestombleson.com/jtom38/newsbot-api/internal/repository"
"github.com/google/uuid"
)
const (
ErrUnexpectedAmountOfRowsUpdated = "got a unexpected of rows updated"
)
type RefreshToken interface {
Create(ctx context.Context, username string) (string, error)
GetByName(ctx context.Context, name string) (domain.RefreshTokenEntity, error)
Delete(ctx context.Context, id int64) (int64, error)
IsRequestValid(ctx context.Context, username, refreshToken string) error
}
// A new jwt token can be made if the user has the correct refresh token for the user.
// It will also require the old JWT token so the expire time is pulled and part of the validation
type RefreshTokenService struct {
table repository.RefreshTokenRepository
}
func NewRefreshTokenService(conn *sql.DB) RefreshTokenService {
return RefreshTokenService{
table: repository.NewRefreshTokenRepository(conn),
}
}
func (rt RefreshTokenService) Create(ctx context.Context, username string) (string, error) {
//if a refresh token already exists for a user, reuse
existingToken, err := rt.GetByName(ctx, username)
if err == nil {
rowsRemoved, err := rt.Delete(ctx, existingToken.ID)
if err != nil {
return "", err
}
if rowsRemoved != 1 {
return "", errors.New(ErrUnexpectedAmountOfRowsUpdated)
}
}
token, err := uuid.NewV7()
if err != nil {
return "", err
}
rows, err := rt.table.Create(ctx, username, token.String())
if err != nil {
return "", err
}
if rows != 1 {
return "", errors.New("expected one row but got none")
}
return token.String(), nil
}
// Find the saved refresh token for a user and return it if it exists
func (rt RefreshTokenService) GetByName(ctx context.Context, name string) (domain.RefreshTokenEntity, error) {
return rt.table.GetByUsername(ctx, name)
}
// This will request that a object is removed from the database
func (rt RefreshTokenService) Delete(ctx context.Context, id int64) (int64, error) {
return rt.table.DeleteById(ctx, id)
}
func (rt RefreshTokenService) IsRequestValid(ctx context.Context, username, refreshToken string) error {
token, err := rt.GetByName(ctx, username)
if err != nil {
return err
}
if token.Token != refreshToken {
return errors.New("the refresh token given does not match")
}
return nil
}

View File

@ -0,0 +1,171 @@
package repositoryservices
import (
"context"
"database/sql"
"errors"
"strings"
"git.jamestombleson.com/jtom38/newsbot-api/internal/domain"
"git.jamestombleson.com/jtom38/newsbot-api/internal/repository"
"golang.org/x/crypto/bcrypt"
)
const (
ErrPasswordNotLongEnough = "password needs to be 8 character or longer"
ErrPasswordMissingSpecialCharacter = "password needs to contain one of the following: !, @, #"
ErrInvalidPassword = "invalid password"
)
type UserServices interface {
DoesUserExist(ctx context.Context, username string) error
DoesPasswordMatchHash(ctx context.Context, username, password string) error
GetUser(ctx context.Context, username string) (domain.UserEntity, error)
AddScopes(ctx context.Context, username string, scopes []string) error
RemoveScopes(ctx context.Context, username string, scopes []string) error
Create(ctx context.Context, name, password, scope string) (domain.UserEntity, error)
CheckPasswordForRequirements(password string) error
}
// This will handle operations that are user related, but one layer higher then the repository
type UserService struct {
repo repository.Users
}
// This is a layer on top of the Users Repository.
// Use this over directly talking to the table when ever possible.
func NewUserService(conn *sql.DB) UserService {
return UserService{
repo: repository.NewUserRepository(conn),
}
}
func (us UserService) DoesUserExist(ctx context.Context, username string) error {
_, err := us.repo.GetByName(ctx, username)
if err != nil {
return err
}
return nil
}
func (us UserService) DoesPasswordMatchHash(ctx context.Context, username, password string) error {
model, err := us.GetUser(ctx, username)
if err != nil {
return err
}
err = bcrypt.CompareHashAndPassword([]byte(model.Hash), []byte(password))
if err != nil {
return errors.New(ErrInvalidPassword)
}
return nil
}
func (us UserService) GetUser(ctx context.Context, username string) (domain.UserEntity, error) {
return us.repo.GetByName(ctx, username)
}
func (us UserService) AddScopes(ctx context.Context, username string, scopes []string) error {
usr, err := us.repo.GetByName(ctx, username)
if err != nil {
return err
}
if usr.Username != username {
return errors.New(repository.ErrUserNotFound)
}
currentScopes := strings.Split(usr.Scopes, ",")
// check the current scopes
for _, item := range scopes {
if !strings.Contains(usr.Scopes, item) {
currentScopes = append(currentScopes, item)
}
}
return us.repo.UpdateScopes(ctx, username, strings.Join(currentScopes, ","))
}
func (us UserService) RemoveScopes(ctx context.Context, username string, scopes []string) error {
usr, err := us.repo.GetByName(ctx, username)
if err != nil {
return err
}
if usr.Username != username {
return errors.New(repository.ErrUserNotFound)
}
var newScopes []string
// check all the scopes that are currently assigned
for _, item := range strings.Split(usr.Scopes, ",") {
// check the scopes given, if one matches skip it
if us.doesScopeExist(scopes, item) {
continue
}
// did not match, add it
newScopes = append(newScopes, item)
}
return us.repo.UpdateScopes(ctx, username, strings.Join(newScopes, ","))
}
func (us UserService) doesScopeExist(scopes []string, target string) bool {
for _, item := range scopes {
if item == target {
return true
}
}
return false
}
func (us UserService) Create(ctx context.Context, name, password, scope string) (domain.UserEntity, error) {
err := us.CheckPasswordForRequirements(password)
if err != nil {
return domain.UserEntity{}, err
}
us.repo.Create(ctx, name, password, domain.ScopeArticleRead)
return domain.UserEntity{}, nil
}
func (us UserService) CheckPasswordForRequirements(password string) error {
err := us.checkPasswordLength(password)
if err != nil {
return err
}
err = us.checkPasswordForSpecialCharacters(password)
if err != nil {
return err
}
return nil
}
func (us UserService) checkPasswordLength(password string) error {
if len(password) < 8 {
return errors.New(ErrPasswordNotLongEnough)
}
return nil
}
func (us UserService) checkPasswordForSpecialCharacters(password string) error {
var chars []string
chars = append(chars, "!")
chars = append(chars, "@")
chars = append(chars, "#")
for _, char := range chars {
if strings.Contains(password, char) {
return nil
}
}
return errors.New(ErrPasswordMissingSpecialCharacter)
}

View File

@ -34,6 +34,8 @@ const (
type Configs struct {
ServerAddress string
JwtSecret string
AdminSecret string
RedditEnabled bool
RedditPullTop bool
@ -64,6 +66,8 @@ func NewConfig() ConfigClient {
func GetEnvConfig() Configs {
return Configs{
ServerAddress: os.Getenv(ServerAddress),
JwtSecret: os.Getenv("JwtSecret"),
AdminSecret: os.Getenv("AdminSecret"),
RedditEnabled: processBoolConfig(os.Getenv(FEATURE_ENABLE_REDDIT_BACKEND)),
RedditPullTop: processBoolConfig(os.Getenv(REDDIT_PULL_TOP)),

View File

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

View File

@ -6,6 +6,7 @@ build: ## builds the application with the current go runtime
~/go/bin/swag f
~/go/bin/swag init -g cmd/server.go
go build cmd/server.go
ls -lh server
docker-build: ## Generates the docker image
docker build -t "newsbot.collector.api" .