John O'Keefe 7d88ec5184 fix(tests): test-created devices and fixture dirs now meet the cleanup contract
Test suites that attach devices to the SHARED admin (testuser, the live
dev account) escaped the harness sweeps whenever their names lacked
"test": createContractDeviceNamed named its devices by surface type
("koreader" — the web progress push adopts the device name as sync
source, so conflict tests drive a second named source without a
separate account), the reinstall regression test uses the web flow's
real "Reinstall Device" label, and opds_test.go's folders test named
its device "OPDS Folders Device". None had self-cleanup either, so
every suite run left 3 more rows on the dev account's device grid —
476 by the time anyone looked. TestDeviceSetup.CreateDevice also never
cleanup-registered, so any CURRENT or future helper-created device was
one rename away from the same leak.

Changes:
- TestDeviceSetup.CreateDevice self-deletes its row at test end
  (t.Cleanup): callers owe nothing to name hygiene any more.
- createContractDeviceNamed self-deletes likewise (contract-% prefix).
- device_test.go: core via cleanupDeviceByIdentifier on every device it
  attaches to the admin ("Reinstall Device" by unique identifier —
  the NAME is user-facing-labeled, sweeps must never torch it).
- Harness setup-time admin-device sweep gains the unambiguous harness
  patterns (dev_contract_* tokens, contract-%/opds-%/repro-reinstall-%
  identifiers) as the drift net; ran once with the rebuilt tests image
  and that sweep alone already purged the existing 476 rows.
- metadata_write_test.go now globs ALL test-metadata-write-* dirs in
  its RemoveAll instead of only its own pid's — killed runs left root-
  owned dirs on the user's uploads bind mount (7 found, deleted).

 audits: collections/saved-filters/libraries/sync_queue are clean;
 the only "test"-named media rows left are scans of the user's own
 on-disk test files (Test/, Bookhoard Test/, embedded " test" in two
 real books' metadata) — content, not suite debris.

Verified: filtered runs (contract/OPDS/reinstall/pairing/metadata)
green, zero device residue after the run, uploads mount clean.
2026-11-02 17:39:29 -05:00

Bookhoard logo Bookhoard

A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring universal cross-device sync, beautiful dark themes, and comprehensive media management.

✨ Why Bookhoard?

🔄 Universal Sync: Your reading position, bookmarks, highlights, and notes sync automatically between KOReader and the web - with native Kobo sync and mobile apps coming later.

📱 Multi-Library: Organize your ebooks, comics, and manga with per-library folders and smart collections.

🎨 Beautiful UI: 11 gorgeous dark themes with responsive design that works on any device.

🔒 Secure: JWT authentication, bcrypt password hashing, rate limiting, and no passwords on devices.


🚀 Quick Start

Prerequisites

  • Podman (recommended) or Docker
  • 5 minutes of your time

Installation (no source checkout needed)

Create a directory on the Docker host. You only need the compose file, an .env, and an uploads folder — everything else rides the published image.

mkdir bookhoard && cd bookhoard
mkdir uploads

docker-compose.yml — copy this wholesale:

services:
  # PostgreSQL Database
  db:
    image: postgres:15-alpine
    container_name: bookhoard_db
    environment:
      POSTGRES_DB: bookhoard
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DBPASS}
      # PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
      PGPORT: ${DB_PORT:-5432}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
      - "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    env_file:
      - .env

  # One-shot bootstrap: creates the bookhoard_srv runtime role and
  # transfers object ownership to it (RLS prerequisite). Idempotent —
  # safe on every startup; the app waits for its success. Passwords
  # accept inline values or .env, like the rest of this file.
  # NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
  # per pair before the container sees the SQL.
  db-init:
    image: postgres:15-alpine
    container_name: bookhoard_db_init
    environment:
      PGHOST: db
      PGPORT: ${DB_PORT:-5432}
      PGUSER: postgres
      PGDATABASE: bookhoard
      PGPASSWORD: ${DBPASS}
      DBSRVPASS: ${DBSRVPASS}
    entrypoint: ["bash", "-c"]
    command:
      - |
        set -e
        : "${DBSRVPASS:?DBSRVPASS required in .env}"
        psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
        DO $$$$
        BEGIN
          IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
            CREATE ROLE bookhoard_srv LOGIN;
          END IF;
        END $$$$;
        ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
        DO $$$$
        DECLARE r RECORD;
        BEGIN
          FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
            EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
          END LOOP;
          FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
            EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
          END LOOP;
          FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
                   JOIN pg_namespace n ON p.pronamespace = n.oid
                   WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
            EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
          END LOOP;
        END $$$$;
        ALTER SCHEMA public OWNER TO bookhoard_srv;
        ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
        EOSQL
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  # Bookhoard Application
  # In production this image is pulled from the Gitea container registry.
  # Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
  app:
    image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
    container_name: bookhoard
    restart: unless-stopped
    environment:
      # Database Configuration
      # Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
      # via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
      # the app must NEVER connect as it (superusers bypass RLS).
      DATABASE_HOST: db
      DATABASE_PORT: ${DB_PORT:-5432}
      DATABASE_USER: bookhoard_srv
      DATABASE_PASSWORD: ${DBSRVPASS}
      DATABASE_NAME: bookhoard

      # Application Configuration
      JWT_SECRET: ${JWT_SECRET}
      SERVER_PORT: ${SERVER_PORT:-8765}
      # IMPORTANT: Device sync requires full URL with protocol
      # Local: http://localhost:8765
      # Local network: http://192.168.1.X:8765
      # Domain: https://bookhoard.example.com
      BASE_URL: ${BASE_URL:-http://localhost:8765}
      # Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
      COOKIE_SECURE: ${COOKIE_SECURE:-false}

      # Rate Limiting Configuration
      TEST_MODE: ${TEST_MODE:-false}
      RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
      REQUESTS_PER_MINUTE: ${REQUESTS_PER_MINUTE:-10}

      # Conversion Service Configuration
      BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
      BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
      BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}

      # Library Maintenance
      # Days an archived item (file missing from disk for 2+ scans) is kept,
      # with its reading history, before library scans purge it for good.
      # Set 0 to keep archived items until purged manually on the library admin page.
      ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}

      # System timezone (fallback for server-side time operations)
      TZ: ${TZ:-UTC}
    ports:
      - "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
    depends_on:
      db:
        condition: service_healthy
      db-init:
        condition: service_completed_successfully
    volumes:
      - ./uploads:/app/uploads
      - bookhoard_conversion_cache:/app/cache/kepub
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

# Named Volumes
volumes:
  postgres_data:
  bookhoard_conversion_cache:

📊 Project Status

Version: 1.0
License: AGPL-3.0
Status: Production-ready ✅


🤝 Contributing

We welcome contributions! Please see docs/developer/development.md for guidelines.


📄 License

AGPL-3.0 - See LICENSE file for details.


Built with ❤️ using Go, PostgreSQL, HTMX, and Tailwind CSS

S
Description
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring universal cross-device sync, beautiful dark themes, and comprehensive media management.
Readme AGPL-3.0
33 MiB
0 Stars 0 Watchers 0 Forks
v2.4.5
Latest
2026-11-02 18:43:55 -05:00
Languages
Go 72%
TypeScript 15.4%
templ 10.5%
CSS 1.3%
Python 0.2%
Other 0.4%