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
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Changelog

Notable changes to Beckham Share, newest first. The project follows a simple
pre-1.0 scheme; dates are when the change reached `main`.

## Unreleased

### Added
- Full reference documentation under `docs/`: architecture, configuration,
operations runbook, security model, and API reference, with a docs index and
a `CONTRIBUTING` guide.

## 2026-06-23

### Added
- Troubleshooting write-up for the Android upload "microphone" prompt, plus a
standalone file-picker diagnostic page kept for reference (#10).

### Fixed
- CI: use a writable `DATA_DIR` for the end-to-end job and stop duplicate
workflow runs.

## 2026-06-22

### Added
- **Initial release.** Upload a file and share it by an opaque UUID link with a
configurable expiry; copy-to-clipboard and email actions.
- Public landing page with a constrained anonymous upload (size cap, rate limit,
short auto-expiry, IP + fingerprint audit trail).
- Identity-gated workspace: OIDC sign-in against Authentik, restricted to the
`dropbox` group at the IdP and re-checked in-app.
- Server-side "email this link" over SMTP, restricted to signed-in members;
anonymous visitors fall back to a `mailto:` link.
- Per-link management: change expiry, revoke, delete; download counting.
- Brand logo and favicon.
- Three-layer automated test suite (backend integration, browser E2E, live
smoke + configuration check) with continuous integration on every push and
pull request, plus `docs/TESTING.md`.

### Fixed
- Sign-in failure caused by an empty `grant_types` on the OIDC provider; the
Authentik setup script now sets them explicitly and the smoke test guards it.
- Share modal that could open stuck and uncloseable.
77 changes: 77 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Contributing

Thanks for working on Beckham Share. This guide covers local setup, the workflow,
and the conventions a change is expected to follow.

## Local setup

```sh
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
pip install -r requirements-dev.txt # test + tooling deps

# Point DATABASE_URL at a local Postgres (or run the compose db service), then:
uvicorn app.main:app --reload
```

The landing page, anonymous uploads, share pages, and downloads all work without
OIDC configured. Only the `/app` workspace requires Authentik — see
[Configuration](docs/CONFIGURATION.md) for the `OIDC_*` variables.

## Workflow

- **Never commit or push to `main`.** Branch, push, open a pull request, merge,
then delete the branch.
- **One focused change per pull request.** Keep diffs reviewable.
- **Use issues for tracking.** File bugs with the `bug` label and planned work
with `enhancement`. Reference the issue from the PR.
- Branch names describe the change, e.g. `add-download-analytics`,
`fix-share-modal`.

## Tests are part of the change

A feature is not done until its real behavior is covered by an automated test —
"the endpoint returns 200" is not "the feature works". Pick the layer that fits
(full detail in [docs/TESTING.md](docs/TESTING.md)):

- New route or business rule → a **backend** test (`tests/backend`, pytest).
- New button / component / page interaction → a **browser E2E** test
(`tests/e2e`, Playwright) so the rendered behavior is covered.
- New deploy-time invariant (a hostname, a provider setting) → a check in
`scripts/smoke.sh`.

Run the relevant suite before opening a PR:

```sh
sh scripts/run-tests.sh # backend integration (throwaway Postgres)
sh scripts/run-e2e.sh # browser end-to-end (throwaway local instance)
```

CI runs the backend and E2E layers on every push and pull request; the PR must be
green before merge.

## Conventions

- **Configuration goes through `app/config.py`.** Don't read `os.environ`
elsewhere; add a typed setting and document it in
[docs/CONFIGURATION.md](docs/CONFIGURATION.md).
- **Keep the access tiers explicit.** Public, member, and infra routes are
distinct; member routes must check `in_required_group`.
- **Never expose the original filename in a URL or path.** Use the UUID; restore
the name only in `Content-Disposition`.
- **Secrets never enter the repo.** `.env`, `secrets/`, and host-specific files
are gitignored; document new settings with safe placeholders in `.env.example`.
- **Update the docs with the code.** A change that adds a route, a setting, or an
operational step updates the matching document under `docs/`.
- Match the surrounding code's style; keep functions small and the modules'
single responsibilities intact (see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)).

## Project layout

