Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 19 additions & 29 deletions cmd/aperture/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/mayvqt/aperture/internal/config"
"github.com/mayvqt/aperture/internal/db"
"github.com/mayvqt/aperture/internal/httpserver"
"github.com/mayvqt/aperture/internal/mediaserver"
"github.com/mayvqt/aperture/internal/mediaserver/router"
)

Expand Down Expand Up @@ -47,59 +45,51 @@ func serve(args []string) error {
cfg.APIKey, cfg.EncryptionKey, cfg.SessionSecret, cfg.InviteSecret,
settings.APIKey, settings.SessionSecret, settings.InviteSecret,
)
if cfg.ProviderManaged {
if err := store.ValidateMediaProvider(context.Background(), cfg.MediaProvider); err != nil {
return err
}
} else if provider, ok := mediaserver.ParseProvider(settings.Provider); ok {
cfg.MediaProvider = string(provider)
}
if !cfg.PublicURLManaged && settings.PublicURL != "" {
cfg.PublicURL = settings.PublicURL
}
if !cfg.CookieManaged {
cfg.CookieSecure = strings.HasPrefix(cfg.PublicURL, "https://")
}
provider, _ := mediaserver.ParseProvider(cfg.MediaProvider)
media, err := router.New(provider)
if err != nil {
return err
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
handler := httpserver.NewServer(cfg, store, router.New)
if err := handler.Initialize(ctx); err != nil {
return err
}
workerDone := make(chan struct{})
go func() {
defer close(workerDone)
httpserver.RunMaintenanceWorker(ctx, cfg, store, media)
handler.RunMaintenance(ctx)
}()

handler, waitForWebhooks := httpserver.NewWithShutdown(cfg, store, media)
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
WriteTimeout: 4 * time.Minute,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 64 << 10,
}

shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
handler.CloseAdmission()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Warn("HTTP shutdown deadline reached", "error", err)
_ = srv.Close()
}
}()

