fcb3de74975de626df4bc6009309f0c21fdda20f

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Remove multi user support

Diff

This diff is truncated to protect this page.

   1diff --git a/.air.toml b/.air.toml
   2index 1d3745f0e4a16dc45ab0667ff503cbfe0e1d4d8b..97c50c5d88668fe6ee05d242de2076d62233f38b 100644
   3--- a/.air.toml
   4+++ b/.air.toml
   5@@ -4,6 +4,7 @@ tmp_dir = "/tmp"
   6 [build]
   7   args_bin = [
   8     "--db-uri=postgres://ytrssil:ytrssil@localhost:5432/ytrssil?sslmode=disable",
   9+    "--auth-token=foo",
  10   ]
  11   entrypoint = "./dist/ytrssil"
  12   cmd = "make build"
  13diff --git a/cmd/main.go b/cmd/main.go
  14index 728a32ca960f9c812dfc566e2437f21b3bd89698..ef0a991aa8e25d311446a3006cd5438ebb15f107 100644
  15--- a/cmd/main.go
  16+++ b/cmd/main.go
  17@@ -21,14 +21,12 @@ import (
  18 	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
  19 )
  20 
  21-const LevelFatal slog.Level = slog.LevelError + 4
  22-
  23 func init() {
  24 	// always use UTC
  25 	time.Local = time.UTC
  26 }
  27 
  28-func fetcherRoutine(ctx context.Context, wg *sync.WaitGroup, l *slog.Logger, h handler.Handler) {
  29+func fetcherRoutine(ctx context.Context, l *slog.Logger, h handler.Handler) {
  30 	ticker := time.NewTicker(5 * time.Minute)
  31 	for {
  32 		select {
  33@@ -47,15 +45,14 @@ func fetcherRoutine(ctx context.Context, wg *sync.WaitGroup, l *slog.Logger, h h
  34 func main() {
  35 	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
  36 
  37-	config, err := config.Parse()
  38+	cfg, err := config.Parse()
  39 	if err != nil {
  40-		logger.Log(context.Background(), LevelFatal, "Failed to parse config", "call", "config.Parse", "error", err)
  41+		logger.Error("Failed to parse config", "call", "config.Parse", "error", err)
  42 		return
  43 	}
  44-	db, err := db.NewPostgresDB(logger, config.DB)
  45+	db, err := db.NewPostgresDB(logger, cfg.DBURI)
  46 	if err != nil {
  47-		logger.Log(
  48-			context.Background(), LevelFatal,
  49+		logger.Error(
  50 			"Failed to create DB connection",
  51 			"call", "db.NewPostgresDB",
  52 			"error", err,
  53@@ -68,11 +65,11 @@ func main() {
  54 	router, err := ytrssil.SetupGinRouter(
  55 		logger,
  56 		handler,
  57-		auth.AuthMiddleware(db),
  58+		auth.APIAuthMiddleware(cfg.AuthToken),
  59+		auth.PageAuthMiddleware(cfg.AuthToken),
  60 	)
  61 	if err != nil {
  62-		logger.Log(
  63-			context.Background(), LevelFatal,
  64+		logger.Error(
  65 			"Failed to set up gin server",
  66 			"call", "ytrssil.SetupGinServer",
  67 			"error", err,
  68@@ -81,7 +78,7 @@ func main() {
  69 	}
  70 
  71 	server := &http.Server{
  72-		Addr:    fmt.Sprintf(":%v", config.Gin.Port),
  73+		Addr:    fmt.Sprintf(":%v", cfg.Port),
  74 		Handler: router,
  75 	}
  76 
  77@@ -92,11 +89,11 @@ func main() {
  78 	// start periodic fetch videos routine
  79 	fetcherContext, cancelFetcher := context.WithCancel(context.Background())
  80 	wg.Go(func() {
  81-		fetcherRoutine(fetcherContext, wg, logger, handler)
  82+		fetcherRoutine(fetcherContext, logger, handler)
  83 	})
  84 
  85 	wg.Go(func() {
  86-		logger.Info("ytrssil API is starting up", "port", config.Gin.Port)
  87+		logger.Info("ytrssil API is starting up", "port", cfg.Port)
  88 		if err := server.ListenAndServe(); err != nil {
  89 			if err != http.ErrServerClosed {
  90 				logger.Error("Server crashed", "call", "server.ListenAndServe", "error", err)
  91diff --git a/cmd/main_test.go b/cmd/main_test.go
  92deleted file mode 100644
  93index 2d1f25576870a5b0de424928cb1e8f3b3cc558df..0000000000000000000000000000000000000000
  94--- a/cmd/main_test.go
  95+++ /dev/null
  96@@ -1,90 +0,0 @@
  97-package main
  98-
  99-import (
 100-	"bytes"
 101-	"context"
 102-	"encoding/json"
 103-	"fmt"
 104-	"io"
 105-	"log/slog"
 106-	"net/http"
 107-	"net/http/httptest"
 108-	"testing"
 109-
 110-	"github.com/gin-gonic/gin"
 111-	"github.com/stretchr/testify/assert"
 112-
 113-	"github.com/TheEdgeOfRage/ytrssil-api/config"
 114-	"github.com/TheEdgeOfRage/ytrssil-api/db"
 115-	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
 116-	"github.com/TheEdgeOfRage/ytrssil-api/handler"
 117-	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
 118-	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
 119-	"github.com/TheEdgeOfRage/ytrssil-api/models"
 120-)
 121-
 122-var testConfig config.Config
 123-
 124-func init() {
 125-	testConfig = config.TestConfig()
 126-}
 127-
 128-func setupTestServer(t *testing.T) (*http.Server, db.DB) {
 129-	l := slog.New(slog.NewTextHandler(io.Discard, nil))
 130-
 131-	db, err := db.NewPostgresDB(l, testConfig.DB)
 132-	if !assert.NoError(t, err) {
 133-		return nil, nil
 134-	}
 135-	parser := feedparser.NewParser(l)
 136-	handler := handler.New(l, db, parser)
 137-	gin.SetMode(gin.TestMode)
 138-	router, err := ytrssil.SetupGinRouter(
 139-		l,
 140-		handler,
 141-		auth.AuthMiddleware(db),
 142-	)
 143-	if !assert.NoError(t, err) {
 144-		return nil, nil
 145-	}
 146-
 147-	return &http.Server{
 148-		Addr:    fmt.Sprintf(":%v", testConfig.Gin.Port),
 149-		Handler: router,
 150-	}, db
 151-}
 152-
 153-func TestHealthz(t *testing.T) {
 154-	server, _ := setupTestServer(t)
 155-	if !assert.NotNil(t, server) {
 156-		return
 157-	}
 158-
 159-	w := httptest.NewRecorder()
 160-	req, _ := http.NewRequest("GET", "/healthz", nil)
 161-	server.Handler.ServeHTTP(w, req)
 162-
 163-	assert.Equal(t, 200, w.Code)
 164-	assert.Equal(t, "healthy", w.Body.String())
 165-}
 166-
 167-func TestCreateUser(t *testing.T) {
 168-	server, db := setupTestServer(t)
 169-	if !assert.NotNil(t, server) {
 170-		return
 171-	}
 172-
 173-	jsonData, err := json.Marshal(models.User{Username: "test", Password: "test"})
 174-	if !assert.Nil(t, err) {
 175-		return
 176-	}
 177-	data := bytes.NewBuffer(jsonData)
 178-	req, _ := http.NewRequest("POST", "/register", data)
 179-	w := httptest.NewRecorder()
 180-	server.Handler.ServeHTTP(w, req)
 181-
 182-	assert.Equal(t, 200, w.Code)
 183-	assert.Equal(t, `{"msg":"user created"}`, w.Body.String())
 184-
 185-	db.DeleteUser(context.TODO(), "test")
 186-}
 187diff --git a/config/config.go b/config/config.go
 188index d018dd006d4f38606389af27f123459d7fc82f98..9374ea34f7a9a4d225f0ae1c25303f3ec3d57116 100644
 189--- a/config/config.go
 190+++ b/config/config.go
 191@@ -7,19 +7,10 @@ import (
 192 	flags "github.com/jessevdk/go-flags"
 193 )
 194 
 195-type DB struct {
 196-	DBURI string `long:"db-uri" env:"DB_URI"`
 197-}
 198-
 199-// Gin contains configuration for the gin framework
 200-type Gin struct {
 201-	Port int `long:"port" env:"PORT" default:"8080"`
 202-}
 203-
 204-// Config ties together all configs
 205 type Config struct {
 206-	DB  DB
 207-	Gin Gin
 208+	Port      int    `long:"port" env:"PORT" default:"8080"`
 209+	DBURI     string `long:"db-uri" env:"DB_URI"`
 210+	AuthToken string `long:"auth-token" env:"AUTH_TOKEN"`
 211 }
 212 
 213 func getenvOrDefault(key string, defaultValue string) string {
 214@@ -46,15 +37,10 @@ func TestConfig() Config {
 215 		dbURI = dbURI + "?sslmode=disable"
 216 	}
 217 
 218-	gin := Gin{
 219-		Port: 8080,
 220-	}
 221-	db := DB{
 222-		DBURI: dbURI,
 223-	}
 224 	config := Config{
 225-		Gin: gin,
 226-		DB:  db,
 227+		Port:      8080,
 228+		DBURI:     dbURI,
 229+		AuthToken: "foo",
 230 	}
 231 
 232 	return config
 233diff --git a/db/channels.go b/db/channels.go
 234index d18ce7034ff8097f4db8105a6a052d27675c60f7..bb55cd89793d0d32ca255eab830610d6cc9065cb 100644
 235--- a/db/channels.go
 236+++ b/db/channels.go
 237@@ -8,7 +8,7 @@ import (
 238 
 239 var createChannelQuery = `INSERT INTO channels (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`
 240 
 241-func (d *postgresDB) CreateChannel(ctx context.Context, channel models.Channel) error {
 242+func (d *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Channel) error {
 243 	resp, err := d.db.ExecContext(ctx, createChannelQuery, channel.ID, channel.Name)
 244 	if err != nil {
 245 		d.l.Error("Failed to create channel", "call", "sql.ExecContext", "error", err)
 246@@ -45,55 +45,9 @@ func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error)
 247 	return channels, nil
 248 }
 249 
 250-var getChannelSubscribersQuery = `SELECT username FROM user_subscriptions WHERE channel_id = $1`
 251-
 252-func (d *postgresDB) GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error) {
 253-	rows, err := d.db.QueryContext(ctx, getChannelSubscribersQuery, channelID)
 254-	if err != nil {
 255-		d.l.Error("Filed to query channel subscribers", "call", "sql.QueryContext", "error", err)
 256-		return nil, err
 257-	}
 258-	defer rows.Close()
 259-
 260-	subs := make([]string, 0)
 261-	for rows.Next() {
 262-		var sub string
 263-		err = rows.Scan(&sub)
 264-		if err != nil {
 265-			d.l.Error("Failed to scan channel subscribers", "call", "sql.Scan", "error", err)
 266-			return nil, err
 267-		}
 268-		subs = append(subs, sub)
 269-	}
 270-
 271-	return subs, nil
 272-}
 273-
 274-var subscribeUserToChannelQuery = `
 275-INSERT INTO user_subscriptions (
 276-	username
 277-	, channel_id
 278-) VALUES ($1, $2)
 279-ON CONFLICT DO NOTHING
 280-`
 281-
 282-func (d *postgresDB) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error {
 283-	resp, err := d.db.ExecContext(ctx, subscribeUserToChannelQuery, username, channelID)
 284-	if err != nil {
 285-		d.l.Error("Failed to subscribe to channel", "call", "sql.ExecContext", "error", err)
 286-		return err
 287-	}
 288-	if affected, _ := resp.RowsAffected(); affected == 0 {
 289-		return ErrAlreadySubscribed
 290-	}
 291-
 292-	return nil
 293-}
 294-
 295-var unsubscribeUserFromChannelQuery = `DELETE FROM user_subscriptions WHERE username = $1 AND channel_id = $2`
 296-
 297-func (d *postgresDB) UnsubscribeUserFromChannel(ctx context.Context, username string, channelID string) error {
 298-	resp, err := d.db.ExecContext(ctx, unsubscribeUserFromChannelQuery, username, channelID)
 299+func (d *postgresDB) UnsubscribeFromChannel(ctx context.Context, channelID string) error {
 300+	query := `DELETE FROM channels WHERE id = $1`
 301+	resp, err := d.db.ExecContext(ctx, query, channelID)
 302 	if err != nil {
 303 		d.l.Error("Failed to unsubscribe from channel", "call", "sql.ExecContext", "error", err)
 304 		return err
 305diff --git a/db/db.go b/db/db.go
 306index 65473339e28cf91c687310040727c123a876b388..a451ad811262762f616dcea2f55e0870b658125a 100644
 307--- a/db/db.go
 308+++ b/db/db.go
 309@@ -13,37 +13,23 @@ var (
 310 	ErrChannelNotFound   = errors.New("no channel with that ID found")
 311 	ErrAlreadySubscribed = errors.New("already subscribed to channel")
 312 	ErrVideoExists       = errors.New("video already exists")
 313-	ErrUserExists        = errors.New("user already exists")
 314 )
 315 
 316 // DB represents a database layer for getting video and channel data
 317 type DB interface {
 318-	// AuthenticateUser verifies a user's password against a hashed value
 319-	AuthenticateUser(ctx context.Context, user models.User) (bool, error)
 320-	// CreateUser registers a new user in the database
 321-	CreateUser(ctx context.Context, user models.User) error
 322-	// DeleteUser registers a new user in the database
 323-	DeleteUser(ctx context.Context, username string) error
 324-
 325-	// CreateChannel starts tracking a new channel and fetch new videos for it
 326-	CreateChannel(ctx context.Context, channel models.Channel) error
 327 	// ListChannels lists all channels from the database
 328 	ListChannels(ctx context.Context) ([]models.Channel, error)
 329-	// GetChannelSubscribers lists all channels from the database
 330-	GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error)
 331-	// SubscribeUserToChannel will start adding new videos from that channel to the user
 332-	SubscribeUserToChannel(ctx context.Context, username string, channelID string) error
 333-	// SubscribeUserToChannel will stop adding videos from that channel to the user
 334-	UnsubscribeUserFromChannel(ctx context.Context, username string, channelID string) error
 335+	// SubscribeToChannel will start fetching new videos from that channel
 336+	SubscribeToChannel(ctx context.Context, channel models.Channel) error
 337+	// UnsubscribeToChannel will stop fetching videos from that channel
 338+	UnsubscribeFromChannel(ctx context.Context, channelID string) error
 339 
 340 	// GetNewVideos returns a list of unwatched videos from all subscribed channels
 341-	GetNewVideos(ctx context.Context, username string, sortDesc bool) ([]models.Video, error)
 342-	// GetWatchedVideos returns a list of all watched videos for a user
 343-	GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error)
 344+	GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
 345+	// GetWatchedVideos returns a list of all watched videos
 346+	GetWatchedVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
 347 	// AddVideo adds a newly published video to the database
 348 	AddVideo(ctx context.Context, video models.Video, channelID string) error
 349-	// AddVideoToUser will list the video in the users feed
 350-	AddVideoToUser(ctx context.Context, username string, videoID string) error
 351-	// SetVideoWatchTime sets or unsets the watch timestamp of a user's video
 352-	SetVideoWatchTime(ctx context.Context, username string, videoID string, watchTime *time.Time) error
 353+	// SetVideoWatchTime sets or unsets the watch timestamp of a video
 354+	SetVideoWatchTime(ctx context.Context, videoID string, watchTime *time.Time) error
 355 }
 356diff --git a/db/psql.go b/db/psql.go
 357index ea3b6e1dc419910bacf911f40794875e68014173..730dad66c8e67fd352c4205393e0ce978668c54b 100644
 358--- a/db/psql.go
 359+++ b/db/psql.go
 360@@ -5,8 +5,6 @@ import (
 361 	"log/slog"
 362 
 363 	_ "github.com/lib/pq"
 364-
 365-	ytrssilConfig "github.com/TheEdgeOfRage/ytrssil-api/config"
 366 )
 367 
 368 type postgresDB struct {
 369@@ -14,8 +12,10 @@ type postgresDB struct {
 370 	db *sql.DB
 371 }
 372 
 373-func NewPostgresDB(log *slog.Logger, dbCfg ytrssilConfig.DB) (*postgresDB, error) {
 374-	db, err := sql.Open("postgres", dbCfg.DBURI)
 375+var _ DB = (*postgresDB)(nil)
 376+
 377+func NewPostgresDB(log *slog.Logger, dbURI string) (*postgresDB, error) {
 378+	db, err := sql.Open("postgres", dbURI)
 379 	if err != nil {
 380 		return nil, err
 381 	}
 382diff --git a/db/users.go b/db/users.go
 383deleted file mode 100644
 384index 535de5952e168b9ab615659b279bdd92bbeb0348..0000000000000000000000000000000000000000
 385--- a/db/users.go
 386+++ /dev/null
 387@@ -1,63 +0,0 @@
 388-package db
 389-
 390-import (
 391-	"context"
 392-	"database/sql"
 393-	"errors"
 394-
 395-	"github.com/alexedwards/argon2id"
 396-
 397-	"github.com/TheEdgeOfRage/ytrssil-api/models"
 398-)
 399-
 400-var authenticateUserQuery = `SELECT password FROM users WHERE username = $1`
 401-
 402-func (d *postgresDB) AuthenticateUser(ctx context.Context, user models.User) (bool, error) {
 403-	row := d.db.QueryRowContext(ctx, authenticateUserQuery, user.Username)
 404-	var hashedPassword string
 405-	err := row.Scan(&hashedPassword)
 406-	if err != nil {
 407-		if errors.Is(err, sql.ErrNoRows) {
 408-			return false, nil
 409-		}
 410-
 411-		d.l.Error("Failed to query user for auth", "error", err)
 412-		return false, err
 413-	}
 414-
 415-	match, err := argon2id.ComparePasswordAndHash(user.Password, hashedPassword)
 416-	if err != nil {
 417-		d.l.Error("Failed to hash password", "error", err)
 418-		return false, err
 419-	}
 420-
 421-	return match, nil
 422-}
 423-
 424-var createUserQuery = `INSERT INTO users (username, password) VALUES ($1, $2) ON CONFLICT DO NOTHING`
 425-
 426-func (d *postgresDB) CreateUser(ctx context.Context, user models.User) error {
 427-	resp, err := d.db.ExecContext(ctx, createUserQuery, user.Username, user.Password)
 428-	if err != nil {
 429-		d.l.Error("Failed to create user", "error", err)
 430-		return err
 431-	}
 432-
 433-	if affected, _ := resp.RowsAffected(); affected == 0 {
 434-		return ErrUserExists
 435-	}
 436-
 437-	return nil
 438-}
 439-
 440-var deleteUserQuery = `DELETE FROM users WHERE username = $1`
 441-
 442-func (d *postgresDB) DeleteUser(ctx context.Context, username string) error {
 443-	_, err := d.db.ExecContext(ctx, deleteUserQuery, username)
 444-	if err != nil {
 445-		d.l.Error("Failed to delete user", "error", err)
 446-		return err
 447-	}
 448-
 449-	return nil
 450-}
 451diff --git a/db/videos.go b/db/videos.go
 452index 70ea0e8a20b3db14a0b2bcbc4ccf3670bf22c35b..70440126e85e3f12a7052c48d8c0f7b13d51b487 100644
 453--- a/db/videos.go
 454+++ b/db/videos.go
 455@@ -7,29 +7,25 @@ import (
 456 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 457 )
 458 
 459-func (d *postgresDB) GetNewVideos(ctx context.Context, username string, sortDesc bool) ([]models.Video, error) {
 460-	getNewVideosQuery := `
 461+func (d *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
 462+	query := `
 463 		SELECT
 464 			videos.id
 465-			, videos.title
 466-			, videos.published_timestamp
 467-			, videos.is_short
 468-			, channels.name as channel_name
 469-			, channels.id as channel_id
 470-		FROM user_videos
 471-		LEFT JOIN videos ON video_id=videos.id
 472-		LEFT JOIN channels ON channel_id=channels.id
 473-		WHERE
 474-			1=1
 475-			AND watch_timestamp IS NULL
 476-			AND username=$1
 477+			, title
 478+			, published_timestamp
 479+			, is_short
 480+			, channels.name
 481+			, channels.id
 482+		FROM videos
 483+		LEFT JOIN channels ON videos.channel_id=channels.id
 484+		WHERE watch_timestamp IS NULL
 485 		ORDER BY published_timestamp
 486 	`
 487 	if sortDesc {
 488-		getNewVideosQuery += "DESC"
 489+		query += " DESC"
 490 	}
 491 
 492-	rows, err := d.db.QueryContext(ctx, getNewVideosQuery, username)
 493+	rows, err := d.db.QueryContext(ctx, query)
 494 	if err != nil {
 495 		d.l.Error("Failed to query new videos", "call", "sql.QueryContext", "error", err)
 496 		return nil, err
 497@@ -57,27 +53,26 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, username string, sortDesc
 498 	return videos, nil
 499 }
 500 
 501-const getWatchedVideosQuery = `
 502-	SELECT
 503-		videos.id
 504-		, videos.title
 505-		, videos.published_timestamp
 506-		, videos.watch_timestamp
 507-		, videos.is_short
 508-		, channels.name as channel_name
 509-		, channels.id as channel_id
 510-	FROM user_videos
 511-	LEFT JOIN videos ON video_id=videos.id
 512-	LEFT JOIN channels ON channel_id=channels.id
 513-	WHERE
 514-		1=1
 515-		AND watch_timestamp IS NOT NULL
 516-		AND username=$1
 517-	ORDER BY watch_timestamp DESC
 518-`
 519-
 520-func (d *postgresDB) GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) {
 521-	rows, err := d.db.QueryContext(ctx, getWatchedVideosQuery, username)
 522+func (d *postgresDB) GetWatchedVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
 523+	query := `
 524+		SELECT
 525+			videos.id
 526+			, title
 527+			, published_timestamp
 528+			, watch_timestamp
 529+			, is_short
 530+			, channels.name
 531+			, channels.id
 532+		FROM videos
 533+		LEFT JOIN channels ON videos.channel_id=channels.id
 534+		WHERE watch_timestamp IS NOT NULL
 535+		ORDER BY watch_timestamp DESC
 536+	`
 537+	if sortDesc {
 538+		query += " DESC"
 539+	}
 540+
 541+	rows, err := d.db.QueryContext(ctx, query)
 542 	if err != nil {
 543 		d.l.Error("Failed to query for watched videos", "call", "sql.QueryContext", "error", err)
 544 		return nil, err
 545@@ -105,21 +100,21 @@ func (d *postgresDB) GetWatchedVideos(ctx context.Context, username string) ([]m
 546 	return videos, nil
 547 }
 548 
 549-const addVideoQuery = `
 550-INSERT INTO videos (
 551-	id
 552-	, title
 553-	, published_timestamp
 554-	, is_short
 555diff --git a/go.mod b/go.mod
 556index 1e455d6a77b8aeb8ecf0f3a9ac3a74e933943f54..d99507e0ee034722b5dabf22374d8634956d1c74 100644
 557--- a/go.mod
 558+++ b/go.mod
 559@@ -4,7 +4,6 @@ go 1.25
 560 
 561 require (
 562 	github.com/a-h/templ v0.3.960
 563-	github.com/alexedwards/argon2id v1.0.0
 564 	github.com/gin-gonic/gin v1.11.0
 565 	github.com/jessevdk/go-flags v1.6.1
 566 	github.com/lib/pq v1.10.9
 567diff --git a/go.sum b/go.sum
 568index adb41e85d1ec16ccf21216c6647335362075c2d7..ae873fe176a15ec20a1a0c42beb9955f35927458 100644
 569--- a/go.sum
 570+++ b/go.sum
 571@@ -1,7 +1,5 @@
 572 github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
 573 github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
 574-github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
 575-github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
 576 github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
 577 github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
 578 github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
 579@@ -72,63 +70,25 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
 580 github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
 581 github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
 582 github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 583-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 584 go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
 585 go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
 586 golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
 587 golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
 588-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 589-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 590-golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
 591 golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
 592 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
 593-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 594-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 595 golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
 596 golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
 597-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 598-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 599-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 600-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 601-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 602 golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
 603 golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
 604-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 605-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 606-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 607 golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
 608 golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 609-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 610-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 611-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 612-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 613-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 614-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 615 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 616-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 617-golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 618 golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
 619 golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 620-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 621-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 622-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 623-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 624-golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
 625-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 626-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 627-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 628-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 629-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 630-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 631 golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
 632 golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
 633-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 634-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 635-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 636-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 637 golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
 638 golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
 639-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 640 google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
 641 google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
 642 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 643diff --git a/handler/channels.go b/handler/channels.go
 644index 1a92a9e38f594391cc8d1b033dfca28cbea86fca..1635f71c60166cb12ac82ca2d5f6dc93cf3a6e84 100644
 645--- a/handler/channels.go
 646+++ b/handler/channels.go
 647@@ -8,7 +8,7 @@ import (
 648 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 649 )
 650 
 651-func (h *handler) SubscribeToChannel(ctx context.Context, username string, channelID string) error {
 652+func (h *handler) SubscribeToChannel(ctx context.Context, channelID string) error {
 653 	parsedChannel, err := h.parser.Parse(channelID)
 654 	if err != nil {
 655 		return err
 656@@ -19,14 +19,14 @@ func (h *handler) SubscribeToChannel(ctx context.Context, username string, chann
 657 		Name: parsedChannel.Name,
 658 	}
 659 
 660-	err = h.db.CreateChannel(ctx, channel)
 661+	err = h.db.SubscribeToChannel(ctx, channel)
 662 	if err != nil && !errors.Is(err, db.ErrChannelExists) {
 663 		return err
 664 	}
 665 
 666-	return h.db.SubscribeUserToChannel(ctx, username, channelID)
 667+	return nil
 668 }
 669 
 670-func (h *handler) UnsubscribeFromChannel(ctx context.Context, username string, channelID string) error {
 671-	return h.db.UnsubscribeUserFromChannel(ctx, username, channelID)
 672+func (h *handler) UnsubscribeFromChannel(ctx context.Context, channelID string) error {
 673+	return h.db.UnsubscribeFromChannel(ctx, channelID)
 674 }
 675diff --git a/handler/handler.go b/handler/handler.go
 676index 5da10ab2147d27ad4224c0fc0022ac7c8dd39db1..17426a27e10b168c7b56cd03dea399eb9cc0ef82 100644
 677--- a/handler/handler.go
 678+++ b/handler/handler.go
 679@@ -10,14 +10,13 @@ import (
 680 )
 681 
 682 type Handler interface {
 683-	CreateUser(ctx context.Context, user models.User) error
 684-	SubscribeToChannel(ctx context.Context, username string, channelID string) error
 685-	UnsubscribeFromChannel(ctx context.Context, username string, channelID string) error
 686-	GetNewVideos(ctx context.Context, username string, sortDesc bool) ([]models.Video, error)
 687-	GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error)
 688+	SubscribeToChannel(ctx context.Context, channelID string) error
 689+	UnsubscribeFromChannel(ctx context.Context, channelID string) error
 690+	GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
 691+	GetWatchedVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
 692 	FetchVideos(ctx context.Context) error
 693-	MarkVideoAsWatched(ctx context.Context, username string, videoID string) error
 694-	MarkVideoAsUnwatched(ctx context.Context, username string, videoID string) error
 695+	MarkVideoAsWatched(ctx context.Context, videoID string) error
 696+	MarkVideoAsUnwatched(ctx context.Context, videoID string) error
 697 }
 698 
 699 type handler struct {
 700diff --git a/handler/handler_test.go b/handler/handler_test.go
 701index ec522f3353906e152cc26ada26ad9482202dc143..e5d789d55f9929ac0d6d0bd84e99582af3ac80b7 100644
 702--- a/handler/handler_test.go
 703+++ b/handler/handler_test.go
 704@@ -24,7 +24,7 @@ func init() {
 705 func TestGetNewVideos(t *testing.T) {
 706 	l := slog.New(slog.NewTextHandler(io.Discard, nil))
 707 	handler := New(l, &db_mock.DBMock{
 708-		GetNewVideosFunc: func(ctx context.Context, username string, _ bool) ([]models.Video, error) {
 709+		GetNewVideosFunc: func(ctx context.Context, _ bool) ([]models.Video, error) {
 710 			return []models.Video{
 711 				{
 712 					ID:            "test",
 713@@ -35,7 +35,7 @@ func TestGetNewVideos(t *testing.T) {
 714 			}, nil
 715 		},
 716 	}, &parser_mock.ParserMock{})
 717-	resp, err := handler.GetNewVideos(context.TODO(), "username", false)
 718+	resp, err := handler.GetNewVideos(context.TODO(), false)
 719 
 720 	if assert.NoError(t, err) {
 721 		if assert.NotNil(t, resp) {
 722diff --git a/handler/users.go b/handler/users.go
 723deleted file mode 100644
 724index 913889547dfb1671704226d3f262bc38820a325a..0000000000000000000000000000000000000000
 725--- a/handler/users.go
 726+++ /dev/null
 727@@ -1,19 +0,0 @@
 728-package handler
 729-
 730-import (
 731-	"context"
 732-
 733-	"github.com/alexedwards/argon2id"
 734-
 735-	"github.com/TheEdgeOfRage/ytrssil-api/models"
 736-)
 737-
 738-func (h *handler) CreateUser(ctx context.Context, user models.User) error {
 739-	hashedPassword, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams)
 740-	if err != nil {
 741-		return err
 742-	}
 743-	user.Password = hashedPassword
 744-
 745-	return h.db.CreateUser(ctx, user)
 746-}
 747diff --git a/handler/videos.go b/handler/videos.go
 748index 5a91db50daa6e3a03ffc7d69955ca9c4a5e845a1..f6ec420bbdeb023351dc5301011b35697be2c393 100644
 749--- a/handler/videos.go
 750+++ b/handler/videos.go
 751@@ -14,30 +14,12 @@ import (
 752 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 753 )
 754 
 755-func (h *handler) GetNewVideos(ctx context.Context, username string, sortDesc bool) ([]models.Video, error) {
 756-	return h.db.GetNewVideos(ctx, username, sortDesc)
 757+func (h *handler) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
 758+	return h.db.GetNewVideos(ctx, sortDesc)
 759 }
 760 
 761-func (h *handler) GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) {
 762-	return h.db.GetWatchedVideos(ctx, username)
 763-}
 764-
 765-func (h *handler) addVideoToAllSubscribers(ctx context.Context, channelID string, videoID string) error {
 766-	subs, err := h.db.GetChannelSubscribers(ctx, channelID)
 767-	if err != nil {
 768-		h.log.Error("failed to get channel subscribers", "call", "db.GetChannelSubscribers", "err", err)
 769-		return err
 770-	}
 771-
 772-	for _, sub := range subs {
 773-		err = h.db.AddVideoToUser(ctx, sub, videoID)
 774-		if err != nil {
 775-			h.log.Error("Failed to add video to user", "call", "db.AddVideoToUser", "err", err)
 776-			continue
 777-		}
 778-	}
 779-
 780-	return nil
 781+func (h *handler) GetWatchedVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
 782+	return h.db.GetWatchedVideos(ctx, sortDesc)
 783 }
 784 
 785 func (h *handler) isShort(ctx context.Context, videoID string) (bool, error) {
 786@@ -92,10 +74,6 @@ func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedpa
 787 			}
 788 			continue
 789 		}
 790-		err = h.addVideoToAllSubscribers(ctx, parsedChannel.ID, videoID)
 791-		if err != nil {
 792-			continue
 793-		}
 794 	}
 795 }
 796 
 797@@ -128,11 +106,11 @@ func (h *handler) FetchVideos(ctx context.Context) error {
 798 	return nil
 799 }
 800 
 801-func (h *handler) MarkVideoAsWatched(ctx context.Context, username string, videoID string) error {
 802+func (h *handler) MarkVideoAsWatched(ctx context.Context, videoID string) error {
 803 	watchTime := time.Now()
 804-	return h.db.SetVideoWatchTime(ctx, username, videoID, &watchTime)
 805+	return h.db.SetVideoWatchTime(ctx, videoID, &watchTime)
 806 }
 807 
 808-func (h *handler) MarkVideoAsUnwatched(ctx context.Context, username string, videoID string) error {
 809-	return h.db.SetVideoWatchTime(ctx, username, videoID, nil)
 810+func (h *handler) MarkVideoAsUnwatched(ctx context.Context, videoID string) error {
 811+	return h.db.SetVideoWatchTime(ctx, videoID, nil)
 812 }
 813diff --git a/httpserver/auth/auth.go b/httpserver/auth/auth.go
 814index 0445cbcb98345951beb3e509c9e7c5b927c6eb85..47b21aa8bcf60a70c67bcab6a82f6327b67797aa 100644
 815--- a/httpserver/auth/auth.go
 816+++ b/httpserver/auth/auth.go
 817@@ -4,35 +4,39 @@ import (
 818 	"net/http"
 819 
 820 	"github.com/gin-gonic/gin"
 821-
 822-	"github.com/TheEdgeOfRage/ytrssil-api/db"
 823-	"github.com/TheEdgeOfRage/ytrssil-api/models"
 824 )
 825 
 826-// AuthMiddleware will authenticate against a static API key
 827-func AuthMiddleware(db db.DB) gin.HandlerFunc {
 828+// PageAuthMiddleware will authenticate against a static auth token
 829+func PageAuthMiddleware(authToken string) gin.HandlerFunc {
 830 	return func(c *gin.Context) {
 831-		username, password, ok := c.Request.BasicAuth()
 832-		if !ok {
 833-			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid basic auth header"})
 834+		tokenCookie, err := c.Request.Cookie("token")
 835+		if err != nil {
 836+			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing auth token cookie"})
 837 			return
 838 		}
 839-		user := models.User{
 840-			Username: username,
 841-			Password: password,
 842+		if tokenCookie.Value != authToken {
 843+			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid auth token"})
 844+			return
 845 		}
 846-		authenticated, err := db.AuthenticateUser(c.Request.Context(), user)
 847-		if err != nil {
 848-			c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
 849+
 850+		// handle request
 851+		c.Next()
 852+	}
 853+}
 854+
 855+// APIAuthMiddleware will authenticate API endpoints against a static auth token
 856+func APIAuthMiddleware(authToken string) gin.HandlerFunc {
 857+	return func(c *gin.Context) {
 858+		token, ok := c.Request.Header["Authorization"]
 859+		if !ok || len(token) != 1 {
 860+			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing Authorization header"})
 861 			return
 862 		}
 863-		if !authenticated {
 864-			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
 865+		if token[0] != authToken {
 866+			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid auth token"})
 867 			return
 868 		}
 869 
 870-		c.Set("username", username)
 871-
 872 		// handle request
 873 		c.Next()
 874 	}
 875diff --git a/httpserver/auth/auth_test.go b/httpserver/auth/auth_test.go
 876index 141a150c11b06ee4e3c8cfee8c4b3a522ea2c409..c6cb38a7f0ef538cebf49cec477484833a7d01ff 100644
 877--- a/httpserver/auth/auth_test.go
 878+++ b/httpserver/auth/auth_test.go
 879@@ -1,70 +1,69 @@
 880 package auth
 881 
 882 import (
 883-	"context"
 884 	"net/http"
 885 	"net/http/httptest"
 886 	"testing"
 887 
 888 	"github.com/gin-gonic/gin"
 889-	"github.com/stretchr/testify/assert"
 890+	"github.com/stretchr/testify/suite"
 891 
 892-	db_mock "github.com/TheEdgeOfRage/ytrssil-api/mocks/db"
 893-	"github.com/TheEdgeOfRage/ytrssil-api/models"
 894+	"github.com/TheEdgeOfRage/ytrssil-api/config"
 895 )
 896 
 897-func setupTestServer() *http.Server {
 898-	db := &db_mock.DBMock{
 899-		AuthenticateUserFunc: func(ctx context.Context, user models.User) (bool, error) {
 900-			return user.Username == "username" && user.Password == "password", nil
 901-		},
 902-	}
 903+type AuthTestSuite struct {
 904+	suite.Suite
 905+
 906+	cfg    config.Config
 907+	server *http.Server
 908+	engine *gin.Engine
 909+}
 910+
 911+func TestAuthTestSuite(t *testing.T) {
 912+	suite.Run(t, new(AuthTestSuite))
 913+}
 914+
 915+func (s *AuthTestSuite) SetupSuite() {
 916+	s.cfg = config.TestConfig()
 917 
 918 	gin.SetMode(gin.TestMode)
 919-	router := gin.New()
 920-	// Middlewares are executed top to bottom in a stack-like manner
 921-	router.Use(
 922-		gin.Recovery(), // Recovery needs to go before other middlewares to catch panics
 923-		AuthMiddleware(db),
 924+	s.engine = gin.New()
 925+	s.engine.Use(
 926+		gin.Recovery(),
 927+		APIAuthMiddleware(s.cfg.AuthToken),
 928 	)
 929-	router.GET("/", func(c *gin.Context) {
 930+	s.engine.GET("/", func(c *gin.Context) {
 931 		c.String(http.StatusOK, "OK")
 932 	})
 933 
 934-	return &http.Server{Handler: router}
 935+	s.server = &http.Server{Handler: s.engine}
 936 }
 937 
 938-func TestSuccessfulAuthentication(t *testing.T) {
 939-	server := setupTestServer()
 940-
 941+func (s *AuthTestSuite) TestSuccessfulAuthentication() {
 942 	w := httptest.NewRecorder()
 943 	req, _ := http.NewRequest("GET", "/", nil)
 944-	req.SetBasicAuth("username", "password") // Valid credentials
 945-	server.Handler.ServeHTTP(w, req)
 946+	req.Header["Authorization"] = []string{"foo"}
 947+	s.server.Handler.ServeHTTP(w, req)
 948 
 949-	assert.Equal(t, http.StatusOK, w.Code)
 950-	assert.Equal(t, "OK", w.Body.String())
 951+	s.Equal(http.StatusOK, w.Code)
 952+	s.Equal("OK", w.Body.String())
 953 }
 954 
 955-func TestMissingAuthorizationHeader(t *testing.T) {
 956-	server := setupTestServer()
 957-
 958+func (s *AuthTestSuite) TestMissingAuthorizationHeader() {
 959 	w := httptest.NewRecorder()
 960 	req, _ := http.NewRequest("GET", "/", nil)
 961-	server.Handler.ServeHTTP(w, req)
 962+	s.server.Handler.ServeHTTP(w, req)
 963 
 964-	assert.Equal(t, http.StatusUnauthorized, w.Code)
 965-	assert.Equal(t, `{"error":"invalid basic auth header"}`, w.Body.String())
 966+	s.Equal(http.StatusUnauthorized, w.Code)
 967+	s.Equal(`{"error":"missing Authorization header"}`, w.Body.String())
 968 }
 969 
 970-func TestWrongCredentials(t *testing.T) {
 971-	server := setupTestServer()
 972-
 973+func (s *AuthTestSuite) TestWrongCredentials() {
 974 	w := httptest.NewRecorder()
 975 	req, _ := http.NewRequest("GET", "/", nil)
 976-	req.SetBasicAuth("test", "test") // Invalid credentials
 977-	server.Handler.ServeHTTP(w, req)
 978+	req.Header["Authorization"] = []string{"bar"}
 979diff --git a/httpserver/ytrssil/api_setup_test.go b/httpserver/ytrssil/api_setup_test.go
 980index 5506873c0dc52e0fc9aa8ba1fcff31aa22b8c488..23b098c4a77a0f7d6da56b113c25e2c27cd0b480 100644
 981--- a/httpserver/ytrssil/api_setup_test.go
 982+++ b/httpserver/ytrssil/api_setup_test.go
 983@@ -35,12 +35,13 @@ func setupTestServer(t *testing.T) *http.Server {
 984 	router, err := ytrssil.SetupGinRouter(
 985 		l,
 986 		handler,
 987-		auth.AuthMiddleware(nil),
 988+		auth.APIAuthMiddleware(""),
 989+		auth.PageAuthMiddleware(""),
 990 	)
 991 	assert.Nil(t, err)
 992 
 993 	return &http.Server{
 994-		Addr:    fmt.Sprintf(":%v", testConfig.Gin.Port),
 995+		Addr:    fmt.Sprintf(":%v", testConfig.Port),
 996 		Handler: router,
 997 	}
 998 }
 999diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go
1000index a726a86ff02960af6e167c467ee3db309c71160e..f4101d5bac44ec10f73b198fee1ed75a784210f7 100644
1001--- a/httpserver/ytrssil/channels.go
1002+++ b/httpserver/ytrssil/channels.go
1003@@ -18,9 +18,8 @@ func (srv *server) SubscribeToChannelJSON(c *gin.Context) {
1004 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
1005 		return
1006 	}
1007-	username := c.GetString("username")
1008 
1009-	err = srv.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID)
1010+	err = srv.handler.SubscribeToChannel(c.Request.Context(), channel.ID)
1011 	if err != nil {
1012 		if errors.Is(err, db.ErrAlreadySubscribed) {
1013 			c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()})
1014@@ -45,9 +44,8 @@ func (srv *server) UnsubscribeFromChannelJSON(c *gin.Context) {
1015 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
1016 		return
1017 	}
1018-	username := c.GetString("username")
1019 
1020-	err = srv.handler.UnsubscribeFromChannel(c.Request.Context(), username, channel.ID)
1021+	err = srv.handler.UnsubscribeFromChannel(c.Request.Context(), channel.ID)
1022 	if err != nil {
1023 		if errors.Is(err, db.ErrChannelNotFound) {
1024 			c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
1025diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
1026index a6c54a8f042819c97619c254d909d14a35d7950f..6e0df289bf3bdad6de5edef4a7e09c2a1bd024af 100644
1027--- a/httpserver/ytrssil/server.go
1028+++ b/httpserver/ytrssil/server.go
1029@@ -37,7 +37,12 @@ func (srv *server) Healthz(c *gin.Context) {
1030 }
1031 
1032 // SetupGinRouter sets up routes for all APIs on a Gin server (aka router)
1033-func SetupGinRouter(l *slog.Logger, handler handler.Handler, authMiddleware func(c *gin.Context)) (*gin.Engine, error) {
1034+func SetupGinRouter(
1035+	l *slog.Logger,
1036+	handler handler.Handler,
1037+	apiAuthMiddleware func(c *gin.Context),
1038+	pageAuthMiddleware func(c *gin.Context),
1039+) (*gin.Engine, error) {
1040 	engine := gin.New()
1041 	// Middlewares are executed top to bottom in a stack-like manner
1042 	engine.Use(
1043@@ -57,18 +62,17 @@ func SetupGinRouter(l *slog.Logger, handler handler.Handler, authMiddleware func
1044 		return nil, err
1045 	}
1046 	engine.GET("/healthz", srv.Healthz)
1047-	engine.POST("/register", srv.CreateUserJSON)
1048 	engine.POST("/fetch", srv.FetchVideosJSON)
1049 
1050 	pages := engine.Group("")
1051-	pages.Use(authMiddleware)
1052+	pages.Use(pageAuthMiddleware)
1053 
1054 	pages.GET("/", srv.NewVideosPage)
1055 	pages.POST("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
1056 
1057 	// all APIs go in this routing group and require authentication
1058 	api := engine.Group("/api")
1059-	api.Use(authMiddleware)
1060+	api.Use(apiAuthMiddleware)
1061 	{
1062 		api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannelJSON)
1063 		api.POST("channels/:channel_id/unsubscribe", srv.UnsubscribeFromChannelJSON)
1064diff --git a/httpserver/ytrssil/users.go b/httpserver/ytrssil/users.go
1065deleted file mode 100644
1066index e9a475e0716834c4ccf2faf45cffd804000a3e3c..0000000000000000000000000000000000000000
1067--- a/httpserver/ytrssil/users.go
1068+++ /dev/null
1069@@ -1,33 +0,0 @@
1070-package ytrssil
1071-
1072-import (
1073-	"errors"
1074-	"net/http"
1075-
1076-	"github.com/gin-gonic/gin"
1077-
1078-	"github.com/TheEdgeOfRage/ytrssil-api/db"
1079-	"github.com/TheEdgeOfRage/ytrssil-api/models"
1080-)
1081-
1082-func (srv *server) CreateUserJSON(c *gin.Context) {
1083-	var user models.User
1084-	err := c.ShouldBindJSON(&user)
1085-	if err != nil {
1086-		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
1087-		return
1088-	}
1089-
1090-	err = srv.handler.CreateUser(c.Request.Context(), user)
1091-	if err != nil {
1092-		if errors.Is(err, db.ErrUserExists) {
1093-			c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()})
1094-			return
1095-		}
1096-
1097-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
1098-		return
1099-	}
1100-
1101-	c.JSON(http.StatusOK, gin.H{"msg": "user created"})
1102-}
1103diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go
1104index d4fe61635c8506c79d69d1a9faeab60663806503..053fd6a0bfb3f4fc18c2643a4a4fd1504e702364 100644
1105--- a/httpserver/ytrssil/videos.go
1106+++ b/httpserver/ytrssil/videos.go
1107@@ -9,8 +9,7 @@ import (
1108 )
1109 
1110 func (srv *server) GetNewVideosJSON(c *gin.Context) {
1111-	username := c.GetString("username")
1112-	videos, err := srv.handler.GetNewVideos(c.Request.Context(), username, false)
1113+	videos, err := srv.handler.GetNewVideos(c.Request.Context(), false)
1114 	if err != nil {
1115 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
1116 		return
1117@@ -22,8 +21,7 @@ func (srv *server) GetNewVideosJSON(c *gin.Context) {
1118 }
1119 
1120 func (srv *server) GetWatchedVideosJSON(c *gin.Context) {
1121-	username := c.GetString("username")
1122-	videos, err := srv.handler.GetWatchedVideos(c.Request.Context(), username)
1123+	videos, err := srv.handler.GetWatchedVideos(c.Request.Context(), false)
1124 	if err != nil {
1125 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
1126 		return
1127@@ -45,7 +43,6 @@ func (srv *server) FetchVideosJSON(c *gin.Context) {
1128 }
1129 
1130 func (srv *server) MarkVideoAsWatchedJSON(c *gin.Context) {
1131-	username := c.GetString("username")
1132 	var req models.VideoURIRequest
1133 	err := c.ShouldBindUri(&req)
1134 	if err != nil {
1135@@ -53,7 +50,7 @@ func (srv *server) MarkVideoAsWatchedJSON(c *gin.Context) {
1136 		return
1137 	}
1138 
1139-	err = srv.handler.MarkVideoAsWatched(c.Request.Context(), username, req.VideoID)
1140+	err = srv.handler.MarkVideoAsWatched(c.Request.Context(), req.VideoID)
1141 	if err != nil {
1142 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
1143 		return
1144@@ -63,7 +60,6 @@ func (srv *server) MarkVideoAsWatchedJSON(c *gin.Context) {
1145 }
1146 
1147 func (srv *server) MarkVideoAsUnwatchedJSON(c *gin.Context) {
1148-	username := c.GetString("username")
1149 	var req models.VideoURIRequest
1150 	err := c.ShouldBindUri(&req)
1151 	if err != nil {
1152@@ -71,7 +67,7 @@ func (srv *server) MarkVideoAsUnwatchedJSON(c *gin.Context) {
1153 		return
1154 	}
1155 
1156-	err = srv.handler.MarkVideoAsUnwatched(c.Request.Context(), username, req.VideoID)
1157+	err = srv.handler.MarkVideoAsUnwatched(c.Request.Context(), req.VideoID)
1158 	if err != nil {
1159 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
1160 		return
1161diff --git a/httpserver/ytrssil/videos_pages.go b/httpserver/ytrssil/videos_pages.go
1162index 288f7294af3bbe2c815ebc4e06658fc06e833eb3..85978d9a0be99a996a9b673b9bbdd03b4de7fd10 100644
1163--- a/httpserver/ytrssil/videos_pages.go
1164+++ b/httpserver/ytrssil/videos_pages.go
1165@@ -10,8 +10,7 @@ import (
1166 )
1167 
1168 func (srv server) NewVideosPage(c *gin.Context) {
1169-	username := c.GetString("username")
1170-	videos, err := srv.handler.GetNewVideos(c.Request.Context(), username, true)
1171+	videos, err := srv.handler.GetNewVideos(c.Request.Context(), true)
1172 	if err != nil {
1173 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
1174 		return
1175@@ -29,7 +28,6 @@ func (srv server) MarkVideoAsWatchedPage(c *gin.Context) {
1176 		Ctx: c.Request.Context(),
1177 	}
1178 
1179-	username := c.GetString("username")
1180 	var req models.VideoURIRequest
1181 	err := c.ShouldBindUri(&req)
1182 	if err != nil {
1183@@ -38,7 +36,7 @@ func (srv server) MarkVideoAsWatchedPage(c *gin.Context) {
1184 		return
1185 	}
1186 
1187-	err = srv.handler.MarkVideoAsWatched(c.Request.Context(), username, req.VideoID)
1188+	err = srv.handler.MarkVideoAsWatched(c.Request.Context(), req.VideoID)
1189 	if err != nil {
1190 		r.Component = pages.ErrorPage(err)
1191 		c.Render(http.StatusInternalServerError, r)
1192diff --git a/migrations/000003_remove_users.down.sql b/migrations/000003_remove_users.down.sql
1193new file mode 100644
1194index 0000000000000000000000000000000000000000..2fe66d4b0938e214102723d74460c941c68c6676
1195--- /dev/null
1196+++ b/migrations/000003_remove_users.down.sql
1197@@ -0,0 +1,19 @@
1198+ALTER TABLE videos DROP COLUMN IF EXISTS watch_timestamp;
1199+
1200+CREATE TABLE IF NOT EXISTS users (
1201+	username text NOT NULL PRIMARY KEY
1202+	, password text NOT NULL
1203+);
1204+
1205+CREATE TABLE IF NOT EXISTS user_videos (
1206+	username text NOT NULL REFERENCES users(username)
1207+	, video_id text NOT NULL REFERENCES videos(id)
1208+	, watch_timestamp timestamp with time zone
1209+	, CONSTRAINT user_videos_pkey PRIMARY KEY (username, video_id)
1210+);
1211+
1212+CREATE TABLE IF NOT EXISTS user_subscriptions (
1213+	username text NOT NULL REFERENCES users(username)
1214+	, channel_id text NOT NULL REFERENCES channels(id)
1215+	, CONSTRAINT user_subscriptions_pkey PRIMARY KEY (channel_id, username)
1216+);
1217diff --git a/migrations/000003_remove_users.up.sql b/migrations/000003_remove_users.up.sql
1218new file mode 100644
1219index 0000000000000000000000000000000000000000..85468061a5bf34db61a76cc413f8cf14a3ec2485
1220--- /dev/null
1221+++ b/migrations/000003_remove_users.up.sql
1222@@ -0,0 +1,10 @@
1223+ALTER TABLE videos ADD COLUMN IF NOT EXISTS watch_timestamp timestamp with time zone DEFAULT NULL;
1224+
1225+UPDATE videos
1226+  SET watch_timestamp = user_videos.watch_timestamp
1227+  FROM user_videos
1228+  WHERE videos.id = user_videos.video_id;
1229+
1230+DROP TABLE IF EXISTS user_subscriptions;
1231+DROP TABLE IF EXISTS user_videos;
1232+DROP TABLE IF EXISTS users;
1233diff --git a/mocks/db/db.go b/mocks/db/db.go
1234index 31c171fec25e6f06997a80fc7eb97f917d43b45b..0f0bb6475f14f597f0c416421d6debd75833875a 100644
1235--- a/mocks/db/db.go
1236+++ b/mocks/db/db.go
1237@@ -24,41 +24,23 @@ var _ db.DB = &DBMock{}
1238 //			AddVideoFunc: func(ctx context.Context, video models.Video, channelID string) error {
1239 //				panic("mock out the AddVideo method")
1240 //			},
1241-//			AddVideoToUserFunc: func(ctx context.Context, username string, videoID string) error {
1242-//				panic("mock out the AddVideoToUser method")
1243-//			},
1244-//			AuthenticateUserFunc: func(ctx context.Context, user models.User) (bool, error) {
1245-//				panic("mock out the AuthenticateUser method")
1246-//			},
1247-//			CreateChannelFunc: func(ctx context.Context, channel models.Channel) error {
1248-//				panic("mock out the CreateChannel method")
1249-//			},
1250-//			CreateUserFunc: func(ctx context.Context, user models.User) error {
1251-//				panic("mock out the CreateUser method")
1252-//			},
1253-//			DeleteUserFunc: func(ctx context.Context, username string) error {
1254-//				panic("mock out the DeleteUser method")
1255-//			},
1256-//			GetChannelSubscribersFunc: func(ctx context.Context, channelID string) ([]string, error) {
1257-//				panic("mock out the GetChannelSubscribers method")
1258-//			},
1259-//			GetNewVideosFunc: func(ctx context.Context, username string, sortDesc bool) ([]models.Video, error) {
1260+//			GetNewVideosFunc: func(ctx context.Context, sortDesc bool) ([]models.Video, error) {
1261 //				panic("mock out the GetNewVideos method")
1262 //			},
1263-//			GetWatchedVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
1264+//			GetWatchedVideosFunc: func(ctx context.Context, sortDesc bool) ([]models.Video, error) {
1265 //				panic("mock out the GetWatchedVideos method")
1266 //			},
1267 //			ListChannelsFunc: func(ctx context.Context) ([]models.Channel, error) {
1268 //				panic("mock out the ListChannels method")
1269 //			},
1270-//			SetVideoWatchTimeFunc: func(ctx context.Context, username string, videoID string, watchTime *time.Time) error {
1271+//			SetVideoWatchTimeFunc: func(ctx context.Context, videoID string, watchTime *time.Time) error {
1272 //				panic("mock out the SetVideoWatchTime method")
1273 //			},
1274-//			SubscribeUserToChannelFunc: func(ctx context.Context, username string, channelID string) error {
1275-//				panic("mock out the SubscribeUserToChannel method")
1276+//			SubscribeToChannelFunc: func(ctx context.Context, channel models.Channel) error {
1277+//				panic("mock out the SubscribeToChannel method")
1278 //			},
1279-//			UnsubscribeUserFromChannelFunc: func(ctx context.Context, username string, channelID string) error {
1280-//				panic("mock out the UnsubscribeUserFromChannel method")
1281+//			UnsubscribeFromChannelFunc: func(ctx context.Context, channelID string) error {
1282+//				panic("mock out the UnsubscribeFromChannel method")
1283 //			},
1284 //		}
1285 //
1286@@ -70,41 +52,23 @@ type DBMock struct {
1287 	// AddVideoFunc mocks the AddVideo method.
1288 	AddVideoFunc func(ctx context.Context, video models.Video, channelID string) error
1289 
1290-	// AddVideoToUserFunc mocks the AddVideoToUser method.
1291-	AddVideoToUserFunc func(ctx context.Context, username string, videoID string) error
1292-
1293-	// AuthenticateUserFunc mocks the AuthenticateUser method.
1294-	AuthenticateUserFunc func(ctx context.Context, user models.User) (bool, error)
1295-
1296-	// CreateChannelFunc mocks the CreateChannel method.
1297-	CreateChannelFunc func(ctx context.Context, channel models.Channel) error
1298-
1299-	// CreateUserFunc mocks the CreateUser method.
1300-	CreateUserFunc func(ctx context.Context, user models.User) error
1301-
1302-	// DeleteUserFunc mocks the DeleteUser method.
1303-	DeleteUserFunc func(ctx context.Context, username string) error
1304-
1305-	// GetChannelSubscribersFunc mocks the GetChannelSubscribers method.
1306-	GetChannelSubscribersFunc func(ctx context.Context, channelID string) ([]string, error)
1307-
1308 	// GetNewVideosFunc mocks the GetNewVideos method.
1309-	GetNewVideosFunc func(ctx context.Context, username string, sortDesc bool) ([]models.Video, error)
1310+	GetNewVideosFunc func(ctx context.Context, sortDesc bool) ([]models.Video, error)
1311 
1312 	// GetWatchedVideosFunc mocks the GetWatchedVideos method.
1313-	GetWatchedVideosFunc func(ctx context.Context, username string) ([]models.Video, error)
1314+	GetWatchedVideosFunc func(ctx context.Context, sortDesc bool) ([]models.Video, error)
1315 
1316 	// ListChannelsFunc mocks the ListChannels method.
1317 	ListChannelsFunc func(ctx context.Context) ([]models.Channel, error)
1318 
1319 	// SetVideoWatchTimeFunc mocks the SetVideoWatchTime method.
1320-	SetVideoWatchTimeFunc func(ctx context.Context, username string, videoID string, watchTime *time.Time) error
1321+	SetVideoWatchTimeFunc func(ctx context.Context, videoID string, watchTime *time.Time) error
1322 
1323-	// SubscribeUserToChannelFunc mocks the SubscribeUserToChannel method.
1324-	SubscribeUserToChannelFunc func(ctx context.Context, username string, channelID string) error
1325+	// SubscribeToChannelFunc mocks the SubscribeToChannel method.
1326+	SubscribeToChannelFunc func(ctx context.Context, channel models.Channel) error
1327 
1328-	// UnsubscribeUserFromChannelFunc mocks the UnsubscribeUserFromChannel method.
1329-	UnsubscribeUserFromChannelFunc func(ctx context.Context, username string, channelID string) error
1330+	// UnsubscribeFromChannelFunc mocks the UnsubscribeFromChannel method.
1331+	UnsubscribeFromChannelFunc func(ctx context.Context, channelID string) error
1332 
1333 	// calls tracks calls to the methods.
1334 	calls struct {
1335@@ -117,56 +81,10 @@ type DBMock struct {
1336 			// ChannelID is the channelID argument value.
1337diff --git a/models/user.go b/models/user.go
1338deleted file mode 100644
1339index a476da2efac83bcd59137e276588b3c5fa518c8b..0000000000000000000000000000000000000000
1340--- a/models/user.go
1341+++ /dev/null
1342@@ -1,6 +0,0 @@
1343-package models
1344-
1345-type User struct {
1346-	Username string `json:"username" db:"username"`
1347-	Password string `json:"password" db:"password"`
1348-}