Files
photoprism/AGENTS.md
2025-09-25 20:08:45 +02:00

26 KiB
Raw Blame History

PhotoPrism® Repository Guidelines

Last Updated: September 25, 2025

Purpose

This file tells automated coding agents (and humans) where to find the single sources of truth for building, testing, and contributing to PhotoPrism. Learn more: https://agents.md/

Sources of Truth

Specifications (Versioning & Usage)

  • Always use the latest spec version for a topic (highest -vN), as linked from specs/README.md and the portal cheatsheet (specs/portal/README.md).
  • Testing Guides: specs/dev/backend-testing.md (Backend/Go), specs/dev/frontend-testing.md (Frontend/JS)
  • Whenever the Change Management instructions for a document require it, publish changes as a new file with an incremented version suffix (e.g., *-v3.md) rather than overwriting the original file.
  • Older spec versions remain in the repo for historical reference but are not linked from the main TOC. Do not base new work on superseded files (e.g., *-v1.md when *-v2.md exists).

Note on specs repository availability

  • The specs/ repository may be private and is not guaranteed to be present in every clone or environment. Do not add Makefile targets in the main project that depend on specs/ paths. When specs/ is available, run its tools directly (e.g., bash specs/scripts/lint-status.sh).

Project Structure & Languages

  • Backend: Go (internal/, pkg/, cmd/) + MariaDB/SQLite
    • Package boundaries: Code in pkg/* MUST NOT import from internal/*.
    • If you need access to config/entity/DB, put new code in a package under internal/ instead of pkg/.
  • Frontend: Vue 3 + Vuetify 3 (frontend/)
  • Docker/compose for dev/CI; Traefik is used for local TLS (*.localssl.dev)

Agent Runtime (Host vs Container)

Agents MAY run either:

  • Inside the Development Environment container (recommended for least privilege).
  • On the host (outside Docker), in which case the agent MAY start/stop the Dev Environment as needed.

Detecting the environment (agent logic)

Agents SHOULD detect the runtime and choose commands accordingly:

  • Inside container if one of the following is true:
    • File exists: /.dockerenv
    • Project path equals (or is a direct child of): /go/src/github.com/photoprism/photoprism

Examples

Bash:

if [ -f "/.dockerenv" ] || [ -d "/go/src/github.com/photoprism/photoprism/.git" ]; then
  echo "container"
else
  echo "host"
fi

Node.js:

const fs = require("fs");
const inContainer = fs.existsSync("/.dockerenv");
const inDevPath = fs.existsSync("/go/src/github.com/photoprism/photoprism/.git");
console.log(inContainer || inDevPath ? "container" : "host");

Agent installation and invocation

  • Inside container: Prefer running agents via npm exec (no global install), for example:

    • npm exec --yes <agent-binary> -- --help
    • Or use npx <agent-binary> ...
    • If the agent is distributed via npm and must be global, install inside the container only:
      • npm install -g <agent-npm-package>
    • Replace <agent-binary> / <agent-npm-package> with the names from the agents official docs.
  • On host: Use the vendors recommended install for your OS. Ensure your agent runs from the repository root so it can discover AGENTS.md and project files.

Build & Run (local)

  • Run make help to see common targets (or open the Makefile).

  • Host mode (agent runs on the host; agent MAY manage Docker lifecycle):

    • Build local dev image (once): make docker-build
    • Start services: docker compose up (add -d to start in the background)
    • Follow live app logs: docker compose logs -f --tail=100 photoprism (Ctrl+C to stop)
      • All services: docker compose logs -f --tail=100
      • Last 10 minutes only: docker compose logs -f --since=10m photoprism
      • Plain output (easier to copy): docker compose logs -f --no-log-prefix --no-color photoprism
    • Execute a single command in the app container: docker compose exec photoprism <command>
      • Example: docker compose exec photoprism ./photoprism help
      • Why ./photoprism? It runs the locally built binary in the project directory.
      • Run as non-root to avoid root-owned files on bind mounts: docker compose exec -u "$(id -u):$(id -g)" photoprism <command>
      • Durable alternative: set the service user or PHOTOPRISM_UID/PHOTOPRISM_GID in compose.yaml; if you hit issues, run make fix-permissions.
    • Open a terminal session in the app container: make terminal
    • Stop everything when done: docker compose --profile=all down --remove-orphans (make down does the same)
  • Container mode (agent runs inside the app container):

    • Install deps: make dep
    • Build frontend/backend: make build-js and make build-go
    • Watch frontend changes (auto-rebuild): make watch-js
      • Or run directly: cd frontend && npm run watch
      • Tips: refresh the browser to see changes; running the watcher outside the container can be faster on non-Linux hosts; stop with Ctrl+C
    • Start the PhotoPrism server: ./photoprism start
    • Do not use the Docker CLI inside the container; starting/stopping services requires host Docker access.

Note: Across our public documentation, official images, and in production, the command-line interface (CLI) name is photoprism. Other PhotoPrism binary names are only used in development builds for side-by-side comparisons of the Community Edition (CE) with PhotoPrism Plus (photoprism-plus) and PhotoPrism Pro (photoprism-pro).

Tests

  • From within the Development Environment:
    • Full unit test suite: make test (runs backend and frontend tests)
    • Test frontend/backend: make test-js and make test-go
    • Go packages: go test (all tests) or go test -run <name> (specific tests only)
  • Go tests live beside sources: for path/to/pkg/<file>.go, add tests in path/to/pkg/<file>_test.go (create if missing). For the same function, group related cases as t.Run(...) sub-tests (table-driven where helpful).
  • Frontend unit tests are driven by Vitest; see scripts in frontend/package.json
    • Vitest watch/coverage: make vitest-watch and make vitest-coverage
  • Acceptance tests: use the acceptance-* targets in the Makefile

FFmpeg Tests & Hardware Gating

  • By default, do not run GPU/HW encoder integrations in CI. Gate with PHOTOPRISM_FFMPEG_ENCODER (one of: vaapi, intel, nvidia).
  • Negative-path tests should remain fast and always run:
    • Missing ffmpeg binary → immediate exec error.
    • Unwritable destination → command fails without creating files.
  • Prefer command-string assertions when hardware is unavailable; enable HW runs locally only when a device is configured.

Fast, Focused Test Recipes

  • Filesystem + archives (fast): go test ./pkg/fs -run 'Copy|Move|Unzip' -count=1
  • Media helpers (fast): go test ./pkg/media/... -count=1
  • Thumbnails (libvips, moderate): go test ./internal/thumb/... -count=1
  • FFmpeg command builders (moderate): go test ./internal/ffmpeg -run 'Remux|Transcode|Extract' -count=1

CLI Testing Gotchas (Go)

  • Exit codes and os.Exit:
    • urfave/cli calls os.Exit(code) when a command returns cli.Exit(...), which will terminate go test abruptly (often after logs like http 401:).
    • Use the test helper RunWithTestContext (in internal/commands/commands_test.go) which temporarily overrides cli.OsExiter so the process doesnt exit; you still receive the error to assert ExitCoder.
    • If you only need to assert the exit code and dont need printed output, you can invoke cmd.Action(ctx) directly and check err.(cli.ExitCoder).ExitCode().
  • Noninteractive mode: set PHOTOPRISM_CLI=noninteractive and/or pass --yes to avoid prompts that block tests and CI.
  • SQLite DSN in tests:
    • config.NewTestConfig("<pkg>") defaults to SQLite with a persuite DSN like .<pkg>.db. Dont assert an empty DSN for SQLite.
    • Clean up any persuite SQLite files in tests with t.Cleanup(func(){ _ = os.Remove(dsn) }) if you capture the DSN.

Code Style & Lint

  • Go: run make fmt-go swag-fmt to reformat the backend code + Swagger annotations (see Makefile for additional targets)
    • Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period.
    • For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted.
  • Every Go package must contain a <package>.go file in its root (for example, internal/auth/jwt/jwt.go) with the standard license header and a short package description comment explaining its purpose.
  • JS/Vue: use the lint/format scripts in frontend/package.json (ESLint + Prettier)
  • All added code and tests must be formatted according to our standards.

Filesystem Permissions & io/fs Aliasing (Go)

  • Always use our shared permission variables from pkg/fs when creating files/directories:
    • Directories: fs.ModeDir (0o755 with umask)
    • Regular files: fs.ModeFile (0o644 with umask)
    • Config files: fs.ModeConfigFile (default 0o664)
    • Secrets/tokens: fs.ModeSecretFile (default 0o600)
    • Backups: fs.ModeBackupFile (default 0o600)
  • Do not pass stdlib io/fs flags (e.g., fs.ModeDir) to functions expecting permission bits.
    • When importing the stdlib package, alias it to avoid collisions: iofs "io/fs" or gofs "io/fs".
    • Our package is github.com/photoprism/photoprism/pkg/fs and provides the only approved permission constants for os.MkdirAll, os.WriteFile, os.OpenFile, and os.Chmod.
  • Prefer filepath.Join for filesystem paths; reserve path.Join for URL paths.

Safety & Data

  • Never commit secrets, local configurations, or cache files. Use environment variables or a local .env.

    • Ensure .env and .local are ignored in .gitignore and .dockerignore.
  • Prefer using existing caches, workers, and batching strategies referenced in code and Makefile. Consider memory/CPU impact; suggest benchmarks or profiling only when justified.

  • Do not run destructive commands against production data. Prefer ephemeral volumes and test fixtures when running acceptance tests.

  • File I/O — Overwrite Policy (force semantics)

  • Default is safety-first: callers must not overwrite non-empty destination files unless they opt-in with a force flag.

  • Replacing empty destination files is allowed without force=true (useful for placeholder files).

  • Open destinations with O_WRONLY|O_CREATE|O_TRUNC to avoid trailing bytes when overwriting; use O_EXCL when the caller must detect collisions.

  • Where this lives:

    • App-level helpers: internal/photoprism/mediafile.go (MediaFile.Copy/Move).
    • Reusable utils: pkg/fs/copy.go, pkg/fs/move.go.
  • When to set force=true:

    • Explicit “replace” actions or admin tools where the user confirmed overwrite.
    • Not for import/index flows; Originals must not be clobbered.
  • Archive Extraction — Security Checklist

  • Always validate ZIP entry names with a safe join; reject:

    • absolute paths (e.g., /etc/passwd).
    • Windows drive/volume paths (e.g., C:\\… or C:/…).
    • any entry that escapes the target directory after cleaning (path traversal via ..).
  • Enforce per-file and total size budgets to prevent resource exhaustion.

  • Skip OS metadata directories (e.g., __MACOSX) and reject suspicious names.

  • Where this lives: pkg/fs/zip.go (Unzip, UnzipFile, safeJoin).

  • Tests to keep:

    • Absolute/volume paths rejected (Windows-specific backslash path covered on Windows).
    • .. traversal skipped; __MACOSX skipped.
    • Per-file and total size limits enforced; directory entries created; nested paths extracted safely.
  • Examples assume a Linux/Unix shell. For Windows specifics, see the Developer Guide FAQ: https://docs.photoprism.app/developer-guide/faq/#can-your-development-environment-be-used-under-windows

HTTP Download — Security Checklist

  • Use the shared safe HTTP helper instead of adhoc net/http code:
    • Package: pkg/service/http/safesafe.Download(destPath, url, *safe.Options).
    • Default policy in this repo: allow only http/https, enforce timeouts and max size, write to a 0600 temp file then rename.
  • SSRF protection (mandatory unless explicitly needed for tests):
    • Set AllowPrivate=false to block private/loopback/multicast/linklocal ranges.
    • All redirect targets are validated; the final connected peer IP is also checked.
    • Prefer an imagefocused Accept header for image downloads: "image/jpeg, image/png, */*;q=0.1".
  • Avatars and small images: use the thin wrapper in internal/thumb/avatar.SafeDownload which applies stricter defaults (15s timeout, 10 MiB, AllowPrivate=false).
  • Tests using httptest.Server on 127.0.0.1 must pass AllowPrivate=true explicitly to succeed.
  • Keep perresource size budgets small; rely on io.LimitReader + Content-Length prechecks.

