From cb6c40728b4f0d65639cd9dc1ee8361bc48ccf95 Mon Sep 17 00:00:00 2001 From: Pavle Portic Date: Thu, 27 Oct 2022 04:04:49 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 16 +++ .gitignore | 31 +++++ .golangci.yml | 17 +++ .yamllint.yaml | 8 ++ Dockerfile | 20 +++ Makefile | 40 ++++++ README.md | 1 + cmd/main.go | 97 ++++++++++++++ cmd/main_test.go | 63 +++++++++ config/config.go | 57 ++++++++ db/db.go | 15 +++ db/psql.go | 27 ++++ db/users.go | 52 ++++++++ db/videos.go | 37 ++++++ docker-compose.yml | 38 ++++++ go.mod | 40 ++++++ go.sum | 118 +++++++++++++++++ handler/handler.go | 39 ++++++ handler/handler_test.go | 50 +++++++ httpserver/auth/auth.go | 34 +++++ httpserver/auth/auth_test.go | 68 ++++++++++ httpserver/ytrssil/api_setup_test.go | 56 ++++++++ httpserver/ytrssil/server.go | 49 +++++++ httpserver/ytrssil/users.go | 25 ++++ httpserver/ytrssil/videos.go | 21 +++ lib/log/log.go | 61 +++++++++ migrations/000001_init.down.sql | 4 + migrations/000001_init.up.sql | 24 ++++ mocks/db/db.go | 189 +++++++++++++++++++++++++++ models/channel.go | 10 ++ models/http.go | 5 + models/user.go | 6 + models/video.go | 18 +++ 33 files changed, 1336 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .yamllint.yaml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/main.go create mode 100644 cmd/main_test.go create mode 100644 config/config.go create mode 100644 db/db.go create mode 100644 db/psql.go create mode 100644 db/users.go create mode 100644 db/videos.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handler/handler.go create mode 100644 handler/handler_test.go create mode 100644 httpserver/auth/auth.go create mode 100644 httpserver/auth/auth_test.go create mode 100644 httpserver/ytrssil/api_setup_test.go create mode 100644 httpserver/ytrssil/server.go create mode 100644 httpserver/ytrssil/users.go create mode 100644 httpserver/ytrssil/videos.go create mode 100644 lib/log/log.go create mode 100644 migrations/000001_init.down.sql create mode 100644 migrations/000001_init.up.sql create mode 100644 mocks/db/db.go create mode 100644 models/channel.go create mode 100644 models/http.go create mode 100644 models/user.go create mode 100644 models/video.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e6445b4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Ignore everything and explicitly include the things we need +* +.* + +!cmd +!config +!db +!handler +!httpserver +!lib +!mocks +!models + +!go.mod +!go.sum +!Makefile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06b4995 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +debug +# editor temp files +*.swp +*.swo +*.orig + +# Local env files +*.env + +# Build artifacts and cache +**/bin +/dist/ + +# Visual studio code configuration +.vscode/ + +# secrets for local use that cannot be encrypted +k8s/local/patches/core-http-config.yaml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..9df66c9 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,17 @@ +--- +linters: + enable: + - goimports + - stylecheck + - lll + disable: + - errcheck + +run: + go: '1.17' + +issues: + exclude-rules: + - linters: + - lll + source: "// nolint:lll" diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..eaeb396 --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,8 @@ +--- +extends: default +# files managed by flux won't pass linting +ignore: | + k8s/*/patches/deployment* + k8s/*/secrets/* +rules: + line-length: disable diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6ff0d9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.19-bullseye AS builder +RUN apt update && apt install -y make + +# first copy just enough to pull all dependencies, to cache this layer +COPY go.mod go.sum Makefile /app/ +WORKDIR /app/ +RUN make setup + +# lint, build, etc.. +COPY . /app/ +RUN make build + +FROM debian:bullseye-slim +RUN apt update \ + && apt install -y ca-certificates \ + && apt clean \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/dist/ /app/ +ENTRYPOINT ["/app/ytrssil-api"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..225859c --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +.PHONY: all setup ytrssil-api build lint k8s-lint yamllint test test-initdb image-build + +DB_URI ?= postgres://ytrssil:ytrssil@localhost:5431/ytrssil?sslmode=disable + +all: lint test build + +setup: bin/golangci-lint + go mod download + +ytrssil-api: + go build -o dist/ytrssil-api cmd/main.go + +build: ytrssil-api + +bin/moq: + GOBIN=$(PWD)/bin go install github.com/matryer/moq@v0.2.7 + +gen-mocks: bin/moq + ./bin/moq -pkg db_mock -out ./mocks/db/db.go ./db DB + go fmt ./... + +bin/golangci-lint: + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.48.0 + +lint: bin/golangci-lint + go fmt ./... + go vet ./... + bin/golangci-lint -c .golangci.yml run ./... + go mod tidy + +test: + go mod tidy + go test -timeout=10s -race -benchmem ./... + +migrate: + migrate -database "$(DB_URI)" -path migrations up + +image-build: + @echo "# Building docker image..." + docker build -t ytrssil-api -f Dockerfile . diff --git a/README.md b/README.md new file mode 100644 index 0000000..efb4ec7 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Ytrssil API diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..a2631bf --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" +) + +func init() { + // always use UTC + time.Local = time.UTC +} + +func main() { + log := log.NewLogger() + + config, err := config.Parse() + if err != nil { + log.Log("level", "FATAL", "call", "config.Parse", "error", err) + return + } + + db, err := db.NewPSQLDB(log, config.DB) + if err != nil { + log.Log("level", "FATAL", "call", "db.NewPSQLDB", "error", err) + return + } + + handler := handler.New(log, db) + gin.SetMode(gin.ReleaseMode) + router, err := ytrssil.SetupGinRouter( + log, + handler, + auth.AuthMiddleware(db), + ) + if err != nil { + log.Log("level", "FATAL", "call", "ytrssil.SetupGinServer", "error", err) + return + } + + server := &http.Server{ + Addr: fmt.Sprintf(":%v", config.Gin.Port), + Handler: router, + } + server.RegisterOnShutdown(func() { + log.Log("level", "INFO", "msg", "shutdown server.Close()") + }) + + quit := make(chan os.Signal, 1) + // handle Interrupt (ctrl-c) Term, used by `kill` et al, HUP which is commonly used to reload configs + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + go func() { + s := <-quit + log.Log( + "level", "INFO", + "msg", "signalRecv, for quitting", + "signal", s, + ) + if err := server.Shutdown(context.Background()); err != nil { + log.Log( + "level", "ERROR", + "call", "server.Shutdown", + "error", err, + ) + } + }() + + log.Log( + "level", "INFO", + "msg", "ytrssil API is starting up", + "port", config.Gin.Port, + ) + if err := server.ListenAndServe(); err != nil { + if err != http.ErrServerClosed { + log.Log( + "level", "ERROR", + "call", "server.ListenAndServe", + "error", err, + ) + } + } + log.Log("level", "INFO", "msg", "exit complete") +} diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..33583fd --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" +) + +var testConfig config.Config + +func init() { + testConfig = config.TestConfig() +} + +func setupTestServer(t *testing.T, authEnabled bool) *http.Server { + l := log.NewNopLogger() + + db, err := db.NewPSQLDB(l, testConfig.DB) + if !assert.NoError(t, err) { + return nil + } + + handler := handler.New(l, db) + gin.SetMode(gin.TestMode) + router, err := ytrssil.SetupGinRouter( + l, + handler, + auth.AuthMiddleware(db), + ) + if !assert.NoError(t, err) { + return nil + } + + return &http.Server{ + Addr: fmt.Sprintf(":%v", testConfig.Gin.Port), + Handler: router, + } +} + +func TestHealthz(t *testing.T) { + server := setupTestServer(t, false) + if !assert.NotNil(t, server) { + return + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/healthz", nil) + server.Handler.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Equal(t, "healthy", w.Body.String()) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..5923c97 --- /dev/null +++ b/config/config.go @@ -0,0 +1,57 @@ +package config + +import ( + "os" + + flags "github.com/jessevdk/go-flags" +) + +type DB struct { + DBURI string `long:"db-uri" env:"DB_URI"` +} + +// Gin contains configuration for the gin framework +type Gin struct { + Port int `long:"port" env:"PORT" default:"8080"` +} + +// Config ties together all configs +type Config struct { + DB DB + Gin Gin +} + +func getenvOrDefault(key string, defaultValue string) string { + value, found := os.LookupEnv(key) + if found { + return value + } + + return defaultValue +} + +// Parse parses all the supplied configurations and returns +func Parse() (Config, error) { + var config Config + parser := flags.NewParser(&config, flags.Default) + _, err := parser.Parse() + return config, err +} + +// TestConfig returns a mostly hardcoded configuration used for running tests +func TestConfig() Config { + dbURI := getenvOrDefault("DB_URI", "postgres://ytrssil:ytrssil@postgres:5432/ytrssil") + + gin := Gin{ + Port: 8080, + } + db := DB{ + DBURI: dbURI, + } + config := Config{ + Gin: gin, + DB: db, + } + + return config +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..0632c65 --- /dev/null +++ b/db/db.go @@ -0,0 +1,15 @@ +package db + +import ( + "context" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +// DB represents a database layer for getting video and channel data +type DB interface { + // GetNewVideos returns unwatched videos from all channels + GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) + AuthenticateUser(ctx context.Context, username string, password string) (bool, error) + CreateUser(ctx context.Context, user models.User) error +} diff --git a/db/psql.go b/db/psql.go new file mode 100644 index 0000000..7045e05 --- /dev/null +++ b/db/psql.go @@ -0,0 +1,27 @@ +package db + +import ( + "database/sql" + + _ "github.com/lib/pq" + + ytrssilConfig "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" +) + +type psqlDB struct { + l log.Logger + db *sql.DB +} + +func NewPSQLDB(log log.Logger, dbCfg ytrssilConfig.DB) (*psqlDB, error) { + db, err := sql.Open("postgres", dbCfg.DBURI) + if err != nil { + return nil, err + } + + return &psqlDB{ + l: log, + db: db, + }, nil +} diff --git a/db/users.go b/db/users.go new file mode 100644 index 0000000..993da92 --- /dev/null +++ b/db/users.go @@ -0,0 +1,52 @@ +package db + +import ( + "context" + "fmt" + + "github.com/alexedwards/argon2id" + "github.com/georgysavva/scany/v2/sqlscan" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +var authenticateUserQuery = `SELECT username, password FROM users WHERE username = $1` + +func (d *psqlDB) AuthenticateUser(ctx context.Context, username string, password string) (bool, error) { + var user []*models.User + err := sqlscan.Select(ctx, d.db, &user, authenticateUserQuery, username) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to query user", "error", err) + return false, err + } + + match, err := argon2id.ComparePasswordAndHash(password, user[0].Password) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to check hashed passsword", "error", err) + return false, err + } + + return match, nil +} + +var createUserQuery = `INSERT INTO users (username, password) VALUES ($1, $2)` + +func (d *psqlDB) CreateUser(ctx context.Context, user models.User) error { + res, err := d.db.Exec(createUserQuery, user.Username, user.Password) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to create user", "error", err) + return err + } + affected, err := res.RowsAffected() + if err != nil { + d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err) + return err + } + + if affected != 1 { + d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err) + return fmt.Errorf("expected to insert one row, but %d were inserted", affected) + } + + return nil +} diff --git a/db/videos.go b/db/videos.go new file mode 100644 index 0000000..ebe995e --- /dev/null +++ b/db/videos.go @@ -0,0 +1,37 @@ +package db + +import ( + "context" + + "github.com/georgysavva/scany/v2/sqlscan" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +var getNewVideosQuery = ` + SELECT + video_id + , title + , published_timestamp + , watch_timestamp + , name as channel_name + FROM user_videos + LEFT JOIN videos ON video_id=videos.id + LEFT JOIN channels ON channel_id=channels.id + WHERE + 1=1 + AND watch_timestamp IS NULL + AND username=$1 + ORDER BY published_timestamp +` + +func (d *psqlDB) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) { + var videos []*models.Video + err := sqlscan.Select(ctx, d.db, &videos, getNewVideosQuery, username) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "msg", "failed to query new videos", "error", err) + return nil, err + } + + return videos, nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2fcc394 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +--- +version: "3" +services: + postgres: + image: postgres:14-alpine + restart: unless-stopped + ports: + - "5431:5432" + environment: + POSTGRES_PASSWORD: ytrssil + POSTGRES_USER: ytrssil + POSTGRES_DB: ytrssil + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: pg_isready + interval: 5s + timeout: 2s + retries: 5 + + api: + build: . + restart: unless-stopped + depends_on: + - postgres + environment: + DB_URI: "postgresql://ytrssil:ytrssil@postgres/ytrssil?sslmode=disable" + PORT: "80" + ports: + - "8080:80" + healthcheck: + test: "curl http://localhost:80/healthz" + interval: 5s + retries: 5 + timeout: 2s + +volumes: + postgres-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3ecac63 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api + +go 1.19 + +require ( + github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 + github.com/georgysavva/scany/v2 v2.0.0-alpha.3 + github.com/gin-gonic/gin v1.8.1 + github.com/go-kit/log v0.2.1 + github.com/jessevdk/go-flags v1.5.0 + github.com/lib/pq v1.10.7 + github.com/stretchr/testify v1.8.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logfmt/logfmt v0.5.1 // indirect + github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/go-playground/validator/v10 v10.11.0 // indirect + github.com/goccy/go-json v0.9.11 // indirect + github.com/google/go-cmp v0.5.8 // indirect + github.com/jackc/pgx/v5 v5.0.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.5 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/ugorji/go/codec v1.2.7 // indirect + golang.org/x/crypto v0.1.0 // indirect + golang.org/x/net v0.1.0 // indirect + golang.org/x/sys v0.1.0 // indirect + golang.org/x/text v0.4.0 // indirect + google.golang.org/protobuf v1.28.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..72d3f1d --- /dev/null +++ b/go.sum @@ -0,0 +1,118 @@ +github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I= +github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc= +github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/georgysavva/scany/v2 v2.0.0-alpha.3 h1:+n7kJr/xyVQJcsE2ICn0jd/a4QaxaWnx+a+GJQTGRrQ= +github.com/georgysavva/scany/v2 v2.0.0-alpha.3/go.mod h1:sigOdh+0qb/+aOs3TVhehVT10p8qJL7K/Zhyz8vWo38= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8= +github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw= +github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= +github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgx/v5 v5.0.3 h1:4flM5ecR/555F0EcnjdaZa6MhBU+nr0QbZIo5vaKjuM= +github.com/jackc/pgx/v5 v5.0.3/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o= +github.com/jackc/puddle/v2 v2.0.0 h1:Kwk/AlLigcnZsDssc3Zun1dk1tAtQNPaBBxBHWn0Mjc= +github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg= +github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler/handler.go b/handler/handler.go new file mode 100644 index 0000000..d1dbdfb --- /dev/null +++ b/handler/handler.go @@ -0,0 +1,39 @@ +package handler + +import ( + "context" + + "github.com/alexedwards/argon2id" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +type Handler interface { + CreateUser(ctx context.Context, user models.User) error + GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) +} + +type handler struct { + log log.Logger + db db.DB +} + +func New(log log.Logger, db db.DB) *handler { + return &handler{log: log, db: db} +} + +func (h *handler) CreateUser(ctx context.Context, user models.User) error { + hashedPassword, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams) + if err != nil { + return err + } + user.Password = hashedPassword + + return h.db.CreateUser(ctx, user) +} + +func (h *handler) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) { + return h.db.GetNewVideos(ctx, username) +} diff --git a/handler/handler_test.go b/handler/handler_test.go new file mode 100644 index 0000000..76ad44e --- /dev/null +++ b/handler/handler_test.go @@ -0,0 +1,50 @@ +package handler + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" + db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +var testConfig config.Config + +func init() { + testConfig = config.TestConfig() +} + +func TestGetNewVideos(t *testing.T) { + // Arrange + l := log.NewNopLogger() + + handler := New(l, &db_mock.DBMock{ + GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) { + return []*models.Video{ + { + VideoID: "test", + ChannelName: "test", + Title: "test", + PublishedTime: time.Now(), + WatchTime: nil, + }, + }, nil + }, + }) + + // Act + resp, err := handler.GetNewVideos(context.TODO(), "username") + + // Assert + if assert.NoError(t, err) { + if assert.NotNil(t, resp) { + assert.Equal(t, resp[0].VideoID, "test") + assert.Equal(t, resp[0].Title, "test") + } + } +} diff --git a/httpserver/auth/auth.go b/httpserver/auth/auth.go new file mode 100644 index 0000000..dbce9a3 --- /dev/null +++ b/httpserver/auth/auth.go @@ -0,0 +1,34 @@ +package auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" +) + +// AuthMiddleware will authenticate against a static API key +func AuthMiddleware(db db.DB) gin.HandlerFunc { + return func(c *gin.Context) { + username, password, ok := c.Request.BasicAuth() + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid basic auth header"}) + return + } + authenticated, err := db.AuthenticateUser(c.Request.Context(), username, password) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + if !authenticated { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) + return + } + + c.Set("username", username) + + // handle request + c.Next() + } +} diff --git a/httpserver/auth/auth_test.go b/httpserver/auth/auth_test.go new file mode 100644 index 0000000..33dee70 --- /dev/null +++ b/httpserver/auth/auth_test.go @@ -0,0 +1,68 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func setupTestServer() *http.Server { + db := &db_mock.DBMock{ + AuthenticateUserFunc: func(ctx context.Context, username, password string) (bool, error) { + return username == "username" && password == "password", nil + }, + } + + gin.SetMode(gin.TestMode) + router := gin.New() + // Middlewares are executed top to bottom in a stack-like manner + router.Use( + gin.Recovery(), // Recovery needs to go before other middlewares to catch panics + AuthMiddleware(db), + ) + router.GET("/", func(c *gin.Context) { + c.String(http.StatusOK, "OK") + }) + + return &http.Server{Handler: router} +} + +func TestSuccessfulAuthentication(t *testing.T) { + server := setupTestServer() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Add("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=") // username:password + server.Handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "OK", w.Body.String()) +} + +func TestMissingAuthorizationHeader(t *testing.T) { + server := setupTestServer() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) + server.Handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, `{"error":"invalid authorization header"}`, w.Body.String()) +} + +func TestWrongCredentials(t *testing.T) { + server := setupTestServer() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Add("Authorization", "Basic d3Jvbmc=") + server.Handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, `{"error":"invalid API Key"}`, w.Body.String()) +} diff --git a/httpserver/ytrssil/api_setup_test.go b/httpserver/ytrssil/api_setup_test.go new file mode 100644 index 0000000..7284a41 --- /dev/null +++ b/httpserver/ytrssil/api_setup_test.go @@ -0,0 +1,56 @@ +package ytrssil_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" +) + +var testConfig config.Config + +func init() { + // always use UTC + time.Local = time.UTC + testConfig = config.TestConfig() +} + +func setupTestServer(t *testing.T) *http.Server { + l := log.NewNopLogger() + + handler := handler.New(l, nil) + + gin.SetMode(gin.TestMode) + router, err := ytrssil.SetupGinRouter( + l, + handler, + auth.AuthMiddleware(nil), + ) + assert.Nil(t, err) + + return &http.Server{ + Addr: fmt.Sprintf(":%v", testConfig.Gin.Port), + Handler: router, + } +} + +func TestHealthz(t *testing.T) { + server := setupTestServer(t) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/healthz", nil) + server.Handler.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Equal(t, "healthy", w.Body.String()) +} diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go new file mode 100644 index 0000000..a0c6d6d --- /dev/null +++ b/httpserver/ytrssil/server.go @@ -0,0 +1,49 @@ +package ytrssil + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" +) + +type server struct { + log log.Logger + handler handler.Handler +} + +func NewServer(log log.Logger, handler handler.Handler) (*server, error) { + return &server{log: log, handler: handler}, nil +} + +func (s *server) Healthz(c *gin.Context) { + c.String(http.StatusOK, "healthy") +} + +// SetupGinRouter sets up routes for all APIs on a Gin server (aka router) +func SetupGinRouter(l log.Logger, handler handler.Handler, authMiddleware func(c *gin.Context)) (*gin.Engine, error) { + engine := gin.New() + // Middlewares are executed top to bottom in a stack-like manner + engine.Use( + gin.LoggerWithFormatter(log.GinFormatterWithUTCAndBodySize), + gin.Recovery(), // Recovery needs to go before other middlewares to catch panics + ) + + srv, err := NewServer(l, handler) + if err != nil { + return nil, err + } + engine.GET("/healthz", srv.Healthz) + engine.POST("/register", srv.CreateUser) + + // all APIs go in this routing group and require authentication + api := engine.Group("/api") + api.Use(authMiddleware) + { + api.GET("videos/new", srv.GetNewVideos) + } + + return engine, nil +} diff --git a/httpserver/ytrssil/users.go b/httpserver/ytrssil/users.go new file mode 100644 index 0000000..b4c5be9 --- /dev/null +++ b/httpserver/ytrssil/users.go @@ -0,0 +1,25 @@ +package ytrssil + +import ( + "net/http" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" + "github.com/gin-gonic/gin" +) + +func (s *server) CreateUser(c *gin.Context) { + var user models.User + err := c.BindJSON(&user) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = s.handler.CreateUser(c.Request.Context(), user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "user created"}) +} diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go new file mode 100644 index 0000000..e3ba2a0 --- /dev/null +++ b/httpserver/ytrssil/videos.go @@ -0,0 +1,21 @@ +package ytrssil + +import ( + "net/http" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" + "github.com/gin-gonic/gin" +) + +func (s *server) GetNewVideos(c *gin.Context) { + username := c.MustGet("username").(string) + videos, err := s.handler.GetNewVideos(c.Request.Context(), username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, models.GetNewVideosResponse{ + Videos: videos, + }) +} diff --git a/lib/log/log.go b/lib/log/log.go new file mode 100644 index 0000000..21b730f --- /dev/null +++ b/lib/log/log.go @@ -0,0 +1,61 @@ +package log + +import ( + "fmt" + "os" + + stdlog "log" + + "github.com/gin-gonic/gin" + "github.com/go-kit/log" +) + +type Logger interface { + Log(keyvals ...interface{}) error +} + +func NewLogger() Logger { + var logger log.Logger + logger = log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)) + stdlog.SetOutput(log.NewStdlibAdapter(logger)) + logger = log.With(logger, "ts", log.DefaultTimestampUTC) + // We're wrapping gotkit, so DefaultCaller would show: log.go:23 + logger = log.With(logger, "caller", log.Caller(3)) + + return logger +} + +// NewNopLogger is go-kit/log.NewNopLogger +func NewNopLogger() log.Logger { + return log.NewNopLogger() +} + +// NewSyncLogger is useful for debugging, use sparingly +func NewSyncLogger() log.Logger { + return log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)) +} + +// Str is used to log unknown types using fmt +func Str(i any) string { return fmt.Sprintf("%+v", i) } + +// GinFormatterWithUTCAndBodySize is the default gin loggger with: +// - UTC times +// - logs the response size in bytes +func GinFormatterWithUTCAndBodySize(param gin.LogFormatterParams) string { + var statusColor, methodColor, resetColor string + if param.IsOutputColor() { + statusColor = param.StatusCodeColor() + methodColor = param.MethodColor() + resetColor = param.ResetColor() + } + return fmt.Sprintf("[DUNE] %v |%s %3d %s| %13v | %6v bytes | %15s |%s %-7s %s %#v\n%s", + param.TimeStamp.UTC().Format("2006/01/02 15:04:05.000"), + statusColor, param.StatusCode, resetColor, + param.Latency, + param.BodySize, + param.ClientIP, + methodColor, param.Method, resetColor, + param.Path, + param.ErrorMessage, + ) +} diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql new file mode 100644 index 0000000..0205bcb --- /dev/null +++ b/migrations/000001_init.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS user_videos; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS videos; +DROP TABLE IF EXISTS channels; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql new file mode 100644 index 0000000..fe887b1 --- /dev/null +++ b/migrations/000001_init.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS channels ( + id text NOT NULL PRIMARY KEY, + name text NOT NULL, + feed_url text NOT NULL +); + +CREATE TABLE IF NOT EXISTS videos ( + id text NOT NULL PRIMARY KEY, + title text NOT NULL, + published_timestamp timestamp with time zone NOT NULL, + channel_id text NOT NULL REFERENCES channels(id) +); + +CREATE TABLE IF NOT EXISTS users ( + username text NOT NULL PRIMARY KEY, + password text NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_videos ( + id SERIAL PRIMARY KEY, + username text NOT NULL REFERENCES users(username), + video_id text NOT NULL REFERENCES videos(id), + watch_timestamp timestamp with time zone +); diff --git a/mocks/db/db.go b/mocks/db/db.go new file mode 100644 index 0000000..e2ed9ca --- /dev/null +++ b/mocks/db/db.go @@ -0,0 +1,189 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package db_mock + +import ( + "context" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" + "sync" +) + +// Ensure, that DBMock does implement db.DB. +// If this is not the case, regenerate this file with moq. +var _ db.DB = &DBMock{} + +// DBMock is a mock implementation of db.DB. +// +// func TestSomethingThatUsesDB(t *testing.T) { +// +// // make and configure a mocked db.DB +// mockedDB := &DBMock{ +// AuthenticateUserFunc: func(ctx context.Context, username string, password string) (bool, error) { +// panic("mock out the AuthenticateUser method") +// }, +// CreateUserFunc: func(ctx context.Context, user models.User) error { +// panic("mock out the CreateUser method") +// }, +// GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) { +// panic("mock out the GetNewVideos method") +// }, +// } +// +// // use mockedDB in code that requires db.DB +// // and then make assertions. +// +// } +type DBMock struct { + // AuthenticateUserFunc mocks the AuthenticateUser method. + AuthenticateUserFunc func(ctx context.Context, username string, password string) (bool, error) + + // CreateUserFunc mocks the CreateUser method. + CreateUserFunc func(ctx context.Context, user models.User) error + + // GetNewVideosFunc mocks the GetNewVideos method. + GetNewVideosFunc func(ctx context.Context, username string) ([]*models.Video, error) + + // calls tracks calls to the methods. + calls struct { + // AuthenticateUser holds details about calls to the AuthenticateUser method. + AuthenticateUser []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Username is the username argument value. + Username string + // Password is the password argument value. + Password string + } + // CreateUser holds details about calls to the CreateUser method. + CreateUser []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // User is the user argument value. + User models.User + } + // GetNewVideos holds details about calls to the GetNewVideos method. + GetNewVideos []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Username is the username argument value. + Username string + } + } + lockAuthenticateUser sync.RWMutex + lockCreateUser sync.RWMutex + lockGetNewVideos sync.RWMutex +} + +// AuthenticateUser calls AuthenticateUserFunc. +func (mock *DBMock) AuthenticateUser(ctx context.Context, username string, password string) (bool, error) { + if mock.AuthenticateUserFunc == nil { + panic("DBMock.AuthenticateUserFunc: method is nil but DB.AuthenticateUser was just called") + } + callInfo := struct { + Ctx context.Context + Username string + Password string + }{ + Ctx: ctx, + Username: username, + Password: password, + } + mock.lockAuthenticateUser.Lock() + mock.calls.AuthenticateUser = append(mock.calls.AuthenticateUser, callInfo) + mock.lockAuthenticateUser.Unlock() + return mock.AuthenticateUserFunc(ctx, username, password) +} + +// AuthenticateUserCalls gets all the calls that were made to AuthenticateUser. +// Check the length with: +// +// len(mockedDB.AuthenticateUserCalls()) +func (mock *DBMock) AuthenticateUserCalls() []struct { + Ctx context.Context + Username string + Password string +} { + var calls []struct { + Ctx context.Context + Username string + Password string + } + mock.lockAuthenticateUser.RLock() + calls = mock.calls.AuthenticateUser + mock.lockAuthenticateUser.RUnlock() + return calls +} + +// CreateUser calls CreateUserFunc. +func (mock *DBMock) CreateUser(ctx context.Context, user models.User) error { + if mock.CreateUserFunc == nil { + panic("DBMock.CreateUserFunc: method is nil but DB.CreateUser was just called") + } + callInfo := struct { + Ctx context.Context + User models.User + }{ + Ctx: ctx, + User: user, + } + mock.lockCreateUser.Lock() + mock.calls.CreateUser = append(mock.calls.CreateUser, callInfo) + mock.lockCreateUser.Unlock() + return mock.CreateUserFunc(ctx, user) +} + +// CreateUserCalls gets all the calls that were made to CreateUser. +// Check the length with: +// +// len(mockedDB.CreateUserCalls()) +func (mock *DBMock) CreateUserCalls() []struct { + Ctx context.Context + User models.User +} { + var calls []struct { + Ctx context.Context + User models.User + } + mock.lockCreateUser.RLock() + calls = mock.calls.CreateUser + mock.lockCreateUser.RUnlock() + return calls +} + +// GetNewVideos calls GetNewVideosFunc. +func (mock *DBMock) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) { + if mock.GetNewVideosFunc == nil { + panic("DBMock.GetNewVideosFunc: method is nil but DB.GetNewVideos was just called") + } + callInfo := struct { + Ctx context.Context + Username string + }{ + Ctx: ctx, + Username: username, + } + mock.lockGetNewVideos.Lock() + mock.calls.GetNewVideos = append(mock.calls.GetNewVideos, callInfo) + mock.lockGetNewVideos.Unlock() + return mock.GetNewVideosFunc(ctx, username) +} + +// GetNewVideosCalls gets all the calls that were made to GetNewVideos. +// Check the length with: +// +// len(mockedDB.GetNewVideosCalls()) +func (mock *DBMock) GetNewVideosCalls() []struct { + Ctx context.Context + Username string +} { + var calls []struct { + Ctx context.Context + Username string + } + mock.lockGetNewVideos.RLock() + calls = mock.calls.GetNewVideos + mock.lockGetNewVideos.RUnlock() + return calls +} diff --git a/models/channel.go b/models/channel.go new file mode 100644 index 0000000..0d779cb --- /dev/null +++ b/models/channel.go @@ -0,0 +1,10 @@ +package models + +type Channel struct { + // YouTube ID of the channel + ChannelID string `json:"channel_id" dynamodbav:"channel_id"` + // Name of the channel + Name string `json:"name" dynamodbav:"name"` + // Feed is the URL for the RSS feed + FeedURL string `json:"feed_url" dynamodbav:"feed_url"` +} diff --git a/models/http.go b/models/http.go new file mode 100644 index 0000000..c907f98 --- /dev/null +++ b/models/http.go @@ -0,0 +1,5 @@ +package models + +type GetNewVideosResponse struct { + Videos []*Video `json:"videos"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..a476da2 --- /dev/null +++ b/models/user.go @@ -0,0 +1,6 @@ +package models + +type User struct { + Username string `json:"username" db:"username"` + Password string `json:"password" db:"password"` +} diff --git a/models/video.go b/models/video.go new file mode 100644 index 0000000..b3a3fb1 --- /dev/null +++ b/models/video.go @@ -0,0 +1,18 @@ +package models + +import ( + "time" +) + +type Video struct { + // YouTube ID of the video + VideoID string `json:"video_id" db:"video_id"` + // Name of the channel the video belongs to + ChannelName string `json:"channel_name" db:"channel_name"` + // Title of the video + Title string `json:"title" db:"title"` + // Video publish timestamp + PublishedTime time.Time `json:"published_timestamp" db:"published_timestamp"` + // Video watch timestamp + WatchTime *time.Time `json:"watch_timestamp" db:"watch_timestamp"` +}