refactor: remove recovery auth surface

This commit is contained in:
2026-04-19 19:40:18 +02:00
parent 4eaa6542ff
commit 1e1a3e8205
11 changed files with 70 additions and 291 deletions

View File

@@ -33,7 +33,9 @@ Technically, I also wanted to prove that a small, server-rendered Go app could s
## What the application offers
For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, and full watchlist management in one server-rendered interface. It also includes account flows such as registration, login, recovery, and recovery-key rotation. The notifications area is tuned for practical tracking, including sequel visibility derived from watchlist context.
For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, full watchlist management, continue-watching, and in-app playback in one server-rendered interface.
Authentication in the web UI is login-only.
## Technical approach
@@ -49,19 +51,24 @@ Instead of treating the repository as one flat service, the codebase is organize
| --- | --- |
| `cmd/server` | Application entrypoint and process lifecycle setup |
| `internal/server` | Route registration and middleware composition |
| `internal/features/anime` | Anime browsing, discovery, search, detail, and notifications logic |
| `internal/features/watchlist` | Watchlist updates, retrieval, import, and export |
| `internal/features/auth` | Authentication, sessions, account recovery, and account settings |
| `internal/features/anime` | Catalog, discovery, search, details, recommendations, and relations |
| `internal/features/watchlist` | Watchlist updates, retrieval, import/export, and continue-watching |
| `internal/features/playback` | Watch page, stream/subtitle proxying, and watch progress APIs |
| `internal/features/auth` | Login/session handling and auth service logic |
| `internal/jikan` | Upstream API client, caching, and retry-aware fetch behavior |
| `internal/worker` | Background relation sync, retry processing, and cache cleanup |
| `internal/database` | Migration runner, generated query layer, and DB models |
| `internal/templates` | Server-rendered page and partial templates |
| `internal/watchorder` | Watch-order scraping and parsing helpers |
| `migrations` | Schema evolution and operational DB changes |
| `static` | CSS, JavaScript, and static assets |
| `static` | Source CSS, TypeScript, and static assets |
| `dist` | Built frontend assets served at `/dist/*` |
## Runtime behavior
On startup, the server opens SQLite using `DATABASE_FILE` (defaulting to `mal.db`), runs migrations automatically, initializes core services, starts the background worker, and then serves HTTP traffic on `PORT` (defaulting to `3000`). A request enters the router, passes through global middleware for origin and authentication boundaries, reaches a feature handler, and then resolves through service logic that combines database access with upstream data where needed before rendering HTML.
On startup, the server opens SQLite using `DATABASE_FILE` (defaulting to `mal.db`), runs migrations automatically, initializes core services, starts the background worker, and then serves HTTP traffic on `PORT` (defaulting to `3000`). A request enters the router, passes through global middleware for origin and auth boundaries, reaches a feature handler, and then resolves through service logic that combines database access with upstream data where needed before rendering HTML.
Public access is intentionally limited. `/`, `/login`, `/search`, `/api/search`, `/api/search-quick`, and static asset routes are available without auth; most other routes require a valid session.
The background worker continuously maintains relation data for sequel awareness, processes queued retryable anime fetches, and periodically removes expired cache records. This keeps user-facing pages stable even when data collection has to happen in multiple phases.
@@ -73,7 +80,7 @@ There are still honest limits. Metadata quality still depends partly on external
## Getting started
For local development, install Go `1.24+`, SQLite, Bun, and the `templ` CLI, then generate templates, build frontend assets, and run the server.
For local development, install Go `1.24+`, Bun, and the `templ` CLI, then generate templates, build frontend assets, and run the server.
```bash
go install github.com/a-h/templ/cmd/templ@latest
@@ -87,13 +94,27 @@ The frontend pipeline uses a single source stylesheet (`static/style.css`) and T
When the server starts, the app is available at `http://localhost:3000`.
For containerized usage, the included `Dockerfile` uses a multi-stage build that generates templates, compiles `cmd/server`, and ships a slim runtime image with SQLite support.
Important notes:
- Environment variables are read directly from the process environment (`PORT`, `DATABASE_FILE`, `ENV`); `.env` is not auto-loaded.
- The web app currently exposes a login route only. If your database has no users yet, create the first user outside the web UI.
For containerized usage, the included `Dockerfile` uses a multi-stage build that installs Bun + templ, builds assets, generates templates, compiles `cmd/server`, and ships a slim runtime image with SQLite support.
```bash
docker build -t myanimelist .
docker run --rm -p 3000:3000 myanimelist
```
For persistent data in containers, set `DATABASE_FILE` to `/app/data/mal.db` and mount a volume:
```bash
docker run --rm \
-p 3000:3000 \
-e DATABASE_FILE=/app/data/mal.db \
-v "$(pwd)/data:/app/data" \
myanimelist
```
## Configuration
| Variable | Default | Description |
@@ -104,14 +125,16 @@ docker run --rm -p 3000:3000 myanimelist
## Database and testing
Migrations run at startup, so schema changes are applied automatically before the server begins accepting traffic. Migration history includes the initial auth and watchlist schema, anime metadata expansion, relation tracking, Jikan cache persistence, indexing updates, recovery key support, and retry-queue support for failed fetches.
Migrations run at startup, so schema changes are applied automatically before the server begins accepting traffic. Migration history includes the initial auth and watchlist schema, anime metadata expansion, relation tracking, Jikan cache persistence, indexing updates, and retry-queue support for failed fetches.
Tests are available for watchlist behavior, relation helpers, auth middleware boundaries, and watch-order parsing. Run the full test suite with:
Current automated tests are unit-focused and cover watchlist behavior, relation helpers, auth middleware boundaries, and watch-order parsing. Run the full test suite with:
```bash
go test ./...
```
There is currently no CI workflow in this repository, so validation is local.
## Contributing
This is primarily a personal project, so development priorities are driven by my own use and preferences. That said, if you spot a bug or have a focused improvement, feel free to open an issue or pull request. Please read `CONTRIBUTING.md` first so expectations around scope, validation, and security handling are clear.