If anything in this file conflicts with the Makefile or the Developer Guide, the Makefile and the documentation win. When unsure, ask for clarification before proceeding.

Agent Quick Tips (Do This)

NextSession Priorities

  • If we add Postgres provisioning support, extend BuildDSN and provisioner.DatabaseDriver handling, add validations, and return driver=postgres consistently in API/CLI.
  • Consider surfacing a short “uuid → db/user” mapping helper in the CLI (e.g., nodes show --creds) if operators request it.

Testing & Fixtures

  • Go tests live next to their sources (path/to/pkg/<file>_test.go); group related cases as t.Run(...) sub-tests to keep table-driven coverage readable.
  • Prefer focused go test runs for speed (go test ./internal/<pkg> -run <Name> -count=1, go test ./internal/commands -run <Name> -count=1) and avoid ./... unless you need the entire suite.
  • Heavy packages such as internal/entity and internal/photoprism run migrations and fixtures; expect 30120s on first run and narrow with -run to keep iterations low.
  • For CLI-driven tests, wrap commands with RunWithTestContext(cmd, args) so urfave/cli cannot exit the process, and assert CLI output with assert.Contains/regex because show reports quote strings.
  • In internal/photoprism tests, rely on photoprism.Config() for runtime-accurate behavior; only build a new config if you replace it via photoprism.SetConfig.
  • Generate identifiers with rnd.GenerateUID(entity.ClientUID) for OAuth client IDs and rnd.UUIDv7() for node UUIDs; treat node.uuid as required in responses.
  • Shared fixtures live under storage/testdata; NewTestConfig("<pkg>") already calls InitializeTestData(), but call c.InitializeTestData() (and optionally c.AssertTestData(t)) when you construct custom configs so originals/import/cache/temp exist. InitializeTestData() clears old data, downloads fixtures if needed, then calls CreateDirectories().