slog.Info("starting aperture", "addr", cfg.HTTPAddr, "db", cfg.DBPath)
err = srv.ListenAndServe()
stop()
<-shutdownDone
<-workerDone
deliveryCtx, cancelDeliveries := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelDeliveries()
if waitErr := waitForWebhooks(deliveryCtx); waitErr != nil {
slog.Warn("webhook deliveries did not finish before shutdown", "error", waitErr)
// Each accepted operation and webhook already has its own deadline. Closing
// SQLite after a separate drain timeout could race their final state writes.
if waitErr := handler.Drain(context.Background()); waitErr != nil {
return waitErr
}
if errors.Is(err, http.ErrServerClosed) {
return nil
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ services:
image: aperture:local
container_name: aperture
restart: unless-stopped
stop_grace_period: 270s
init: true
security_opt:
- no-new-privileges:true
Expand Down
11 changes: 9 additions & 2 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,19 @@ Initial setup is serialized within the application process so a delayed request
cannot overwrite completed setup. Run one Aperture process per state directory.
After an invite use is reserved, account provisioning continues for a bounded
period even if the browser disconnects. Incomplete accounts are disabled when
the media server is reachable; failed cleanup requires administrator intervention.
the media server is reachable. Cleanup is persisted before access changes and
retried after failures or restarts, independently of the template retry limit.
The account ID is saved before Emby password setup begins.
Accounts with incomplete password setup retain their external ID for review and
cannot be enabled through automatic or manual template-only retries.

Template policies always disable administrator access, regardless of property
casing. Conflicting property names are rejected. Applying a template preserves
the target account's authentication and password-reset providers and merges its
remaining defaults, so imported authentication settings do not cross accounts.

Before upgrading an existing installation, review older `needs_attention`
registrations in the media server, especially password-setup failures. Earlier
records are not reclassified by this release. Keep any incomplete accounts
records may not identify the interrupted password step. Keep any incomplete accounts
disabled and finish password setup before allowing a template retry. Do this
before restarting Aperture, since maintenance can retry eligible records.
7 changes: 4 additions & 3 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ Aperture provides:

- browser-based first-run configuration for Jellyfin or Emby;
- administrator authentication through the configured media server;
- reusable non-administrator policy templates, including import from an existing user;
- reusable non-administrator policy templates that preserve target authentication defaults, including import from an existing user;
- bounded, expiring invite links with usage limits and optional account expiry;
- account creation, policy application, retry, disable, and recovery workflows;
- managed-user and registration history views;
- account creation, policy application, retry, disable, and recovery workflows with durable incomplete-account cleanup;
- paginated invite and registration history, including older records that need review;
- server-scoped account tracking with explicit review when changing servers;
- Discord and generic JSON webhooks with selected events; and
- administrative audit history with bounded retention.

Expand Down
29 changes: 29 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,32 @@ and use a compatible Aperture version; never delete the database as a normal upg

Admins can configure Discord or generic JSON webhooks, events, and optional Discord role IDs in the web UI. URLs are
encrypted. Failed template application is retried automatically up to six times.

## Account recovery

Registrations show access retries and pending disables separately. After six
automatic access retries, review the account and use **Retry access** when ready.
Failed disables keep retrying with backoff until the account is disabled or no
longer exists. Password-incomplete accounts require administrator review and
cannot be enabled by retrying a template.

An account's expiry is fixed when its invite use is reserved. Recovery never
extends it, and an expired account can only be disabled. Accounts undergoing
creation or recovery cannot be removed until that operation finishes.

## Changing servers and reviewing history

Ownership includes the provider, normalized server URL and authenticated server ID.
Changing any of these signs out all administrators. Existing invites and accounts
remain associated with their original server; automatic work pauses for records
that belong elsewhere. A cloned server ID at a different URL is a separate server.

After an upgrade, older records show **Unverified server**. Use **Review server** on
an invite or registration to inspect the destination and confirm its assignment.
Review tracked-only users under **Users → Tracked user history**. Assignment keeps
invite links, usage counts and account deadlines. It never assigns all accounts
from an invite together. The account ID must exist on the destination server with
a verified non-administrator policy; Aperture does not guess ownership from names.

Use **Needs review** to find older records requiring attention. Invite and
registration history pages show 50 records at a time, with links to older pages.
27 changes: 21 additions & 6 deletions docs/development/architecture.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Architecture

`cmd/aperture` loads configuration, opens the store, initializes schema and
runtime secrets, selects a media-server adapter, starts the maintenance worker,
and serves `internal/httpserver`. Handlers depend on narrow store and media-server
runtime secrets, selects a media-server adapter, and starts one HTTP server that
owns maintenance and notification delivery. Handlers depend on narrow store and media-server
interfaces; persistence stays in `internal/db`, while outbound Jellyfin/Emby
protocol details stay in `internal/mediaserver`.

Expand All @@ -20,6 +20,9 @@ protocol details stay in `internal/mediaserver`.
- Jellyfin and Emby request shapes and authorization are provider contracts.
Verify changes against the supported upstream documentation and cover them
with deterministic HTTP fixtures before any opt-in live smoke test.
- Policy application reads the target user's complete policy before merging
template overrides. Shared normalization rejects ambiguous case aliases,
preserves target authentication providers, and forces non-administrator access.

## Security and concurrency boundaries

Expand All @@ -30,8 +33,20 @@ and no-redirect outbound clients.

SQLite is deliberately limited to one connection and uses WAL, foreign keys, and
a busy timeout. Setup is serialized in-process. Registration reserves invite
capacity before provisioning; ambiguous external failures retain evidence rather
than silently releasing capacity. The maintenance worker reconciles stale work,
retries templates, disables expired users, and prunes audit events. Webhook
deliveries are tracked and receive a bounded graceful-shutdown window. Changes to
capacity and an immutable expiry before provisioning; ambiguous external failures
retain evidence rather than silently releasing capacity. Shared account recovery
coordinates manual and automatic retries with per-registration operation guards,
transactional claims, and durable cleanup. The maintenance worker reconciles
stale work, disables incomplete and expired accounts before retrying templates,
and prunes audit events. Graceful shutdown drains accepted HTTP and maintenance
work before notifications and before closing SQLite. Changes to
these flows must preserve idempotency, bounded work, and cancellation behavior.

`internal/connection` resolves deployment overrides and publishes immutable
connection snapshots through a media adapter factory. Each accepted operation
retains its adapter, URL, credentials and verified ownership. Administrative
sessions must match both binding and generation before their tokens are sent;
authenticated system information detects a replacement at the same URL.
Background account queries filter ownership before limits and grouping. History
remains readable across origins; explicit assignment uses account operation guards
and verifies an existing non-administrator account at the destination.
5 changes: 3 additions & 2 deletions docs/development/codebase-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
| --- | --- |
| `cmd/aperture` | CLI dispatch, configuration startup, logging, process lifecycle, and version output. |
| `internal/config` | Environment/flag parsing, defaults, URL validation, and generated encryption-key bootstrap. |
| `internal/connection` | Effective configuration, immutable operation snapshots, server identity verification and atomic connection publication. |
| `internal/db` | SQLite schema, migrations, settings, invites, templates, sessions, registrations, managed users, and audit records. |
| `internal/httpserver` | Routes, middleware, browser workflows, embedded templates/assets, webhooks, and maintenance coordination. |
| `internal/httpserver` | Routes, middleware, browser workflows, embedded templates/assets, shared account recovery, webhooks, and maintenance coordination. |
| `internal/mediaserver` | Provider-neutral contracts and URL rules. |
| `internal/mediaserver/jellyfin`, `emby`, `protocol`, `router` | Provider adapters, HTTP protocol, and runtime provider selection. |
| `internal/mediaserver/jellyfin`, `emby`, `protocol`, `router` | Provider adapters, HTTP protocol, and immutable adapter factory. |
| `internal/security` | Encryption, token helpers, and diagnostic redaction. |
| `scripts`, `docker-entrypoint.sh`, `Dockerfile`, `docker-compose.yml` | Container build, startup, ownership, and regression checks. |
| `templates/unraid` | Unraid application template. |
Expand Down
13 changes: 13 additions & 0 deletions docs/development/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,16 @@ Every schema change requires:

Never repair an upgrade by deleting the database. Back up and restore the whole
state set as described in [Operations](operations.md).

Revision 5 adds durable account cleanup state. Expiry is captured in the invite
reservation transaction and retained through failures and recovery. Migration
preserves existing deadlines and reconstructs missing finite deadlines from the
original registration time and retained invite duration. Incomplete accounts
with known upstream IDs enter cleanup independently of access retry attempts.

Revision 6 adds server ownership using provider, normalized URL and the authenticated
server ID. Existing invite, registration and tracked-user rows retain their data
with unknown ownership until an administrator reviews each record. Connection
publication atomically saves settings and revokes sessions on an origin change.
Reassigning an account preserves its deadline and resets the old server's disable
acknowledgement so expired access is checked again.
15 changes: 15 additions & 0 deletions docs/development/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,18 @@ the pre-upgrade state with the previous immutable image. Stop on failed health o
data verification; preserve failed state for diagnosis. Maintenance or repair
actions that mutate users, registrations, or SQLite state are explicit,
operator-approved procedures, never automatic troubleshooting steps.

Revision 5 resumes incomplete-account disables from durable state. Review older
password-setup failures before upgrading as described in [Security](../SECURITY.md).
Allow four minutes for accepted account operations to finish before SQLite
closes. The supplied Compose and Unraid configurations allow 270 seconds,
including notification delivery, before forcing the process to stop.

After upgrading to revision 6, sign in again and review saved invites and accounts.
Legacy ownership is unknown, so automatic account changes pause until each account
is assigned. On **Invites** and **Registrations**, use **Review server**; previously
imported users are under **Users → Tracked user history**. Confirm the displayed
server and account ID before assigning. Existing deadlines are preserved, so an
expired or incomplete assigned account may be disabled on the next maintenance
run. Reconnecting the same provider, normalized URL and server ID restores its
existing ownership; an outage does not silently assign records elsewhere.
14 changes: 14 additions & 0 deletions docs/development/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ Format touched Go files with `gofmt -w`. Performance or refactor claims require
representative before/after benchmark or trace and a regression threshold; a
clean test run alone is not performance evidence.

Media-server policy fixtures cover complete target defaults, case-insensitive
overrides, duplicate access flags, and disabling an account after an ambiguous
failure. Keep these checks when changing template import or application.

Account lifecycle regressions cover interrupted password setup, lost policy and
completion responses, exhausted template retries with pending cleanup, fixed
expiry, operation collisions, and fresh/legacy/rollback migration behavior with
encrypted-value preservation.

## UI evidence

Rebuild before visual checks because templates, CSS, and JavaScript are embedded.
Expand All @@ -37,3 +46,8 @@ The one comprehensive gate is the complete GitHub Actions `CI` workflow in
whitespace, `go test ./...`, the entrypoint, vet, race detection, pinned
Staticcheck and govulncheck versions, a release-style build, and the Docker image.
Do not claim release readiness until both jobs pass on that revision.

Origin regressions cover upgrades from revisions 1–5, encrypted invite preservation,
rollback, unknown ownership, cloned IDs at different URLs, replacement servers,
API-key rotation, session revocation, lost settings acknowledgements, optional-key
login, scoped work queues and paginated review of old records.
4 changes: 4 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Open `http://localhost:8099` from a trusted network. Choose Jellyfin or Emby, en
and API key, then sign in as a media-server administrator. Create or import a non-admin template before creating an
invite.

The default template uses the media server's account defaults with administrator
access disabled. Imported policies keep the new account's authentication and
password-reset providers. JSON property names must be unique regardless of casing.

Standalone installs use `aperture serve`. Persist and back up the config directory. Do not expose Aperture publicly
until the unauthenticated first-run setup is complete.

Expand Down
Loading