```
app/ FastAPI application (routes, models, auth, storage, templates, static)
caddy/ reverse-proxy route snippet for the shared Caddy front
scripts/ deploy, secret generation, Authentik setup, test runners
tests/ backend (pytest) + e2e (Playwright) suites
docs/ architecture, configuration, operations, security, API, testing
```
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,24 @@ sh scripts/run-e2e.sh # browser end-to-end tests (throwaway local instance)
sh scripts/smoke.sh # post-deploy checks against the live deployment
```

## Documentation

Full reference documentation lives in [`docs/`](docs/README.md):

- [Architecture](docs/ARCHITECTURE.md) — components, request flows, data model.
- [Configuration](docs/CONFIGURATION.md) — every environment variable.
- [Operations](docs/OPERATIONS.md) — deploy and the day-two runbook.
- [Security](docs/SECURITY.md) — auth, abuse controls, threat model.
- [API reference](docs/API.md) — every HTTP route.
- [Testing](docs/TESTING.md) — the three test layers.
- [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)

## Project layout

```
app/ FastAPI application (routes, models, auth, storage, templates, static)
caddy/ reverse-proxy route snippet for the shared Caddy front
scripts/ deploy, secret generation, Authentik setup
docs/ project notes
scripts/ deploy, secret generation, Authentik setup, test runners
tests/ backend (pytest) + e2e (Playwright) suites
docs/ architecture, configuration, operations, security, API, testing
```
126 changes: 126 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# API reference

Every HTTP route exposed by the application, grouped by access tier. Routes are
defined in [`app/main.py`](../app/main.py). The interactive OpenAPI docs
(`/docs`, `/redoc`) are intentionally disabled.

Auth tiers:

- **Public** — no authentication.
- **Member** — requires a valid OIDC session **and** membership in the
`REQUIRED_GROUP` (`dropbox`). Otherwise `403`.
- **Infra** — health/operational endpoints.

## Public — pages

### `GET /`
The landing page. If the request already has a valid member session, redirects to
`/app` (302). Otherwise renders the public upload page.

### `GET /s/{token}`
Human-facing share page for a link: filename, size, expiry, and copy / email
actions. Returns `404` if the link is unknown or its file was deleted, `410` if
the link has expired.

### `GET /d/{token}`
Downloads the file bytes for a link. Increments the link's `download_count`.
Restores the original filename via `Content-Disposition`. Returns `404` if
missing/deleted, `410` if expired.

## Public — anonymous upload

### `POST /api/anon-upload`
Constrained, account-free upload from the landing page.

**Body** (`multipart/form-data`):

| Field | Type | Notes |
|---|---|---|
| `file` | file | **Required.** The upload. Capped at `ANON_MAX_UPLOAD_BYTES`. |
| `fp` | string | Optional client-computed fingerprint hash. |
| `fp_data` | string | Optional JSON bundle of raw fingerprint signals. |

**Responses:**

- `200` — `{ ok: true, token, share_url, filename, size, expires_at }`. The
anonymous link expires after `ANON_SHARE_EXPIRY_HOURS`.
- `413` — `{ ok: false, error }` when the file exceeds the anonymous size cap.
- `429` — `{ ok: false, error }` with a `Retry-After` header when the client is
over its hourly or daily budget (keyed by IP **or** fingerprint).

Every call writes an `upload_events` audit row (IP, UA, fingerprint). See
[Security](SECURITY.md).

## Authentication

### `GET /login`
Starts the OIDC Authorization Code flow (redirects to Authentik). Redirects to
the canonical host first if reached on a non-canonical hostname. Returns `503` if
OIDC is not configured.

### `GET /auth/callback`
OIDC redirect target. Exchanges the code for tokens, reads `userinfo` (including
the `groups` claim), stores the identity in the session cookie, and redirects to
`/app`. On failure, renders a friendly error page with status `400`.

### `GET /logout`
Clears the session and redirects to `/`.

## Member — workspace & file management

### `GET /app`
The authenticated workspace: the signed-in member's files, each with its share
link, expiry, download count, and management actions. Redirects to `/login` if
unauthenticated; renders a `403` "not authorized" page for a signed-in
non-member.

### `POST /api/files`
Upload a file as a member.

**Body** (`multipart/form-data`):

| Field | Type | Notes |
|---|---|---|
| `file` | file | **Required.** Capped at `MAX_UPLOAD_BYTES`. |
| `expiry_hours` | int | One of `SHARE_EXPIRY_OPTIONS_HOURS`; invalid values fall back to the default. |
| `fp`, `fp_data` | string | Optional fingerprint signals (recorded on the audit row). |

**Responses:** `200` `{ ok: true, ...file_row }`; `403` if not a member; `413` if
over the size cap.

### `POST /api/shares/{token}/expiry`
Change a link's expiry. Body: `expiry_hours` (must be an allowed option; `400`
otherwise). Clears any prior revocation. Owner-only (`404` for a token the caller
doesn't own). Returns `{ ok: true, token, expires_at }`.

### `POST /api/shares/{token}/revoke`
Revoke a link immediately (it then reads as expired). Owner-only. Returns
`{ ok: true }`.

### `DELETE /api/files/{file_id}`
Soft-delete a file and remove its blob from disk. Owner-only (`404` otherwise),
member-gated (`403` otherwise). Returns `{ ok: true }`.

### `POST /api/shares/{token}/email`
Send the share link by email via the server-side SMTP relay. Body: `to` (the
recipient). **Members only** — anonymous visitors use a `mailto:` link instead,
so the public share page can't be turned into a spam relay.

**Responses:** `200` `{ ok: true }`; `403` for non-members; `404` if the link is
missing/expired; `503` `{ ok:false, reason:"email_not_configured" }` if SMTP is
unset; `502` `{ ok:false, reason:"send_failed" }` on a send error.

## Infra

### `GET /healthz`
Returns `{ status: "ok" }`. Used by the container health check and the smoke
test. Hidden from the schema.

## Conventions

- JSON API endpoints return `{ ok: true, ... }` on success and `{ ok: false,
error | reason }` on handled failures, with an appropriate HTTP status.
- The only identifier in any public URL is the share-link `token` (a UUID). File
IDs appear only in member-authenticated management calls.
- All cookies are `Secure`, `HttpOnly` is managed by Starlette's session
middleware, and `SameSite=Lax`.
Loading
Loading