Roles & ACL

  • Map roles via the shared tables: users through acl.ParseRole(s) / acl.UserRoles[...], clients through acl.ClientRoles[...].
  • Treat RoleAliasNone ("none") and an empty string as RoleNone; no caller-specific overrides.
  • Default unknown client roles to RoleClient; acl.ParseRole already handles 0/false/nil as none for users.
  • Build CLI role help from Roles.CliUsageString() (e.g., acl.ClientRoles.CliUsageString()); never hand-maintain role lists.

Import/Index

  • ImportWorker may skip files if an identical file already exists (duplicate detection). Use unique copies or assert DB rows after ensuring a nonduplicate destination.
  • Mixed roots: when testing related files, keep ExamplesPath()/ImportPath()/OriginalsPath() consistent so RelatedFiles and AllowExt behave as expected.

CLI Usage & Assertions

  • Wrap CLI tests in RunWithTestContext(cmd, args) so urfave/cli cannot exit the process; assert quoted show output with assert.Contains/regex for the trailing ", or " rule.
  • Prefer --json responses for automation. photoprism show commands --json [--nested] exposes the tree view (add --all for hidden entries).
  • Use internal/commands/catalog to inspect commands/flags without running the binary; when validating large JSON docs, marshal DTOs via catalog.BuildFlat/BuildNode instead of parsing CLI stdout.
  • Expect show commands to return arrays of snake_case rows, except photoprism show config, which yields { sections: [...] }, and the config-options/config-yaml variants, which flatten to a top-level array.