View File

@@ -76,11 +76,10 @@ type Session struct {
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
CreatedAt time.Time `json:"created_at"`
RecoveryKeyHash string `json:"recovery_key_hash"`
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
CreatedAt time.Time `json:"created_at"`
}
type WatchListEntry struct {

View File

@@ -30,7 +30,6 @@ type Querier interface {
GetUpcomingSeasons(ctx context.Context, userID string) ([]GetUpcomingSeasonsRow, error)
GetUser(ctx context.Context, id string) (User, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
GetUserByUsernameAndRecoveryKeyHash(ctx context.Context, arg GetUserByUsernameAndRecoveryKeyHashParams) (User, error)
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
@@ -39,7 +38,6 @@ type Querier interface {
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelationParams) error
UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error)

View File

@@ -5,18 +5,10 @@ SELECT * FROM user WHERE id = ? LIMIT 1;
SELECT * FROM user WHERE username = ? LIMIT 1;
-- name: CreateUser :one
INSERT INTO user (id, username, password_hash, recovery_key_hash)
VALUES (?, ?, ?, ?)
INSERT INTO user (id, username, password_hash)
VALUES (?, ?, ?)
RETURNING *;
-- name: GetUserByUsernameAndRecoveryKeyHash :one
SELECT * FROM user WHERE username = ? AND recovery_key_hash = ? LIMIT 1;
-- name: UpdateUserPasswordAndRecoveryKeyHash :exec
UPDATE user
SET password_hash = ?, recovery_key_hash = ?
WHERE id = ?;
-- name: CreateSession :one
INSERT INTO session (id, user_id, expires_at)
VALUES (?, ?, ?)

View File

@@ -49,32 +49,25 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
}
const createUser = `-- name: CreateUser :one
INSERT INTO user (id, username, password_hash, recovery_key_hash)
VALUES (?, ?, ?, ?)
RETURNING id, username, password_hash, created_at, recovery_key_hash
INSERT INTO user (id, username, password_hash)
VALUES (?, ?, ?)
RETURNING id, username, password_hash, created_at
`
type CreateUserParams struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
RecoveryKeyHash string `json:"recovery_key_hash"`
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, createUser,
arg.ID,
arg.Username,
arg.PasswordHash,
arg.RecoveryKeyHash,
)
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.RecoveryKeyHash,
)
return i, err
}
@@ -499,7 +492,7 @@ func (q *Queries) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetU
}
const getUser = `-- name: GetUser :one
SELECT id, username, password_hash, created_at, recovery_key_hash FROM user WHERE id = ? LIMIT 1
SELECT id, username, password_hash, created_at FROM user WHERE id = ? LIMIT 1
`
func (q *Queries) GetUser(ctx context.Context, id string) (User, error) {
@@ -510,13 +503,12 @@ func (q *Queries) GetUser(ctx context.Context, id string) (User, error) {
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.RecoveryKeyHash,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, created_at, recovery_key_hash FROM user WHERE username = ? LIMIT 1
SELECT id, username, password_hash, created_at FROM user WHERE username = ? LIMIT 1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -527,29 +519,6 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.RecoveryKeyHash,
)
return i, err
}
const getUserByUsernameAndRecoveryKeyHash = `-- name: GetUserByUsernameAndRecoveryKeyHash :one
SELECT id, username, password_hash, created_at, recovery_key_hash FROM user WHERE username = ? AND recovery_key_hash = ? LIMIT 1
`
type GetUserByUsernameAndRecoveryKeyHashParams struct {
Username string `json:"username"`
RecoveryKeyHash string `json:"recovery_key_hash"`
}
func (q *Queries) GetUserByUsernameAndRecoveryKeyHash(ctx context.Context, arg GetUserByUsernameAndRecoveryKeyHashParams) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByUsernameAndRecoveryKeyHash, arg.Username, arg.RecoveryKeyHash)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.RecoveryKeyHash,
)
return i, err
}
@@ -807,23 +776,6 @@ func (q *Queries) UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusPa
return err
}
const updateUserPasswordAndRecoveryKeyHash = `-- name: UpdateUserPasswordAndRecoveryKeyHash :exec
UPDATE user
SET password_hash = ?, recovery_key_hash = ?
WHERE id = ?
`
type UpdateUserPasswordAndRecoveryKeyHashParams struct {
PasswordHash string `json:"password_hash"`
RecoveryKeyHash string `json:"recovery_key_hash"`
ID string `json:"id"`
}
func (q *Queries) UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error {
_, err := q.db.ExecContext(ctx, updateUserPasswordAndRecoveryKeyHash, arg.PasswordHash, arg.RecoveryKeyHash, arg.ID)
return err
}
const upsertAnime = `-- name: UpsertAnime :one
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing)
VALUES (?, ?, ?, ?, ?, ?)

View File

@@ -3,18 +3,14 @@ package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"time"
"unicode"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"mal/internal/database"
@@ -22,10 +18,7 @@ import (
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserExists = errors.New("username already exists")
ErrNotAuthenticated = errors.New("not authenticated")
ErrInvalidPassword = errors.New("password does not meet security requirements")
ErrInvalidRecoveryKey = errors.New("invalid recovery details")
)
const bcryptCost = 12
@@ -50,171 +43,6 @@ func generateSessionToken() (string, error) {
return generateToken(32)
}
func generateRecoveryKey() (string, error) {
return generateToken(24)
}
func hashRecoveryKey(recoveryKey string) string {
sum := sha256.Sum256([]byte(recoveryKey))
return hex.EncodeToString(sum[:])
}
func ValidatePassword(password string) error {
if len(password) < 12 {
return fmt.Errorf("password must be at least 12 characters long")
}
var hasUpper, hasLower, hasNumber, hasSpecial bool
for _, c := range password {
switch {
case unicode.IsNumber(c):
hasNumber = true
case unicode.IsUpper(c):
hasUpper = true
case unicode.IsLower(c):
hasLower = true
case unicode.IsPunct(c) || unicode.IsSymbol(c) || !unicode.IsLetter(c) && !unicode.IsNumber(c):
hasSpecial = true
}
}
if !hasUpper || !hasLower || !hasNumber || !hasSpecial {
return fmt.Errorf("password must contain at least one uppercase letter, one lowercase letter, one number, and one special character")
}
return nil
}
func (s *Service) RegisterUser(ctx context.Context, username, password string) (*database.User, string, error) {
if err := ValidatePassword(password); err != nil {
return nil, "", fmt.Errorf("%w: %v", ErrInvalidPassword, err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return nil, "", fmt.Errorf("failed to hash password: %w", err)
}
recoveryKey, err := generateRecoveryKey()
if err != nil {
return nil, "", fmt.Errorf("failed to generate recovery key: %w", err)
}
id := uuid.New().String()
user, err := s.db.CreateUser(ctx, database.CreateUserParams{
ID: id,
Username: username,
PasswordHash: string(hash),
RecoveryKeyHash: hashRecoveryKey(recoveryKey),
})
if err != nil {
// Assuming unique constraint failure for username
return nil, "", ErrUserExists
}
return &user, recoveryKey, nil
}
func (s *Service) RecoverAccount(ctx context.Context, username, recoveryKey, newPassword string) (string, error) {
if err := ValidatePassword(newPassword); err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidPassword, err)
}
user, err := s.db.GetUserByUsernameAndRecoveryKeyHash(ctx, database.GetUserByUsernameAndRecoveryKeyHashParams{
Username: username,
RecoveryKeyHash: hashRecoveryKey(recoveryKey),
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrInvalidRecoveryKey
}
return "", fmt.Errorf("failed to lookup user for recovery: %w", err)
}
newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return "", fmt.Errorf("failed to hash new password: %w", err)
}
newRecoveryKey, err := generateRecoveryKey()
if err != nil {
return "", fmt.Errorf("failed to generate new recovery key: %w", err)
}
err = s.db.UpdateUserPasswordAndRecoveryKeyHash(ctx, database.UpdateUserPasswordAndRecoveryKeyHashParams{
ID: user.ID,
PasswordHash: string(newPasswordHash),
RecoveryKeyHash: hashRecoveryKey(newRecoveryKey),
})
if err != nil {
return "", fmt.Errorf("failed to update recovered account: %w", err)
}
err = s.db.DeleteUserSessions(ctx, user.ID)
if err != nil {
return "", fmt.Errorf("failed to clear existing sessions: %w", err)
}
return newRecoveryKey, nil
}
func (s *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
if err := ValidatePassword(newPassword); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPassword, err)
}
user, err := s.db.GetUser(ctx, userID)
if err != nil {
return fmt.Errorf("failed to lookup user: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil {
return ErrInvalidCredentials
}
newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return fmt.Errorf("failed to hash new password: %w", err)
}
err = s.db.UpdateUserPasswordAndRecoveryKeyHash(ctx, database.UpdateUserPasswordAndRecoveryKeyHashParams{
ID: user.ID,
PasswordHash: string(newPasswordHash),
RecoveryKeyHash: user.RecoveryKeyHash,
})
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
return nil
}
func (s *Service) RegenerateRecoveryKey(ctx context.Context, userID, password string) (string, error) {
user, err := s.db.GetUser(ctx, userID)
if err != nil {
return "", fmt.Errorf("failed to lookup user: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return "", ErrInvalidCredentials
}
newRecoveryKey, err := generateRecoveryKey()
if err != nil {
return "", fmt.Errorf("failed to generate new recovery key: %w", err)
}
err = s.db.UpdateUserPasswordAndRecoveryKeyHash(ctx, database.UpdateUserPasswordAndRecoveryKeyHashParams{
ID: user.ID,
PasswordHash: user.PasswordHash,
RecoveryKeyHash: hashRecoveryKey(newRecoveryKey),
})
if err != nil {
return "", fmt.Errorf("failed to rotate recovery key: %w", err)
}
return newRecoveryKey, nil
}
func (s *Service) Login(ctx context.Context, username, password string) (*database.Session, error) {
user, err := s.db.GetUserByUsername(ctx, username)
if err != nil {
@@ -246,10 +74,6 @@ func (s *Service) Login(ctx context.Context, username, password string) (*databa
return &session, nil
}
func (s *Service) Logout(ctx context.Context, sessionID string) error {
return s.db.DeleteSession(ctx, sessionID)
}
func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*database.User, error) {
session, err := s.db.GetSession(ctx, sessionID)
if err != nil {
@@ -284,16 +108,3 @@ func SetSessionCookie(w http.ResponseWriter, sessionID string, expiresAt time.Ti
Path: "/",
})
}
func ClearSessionCookie(w http.ResponseWriter) {
isProd := os.Getenv("ENV") == "production"
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "",
Expires: time.Unix(0, 0),
HttpOnly: true,
Secure: isProd,
SameSite: http.SameSiteStrictMode,
Path: "/",
})
}

View File

@@ -51,17 +51,6 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err == nil {
_ = h.authService.Logout(r.Context(), cookie.Value)
}
ClearSessionCookie(w)
w.Header().Set("HX-Redirect", "/")
http.Redirect(w, r, "/", http.StatusFound)
}
func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
templates.Login(rateLimitErrorFromQuery(r), "").Render(r.Context(), w)
}

View File

@@ -71,9 +71,6 @@ func NewRouter(cfg Config) http.Handler {
middleware.RateLimitAuth(middleware.VerifyOrigin(http.HandlerFunc(authHandler.HandleLogin))).ServeHTTP(w, r)
}
})
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
middleware.VerifyOrigin(http.HandlerFunc(authHandler.HandleLogout)).ServeHTTP(w, r)
})
// Watchlist Endpoints
mux.HandleFunc("/api/watchlist/export", watchlistHandler.HandleExportWatchlist)

View File

@@ -28,8 +28,6 @@ func Auth(authService *auth.Service) func(http.Handler) http.Handler {
user, err := authService.ValidateSession(r.Context(), cookie.Value)
if err != nil {
// Invalid session, proceed as unauthenticated
// Might also want to clear the invalid cookie here
auth.ClearSessionCookie(w)
next.ServeHTTP(w, r)
return
}

View File

@@ -1,2 +0,0 @@
ALTER TABLE user
ADD COLUMN recovery_key_hash TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,22 @@
PRAGMA foreign_keys = OFF;
BEGIN TRANSACTION;
CREATE TABLE user_new (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO user_new (id, username, password_hash, created_at)
SELECT id, username, password_hash, created_at
FROM user;
DROP TABLE user;
ALTER TABLE user_new RENAME TO user;
COMMIT;
PRAGMA foreign_keys = ON;