cb6c40728b4f0d65639cd9dc1ee8361bc48ccf95
- Author
- Pavle Portic <git@theedgeofrage.com>
- Committer
- Pavle Portic <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.dockerignore b/.dockerignore
2new file mode 100644
3index 0000000000000000000000000000000000000000..e6445b4dd0042b1297a4483ccff9d3c1cfa6dfcd
4--- /dev/null
5+++ b/.dockerignore
6@@ -0,0 +1,16 @@
7+# Ignore everything and explicitly include the things we need
8+*
9+.*
10+
11+!cmd
12+!config
13+!db
14+!handler
15+!httpserver
16+!lib
17+!mocks
18+!models
19+
20+!go.mod
21+!go.sum
22+!Makefile
23diff --git a/.gitignore b/.gitignore
24new file mode 100644
25index 0000000000000000000000000000000000000000..06b499509c8a359b8ed98a137c71f61b9b4b7657
26--- /dev/null
27+++ b/.gitignore
28@@ -0,0 +1,31 @@
29+# Binaries for programs and plugins
30+*.exe
31+*.exe~
32+*.dll
33+*.so
34+*.dylib
35+
36+# Test binary, built with `go test -c`
37+*.test
38+
39+# Output of the go coverage tool, specifically when used with LiteIDE
40+*.out
41+
42+debug
43+# editor temp files
44+*.swp
45+*.swo
46+*.orig
47+
48+# Local env files
49+*.env
50+
51+# Build artifacts and cache
52+**/bin
53+/dist/
54+
55+# Visual studio code configuration
56+.vscode/
57+
58+# secrets for local use that cannot be encrypted
59+k8s/local/patches/core-http-config.yaml
60diff --git a/.golangci.yml b/.golangci.yml
61new file mode 100644
62index 0000000000000000000000000000000000000000..9df66c947e8a1f356a8453dbcd3e6cde88ece7ee
63--- /dev/null
64+++ b/.golangci.yml
65@@ -0,0 +1,17 @@
66+---
67+linters:
68+ enable:
69+ - goimports
70+ - stylecheck
71+ - lll
72+ disable:
73+ - errcheck
74+
75+run:
76+ go: '1.17'
77+
78+issues:
79+ exclude-rules:
80+ - linters:
81+ - lll
82+ source: "// nolint:lll"
83diff --git a/.yamllint.yaml b/.yamllint.yaml
84new file mode 100644
85index 0000000000000000000000000000000000000000..eaeb396ef9547450de7152108c9a8af3a69b3de1
86--- /dev/null
87+++ b/.yamllint.yaml
88@@ -0,0 +1,8 @@
89+---
90+extends: default
91+# files managed by flux won't pass linting
92+ignore: |
93+ k8s/*/patches/deployment*
94+ k8s/*/secrets/*
95+rules:
96+ line-length: disable
97diff --git a/Dockerfile b/Dockerfile
98new file mode 100644
99index 0000000000000000000000000000000000000000..6ff0d9bbe009fec8d61895846128a260c7d2737d
100--- /dev/null
101+++ b/Dockerfile
102@@ -0,0 +1,20 @@
103+FROM golang:1.19-bullseye AS builder
104+RUN apt update && apt install -y make
105+
106+# first copy just enough to pull all dependencies, to cache this layer
107+COPY go.mod go.sum Makefile /app/
108+WORKDIR /app/
109+RUN make setup
110+
111+# lint, build, etc..
112+COPY . /app/
113+RUN make build
114+
115+FROM debian:bullseye-slim
116+RUN apt update \
117+ && apt install -y ca-certificates \
118+ && apt clean \
119+ && rm -rf /var/lib/apt/lists/*
120+
121+COPY --from=builder /app/dist/ /app/
122+ENTRYPOINT ["/app/ytrssil-api"]
123diff --git a/Makefile b/Makefile
124new file mode 100644
125index 0000000000000000000000000000000000000000..225859c98a1af1d649ef175bc9b80178fe6ae1dc
126--- /dev/null
127+++ b/Makefile
128@@ -0,0 +1,40 @@
129+.PHONY: all setup ytrssil-api build lint k8s-lint yamllint test test-initdb image-build
130+
131+DB_URI ?= postgres://ytrssil:ytrssil@localhost:5431/ytrssil?sslmode=disable
132+
133+all: lint test build
134+
135+setup: bin/golangci-lint
136+ go mod download
137+
138+ytrssil-api:
139+ go build -o dist/ytrssil-api cmd/main.go
140+
141+build: ytrssil-api
142+
143+bin/moq:
144+ GOBIN=$(PWD)/bin go install github.com/matryer/moq@v0.2.7
145+
146+gen-mocks: bin/moq
147+ ./bin/moq -pkg db_mock -out ./mocks/db/db.go ./db DB
148+ go fmt ./...
149+
150+bin/golangci-lint:
151+ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.48.0
152+
153+lint: bin/golangci-lint
154+ go fmt ./...
155+ go vet ./...
156+ bin/golangci-lint -c .golangci.yml run ./...
157+ go mod tidy
158+
159+test:
160+ go mod tidy
161+ go test -timeout=10s -race -benchmem ./...
162+
163+migrate:
164+ migrate -database "$(DB_URI)" -path migrations up
165+
166+image-build:
167+ @echo "# Building docker image..."
168+ docker build -t ytrssil-api -f Dockerfile .
169diff --git a/README.md b/README.md
170new file mode 100644
171index 0000000000000000000000000000000000000000..efb4ec792e301759441dc91172651182e60f16d1
172--- /dev/null
173+++ b/README.md
174@@ -0,0 +1 @@
175+# Ytrssil API
176diff --git a/cmd/main.go b/cmd/main.go
177new file mode 100644
178index 0000000000000000000000000000000000000000..a2631bf8a560035621fe120942315591789cb08c
179--- /dev/null
180+++ b/cmd/main.go
181@@ -0,0 +1,97 @@
182+package main
183+
184+import (
185+ "context"
186+ "fmt"
187+ "net/http"
188+ "os"
189+ "os/signal"
190+ "syscall"
191+ "time"
192+
193+ "github.com/gin-gonic/gin"
194+
195+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
196+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
197+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler"
198+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
199+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
200+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
201+)
202+
203+func init() {
204+ // always use UTC
205+ time.Local = time.UTC
206+}
207+
208+func main() {
209+ log := log.NewLogger()
210+
211+ config, err := config.Parse()
212+ if err != nil {
213+ log.Log("level", "FATAL", "call", "config.Parse", "error", err)
214+ return
215+ }
216+
217+ db, err := db.NewPSQLDB(log, config.DB)
218+ if err != nil {
219+ log.Log("level", "FATAL", "call", "db.NewPSQLDB", "error", err)
220+ return
221+ }
222+
223+ handler := handler.New(log, db)
224+ gin.SetMode(gin.ReleaseMode)
225+ router, err := ytrssil.SetupGinRouter(
226+ log,
227+ handler,
228+ auth.AuthMiddleware(db),
229+ )
230+ if err != nil {
231+ log.Log("level", "FATAL", "call", "ytrssil.SetupGinServer", "error", err)
232+ return
233+ }
234+
235+ server := &http.Server{
236+ Addr: fmt.Sprintf(":%v", config.Gin.Port),
237+ Handler: router,
238+ }
239+ server.RegisterOnShutdown(func() {
240+ log.Log("level", "INFO", "msg", "shutdown server.Close()")
241+ })
242+
243+ quit := make(chan os.Signal, 1)
244+ // handle Interrupt (ctrl-c) Term, used by `kill` et al, HUP which is commonly used to reload configs
245+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
246+
247+ go func() {
248+ s := <-quit
249+ log.Log(
250+ "level", "INFO",
251+ "msg", "signalRecv, for quitting",
252+ "signal", s,
253+ )
254+ if err := server.Shutdown(context.Background()); err != nil {
255+ log.Log(
256+ "level", "ERROR",
257+ "call", "server.Shutdown",
258+ "error", err,
259+ )
260+ }
261+ }()
262+
263+ log.Log(
264+ "level", "INFO",
265+ "msg", "ytrssil API is starting up",
266+ "port", config.Gin.Port,
267+ )
268+ if err := server.ListenAndServe(); err != nil {
269+ if err != http.ErrServerClosed {
270+ log.Log(
271+ "level", "ERROR",
272+ "call", "server.ListenAndServe",
273+ "error", err,
274+ )
275+ }
276+ }
277+ log.Log("level", "INFO", "msg", "exit complete")
278+}
279diff --git a/cmd/main_test.go b/cmd/main_test.go
280new file mode 100644
281index 0000000000000000000000000000000000000000..33583fd2d6bd1ff069a2cc354c00109dca340af7
282--- /dev/null
283+++ b/cmd/main_test.go
284@@ -0,0 +1,63 @@
285+package main
286+
287+import (
288+ "fmt"
289+ "net/http"
290+ "net/http/httptest"
291+ "testing"
292+
293+ "github.com/gin-gonic/gin"
294+ "github.com/stretchr/testify/assert"
295+
296+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
297+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
298+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler"
299+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
300+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
301+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
302+)
303+
304+var testConfig config.Config
305+
306+func init() {
307+ testConfig = config.TestConfig()
308+}
309+
310+func setupTestServer(t *testing.T, authEnabled bool) *http.Server {
311+ l := log.NewNopLogger()
312+
313+ db, err := db.NewPSQLDB(l, testConfig.DB)
314+ if !assert.NoError(t, err) {
315+ return nil
316+ }
317+
318+ handler := handler.New(l, db)
319+ gin.SetMode(gin.TestMode)
320+ router, err := ytrssil.SetupGinRouter(
321+ l,
322+ handler,
323+ auth.AuthMiddleware(db),
324+ )
325+ if !assert.NoError(t, err) {
326+ return nil
327+ }
328+
329+ return &http.Server{
330+ Addr: fmt.Sprintf(":%v", testConfig.Gin.Port),
331+ Handler: router,
332+ }
333+}
334+
335+func TestHealthz(t *testing.T) {
336+ server := setupTestServer(t, false)
337+ if !assert.NotNil(t, server) {
338+ return
339+ }
340+
341+ w := httptest.NewRecorder()
342+ req, _ := http.NewRequest("GET", "/healthz", nil)
343+ server.Handler.ServeHTTP(w, req)
344+
345+ assert.Equal(t, 200, w.Code)
346+ assert.Equal(t, "healthy", w.Body.String())
347+}
348diff --git a/config/config.go b/config/config.go
349new file mode 100644
350index 0000000000000000000000000000000000000000..5923c973b296ea9728defdc3c63a51bd2a0f8ec2
351--- /dev/null
352+++ b/config/config.go
353@@ -0,0 +1,57 @@
354+package config
355+
356+import (
357+ "os"
358+
359+ flags "github.com/jessevdk/go-flags"
360+)
361+
362+type DB struct {
363+ DBURI string `long:"db-uri" env:"DB_URI"`
364+}
365+
366+// Gin contains configuration for the gin framework
367+type Gin struct {
368+ Port int `long:"port" env:"PORT" default:"8080"`
369+}
370+
371+// Config ties together all configs
372+type Config struct {
373+ DB DB
374+ Gin Gin
375+}
376+
377+func getenvOrDefault(key string, defaultValue string) string {
378+ value, found := os.LookupEnv(key)
379+ if found {
380+ return value
381+ }
382+
383+ return defaultValue
384+}
385+
386+// Parse parses all the supplied configurations and returns
387+func Parse() (Config, error) {
388+ var config Config
389+ parser := flags.NewParser(&config, flags.Default)
390+ _, err := parser.Parse()
391+ return config, err
392+}
393+
394+// TestConfig returns a mostly hardcoded configuration used for running tests
395+func TestConfig() Config {
396+ dbURI := getenvOrDefault("DB_URI", "postgres://ytrssil:ytrssil@postgres:5432/ytrssil")
397+
398+ gin := Gin{
399+ Port: 8080,
400+ }
401+ db := DB{
402+ DBURI: dbURI,
403+ }
404+ config := Config{
405+ Gin: gin,
406+ DB: db,
407+ }
408+
409+ return config
410+}
411diff --git a/db/db.go b/db/db.go
412new file mode 100644
413index 0000000000000000000000000000000000000000..0632c65f5827a20b6393c6b62cc3fe893caa2346
414--- /dev/null
415+++ b/db/db.go
416@@ -0,0 +1,15 @@
417+package db
418+
419+import (
420+ "context"
421+
422+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
423+)
424+
425+// DB represents a database layer for getting video and channel data
426+type DB interface {
427+ // GetNewVideos returns unwatched videos from all channels
428+ GetNewVideos(ctx context.Context, username string) ([]*models.Video, error)
429+ AuthenticateUser(ctx context.Context, username string, password string) (bool, error)
430+ CreateUser(ctx context.Context, user models.User) error
431+}
432diff --git a/db/psql.go b/db/psql.go
433new file mode 100644
434index 0000000000000000000000000000000000000000..7045e05f625705a16fbc898f364c16ba98e19d62
435--- /dev/null
436+++ b/db/psql.go
437@@ -0,0 +1,27 @@
438+package db
439+
440+import (
441+ "database/sql"
442+
443+ _ "github.com/lib/pq"
444+
445+ ytrssilConfig "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
446+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
447+)
448+
449+type psqlDB struct {
450+ l log.Logger
451+ db *sql.DB
452+}
453+
454+func NewPSQLDB(log log.Logger, dbCfg ytrssilConfig.DB) (*psqlDB, error) {
455+ db, err := sql.Open("postgres", dbCfg.DBURI)
456+ if err != nil {
457+ return nil, err
458+ }
459+
460+ return &psqlDB{
461+ l: log,
462+ db: db,
463+ }, nil
464+}
465diff --git a/db/users.go b/db/users.go
466new file mode 100644
467index 0000000000000000000000000000000000000000..993da9265052b1d20ba3ba49a61179182c9257d4
468--- /dev/null
469+++ b/db/users.go
470@@ -0,0 +1,52 @@
471+package db
472+
473+import (
474+ "context"
475+ "fmt"
476+
477+ "github.com/alexedwards/argon2id"
478+ "github.com/georgysavva/scany/v2/sqlscan"
479+
480+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
481+)
482+
483+var authenticateUserQuery = `SELECT username, password FROM users WHERE username = $1`
484+
485+func (d *psqlDB) AuthenticateUser(ctx context.Context, username string, password string) (bool, error) {
486+ var user []*models.User
487+ err := sqlscan.Select(ctx, d.db, &user, authenticateUserQuery, username)
488+ if err != nil {
489+ d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to query user", "error", err)
490+ return false, err
491+ }
492+
493+ match, err := argon2id.ComparePasswordAndHash(password, user[0].Password)
494+ if err != nil {
495+ d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to check hashed passsword", "error", err)
496+ return false, err
497+ }
498+
499+ return match, nil
500+}
501+
502+var createUserQuery = `INSERT INTO users (username, password) VALUES ($1, $2)`
503+
504+func (d *psqlDB) CreateUser(ctx context.Context, user models.User) error {
505+ res, err := d.db.Exec(createUserQuery, user.Username, user.Password)
506+ if err != nil {
507+ d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to create user", "error", err)
508+ return err
509+ }
510+ affected, err := res.RowsAffected()
511+ if err != nil {
512+ d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err)
513+ return err
514+ }
515+
516+ if affected != 1 {
517+ d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err)
518+ return fmt.Errorf("expected to insert one row, but %d were inserted", affected)
519+ }
520+
521+ return nil
522+}
523diff --git a/db/videos.go b/db/videos.go
524new file mode 100644
525index 0000000000000000000000000000000000000000..ebe995edbc00fd2a0ddcc0297cd022b2f2a3f981
526--- /dev/null
527+++ b/db/videos.go
528@@ -0,0 +1,37 @@
529+package db
530+
531+import (
532+ "context"
533+
534+ "github.com/georgysavva/scany/v2/sqlscan"
535+
536+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
537+)
538+
539+var getNewVideosQuery = `
540+ SELECT
541+ video_id
542+ , title
543+ , published_timestamp
544+ , watch_timestamp
545+ , name as channel_name
546+ FROM user_videos
547+ LEFT JOIN videos ON video_id=videos.id
548+ LEFT JOIN channels ON channel_id=channels.id
549+ WHERE
550+ 1=1
551+ AND watch_timestamp IS NULL
552+ AND username=$1
553+ ORDER BY published_timestamp
554+`
555+
556+func (d *psqlDB) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) {
557+ var videos []*models.Video
558+ err := sqlscan.Select(ctx, d.db, &videos, getNewVideosQuery, username)
559+ if err != nil {
560+ d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "msg", "failed to query new videos", "error", err)
561+ return nil, err
562+ }
563+
564+ return videos, nil
565+}
566diff --git a/docker-compose.yml b/docker-compose.yml
567new file mode 100644
568index 0000000000000000000000000000000000000000..2fcc394dd6df3e6c52762d0b162adee42aea617b
569--- /dev/null
570+++ b/docker-compose.yml
571@@ -0,0 +1,38 @@
572+---
573+version: "3"
574+services:
575+ postgres:
576+ image: postgres:14-alpine
577+ restart: unless-stopped
578+ ports:
579+ - "5431:5432"
580+ environment:
581+ POSTGRES_PASSWORD: ytrssil
582+ POSTGRES_USER: ytrssil
583+ POSTGRES_DB: ytrssil
584+ volumes:
585+ - postgres-data:/var/lib/postgresql/data
586+ healthcheck:
587+ test: pg_isready
588+ interval: 5s
589+ timeout: 2s
590+ retries: 5
591+
592+ api:
593+ build: .
594+ restart: unless-stopped
595+ depends_on:
596+ - postgres
597+ environment:
598+ DB_URI: "postgresql://ytrssil:ytrssil@postgres/ytrssil?sslmode=disable"
599+ PORT: "80"
600+ ports:
601+ - "8080:80"
602+ healthcheck:
603+ test: "curl http://localhost:80/healthz"
604+ interval: 5s
605+ retries: 5
606+ timeout: 2s
607+
608+volumes:
609+ postgres-data:
610diff --git a/go.mod b/go.mod
611new file mode 100644
612index 0000000000000000000000000000000000000000..3ecac63c2506b9236a5931a8c508c30f35378b1e
613--- /dev/null
614+++ b/go.mod
615@@ -0,0 +1,40 @@
616+module gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api
617+
618+go 1.19
619+
620+require (
621+ github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
622+ github.com/georgysavva/scany/v2 v2.0.0-alpha.3
623+ github.com/gin-gonic/gin v1.8.1
624+ github.com/go-kit/log v0.2.1
625+ github.com/jessevdk/go-flags v1.5.0
626+ github.com/lib/pq v1.10.7
627+ github.com/stretchr/testify v1.8.0
628+)
629+
630+require (
631+ github.com/davecgh/go-spew v1.1.1 // indirect
632+ github.com/gin-contrib/sse v0.1.0 // indirect
633+ github.com/go-logfmt/logfmt v0.5.1 // indirect
634+ github.com/go-playground/locales v0.14.0 // indirect
635+ github.com/go-playground/universal-translator v0.18.0 // indirect
636+ github.com/go-playground/validator/v10 v10.11.0 // indirect
637+ github.com/goccy/go-json v0.9.11 // indirect
638+ github.com/google/go-cmp v0.5.8 // indirect
639+ github.com/jackc/pgx/v5 v5.0.3 // indirect
640+ github.com/json-iterator/go v1.1.12 // indirect
641+ github.com/leodido/go-urn v1.2.1 // indirect
642+ github.com/mattn/go-isatty v0.0.16 // indirect
643+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
644+ github.com/modern-go/reflect2 v1.0.2 // indirect
645+ github.com/pelletier/go-toml/v2 v2.0.5 // indirect
646+ github.com/pmezard/go-difflib v1.0.0 // indirect
647+ github.com/ugorji/go/codec v1.2.7 // indirect
648+ golang.org/x/crypto v0.1.0 // indirect
649+ golang.org/x/net v0.1.0 // indirect
650+ golang.org/x/sys v0.1.0 // indirect
651+ golang.org/x/text v0.4.0 // indirect
652+ google.golang.org/protobuf v1.28.0 // indirect
653+ gopkg.in/yaml.v2 v2.4.0 // indirect
654+ gopkg.in/yaml.v3 v3.0.1 // indirect
655+)
656diff --git a/go.sum b/go.sum
657new file mode 100644
658index 0000000000000000000000000000000000000000..72d3f1ddd805a8f525478f584b3a555ab67842f2
659--- /dev/null
660+++ b/go.sum
661@@ -0,0 +1,118 @@
662+github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I=
663+github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc=
664+github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs=
665+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
666+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
667+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
668+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
669+github.com/georgysavva/scany/v2 v2.0.0-alpha.3 h1:+n7kJr/xyVQJcsE2ICn0jd/a4QaxaWnx+a+GJQTGRrQ=
670+github.com/georgysavva/scany/v2 v2.0.0-alpha.3/go.mod h1:sigOdh+0qb/+aOs3TVhehVT10p8qJL7K/Zhyz8vWo38=
671+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
672+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
673+github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
674+github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
675+github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
676+github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
677+github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
678+github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
679+github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
680+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
681+github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
682+github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
683+github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
684+github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
685+github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw=
686+github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
687+github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk=
688+github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
689+github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
690+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
691+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
692+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
693+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
694+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
695+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
696+github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
697+github.com/jackc/pgx/v5 v5.0.3 h1:4flM5ecR/555F0EcnjdaZa6MhBU+nr0QbZIo5vaKjuM=
698+github.com/jackc/pgx/v5 v5.0.3/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o=
699+github.com/jackc/puddle/v2 v2.0.0 h1:Kwk/AlLigcnZsDssc3Zun1dk1tAtQNPaBBxBHWn0Mjc=
700+github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
701+github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
702+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
703+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
704+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
705+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
706+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
707+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
708+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
709+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
710+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
711+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
712+github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
713+github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
714+github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
715+github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
716+github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
717+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
718+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
719+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
720+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
721+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
722+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
723+github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg=
724+github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
725+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
726+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
727+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
728+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
729+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
730+github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
731+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
732+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
733+github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
734+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
735+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
736+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
737+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
738+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
739+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
740+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
741+github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
742+github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
743+github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
744+golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
745+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
746+golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
747+golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
748+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
749+golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
750+golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
751+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
752+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
753+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
754+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
755+golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
756+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
757+golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
758+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
759+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
760+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
761diff --git a/handler/handler.go b/handler/handler.go
762new file mode 100644
763index 0000000000000000000000000000000000000000..d1dbdfbfbe224046ba2025bf74ee8321e104a54d
764--- /dev/null
765+++ b/handler/handler.go
766@@ -0,0 +1,39 @@
767+package handler
768+
769+import (
770+ "context"
771+
772+ "github.com/alexedwards/argon2id"
773+
774+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
775+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
776+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
777+)
778+
779+type Handler interface {
780+ CreateUser(ctx context.Context, user models.User) error
781+ GetNewVideos(ctx context.Context, username string) ([]*models.Video, error)
782+}
783+
784+type handler struct {
785+ log log.Logger
786+ db db.DB
787+}
788+
789+func New(log log.Logger, db db.DB) *handler {
790+ return &handler{log: log, db: db}
791+}
792+
793+func (h *handler) CreateUser(ctx context.Context, user models.User) error {
794+ hashedPassword, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams)
795+ if err != nil {
796+ return err
797+ }
798+ user.Password = hashedPassword
799+
800+ return h.db.CreateUser(ctx, user)
801+}
802+
803+func (h *handler) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) {
804+ return h.db.GetNewVideos(ctx, username)
805+}
806diff --git a/handler/handler_test.go b/handler/handler_test.go
807new file mode 100644
808index 0000000000000000000000000000000000000000..76ad44e43f0c24f9f047468c6cfa962020426125
809--- /dev/null
810+++ b/handler/handler_test.go
811@@ -0,0 +1,50 @@
812+package handler
813+
814+import (
815+ "context"
816+ "testing"
817+ "time"
818+
819+ "github.com/stretchr/testify/assert"
820+
821+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
822+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
823+ db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db"
824+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
825+)
826+
827+var testConfig config.Config
828+
829+func init() {
830+ testConfig = config.TestConfig()
831+}
832+
833+func TestGetNewVideos(t *testing.T) {
834+ // Arrange
835+ l := log.NewNopLogger()
836+
837+ handler := New(l, &db_mock.DBMock{
838+ GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) {
839+ return []*models.Video{
840+ {
841+ VideoID: "test",
842+ ChannelName: "test",
843+ Title: "test",
844+ PublishedTime: time.Now(),
845+ WatchTime: nil,
846+ },
847+ }, nil
848+ },
849+ })
850+
851+ // Act
852+ resp, err := handler.GetNewVideos(context.TODO(), "username")
853+
854+ // Assert
855+ if assert.NoError(t, err) {
856+ if assert.NotNil(t, resp) {
857+ assert.Equal(t, resp[0].VideoID, "test")
858+ assert.Equal(t, resp[0].Title, "test")
859+ }
860+ }
861+}
862diff --git a/httpserver/auth/auth.go b/httpserver/auth/auth.go
863new file mode 100644
864index 0000000000000000000000000000000000000000..dbce9a38fd76aeeff8bab043f978cda357eed3f0
865--- /dev/null
866+++ b/httpserver/auth/auth.go
867@@ -0,0 +1,34 @@
868+package auth
869+
870+import (
871+ "net/http"
872+
873+ "github.com/gin-gonic/gin"
874+
875+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
876+)
877+
878+// AuthMiddleware will authenticate against a static API key
879+func AuthMiddleware(db db.DB) gin.HandlerFunc {
880+ return func(c *gin.Context) {
881+ username, password, ok := c.Request.BasicAuth()
882+ if !ok {
883+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid basic auth header"})
884+ return
885+ }
886+ authenticated, err := db.AuthenticateUser(c.Request.Context(), username, password)
887+ if err != nil {
888+ c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
889+ return
890+ }
891+ if !authenticated {
892+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
893+ return
894+ }
895+
896+ c.Set("username", username)
897+
898+ // handle request
899+ c.Next()
900+ }
901+}
902diff --git a/httpserver/auth/auth_test.go b/httpserver/auth/auth_test.go
903new file mode 100644
904index 0000000000000000000000000000000000000000..33dee70b866eed94d03f4439711f6e6412f7a289
905--- /dev/null
906+++ b/httpserver/auth/auth_test.go
907@@ -0,0 +1,68 @@
908+package auth
909+
910+import (
911+ "context"
912+ "net/http"
913+ "net/http/httptest"
914+ "testing"
915+
916+ db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db"
917+ "github.com/gin-gonic/gin"
918+ "github.com/stretchr/testify/assert"
919+)
920+
921+func setupTestServer() *http.Server {
922+ db := &db_mock.DBMock{
923+ AuthenticateUserFunc: func(ctx context.Context, username, password string) (bool, error) {
924+ return username == "username" && password == "password", nil
925+ },
926+ }
927+
928+ gin.SetMode(gin.TestMode)
929+ router := gin.New()
930+ // Middlewares are executed top to bottom in a stack-like manner
931+ router.Use(
932+ gin.Recovery(), // Recovery needs to go before other middlewares to catch panics
933+ AuthMiddleware(db),
934+ )
935+ router.GET("/", func(c *gin.Context) {
936+ c.String(http.StatusOK, "OK")
937+ })
938+
939+ return &http.Server{Handler: router}
940+}
941+
942+func TestSuccessfulAuthentication(t *testing.T) {
943+ server := setupTestServer()
944+
945+ w := httptest.NewRecorder()
946+ req, _ := http.NewRequest("GET", "/", nil)
947+ req.Header.Add("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=") // username:password
948+ server.Handler.ServeHTTP(w, req)
949+
950+ assert.Equal(t, http.StatusOK, w.Code)
951+ assert.Equal(t, "OK", w.Body.String())
952+}
953+
954+func TestMissingAuthorizationHeader(t *testing.T) {
955+ server := setupTestServer()
956+
957+ w := httptest.NewRecorder()
958+ req, _ := http.NewRequest("GET", "/", nil)
959+ server.Handler.ServeHTTP(w, req)
960+
961+ assert.Equal(t, http.StatusUnauthorized, w.Code)
962+ assert.Equal(t, `{"error":"invalid authorization header"}`, w.Body.String())
963+}
964+
965+func TestWrongCredentials(t *testing.T) {
966+ server := setupTestServer()
967+
968+ w := httptest.NewRecorder()
969+ req, _ := http.NewRequest("GET", "/", nil)
970+ req.Header.Add("Authorization", "Basic d3Jvbmc=")
971+ server.Handler.ServeHTTP(w, req)
972+
973+ assert.Equal(t, http.StatusUnauthorized, w.Code)
974+ assert.Equal(t, `{"error":"invalid API Key"}`, w.Body.String())
975+}
976diff --git a/httpserver/ytrssil/api_setup_test.go b/httpserver/ytrssil/api_setup_test.go
977new file mode 100644
978index 0000000000000000000000000000000000000000..7284a41415d8101e9b73b62f97f1aec5f87e14a4
979--- /dev/null
980+++ b/httpserver/ytrssil/api_setup_test.go
981@@ -0,0 +1,56 @@
982+package ytrssil_test
983+
984+import (
985+ "fmt"
986+ "net/http"
987+ "net/http/httptest"
988+ "testing"
989+ "time"
990+
991+ "github.com/gin-gonic/gin"
992+ "github.com/stretchr/testify/assert"
993+
994+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
995+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler"
996+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
997+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
998+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
999+)
1000+
1001+var testConfig config.Config
1002+
1003+func init() {
1004+ // always use UTC
1005+ time.Local = time.UTC
1006+ testConfig = config.TestConfig()
1007+}
1008+
1009+func setupTestServer(t *testing.T) *http.Server {
1010+ l := log.NewNopLogger()
1011+
1012+ handler := handler.New(l, nil)
1013+
1014+ gin.SetMode(gin.TestMode)
1015+ router, err := ytrssil.SetupGinRouter(
1016+ l,
1017+ handler,
1018+ auth.AuthMiddleware(nil),
1019+ )
1020+ assert.Nil(t, err)
1021+
1022+ return &http.Server{
1023+ Addr: fmt.Sprintf(":%v", testConfig.Gin.Port),
1024+ Handler: router,
1025+ }
1026+}
1027+
1028+func TestHealthz(t *testing.T) {
1029+ server := setupTestServer(t)
1030+
1031+ w := httptest.NewRecorder()
1032+ req, _ := http.NewRequest("GET", "/healthz", nil)
1033+ server.Handler.ServeHTTP(w, req)
1034+
1035+ assert.Equal(t, 200, w.Code)
1036+ assert.Equal(t, "healthy", w.Body.String())
1037+}
1038diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
1039new file mode 100644
1040index 0000000000000000000000000000000000000000..a0c6d6da11351cf301367aa33aa28049755263d6
1041--- /dev/null
1042+++ b/httpserver/ytrssil/server.go
1043@@ -0,0 +1,49 @@
1044+package ytrssil
1045+
1046+import (
1047+ "net/http"
1048+
1049+ "github.com/gin-gonic/gin"
1050+
1051+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler"
1052+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
1053+)
1054+
1055+type server struct {
1056+ log log.Logger
1057+ handler handler.Handler
1058+}
1059+
1060+func NewServer(log log.Logger, handler handler.Handler) (*server, error) {
1061+ return &server{log: log, handler: handler}, nil
1062+}
1063+
1064+func (s *server) Healthz(c *gin.Context) {
1065+ c.String(http.StatusOK, "healthy")
1066+}
1067+
1068+// SetupGinRouter sets up routes for all APIs on a Gin server (aka router)
1069+func SetupGinRouter(l log.Logger, handler handler.Handler, authMiddleware func(c *gin.Context)) (*gin.Engine, error) {
1070+ engine := gin.New()
1071+ // Middlewares are executed top to bottom in a stack-like manner
1072+ engine.Use(
1073+ gin.LoggerWithFormatter(log.GinFormatterWithUTCAndBodySize),
1074+ gin.Recovery(), // Recovery needs to go before other middlewares to catch panics
1075+ )
1076+
1077+ srv, err := NewServer(l, handler)
1078+ if err != nil {
1079+ return nil, err
1080+ }
1081+ engine.GET("/healthz", srv.Healthz)
1082+ engine.POST("/register", srv.CreateUser)
1083+
1084+ // all APIs go in this routing group and require authentication
1085+ api := engine.Group("/api")
1086+ api.Use(authMiddleware)
1087+ {
1088+ api.GET("videos/new", srv.GetNewVideos)
1089+ }
1090+
1091+ return engine, nil
1092+}
1093diff --git a/httpserver/ytrssil/users.go b/httpserver/ytrssil/users.go
1094new file mode 100644
1095index 0000000000000000000000000000000000000000..b4c5be9ef4aad9d2304388f06b2de25611d090dd
1096--- /dev/null
1097+++ b/httpserver/ytrssil/users.go
1098@@ -0,0 +1,25 @@
1099+package ytrssil
1100+
1101+import (
1102+ "net/http"
1103+
1104+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
1105+ "github.com/gin-gonic/gin"
1106+)
1107+
1108+func (s *server) CreateUser(c *gin.Context) {
1109+ var user models.User
1110+ err := c.BindJSON(&user)
1111+ if err != nil {
1112+ c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
1113+ return
1114+ }
1115+
1116+ err = s.handler.CreateUser(c.Request.Context(), user)
1117+ if err != nil {
1118+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
1119+ return
1120+ }
1121+
1122+ c.JSON(http.StatusOK, gin.H{"msg": "user created"})
1123+}
1124diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go
1125new file mode 100644
1126index 0000000000000000000000000000000000000000..e3ba2a02decbd4c023cfbe6dbb58a7226092aabf
1127--- /dev/null
1128+++ b/httpserver/ytrssil/videos.go
1129@@ -0,0 +1,21 @@
1130+package ytrssil
1131+
1132+import (
1133+ "net/http"
1134+
1135+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
1136+ "github.com/gin-gonic/gin"
1137+)
1138+
1139+func (s *server) GetNewVideos(c *gin.Context) {
1140+ username := c.MustGet("username").(string)
1141+ videos, err := s.handler.GetNewVideos(c.Request.Context(), username)
1142+ if err != nil {
1143+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
1144+ return
1145+ }
1146+
1147+ c.JSON(http.StatusOK, models.GetNewVideosResponse{
1148+ Videos: videos,
1149+ })
1150+}
1151diff --git a/lib/log/log.go b/lib/log/log.go
1152new file mode 100644
1153index 0000000000000000000000000000000000000000..21b730fda5dd24721d6cc0d6c723f16c42ca3256
1154--- /dev/null
1155+++ b/lib/log/log.go
1156@@ -0,0 +1,61 @@
1157+package log
1158+
1159+import (
1160+ "fmt"
1161+ "os"
1162+
1163+ stdlog "log"
1164+
1165+ "github.com/gin-gonic/gin"
1166+ "github.com/go-kit/log"
1167+)
1168+
1169+type Logger interface {
1170+ Log(keyvals ...interface{}) error
1171+}
1172+
1173+func NewLogger() Logger {
1174+ var logger log.Logger
1175+ logger = log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr))
1176+ stdlog.SetOutput(log.NewStdlibAdapter(logger))
1177+ logger = log.With(logger, "ts", log.DefaultTimestampUTC)
1178+ // We're wrapping gotkit, so DefaultCaller would show: log.go:23
1179+ logger = log.With(logger, "caller", log.Caller(3))
1180+
1181+ return logger
1182+}
1183+
1184+// NewNopLogger is go-kit/log.NewNopLogger
1185+func NewNopLogger() log.Logger {
1186+ return log.NewNopLogger()
1187+}
1188+
1189+// NewSyncLogger is useful for debugging, use sparingly
1190+func NewSyncLogger() log.Logger {
1191+ return log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr))
1192+}
1193+
1194+// Str is used to log unknown types using fmt
1195+func Str(i any) string { return fmt.Sprintf("%+v", i) }
1196+
1197+// GinFormatterWithUTCAndBodySize is the default gin loggger with:
1198+// - UTC times
1199+// - logs the response size in bytes
1200+func GinFormatterWithUTCAndBodySize(param gin.LogFormatterParams) string {
1201+ var statusColor, methodColor, resetColor string
1202+ if param.IsOutputColor() {
1203+ statusColor = param.StatusCodeColor()
1204+ methodColor = param.MethodColor()
1205+ resetColor = param.ResetColor()
1206+ }
1207+ return fmt.Sprintf("[DUNE] %v |%s %3d %s| %13v | %6v bytes | %15s |%s %-7s %s %#v\n%s",
1208+ param.TimeStamp.UTC().Format("2006/01/02 15:04:05.000"),
1209+ statusColor, param.StatusCode, resetColor,
1210+ param.Latency,
1211+ param.BodySize,
1212+ param.ClientIP,
1213+ methodColor, param.Method, resetColor,
1214+ param.Path,
1215+ param.ErrorMessage,
1216+ )
1217+}
1218diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql
1219new file mode 100644
1220index 0000000000000000000000000000000000000000..0205bcb86a5095c48e03d2dc9a21d2f6a50f8331
1221--- /dev/null
1222+++ b/migrations/000001_init.down.sql
1223@@ -0,0 +1,4 @@
1224+DROP TABLE IF EXISTS user_videos;
1225+DROP TABLE IF EXISTS users;
1226+DROP TABLE IF EXISTS videos;
1227+DROP TABLE IF EXISTS channels;
1228diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql
1229new file mode 100644
1230index 0000000000000000000000000000000000000000..fe887b1bb7cfccc754dd9f2b1d449e8273abdc08
1231--- /dev/null
1232+++ b/migrations/000001_init.up.sql
1233@@ -0,0 +1,24 @@
1234+CREATE TABLE IF NOT EXISTS channels (
1235+ id text NOT NULL PRIMARY KEY,
1236+ name text NOT NULL,
1237+ feed_url text NOT NULL
1238+);
1239+
1240+CREATE TABLE IF NOT EXISTS videos (
1241+ id text NOT NULL PRIMARY KEY,
1242+ title text NOT NULL,
1243+ published_timestamp timestamp with time zone NOT NULL,
1244+ channel_id text NOT NULL REFERENCES channels(id)
1245+);
1246+
1247+CREATE TABLE IF NOT EXISTS users (
1248+ username text NOT NULL PRIMARY KEY,
1249+ password text NOT NULL
1250+);
1251+
1252+CREATE TABLE IF NOT EXISTS user_videos (
1253+ id SERIAL PRIMARY KEY,
1254+ username text NOT NULL REFERENCES users(username),
1255+ video_id text NOT NULL REFERENCES videos(id),
1256+ watch_timestamp timestamp with time zone
1257+);
1258diff --git a/mocks/db/db.go b/mocks/db/db.go
1259new file mode 100644
1260index 0000000000000000000000000000000000000000..e2ed9ca7a9469f8df886ebc215a4affbbbe61234
1261--- /dev/null
1262+++ b/mocks/db/db.go
1263@@ -0,0 +1,189 @@
1264+// Code generated by moq; DO NOT EDIT.
1265+// github.com/matryer/moq
1266+
1267+package db_mock
1268+
1269+import (
1270+ "context"
1271+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
1272+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
1273+ "sync"
1274+)
1275+
1276+// Ensure, that DBMock does implement db.DB.
1277+// If this is not the case, regenerate this file with moq.
1278+var _ db.DB = &DBMock{}
1279+
1280+// DBMock is a mock implementation of db.DB.
1281+//
1282+// func TestSomethingThatUsesDB(t *testing.T) {
1283+//
1284+// // make and configure a mocked db.DB
1285+// mockedDB := &DBMock{
1286+// AuthenticateUserFunc: func(ctx context.Context, username string, password string) (bool, error) {
1287+// panic("mock out the AuthenticateUser method")
1288+// },
1289+// CreateUserFunc: func(ctx context.Context, user models.User) error {
1290+// panic("mock out the CreateUser method")
1291+// },
1292+// GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) {
1293+// panic("mock out the GetNewVideos method")
1294+// },
1295+// }
1296+//
1297+// // use mockedDB in code that requires db.DB
1298+// // and then make assertions.
1299+//
1300+// }
1301+type DBMock struct {
1302+ // AuthenticateUserFunc mocks the AuthenticateUser method.
1303+ AuthenticateUserFunc func(ctx context.Context, username string, password string) (bool, error)
1304+
1305+ // CreateUserFunc mocks the CreateUser method.
1306+ CreateUserFunc func(ctx context.Context, user models.User) error
1307+
1308+ // GetNewVideosFunc mocks the GetNewVideos method.
1309+ GetNewVideosFunc func(ctx context.Context, username string) ([]*models.Video, error)
1310+
1311+ // calls tracks calls to the methods.
1312+ calls struct {
1313+ // AuthenticateUser holds details about calls to the AuthenticateUser method.
1314+ AuthenticateUser []struct {
1315+ // Ctx is the ctx argument value.
1316+ Ctx context.Context
1317+ // Username is the username argument value.
1318+ Username string
1319+ // Password is the password argument value.
1320+ Password string
1321+ }
1322+ // CreateUser holds details about calls to the CreateUser method.
1323+ CreateUser []struct {
1324+ // Ctx is the ctx argument value.
1325+ Ctx context.Context
1326+ // User is the user argument value.
1327+ User models.User
1328+ }
1329+ // GetNewVideos holds details about calls to the GetNewVideos method.
1330+ GetNewVideos []struct {
1331+ // Ctx is the ctx argument value.
1332+ Ctx context.Context
1333+ // Username is the username argument value.
1334+ Username string
1335+ }
1336+ }
1337+ lockAuthenticateUser sync.RWMutex
1338+ lockCreateUser sync.RWMutex
1339+ lockGetNewVideos sync.RWMutex
1340+}
1341+
1342+// AuthenticateUser calls AuthenticateUserFunc.
1343+func (mock *DBMock) AuthenticateUser(ctx context.Context, username string, password string) (bool, error) {
1344+ if mock.AuthenticateUserFunc == nil {
1345+ panic("DBMock.AuthenticateUserFunc: method is nil but DB.AuthenticateUser was just called")
1346+ }
1347+ callInfo := struct {
1348+ Ctx context.Context
1349+ Username string
1350+ Password string
1351+ }{
1352+ Ctx: ctx,
1353+ Username: username,
1354+ Password: password,
1355+ }
1356+ mock.lockAuthenticateUser.Lock()
1357+ mock.calls.AuthenticateUser = append(mock.calls.AuthenticateUser, callInfo)
1358+ mock.lockAuthenticateUser.Unlock()
1359+ return mock.AuthenticateUserFunc(ctx, username, password)
1360+}
1361+
1362+// AuthenticateUserCalls gets all the calls that were made to AuthenticateUser.
1363diff --git a/models/channel.go b/models/channel.go
1364new file mode 100644
1365index 0000000000000000000000000000000000000000..0d779cb22e1e3728e0727d316647526f3a032fe3
1366--- /dev/null
1367+++ b/models/channel.go
1368@@ -0,0 +1,10 @@
1369+package models
1370+
1371+type Channel struct {
1372+ // YouTube ID of the channel
1373+ ChannelID string `json:"channel_id" dynamodbav:"channel_id"`
1374+ // Name of the channel
1375+ Name string `json:"name" dynamodbav:"name"`
1376+ // Feed is the URL for the RSS feed
1377+ FeedURL string `json:"feed_url" dynamodbav:"feed_url"`
1378+}
1379diff --git a/models/http.go b/models/http.go
1380new file mode 100644
1381index 0000000000000000000000000000000000000000..c907f98ea25ff66d8cf4022fe9d6fe301fc764e2
1382--- /dev/null
1383+++ b/models/http.go
1384@@ -0,0 +1,5 @@
1385+package models
1386+
1387+type GetNewVideosResponse struct {
1388+ Videos []*Video `json:"videos"`
1389+}
1390diff --git a/models/user.go b/models/user.go
1391new file mode 100644
1392index 0000000000000000000000000000000000000000..a476da2efac83bcd59137e276588b3c5fa518c8b
1393--- /dev/null
1394+++ b/models/user.go
1395@@ -0,0 +1,6 @@
1396+package models
1397+
1398+type User struct {
1399+ Username string `json:"username" db:"username"`
1400+ Password string `json:"password" db:"password"`
1401+}
1402diff --git a/models/video.go b/models/video.go
1403new file mode 100644
1404index 0000000000000000000000000000000000000000..b3a3fb1bc1ee5837c4bd71f5df683abc22eac915
1405--- /dev/null
1406+++ b/models/video.go
1407@@ -0,0 +1,18 @@
1408+package models
1409+
1410+import (
1411+ "time"
1412+)
1413+
1414+type Video struct {
1415+ // YouTube ID of the video
1416+ VideoID string `json:"video_id" db:"video_id"`
1417+ // Name of the channel the video belongs to
1418+ ChannelName string `json:"channel_name" db:"channel_name"`
1419+ // Title of the video
1420+ Title string `json:"title" db:"title"`
1421+ // Video publish timestamp
1422+ PublishedTime time.Time `json:"published_timestamp" db:"published_timestamp"`
1423+ // Video watch timestamp
1424+ WatchTime *time.Time `json:"watch_timestamp" db:"watch_timestamp"`
1425+}