API & Config Changes

  • Respect precedence: options.yml overrides CLI/env values, which override defaults. When adding a new option, update internal/config/options.go (yaml/flag tags), register it in internal/config/flags.go, expose a getter, surface it in *config.Report(), and write generated values back to options.yml by setting c.options.OptionsYaml before persisting. Use CliTestContext in internal/config/test.go to exercise new flags.
  • When touching configuration in Go code, use the public accessors on *config.Config (e.g. Config.JWKSUrl(), Config.SetJWKSUrl(), Config.ClusterUUID()) instead of mutating Config.Options() directly; reserve raw option tweaks for test fixtures only.
  • Logging: use the shared logger (event.Log) via the package-level log variable (see internal/auth/jwt/logger.go) instead of direct fmt.Print* or ad-hoc loggers.
  • Cluster registry tests (internal/service/cluster/registry) currently rely on a full test config because they persist entity.Client rows. They run migrations and seed the SQLite DB, so they are intentionally slow. If you refactor them, consider sharing a single config.TestConfig() across subtests or building a lightweight schema harness; do not swap to the minimal config helper unless the tests stop touching the database.
  • Favor explicit CLI flags: check c.cliCtx.IsSet("<flag>") before overriding user-supplied values, and follow the ClusterUUID pattern (options.yml → CLI/env → generated UUIDv4 persisted).
  • Database helpers: reuse conf.Db() / conf.Database*(), avoid GORM WithContext, quote MySQL identifiers, and reject unsupported drivers early.
  • Handler conventions: reuse limiter stacks (limiter.Auth, limiter.Login) and limiter.AbortJSON for 429s, lean on api.ClientIP, header.BearerToken, and Abort* helpers, compare secrets with constant time checks, set Cache-Control: no-store on sensitive responses, and register routes in internal/server/routes.go. For new list endpoints default count=100 (max 1000) and offset≥0, document parameters explicitly, and set portal mode via PHOTOPRISM_NODE_ROLE=portal plus PHOTOPRISM_JOIN_TOKEN when needed.
  • Swagger & docs: annotate only routed handlers in internal/api/*.go, use full /api/v1/... paths, skip helpers, and regenerate docs with make fmt-go swag-fmt swag or make swag-json (which also strips duplicate time.Duration enums). When iterating, target packages with go test ./internal/api -run Cluster -count=1 or similarly scoped runs.
  • Testing helpers: isolate config paths with t.TempDir(), reuse NewConfig, CliTestContext, and NewApiTest() harnesses, authenticate via AuthenticateAdmin, AuthenticateUser, or OAuthToken, toggle auth with conf.SetAuthMode(config.AuthModePasswd), and prefer OAuth client tokens over non-admin fixtures for negative permission checks.
  • Registry data and secrets: store portal/node registry files under conf.PortalConfigPath()/nodes/ with mode 0600, keep secrets out of logs, and only return them on creation/rotation flows.

Formatting (Go)

  • Go is formatted by gofmt and uses tabs. Do not hand-format indentation.
  • Always run after edits: make fmt-go (gofmt + goimports).

API Shape Checklist

  • When renaming or adding fields:
    • Update DTOs in internal/service/cluster/response.go and any mappers.
    • Update handlers and regenerate Swagger: make fmt-go swag-fmt swag.
    • Update tests (search/replace old field names) and examples in specs/.
    • Quick grep: rg -n 'oldField|newField' -S across code, tests, and specs.

API/CLI Tests: Known Pitfalls

  • Gin routes: Register CreateSession(router) once per test router; reusing it twice panics on duplicate route.
  • CLI commands: Some commands defer conf.Shutdown() or emit signals that close the DB. The harness reopens DB before each run, but avoid invoking start or emitting signals in unit tests.
  • Signals: internal/commands/start.go waits on process.Signal; calling process.Shutdown()/Restart() can close DB. Prefer not to trigger signals in tests.

Download CLI Workbench (yt-dlp, remux, importer)

  • Code anchors

    • CLI flags and examples: internal/commands/download.go
    • Core implementation (testable): internal/commands/download_impl.go
    • yt-dlp helpers and arg wiring: internal/photoprism/dl/* (options.go, info.go, file.go, meta.go)
    • Importer entry point: internal/photoprism/get/import.go; options: internal/photoprism/import_options.go
  • Quick test runs (fast feedback)

    • yt-dlp package: go test ./internal/photoprism/dl -run 'Options|Created|PostprocessorArgs' -count=1
    • CLI command: go test ./internal/commands -run 'DownloadImpl|HelpFlags' -count=1
  • FFmpeg-less tests

    • In tests: set c.Options().FFmpegBin = "/bin/false" and c.Settings().Index.Convert = false to avoid ffmpeg dependencies when not validating remux.
  • Stubbing yt-dlp (no network)

    • Use a tiny shell script that:
      • prints minimal JSON for --dump-single-json
      • creates a file and prints its path when --print is requested
    • Harness env vars (supported by our tests):
      • YTDLP_ARGS_LOG — append final args for assertion
      • YTDLP_OUTPUT_FILE — absolute file path to create for --print
      • YTDLP_DUMMY_CONTENT — file contents to avoid importer duplicate detection between tests
  • Remux policy and metadata

    • Pipe method: PhotoPrism remux (ffmpeg) always embeds title/description/created.
    • File method: ytdlp writes files; we pass --postprocessor-args 'ffmpeg:-metadata creation_time=<RFC3339>' so imports get Created even without local remux (fallback from upload_date/release_date).
    • Default remux policy: auto; use always for the most complete metadata (chapters, extended tags).
  • Testing workflow: lean on the focused commands above; if importer dedupe kicks in, vary bytes with YTDLP_DUMMY_CONTENT or adjust dest, and remember internal/photoprism is heavy so validate downstream packages first.

Sessions & Redaction (building sessions in tests)

  • Admin session (full view): AuthenticateAdmin(app, router).
  • User session: Create a nonadmin test user (role=guest), set a password, then AuthenticateUser.
  • Client session (redacted internal fields; siteUrl visible):
    s, _ := entity.AddClientSession("test-client", conf.SessionMaxAge(), "cluster", authn.GrantClientCredentials, nil)
    token := s.AuthToken()
    r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", token)
    
    Admins see advertiseUrl and database; client/user sessions dont. siteUrl is safe to show to all roles.

Preflight Checklist

  • go build ./...
  • make fmt-go swag-fmt swag
  • go test ./internal/service/cluster/registry -count=1
  • go test ./internal/api -run 'Cluster' -count=1
  • go test ./internal/commands -run 'ClusterRegister|ClusterNodesRotate' -count=1
  • Tooling constraints: make swag may fetch modules, so confirm network access before running it.

Cluster Operations

  • Keep bootstrap code decoupled: avoid importing internal/service/cluster/instance/* from internal/config or the cluster root, let instances talk to the Portal over HTTP(S), and rely on constants from internal/service/cluster/const.go.
  • Config init order: load options.yml (c.initSettings()), run EarlyExt().InitEarly(c), connect/register the DB, then invoke Ext().Init(c).
  • Theme endpoint: GET /api/v1/cluster/theme streams a zip from conf.ThemePath(); only reinstall when app.js is missing and always use the header helpers in pkg/service/http/header.
  • Registration flow: send rotate=true only for MySQL/MariaDB nodes without credentials, treat 401/403/404 as terminal, include clientId + clientSecret when renaming an existing node, and persist only newly generated secrets or DB settings.
  • Registry & DTOs: use the client-backed registry (NewClientRegistryWithConfig)—the file-backed version is legacy—and treat migration as complete only after swapping callsites, building, and running focused API/CLI tests. Nodes are keyed by UUID v7 (/api/v1/cluster/nodes/{uuid}), the registry interface stays UUID-first (Get, FindByNodeUUID, FindByClientID, RotateSecret, DeleteAllByUUID), CLI lookups resolve uuid → clientId → name, and DTOs normalize database.{name,user,driver,rotatedAt} while exposing clientSecret only during creation/rotation. nodes rm --all-ids cleans duplicate client rows, admin responses may include advertiseUrl/database, client/user sessions stay redacted, registry files live under conf.PortalConfigPath()/nodes/ (mode 0600), and ClientData no longer stores NodeUUID.
  • Provisioner & DSN: database/user names use UUID-based HMACs (photoprism_d<hmac11>, photoprism_u<hmac11>); BuildDSN accepts a driver but falls back to MySQL format with a warning when unsupported.
  • Testing: exercise Portal endpoints with httptest, guard extraction paths with pkg/fs.Unzip size caps, and expect admin-only fields to disappear when authenticated as a client/user session.