cb6c40728b4f0d65639cd9dc1ee8361bc48ccf95

Author
Pavle Portic <git@theedgeofrage.com>
Committer
Pavle Portic <git@theedgeofrage.com>
Date

Message

Initial commit

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