docs(project): sync docs with code and bump to 3.0.0
Prepare the 3.0.0 release: - Bump the version in openapi.yaml and frontend/package.json to 3.0.0. - Document the existing GET /health endpoint in openapi.yaml (served at the root, outside /api/v1) and refine the auth note. - Add docs/REQUIREMENTS.md (product requirements, in English) and docs/ARCHITECTURE.md (system overview); remove the old Russian docs/Описание.md. - Rewrite GO_PROJECT_STRUCTURE.md and FRONTEND_STRUCTURE.md to match the current code (dedup CLI, imagehash, real components/stores, Tailwind v4). - Fix stale counts and references in CLAUDE.md and link the new docs from README.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,16 +13,18 @@ Monorepo: `backend/` (Go) + `frontend/` (SvelteKit).
|
|||||||
|
|
||||||
## Key documents (read before coding)
|
## Key documents (read before coding)
|
||||||
|
|
||||||
- `openapi.yaml` — full REST API specification (36 paths, 58 operations)
|
- `openapi.yaml` — full REST API specification (44 paths, 67 operations)
|
||||||
|
- `docs/REQUIREMENTS.md` — product requirements (functional + non-functional)
|
||||||
|
- `docs/ARCHITECTURE.md` — system architecture overview (layers, data flow, decisions)
|
||||||
- `docs/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern
|
- `docs/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern
|
||||||
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
|
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
|
||||||
- `docs/Описание.md` — product requirements in Russian
|
- `backend/migrations/` — database schema as goose migrations (4 schemas, 19 tables)
|
||||||
- `backend/migrations/001_init.sql` — database schema (4 schemas, 16 tables)
|
|
||||||
|
|
||||||
## Design reference
|
## Design reference
|
||||||
|
|
||||||
Visual design tokens for the frontend (carried over from the previous
|
Visual design tokens for the frontend (carried over from the previous
|
||||||
Python/Flask version):
|
Python/Flask version):
|
||||||
|
|
||||||
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
|
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
|
||||||
- Font: Epilogue (variable weight)
|
- Font: Epilogue (variable weight)
|
||||||
- Dark theme is primary
|
- Dark theme is primary
|
||||||
@@ -32,6 +34,7 @@ Python/Flask version):
|
|||||||
- Floating selection bar for multi-select
|
- Floating selection bar for multi-select
|
||||||
|
|
||||||
## Backend commands
|
## Backend commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
go run ./cmd/server # run dev server
|
go run ./cmd/server # run dev server
|
||||||
@@ -39,6 +42,7 @@ go test ./... # run all tests
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Frontend commands
|
## Frontend commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run dev # vite dev server
|
npm run dev # vite dev server
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ built SPA on one port.
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [`openapi.yaml`](openapi.yaml) — full REST API specification
|
- [`openapi.yaml`](openapi.yaml) — full REST API specification
|
||||||
|
- [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) — product requirements
|
||||||
|
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — system architecture overview
|
||||||
- [`docs/DEPLOY.md`](docs/DEPLOY.md) — production deploy (Gitea Actions → host)
|
- [`docs/DEPLOY.md`](docs/DEPLOY.md) — production deploy (Gitea Actions → host)
|
||||||
- [`docs/GO_PROJECT_STRUCTURE.md`](docs/GO_PROJECT_STRUCTURE.md) — backend architecture
|
- [`docs/GO_PROJECT_STRUCTURE.md`](docs/GO_PROJECT_STRUCTURE.md) — backend architecture
|
||||||
- [`docs/FRONTEND_STRUCTURE.md`](docs/FRONTEND_STRUCTURE.md) — frontend architecture
|
- [`docs/FRONTEND_STRUCTURE.md`](docs/FRONTEND_STRUCTURE.md) — frontend architecture
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Tanabata File Manager — Architecture
|
||||||
|
|
||||||
|
System-level overview of Tanabata File Manager (TFM). For the product-level
|
||||||
|
requirements see [REQUIREMENTS.md](REQUIREMENTS.md); for per-side detail see
|
||||||
|
[GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md) (backend) and
|
||||||
|
[FRONTEND_STRUCTURE.md](FRONTEND_STRUCTURE.md) (frontend). The full HTTP contract
|
||||||
|
lives in [`openapi.yaml`](../openapi.yaml).
|
||||||
|
|
||||||
|
## System Context
|
||||||
|
|
||||||
|
TFM is a multi-user, tag-based web file manager for images and video. It is a
|
||||||
|
single deployable unit — one Docker image that serves both the REST API and the
|
||||||
|
built single-page app on one port — plus a PostgreSQL database.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────┐
|
||||||
|
Browser / installed │ Reverse proxy (nginx, TLS) │
|
||||||
|
PWA (desktop/mobile) │ host: 443 → 127.0.0.1:${APP_PORT} │
|
||||||
|
│ HTTPS └─────────────────────┬─────────────────────┘
|
||||||
|
└─────────────────────────────────────┼──────────────► 127.0.0.1:42776
|
||||||
|
│
|
||||||
|
┌─────────────────────────▼────────────────────────┐
|
||||||
|
│ Tanabata container (single image) │
|
||||||
|
│ │
|
||||||
|
│ Go server (Gin) │
|
||||||
|
│ ├─ /api/v1/* REST API │
|
||||||
|
│ ├─ /health liveness │
|
||||||
|
│ └─ /* static SPA + index.html │
|
||||||
|
│ fallback │
|
||||||
|
│ │
|
||||||
|
│ Disk: /data/files (originals, name = UUID) │
|
||||||
|
│ /data/thumbs (thumbnail/preview cache) │
|
||||||
|
│ /data/import (server-side import drop) │
|
||||||
|
└─────────────────────────┬────────────────────────┘
|
||||||
|
│ pgx (private network)
|
||||||
|
┌─────────▼─────────┐
|
||||||
|
│ PostgreSQL 14+ │
|
||||||
|
│ (bundled or host)│
|
||||||
|
└───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional companion process: a one-shot **dedup CLI** (same image, different
|
||||||
|
entrypoint) that backfills perceptual hashes and rebuilds the duplicate-pairs
|
||||||
|
table. It is not a daemon — it is run on demand.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Tech | Responsibility |
|
||||||
|
| -------------- | -------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||||
|
| Frontend (SPA) | SvelteKit (adapter-static, `ssr=false`), Svelte 5, Tailwind v4, TypeScript | UI, client routing, PWA/offline, calls the REST API |
|
||||||
|
| API server | Go + Gin, Clean Architecture | REST API, auth, ACL, business logic, thumbnailing, audit |
|
||||||
|
| Database | PostgreSQL 14+ (pgx v5, goose) | All structured data across 4 schemas / 19 tables |
|
||||||
|
| File storage | Local disk, flat, keyed by UUID | Originals + a regenerable thumbnail/preview cache |
|
||||||
|
| dedup CLI | Go (same image) | Offline perceptual-hash backfill + pairs rescan |
|
||||||
|
| Reverse proxy | nginx (host, not shipped) | TLS termination, large-body/streaming config |
|
||||||
|
|
||||||
|
## Backend Architecture (Clean Architecture)
|
||||||
|
|
||||||
|
Dependencies point inward; no layer imports a layer above it.
|
||||||
|
|
||||||
|
```
|
||||||
|
handler → service → port (interfaces) ← db/postgres, storage, imagehash
|
||||||
|
↓
|
||||||
|
domain (entities, value objects, errors) — stdlib only
|
||||||
|
```
|
||||||
|
|
||||||
|
- **domain** — entities and errors, zero internal imports.
|
||||||
|
- **port** — interfaces (repositories, `FileStorage`, `Transactor`).
|
||||||
|
- **service** — use cases; the only place business rules live.
|
||||||
|
- **handler** — Gin HTTP layer; maps domain errors to HTTP status codes.
|
||||||
|
- **db/postgres**, **storage**, **imagehash** — adapters implementing the ports.
|
||||||
|
|
||||||
|
Wiring is manual in `cmd/server/main.go` (no DI framework). See
|
||||||
|
[GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md) for the file-by-file layout,
|
||||||
|
the transaction/context patterns, and the DI sketch.
|
||||||
|
|
||||||
|
## Request Flow (typical authenticated call)
|
||||||
|
|
||||||
|
1. The SPA sends `Authorization: Bearer <access token>` to `/api/v1/...`.
|
||||||
|
2. Gin middleware runs: security headers → (for `/auth`) per-IP rate limiter →
|
||||||
|
auth middleware validates the JWT and puts `(userID, isAdmin, sessionID)`
|
||||||
|
into the request context.
|
||||||
|
3. The handler parses/validates input and calls a service method
|
||||||
|
(`ctx` first arg).
|
||||||
|
4. The service enforces ACL via `ACLService`, performs the use case — composing
|
||||||
|
repository calls inside a `Transactor.WithTx` when several writes must be
|
||||||
|
atomic — and writes an audit entry.
|
||||||
|
5. Repositories run SQL through pgx (pool or the tx carried in `ctx`).
|
||||||
|
6. The handler serializes the result; domain errors are mapped to
|
||||||
|
`{ code, message, details? }` with the right HTTP status.
|
||||||
|
|
||||||
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
|
### Authentication & sessions
|
||||||
|
|
||||||
|
JWT bearer auth. A short-lived **access token** (15 min default) authorizes API
|
||||||
|
calls; a long-lived **refresh token** (30 days default) rotates on use and is
|
||||||
|
stored as a hash in `activity.sessions`. A separate **content token** (6 h
|
||||||
|
default) is a single-file capability embedded in media URLs so long video keeps
|
||||||
|
streaming past access-token expiry. The `/auth` endpoints are rate-limited per
|
||||||
|
client IP.
|
||||||
|
|
||||||
|
### Authorization (ACL)
|
||||||
|
|
||||||
|
Private-by-default. Admins see everything; otherwise access requires a `public`
|
||||||
|
flag, creator ownership, or an explicit grant in `acl.permissions` (read / edit).
|
||||||
|
All checks are centralized in `ACLService` and applied before reads and writes.
|
||||||
|
|
||||||
|
### File storage
|
||||||
|
|
||||||
|
Originals are stored flat under `FILES_PATH`, each named by its file UUID (no
|
||||||
|
directory tree, no original-name collisions). Thumbnails and previews are a
|
||||||
|
**regenerable cache** under `THUMBS_CACHE_PATH`: still images via vipsthumbnail
|
||||||
|
(shrink-on-load) with a pure-Go `imaging` fallback, video frames via ffmpeg;
|
||||||
|
metadata/EXIF via exiftool with a pure-Go fallback. Uploads are rejected unless
|
||||||
|
their sniffed MIME type is whitelisted in `core.mime_types`.
|
||||||
|
|
||||||
|
### Near-duplicate detection
|
||||||
|
|
||||||
|
Images are dHash-ed (64-bit perceptual hash) inline on upload; video hashes are
|
||||||
|
backfilled by the dedup CLI. A rescan rebuilds `data.duplicate_pairs` using a
|
||||||
|
BK-tree over Hamming distance (within `DUPLICATE_HASH_THRESHOLD`), and the API
|
||||||
|
groups pairs into connected-component clusters. Dismissed pairs are remembered so
|
||||||
|
they stop resurfacing. See the duplicate sections in
|
||||||
|
[GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md).
|
||||||
|
|
||||||
|
### Audit logging
|
||||||
|
|
||||||
|
User-visible actions (file/tag/category/pool CRUD, relations, ACL changes,
|
||||||
|
auth, session termination, admin user actions) are recorded in
|
||||||
|
`activity.audit_log` against a seeded set of action types.
|
||||||
|
|
||||||
|
### Frontend / PWA
|
||||||
|
|
||||||
|
Pure client-side SPA: static assets served by the Go binary, with `index.html`
|
||||||
|
as the fallback for client routes. Installable PWA with a service worker for
|
||||||
|
app-shell caching and optional offline viewing of pinned files. See
|
||||||
|
[FRONTEND_STRUCTURE.md](FRONTEND_STRUCTURE.md).
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
PostgreSQL, four schemas (see `backend/migrations/`):
|
||||||
|
|
||||||
|
- **core** — users, MIME whitelist, object types.
|
||||||
|
- **data** — categories, tags, tag rules, files, file–tag, pools, file–pool,
|
||||||
|
duplicate pairs, duplicate dismissals.
|
||||||
|
- **acl** — per-object permission grants.
|
||||||
|
- **activity** — sessions, file/pool views, tag uses, audit log, action types.
|
||||||
|
|
||||||
|
Migrations are goose files embedded via `go:embed` and applied automatically on
|
||||||
|
server startup, so a fresh database bootstraps itself.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- **One image, one port.** The multi-stage `Dockerfile` builds the SPA (Node
|
||||||
|
stage) and the static Go binary (Go stage), then ships an Alpine runtime with
|
||||||
|
vips-tools / ffmpeg / exiftool and a non-root user. The server serves both the
|
||||||
|
API and the SPA on port **42776** (the sum of the code points of 七夕).
|
||||||
|
- **Compose.** `docker-compose.yml` runs the app plus, optionally, a bundled
|
||||||
|
PostgreSQL (`with-db` profile); a host Postgres is supported by leaving the
|
||||||
|
profile empty. The app is published on loopback only and expects a host
|
||||||
|
reverse proxy; the DB sits on a private `internal` network with no route
|
||||||
|
off-host. The dedup CLI is a `tools`-profile, run-on-demand service.
|
||||||
|
- **Config.** All runtime config is environment variables, fully documented in
|
||||||
|
[`.env.example`](../.env.example) (1:1 with `config.Config`). Secrets
|
||||||
|
(`JWT_SECRET`, `ADMIN_PASSWORD`, `DATABASE_URL`) are never baked into the image.
|
||||||
|
- **First run.** Migrations auto-apply and the initial admin is bootstrapped
|
||||||
|
from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, so setup is: fill `.env`,
|
||||||
|
`docker compose up`.
|
||||||
|
|
||||||
|
See [DEPLOY.md](DEPLOY.md) for the production deploy (Gitea Actions → host) and
|
||||||
|
the reverse-proxy notes in [README.md](../README.md).
|
||||||
|
|
||||||
|
## Design Constraints & Future Direction
|
||||||
|
|
||||||
|
- **DDD / Clean Architecture** on the server keeps business rules independent of
|
||||||
|
Gin and pgx.
|
||||||
|
- **PostgreSQL-specific adapters are isolated** behind the `port` interfaces (the
|
||||||
|
filter DSL → SQL translation lives in `db/postgres`), leaving room for other
|
||||||
|
database engines in a future version without touching the service layer.
|
||||||
+169
-315
@@ -1,13 +1,21 @@
|
|||||||
# Tanabata File Manager — Frontend Structure
|
# Tanabata File Manager — Frontend Structure
|
||||||
|
|
||||||
|
> Frontend counterpart of [ARCHITECTURE.md](ARCHITECTURE.md). This document
|
||||||
|
> details the SvelteKit layout, the CSS approach, and the API client.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Framework**: SvelteKit (SPA mode, `ssr: false`)
|
- **Framework**: SvelteKit in **SPA mode** (`adapter-static`, `ssr = false`),
|
||||||
- **Language**: TypeScript
|
Svelte 5 (runes)
|
||||||
- **CSS**: Tailwind CSS + CSS custom properties (hybrid)
|
- **Language**: TypeScript (strict)
|
||||||
- **API types**: Auto-generated via openapi-typescript
|
- **Build**: Vite 7
|
||||||
- **PWA**: Service worker + web manifest
|
- **CSS**: Tailwind CSS **v4** via `@tailwindcss/vite` + CSS custom properties
|
||||||
|
(`@theme` in `app.css`) — no `tailwind.config.*` / `postcss.config.*` file
|
||||||
|
- **API types**: auto-generated via `openapi-typescript` (`src/lib/api/schema.ts`)
|
||||||
|
- **PWA**: service worker + web manifest
|
||||||
- **Font**: Epilogue (variable weight)
|
- **Font**: Epilogue (variable weight)
|
||||||
|
- **Dev**: `vite-mock-plugin.ts` serves a mock API so the UI can run without the
|
||||||
|
Go backend
|
||||||
- **Package manager**: npm
|
- **Package manager**: npm
|
||||||
|
|
||||||
## SPA mode — why SvelteKit without the server
|
## SPA mode — why SvelteKit without the server
|
||||||
@@ -19,7 +27,7 @@ static assets, and the only backend is the Go API. SvelteKit is used here
|
|||||||
purely as an SPA framework: file-based routing, the client router, and build
|
purely as an SPA framework: file-based routing, the client router, and build
|
||||||
tooling.
|
tooling.
|
||||||
|
|
||||||
**SvelteKit features we *do* use:**
|
**SvelteKit features we _do_ use:**
|
||||||
|
|
||||||
- File-based routing with nested layouts (`admin/` has its own guard) and
|
- File-based routing with nested layouts (`admin/` has its own guard) and
|
||||||
dynamic segments (`[id]`).
|
dynamic segments (`[id]`).
|
||||||
@@ -30,11 +38,11 @@ tooling.
|
|||||||
over the still-mounted list via shallow routing, so the browser back button
|
over the still-mounted list via shallow routing, so the browser back button
|
||||||
dismisses it without reloading the grid. This is the single biggest reason we
|
dismisses it without reloading the grid. This is the single biggest reason we
|
||||||
stay on SvelteKit rather than a plain router.
|
stay on SvelteKit rather than a plain router.
|
||||||
- `load` functions, used *only* as client-side route guards (auth redirect,
|
- `load` functions, used _only_ as client-side route guards (auth redirect,
|
||||||
admin redirect, `/` → `/files`).
|
admin redirect, `/` → `/files`).
|
||||||
- `$lib` alias, generated `./$types`, Vite/HMR integration.
|
- `$lib` alias, generated `./$types`, Vite/HMR integration.
|
||||||
|
|
||||||
**SvelteKit features we deliberately do *not* use** (the "server half"):
|
**SvelteKit features we deliberately do _not_ use** (the "server half"):
|
||||||
|
|
||||||
- SSR / hydration.
|
- SSR / hydration.
|
||||||
- `+page.server.ts`, `+server.ts` endpoints, form actions — all data goes
|
- `+page.server.ts`, `+server.ts` endpoints, form actions — all data goes
|
||||||
@@ -43,7 +51,7 @@ tooling.
|
|||||||
`hooks.client.ts` files exist.
|
`hooks.client.ts` files exist.
|
||||||
|
|
||||||
**Decision: stay on SvelteKit, do not migrate to a bare Svelte + router SPA.**
|
**Decision: stay on SvelteKit, do not migrate to a bare Svelte + router SPA.**
|
||||||
The project already *is* an SPA, so there is no runtime gain from switching
|
The project already _is_ an SPA, so there is no runtime gain from switching
|
||||||
(adapter-static tree-shakes the unused server bits; the client-runtime size
|
(adapter-static tree-shakes the unused server bits; the client-runtime size
|
||||||
difference is negligible). A migration would mean re-implementing nested
|
difference is negligible). A migration would mean re-implementing nested
|
||||||
layouts, guards, dynamic params, and — most painfully — shallow routing /
|
layouts, guards, dynamic params, and — most painfully — shallow routing /
|
||||||
@@ -55,15 +63,7 @@ expect SSR, endpoints, or hooks to do anything here; that is intentional.
|
|||||||
```
|
```
|
||||||
tanabata/
|
tanabata/
|
||||||
├── backend/ ← Go project (go.mod in here)
|
├── backend/ ← Go project (go.mod in here)
|
||||||
│ ├── cmd/
|
|
||||||
│ ├── internal/
|
|
||||||
│ ├── migrations/
|
|
||||||
│ ├── go.mod
|
|
||||||
│ └── go.sum
|
|
||||||
│
|
|
||||||
├── frontend/ ← SvelteKit project (package.json in here)
|
├── frontend/ ← SvelteKit project (package.json in here)
|
||||||
│ └── (see below)
|
|
||||||
│
|
|
||||||
├── openapi.yaml ← Shared API contract (root level)
|
├── openapi.yaml ← Shared API contract (root level)
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
@@ -71,352 +71,206 @@ tanabata/
|
|||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
`openapi.yaml` lives at repository root — both backend and frontend
|
`openapi.yaml` lives at repository root — both backend and frontend reference
|
||||||
reference it. The frontend generates types from it; the backend
|
it. The frontend generates types from it; the backend implements it.
|
||||||
validates its handlers against it.
|
|
||||||
|
|
||||||
## Frontend Directory Layout
|
## Frontend Directory Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
├── package.json
|
├── package.json
|
||||||
├── svelte.config.js
|
├── svelte.config.js # adapter-static, fallback: index.html
|
||||||
├── vite.config.ts
|
├── vite.config.ts # plugins: tailwindcss(), sveltekit(), mockApiPlugin()
|
||||||
|
├── vite-mock-plugin.ts # dev-only mock API (run the UI without the Go backend)
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
├── tailwind.config.ts
|
|
||||||
├── postcss.config.js
|
|
||||||
│
|
│
|
||||||
├── src/
|
├── static/ # Copied verbatim into the build
|
||||||
│ ├── app.html # Shell HTML (PWA meta, font preload)
|
│ ├── manifest.webmanifest # PWA manifest
|
||||||
│ ├── app.css # Tailwind directives + CSS custom properties
|
│ ├── browserconfig.xml
|
||||||
│ │ # (no hooks.* — see "SPA mode" above)
|
│ ├── robots.txt
|
||||||
│ │
|
│ ├── favicon.ico
|
||||||
│ ├── lib/ # Shared code ($lib/ alias)
|
│ ├── fonts/
|
||||||
│ │ │
|
│ │ └── Epilogue-VariableFont_wght.ttf
|
||||||
│ │ ├── api/ # API client layer
|
│ └── images/ # PWA icons, section icons (svg), login decorations
|
||||||
│ │ │ ├── client.ts # Base fetch wrapper: auth headers, token refresh,
|
|
||||||
│ │ │ │ # error parsing, base URL
|
|
||||||
│ │ │ ├── files.ts # listFiles, getFile, uploadFile, deleteFile, etc.
|
|
||||||
│ │ │ ├── tags.ts # listTags, createTag, getTag, updateTag, etc.
|
|
||||||
│ │ │ ├── categories.ts # Category API functions
|
|
||||||
│ │ │ ├── pools.ts # Pool API functions
|
|
||||||
│ │ │ ├── auth.ts # login, logout, refresh, listSessions
|
|
||||||
│ │ │ ├── acl.ts # getPermissions, setPermissions
|
|
||||||
│ │ │ ├── users.ts # getMe, updateMe, admin user CRUD
|
|
||||||
│ │ │ ├── audit.ts # queryAuditLog
|
|
||||||
│ │ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (do not edit)
|
|
||||||
│ │ │ └── types.ts # Friendly type aliases:
|
|
||||||
│ │ │ # export type File = components["schemas"]["File"]
|
|
||||||
│ │ │ # export type Tag = components["schemas"]["Tag"]
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── components/ # Reusable UI components
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── layout/ # App shell
|
|
||||||
│ │ │ │ ├── Navbar.svelte # Bottom navigation bar (mobile-first)
|
|
||||||
│ │ │ │ ├── Header.svelte # Section header with sorting controls
|
|
||||||
│ │ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions
|
|
||||||
│ │ │ │ └── Loader.svelte # Full-screen loading overlay
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── file/ # File-related components
|
|
||||||
│ │ │ │ ├── FileGrid.svelte # Thumbnail grid with infinite scroll
|
|
||||||
│ │ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable)
|
|
||||||
│ │ │ │ ├── FileViewer.svelte # Full-screen preview with prev/next navigation
|
|
||||||
│ │ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop zone
|
|
||||||
│ │ │ │ ├── FileDetail.svelte # Metadata editor (notes, datetime, tags)
|
|
||||||
│ │ │ │ └── FilterBar.svelte # DSL filter builder UI
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── tag/ # Tag-related components
|
|
||||||
│ │ │ │ ├── TagBadge.svelte # Colored pill with tag name
|
|
||||||
│ │ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove)
|
|
||||||
│ │ │ │ ├── TagList.svelte # Tag grid for section view
|
|
||||||
│ │ │ │ └── TagRuleEditor.svelte # Auto-tag rule management
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── pool/ # Pool-related components
|
|
||||||
│ │ │ │ ├── PoolCard.svelte # Pool preview card
|
|
||||||
│ │ │ │ ├── PoolFileList.svelte # Ordered file list with drag reorder
|
|
||||||
│ │ │ │ └── PoolDetail.svelte # Pool metadata editor
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── acl/ # Access control components
|
|
||||||
│ │ │ │ └── PermissionEditor.svelte # User permission grid
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └── common/ # Shared primitives
|
|
||||||
│ │ │ ├── Button.svelte
|
|
||||||
│ │ │ ├── Modal.svelte
|
|
||||||
│ │ │ ├── ConfirmDialog.svelte
|
|
||||||
│ │ │ ├── Toast.svelte
|
|
||||||
│ │ │ ├── InfiniteScroll.svelte
|
|
||||||
│ │ │ ├── Pagination.svelte
|
|
||||||
│ │ │ ├── SortDropdown.svelte
|
|
||||||
│ │ │ ├── SearchInput.svelte
|
|
||||||
│ │ │ ├── ColorPicker.svelte
|
|
||||||
│ │ │ ├── Checkbox.svelte # Three-state: checked, unchecked, partial
|
|
||||||
│ │ │ └── EmptyState.svelte
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── stores/ # Svelte stores (global state)
|
|
||||||
│ │ │ ├── auth.ts # Current user, JWT tokens, isAuthenticated
|
|
||||||
│ │ │ ├── selection.ts # Selected item IDs, selection mode toggle
|
|
||||||
│ │ │ ├── sorting.ts # Per-section sort key + order (persisted to localStorage)
|
|
||||||
│ │ │ ├── theme.ts # Dark/light mode (persisted, respects prefers-color-scheme)
|
|
||||||
│ │ │ └── toast.ts # Notification queue (success, error, info)
|
|
||||||
│ │ │
|
|
||||||
│ │ └── utils/ # Pure helper functions
|
|
||||||
│ │ ├── format.ts # formatDate, formatFileSize, formatDuration
|
|
||||||
│ │ ├── dsl.ts # Filter DSL builder: UI state → query string
|
|
||||||
│ │ ├── pwa.ts # PWA reset, cache clear, update prompt
|
|
||||||
│ │ └── keyboard.ts # Keyboard shortcut helpers (Ctrl+A, Escape, etc.)
|
|
||||||
│ │
|
|
||||||
│ ├── routes/ # SvelteKit file-based routing
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── +layout.svelte # Root layout: Navbar, theme wrapper, toast container
|
|
||||||
│ │ ├── +layout.ts # Root load: auth guard → redirect to /login if no token
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── +page.svelte # / → redirect to /files
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── login/
|
|
||||||
│ │ │ └── +page.svelte # Login form (decorative Tanabata images)
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── files/
|
|
||||||
│ │ │ ├── +page.svelte # File grid: filter bar, sort, multi-select, upload
|
|
||||||
│ │ │ ├── +page.ts # Load: initial file list (cursor page)
|
|
||||||
│ │ │ ├── [id]/
|
|
||||||
│ │ │ │ ├── +page.svelte # File view: preview, metadata, tags, ACL
|
|
||||||
│ │ │ │ └── +page.ts # Load: file detail + tags
|
|
||||||
│ │ │ └── trash/
|
|
||||||
│ │ │ ├── +page.svelte # Trash: restore / permanent delete
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── tags/
|
|
||||||
│ │ │ ├── +page.svelte # Tag list: search, sort, multi-select
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ │ └── +page.svelte # Create tag form
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # Tag detail: edit, category, rules, parent tags
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── categories/
|
|
||||||
│ │ │ ├── +page.svelte # Category list
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ │ └── +page.svelte
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # Category detail: edit, view tags
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── pools/
|
|
||||||
│ │ │ ├── +page.svelte # Pool list
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ │ └── +page.svelte
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # Pool detail: files (reorderable), filter, edit
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── settings/
|
|
||||||
│ │ │ ├── +page.svelte # Profile: name, password, active sessions
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ │
|
|
||||||
│ │ └── admin/
|
|
||||||
│ │ ├── +layout.svelte # Admin layout: restrict to is_admin
|
|
||||||
│ │ ├── users/
|
|
||||||
│ │ │ ├── +page.svelte # User management list
|
|
||||||
│ │ │ ├── +page.ts
|
|
||||||
│ │ │ └── [id]/
|
|
||||||
│ │ │ ├── +page.svelte # User detail: role, block/unblock
|
|
||||||
│ │ │ └── +page.ts
|
|
||||||
│ │ └── audit/
|
|
||||||
│ │ ├── +page.svelte # Audit log with filters
|
|
||||||
│ │ └── +page.ts
|
|
||||||
│ │
|
|
||||||
│ └── service-worker.ts # PWA: offline cache for pinned files, app shell caching
|
|
||||||
│
|
│
|
||||||
└── static/
|
└── src/
|
||||||
├── favicon.png
|
├── app.html # Shell HTML (PWA meta, font preload)
|
||||||
├── favicon.ico
|
├── app.css # `@import 'tailwindcss'` + `@theme` custom properties
|
||||||
├── manifest.webmanifest # PWA manifest (name, icons, theme_color)
|
├── app.d.ts # Ambient types
|
||||||
├── images/
|
├── service-worker.ts # PWA: app-shell + pinned-file offline cache
|
||||||
│ ├── tanabata-left.png # Login page decorations (from current design)
|
│
|
||||||
│ ├── tanabata-right.png
|
├── lib/ # Shared code ($lib alias)
|
||||||
│ └── icons/ # PWA icons (192×192, 512×512, etc.)
|
│ ├── index.ts
|
||||||
└── fonts/
|
│ │
|
||||||
└── Epilogue-VariableFont_wght.ttf
|
│ ├── api/ # API client layer
|
||||||
|
│ │ ├── client.ts # fetch wrapper: bearer auth, 401 refresh+retry, error parsing,
|
||||||
|
│ │ │ # upload-with-progress (XHR), NDJSON streaming; exports `api`
|
||||||
|
│ │ ├── auth.ts # login, refresh, logout, sessions
|
||||||
|
│ │ ├── tags.ts # tag + tag-rule calls
|
||||||
|
│ │ ├── categories.ts # category calls
|
||||||
|
│ │ ├── duplicates.ts # duplicate list / dismiss / resolve
|
||||||
|
│ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (gitignored; do not edit)
|
||||||
|
│ │ └── types.ts # Friendly aliases: components['schemas'][...]
|
||||||
|
│ │
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── layout/
|
||||||
|
│ │ │ ├── Header.svelte # Section header with sorting controls
|
||||||
|
│ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions
|
||||||
|
│ │ │ └── KeyboardHelp.svelte # Keyboard-shortcut overlay
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── file/
|
||||||
|
│ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable)
|
||||||
|
│ │ │ ├── Thumb.svelte # Lazy-loaded thumbnail (IntersectionObserver)
|
||||||
|
│ │ │ ├── FileViewer.svelte # Full-screen viewer with prev/next
|
||||||
|
│ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop
|
||||||
|
│ │ │ ├── FilterBar.svelte # DSL filter builder UI
|
||||||
|
│ │ │ ├── MetadataEditor.svelte # Notes / datetime / metadata (nested) editor
|
||||||
|
│ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove)
|
||||||
|
│ │ │ ├── PoolPicker.svelte # Add-to-pool dialog
|
||||||
|
│ │ │ ├── BulkTagEditor.svelte # Multi-select tag add/remove
|
||||||
|
│ │ │ └── DuplicateMergeDialog.svelte # Field-by-field duplicate resolution
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── tag/
|
||||||
|
│ │ │ ├── TagBadge.svelte # Colored pill
|
||||||
|
│ │ │ └── TagRuleEditor.svelte # Auto-tag rule management
|
||||||
|
│ │ │
|
||||||
|
│ │ └── common/
|
||||||
|
│ │ ├── ConfirmDialog.svelte
|
||||||
|
│ │ └── InfiniteScroll.svelte # Below-the-fold lazy loading on scroll
|
||||||
|
│ │
|
||||||
|
│ ├── stores/ # Svelte stores (global state)
|
||||||
|
│ │ ├── auth.ts # Current user + JWT tokens (persisted, cross-tab sync)
|
||||||
|
│ │ ├── selection.ts # Selected item IDs, selection mode
|
||||||
|
│ │ ├── sorting.ts # Per-section sort key + order (persisted)
|
||||||
|
│ │ ├── theme.ts # Dark/light theme (persisted)
|
||||||
|
│ │ ├── appSettings.ts # Misc client-side settings
|
||||||
|
│ │ ├── listScroll.ts # Restore list scroll position after overlay/back
|
||||||
|
│ │ └── sectionCache.ts # Cached list snapshots, invalidated on mutation
|
||||||
|
│ │
|
||||||
|
│ └── utils/ # Pure helpers
|
||||||
|
│ ├── dsl.ts # Filter DSL builder: UI state → query string
|
||||||
|
│ ├── metadata.ts # Nested metadata <-> editor rows
|
||||||
|
│ ├── pwa.ts # PWA reset / update prompt
|
||||||
|
│ └── rovingGrid.svelte.ts # Roving-tabindex keyboard grid navigation
|
||||||
|
│
|
||||||
|
└── routes/ # SvelteKit file-based routing (guards only in load)
|
||||||
|
├── +layout.svelte # Root layout: nav, theme
|
||||||
|
├── +layout.ts # ssr=false; root auth guard
|
||||||
|
├── +page.svelte / +page.ts # / → redirect to /files
|
||||||
|
├── login/+page.svelte
|
||||||
|
├── files/
|
||||||
|
│ ├── +page.svelte / +page.ts # Grid: filter, sort, multi-select, upload
|
||||||
|
│ ├── [id]/+page.svelte # File view (also opened as shallow-routing overlay)
|
||||||
|
│ ├── duplicates/+page.svelte # Duplicate clusters
|
||||||
|
│ └── trash/+page.svelte # Trash: restore / permanent delete
|
||||||
|
├── tags/ { +page.svelte, new/, [id]/ }
|
||||||
|
├── categories/ { +page.svelte, new/, [id]/ }
|
||||||
|
├── pools/ { +page.svelte, new/, [id]/ }
|
||||||
|
├── settings/+page.svelte # Profile: name, password, sessions, import path
|
||||||
|
└── admin/
|
||||||
|
├── +layout.svelte / +layout.ts # Restrict to admins
|
||||||
|
├── users/{ +page.svelte, [id]/ }
|
||||||
|
└── audit/+page.svelte
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Architecture Decisions
|
## Key Architecture Decisions
|
||||||
|
|
||||||
### CSS Hybrid: Tailwind + Custom Properties
|
### CSS: Tailwind v4 + Custom Properties
|
||||||
|
|
||||||
Theme colors defined as CSS custom properties in `app.css`:
|
Tailwind v4 is configured **in CSS**, not in a JS config file. `app.css` imports
|
||||||
|
Tailwind and declares the theme tokens as CSS custom properties inside `@theme`;
|
||||||
|
Tailwind then generates utilities (`bg-bg-primary`, `text-text-primary`,
|
||||||
|
`font-sans`, …) from those tokens automatically.
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@tailwind base;
|
/* src/app.css */
|
||||||
@tailwind components;
|
@import "tailwindcss";
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
@theme {
|
||||||
--color-bg-primary: #312F45;
|
--color-bg-primary: #312f45;
|
||||||
--color-bg-secondary: #181721;
|
--color-bg-secondary: #181721;
|
||||||
--color-bg-elevated: #111118;
|
--color-bg-elevated: #111118;
|
||||||
--color-accent: #9592B5;
|
--color-accent: #9592b5;
|
||||||
--color-accent-hover: #7D7AA4;
|
--color-accent-hover: #7d7aa4;
|
||||||
--color-text-primary: #f0f0f0;
|
--color-text-primary: #f0f0f0;
|
||||||
--color-text-muted: #9999AD;
|
--color-tag-default: #444455;
|
||||||
--color-danger: #DB6060;
|
/* … info / danger / warning / success / nav tokens … */
|
||||||
--color-info: #4DC7ED;
|
|
||||||
--color-warning: #F5E872;
|
|
||||||
--color-tag-default: #444455;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
--font-sans: "Epilogue", sans-serif;
|
||||||
--color-bg-primary: #f5f5f5;
|
|
||||||
--color-bg-secondary: #ffffff;
|
|
||||||
/* ... */
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Tailwind references them in `tailwind.config.ts`:
|
Dark theme is primary; the light theme overrides the same custom properties.
|
||||||
|
|
||||||
```ts
|
|
||||||
export default {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
bg: {
|
|
||||||
primary: 'var(--color-bg-primary)',
|
|
||||||
secondary: 'var(--color-bg-secondary)',
|
|
||||||
elevated: 'var(--color-bg-elevated)',
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: 'var(--color-accent)',
|
|
||||||
hover: 'var(--color-accent-hover)',
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Epilogue', 'sans-serif'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
darkMode: 'class', // controlled via data-theme attribute
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage in components: `<div class="bg-bg-primary text-text-primary rounded-xl p-4">`.
|
Usage in components: `<div class="bg-bg-primary text-text-primary rounded-xl p-4">`.
|
||||||
Complex cases use scoped `<style>` inside `.svelte` files.
|
Complex cases use scoped `<style>` inside `.svelte` files.
|
||||||
|
|
||||||
### API Client Pattern
|
### API Client Pattern
|
||||||
|
|
||||||
`client.ts` — thin wrapper around fetch:
|
`src/lib/api/client.ts` is a thin fetch wrapper exporting a generic `api`
|
||||||
|
object. It attaches the bearer token, transparently handles a single `401`
|
||||||
|
refresh-and-retry (deduplicating concurrent refreshes and syncing rotated
|
||||||
|
tokens across tabs), parses `{ code, message, details }` errors into `ApiError`,
|
||||||
|
and invalidates cached list snapshots on mutation. It also provides
|
||||||
|
`uploadWithProgress` (XHR, for upload progress) and `postStream` (NDJSON, for
|
||||||
|
the live import progress).
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// $lib/api/client.ts
|
// $lib/api/client.ts (shape)
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
|
|
||||||
const BASE = '/api/v1';
|
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const token = get(authStore).accessToken;
|
|
||||||
const res = await fetch(BASE + path, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
...init?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
|
||||||
// attempt refresh, retry once
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
throw new ApiError(res.status, err.code, err.message, err.details);
|
|
||||||
}
|
|
||||||
if (res.status === 204) return undefined as T;
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string) => request<T>(path),
|
get: <T>(path) => request<T>(path),
|
||||||
post: <T>(path: string, body?: unknown) =>
|
post: <T>(path, body?) =>
|
||||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||||
patch: <T>(path: string, body?: unknown) =>
|
patch: <T>(path, body?) =>
|
||||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
put: <T>(path: string, body?: unknown) =>
|
put: <T>(path, body?) =>
|
||||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
delete: <T>(path) => request<T>(path, { method: "DELETE" }),
|
||||||
upload: <T>(path: string, formData: FormData) =>
|
upload: <T>(path, fd) => request<T>(path, { method: "POST", body: fd }),
|
||||||
request<T>(path, { method: 'POST', body: formData, headers: {} }),
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Domain-specific modules use it:
|
Resource modules (`auth.ts`, `tags.ts`, `categories.ts`, `duplicates.ts`) wrap
|
||||||
|
`api` with typed helpers. Endpoints without a dedicated module (files, pools,
|
||||||
```ts
|
users, acl, audit) are called through `api.*` directly from their route
|
||||||
// $lib/api/files.ts
|
components.
|
||||||
import { api } from './client';
|
|
||||||
import type { File, FileCursorPage } from './types';
|
|
||||||
|
|
||||||
export function listFiles(params: Record<string, string>) {
|
|
||||||
const qs = new URLSearchParams(params).toString();
|
|
||||||
return api.get<FileCursorPage>(`/files?${qs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uploadFile(formData: FormData) {
|
|
||||||
return api.upload<File>('/files', formData);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Generation
|
### Type Generation
|
||||||
|
|
||||||
Script in `package.json`:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
// package.json
|
||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
||||||
"dev": "npm run generate:types && vite dev",
|
"dev": "npm run generate:types && vite dev",
|
||||||
"build": "npm run generate:types && vite build"
|
"build": "npm run generate:types && vite build"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Friendly aliases in `types.ts`:
|
`schema.ts` is generated (and gitignored) — never edit it by hand. `types.ts`
|
||||||
|
re-exports friendly aliases:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import type { components } from './schema';
|
import type { components } from "./schema";
|
||||||
|
export type File = components["schemas"]["File"];
|
||||||
export type File = components['schemas']['File'];
|
export type Tag = components["schemas"]["Tag"];
|
||||||
export type Tag = components['schemas']['Tag'];
|
// …
|
||||||
export type Category = components['schemas']['Category'];
|
|
||||||
export type Pool = components['schemas']['Pool'];
|
|
||||||
export type FileCursorPage = components['schemas']['FileCursorPage'];
|
|
||||||
export type TagOffsetPage = components['schemas']['TagOffsetPage'];
|
|
||||||
export type Error = components['schemas']['Error'];
|
|
||||||
// ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### SPA Mode
|
### SPA Mode (build)
|
||||||
|
|
||||||
`svelte.config.js`:
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import adapter from '@sveltejs/adapter-static';
|
// svelte.config.js
|
||||||
|
import adapter from "@sveltejs/adapter-static";
|
||||||
export default {
|
export default { kit: { adapter: adapter({ fallback: "index.html" }) } };
|
||||||
kit: {
|
|
||||||
adapter: adapter({ fallback: 'index.html' }),
|
|
||||||
// SPA: all routes handled client-side
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Go backend serves `index.html` for all non-API routes (SPA fallback).
|
The Go backend serves `index.html` for all non-API routes (SPA fallback, see
|
||||||
In development, Vite dev server proxies `/api` to the Go backend.
|
`handler/static.go`). In development the Vite dev server serves the UI and the
|
||||||
|
mock plugin (or a proxied Go backend) answers `/api`.
|
||||||
|
|
||||||
### PWA
|
### PWA
|
||||||
|
|
||||||
`service-worker.ts` handles:
|
`service-worker.ts` handles app-shell caching (HTML/CSS/JS/fonts) and optional
|
||||||
- App shell caching (HTML, CSS, JS, fonts)
|
user-pinned file caching for offline viewing; `utils/pwa.ts` exposes the reset /
|
||||||
- User-pinned file caching (explicit, via UI button)
|
update flow (clear caches and reload from the server, keeping pinned files).
|
||||||
- Cache versioning and cleanup on update
|
|
||||||
- Reset function (clear all caches except pinned files)
|
|
||||||
|
|||||||
+168
-156
@@ -1,18 +1,28 @@
|
|||||||
# Tanabata File Manager — Go Project Structure
|
# Tanabata File Manager — Go Project Structure
|
||||||
|
|
||||||
|
> Backend counterpart of [ARCHITECTURE.md](ARCHITECTURE.md). This document
|
||||||
|
> details the Go layout, the layer rules, and the key backend decisions.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Router**: Gin
|
- **Router**: Gin
|
||||||
- **Database**: pgx v5 (pgxpool)
|
- **Database**: pgx v5 (pgxpool)
|
||||||
- **Migrations**: goose v3 + go:embed (auto-migrate on startup)
|
- **Migrations**: goose v3 + `go:embed` (auto-applied on startup)
|
||||||
- **Auth**: JWT (golang-jwt/jwt/v5)
|
- **Auth**: JWT (golang-jwt/jwt/v5), Bearer access tokens + rotating refresh tokens
|
||||||
- **Config**: environment variables via .env (joho/godotenv)
|
- **Config**: environment variables via `.env` (joho/godotenv)
|
||||||
- **Logging**: slog (stdlib, Go 1.21+)
|
- **Logging**: slog (stdlib)
|
||||||
- **Validation**: go-playground/validator/v10
|
- **Metadata**: exiftool (external, preferred) with a pure-Go EXIF fallback
|
||||||
- **EXIF**: rwcarlsen/goexif or dsoprea/go-exif
|
(rwcarlsen/goexif)
|
||||||
- **Image processing**: disintegration/imaging (thumbnails, previews)
|
- **Thumbnails / previews**: vipsthumbnail (external, shrink-on-load) and ffmpeg
|
||||||
|
(video frames), with a pure-Go fallback (disintegration/imaging)
|
||||||
|
- **Near-duplicate detection**: 64-bit dHash perceptual hashing + a BK-tree /
|
||||||
|
Hamming-distance pairing (`internal/imagehash`, `internal/service/duplicate_*`)
|
||||||
- **Architecture**: Clean Architecture (domain → service → repository/handler)
|
- **Architecture**: Clean Architecture (domain → service → repository/handler)
|
||||||
|
|
||||||
|
The binary is fully static (`CGO_ENABLED=0`). External tools are invoked as
|
||||||
|
subprocesses when present and are optional — the pure-Go paths keep the server
|
||||||
|
working without them.
|
||||||
|
|
||||||
## Monorepo Layout
|
## Monorepo Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -31,81 +41,98 @@ tanabata/
|
|||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ └── server/
|
│ ├── server/
|
||||||
│ └── main.go # Entrypoint: config → DB → migrate → wire → run
|
│ │ └── main.go # Entrypoint: config → DB → migrate → bootstrap admin → wire → serve
|
||||||
|
│ └── dedup/
|
||||||
|
│ └── main.go # Offline maintenance CLI: perceptual-hash backfill + duplicate-pairs rescan
|
||||||
│
|
│
|
||||||
├── internal/
|
├── internal/
|
||||||
│ │
|
│ │
|
||||||
│ ├── domain/ # Pure business entities & value objects
|
│ ├── domain/ # Pure business entities & value objects (stdlib only)
|
||||||
│ │ ├── file.go # File, FileFilter, FilePage
|
│ │ ├── file.go # File, FileFilter, FileListParams, FilePage
|
||||||
│ │ ├── tag.go # Tag, TagRule
|
│ │ ├── tag.go # Tag, TagRule
|
||||||
│ │ ├── category.go # Category
|
│ │ ├── category.go # Category
|
||||||
│ │ ├── pool.go # Pool, PoolFile
|
│ │ ├── pool.go # Pool, PoolFile
|
||||||
│ │ ├── user.go # User, Session
|
│ │ ├── user.go # User, Session
|
||||||
│ │ ├── acl.go # Permission, ObjectType
|
│ │ ├── acl.go # Permission, ObjectType
|
||||||
│ │ ├── audit.go # AuditEntry, ActionType
|
│ │ ├── audit.go # AuditEntry, ActionType
|
||||||
│ │ └── errors.go # Domain error types (ErrNotFound, ErrForbidden, etc.)
|
│ │ ├── duplicate.go # DuplicatePair, PHashEntry
|
||||||
|
│ │ ├── context.go # WithUser / UserFromContext (identity + session in ctx)
|
||||||
|
│ │ └── errors.go # Domain error types (ErrNotFound, ErrForbidden, …)
|
||||||
│ │
|
│ │
|
||||||
│ ├── port/ # Interfaces (ports) — contracts between layers
|
│ ├── port/ # Interfaces (ports) — contracts between layers
|
||||||
│ │ ├── repository.go # FileRepo, TagRepo, CategoryRepo, PoolRepo,
|
│ │ ├── repository.go # Transactor, FileRepo, TagRepo, TagRuleRepo, CategoryRepo,
|
||||||
│ │ │ # UserRepo, SessionRepo, ACLRepo, AuditRepo,
|
│ │ │ # PoolRepo, UserRepo, SessionRepo, ACLRepo, AuditRepo,
|
||||||
│ │ │ # MimeRepo, TagRuleRepo
|
│ │ │ # MimeRepo, DuplicatePairRepo, DismissalRepo
|
||||||
│ │ └── storage.go # FileStorage interface (disk operations)
|
│ │ └── storage.go # FileStorage (originals + thumbnail/preview cache)
|
||||||
│ │
|
│ │
|
||||||
│ ├── service/ # Business logic (use cases)
|
│ ├── service/ # Business logic (use cases)
|
||||||
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace,
|
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace, import, filter/list
|
||||||
│ │ │ # import, filter/list, duplicate detection
|
│ │ ├── tag_service.go # CRUD + auto-tag (rule) application
|
||||||
│ │ ├── tag_service.go # CRUD + auto-tag application logic
|
│ │ ├── category_service.go # CRUD (thin: repo + ACL + audit)
|
||||||
│ │ ├── category_service.go # CRUD (thin, delegates to repo + ACL + audit)
|
|
||||||
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
|
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
|
||||||
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, session management
|
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, content tokens, sessions
|
||||||
│ │ ├── acl_service.go # Permission checks, grant/revoke
|
│ │ ├── acl_service.go # Permission checks, grant/revoke
|
||||||
│ │ ├── audit_service.go # Log actions, query audit log
|
│ │ ├── audit_service.go # Log actions, query audit log
|
||||||
│ │ └── user_service.go # Profile update, admin CRUD, block/unblock
|
│ │ ├── user_service.go # Profile update, admin CRUD, block/unblock, EnsureAdmin
|
||||||
|
│ │ ├── duplicate_service.go # Cluster / resolve (merge) / dismiss + rescan orchestration
|
||||||
|
│ │ ├── duplicate_index.go # BK-tree, Hamming pairing, connected-component clustering
|
||||||
|
│ │ └── metadata.go # EXIF / media metadata extraction (exiftool + pure-Go fallback)
|
||||||
│ │
|
│ │
|
||||||
│ ├── handler/ # HTTP layer (Gin handlers)
|
│ ├── handler/ # HTTP layer (Gin handlers)
|
||||||
│ │ ├── router.go # Route registration, middleware wiring
|
│ │ ├── router.go # Route registration, middleware, security headers, SPA fallback
|
||||||
│ │ ├── middleware.go # Auth middleware (JWT extraction → context)
|
│ │ ├── middleware.go # Auth middleware (JWT / content token → context)
|
||||||
│ │ ├── request.go # Common request parsing helpers
|
│ │ ├── ratelimit.go # Per-IP token-bucket limiter for /auth
|
||||||
│ │ ├── response.go # Error/success response builders
|
│ │ ├── response.go # Error/success builders, domain-error → HTTP mapping
|
||||||
|
│ │ ├── static.go # Built SPA serving + index.html fallback
|
||||||
│ │ ├── file_handler.go # /files endpoints
|
│ │ ├── file_handler.go # /files endpoints
|
||||||
│ │ ├── tag_handler.go # /tags endpoints
|
│ │ ├── duplicate_handler.go # /files/duplicates endpoints
|
||||||
|
│ │ ├── tag_handler.go # /tags endpoints (+ file–tag relations)
|
||||||
│ │ ├── category_handler.go # /categories endpoints
|
│ │ ├── category_handler.go # /categories endpoints
|
||||||
│ │ ├── pool_handler.go # /pools endpoints
|
│ │ ├── pool_handler.go # /pools endpoints
|
||||||
│ │ ├── auth_handler.go # /auth endpoints
|
│ │ ├── auth_handler.go # /auth endpoints
|
||||||
│ │ ├── acl_handler.go # /acl endpoints
|
│ │ ├── acl_handler.go # /acl endpoints
|
||||||
│ │ ├── user_handler.go # /users endpoints
|
│ │ ├── user_handler.go # /users endpoints
|
||||||
│ │ └── audit_handler.go # /audit endpoints
|
│ │ └── audit_handler.go # /audit endpoint
|
||||||
│ │
|
│ │
|
||||||
│ ├── db/ # Database adapters
|
│ ├── db/ # Database adapters
|
||||||
│ │ ├── db.go # Common helpers: pagination, repo factory, transactor base
|
│ │ ├── db.go # Shared helpers: Querier, tx-from-context, ScanRow, limit/offset clamps
|
||||||
│ │ └── postgres/ # PostgreSQL implementation
|
│ │ └── postgres/ # PostgreSQL implementation
|
||||||
│ │ ├── postgres.go # pgxpool init, tx-from-context helpers
|
│ │ ├── postgres.go # pgxpool init, Transactor, conn-or-tx helper
|
||||||
│ │ ├── file_repo.go # FileRepo implementation
|
│ │ ├── file_repo.go # FileRepo (incl. perceptual-hash projections)
|
||||||
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo implementation
|
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo
|
||||||
│ │ ├── category_repo.go # CategoryRepo implementation
|
│ │ ├── category_repo.go # CategoryRepo
|
||||||
│ │ ├── pool_repo.go # PoolRepo implementation
|
│ │ ├── pool_repo.go # PoolRepo
|
||||||
│ │ ├── user_repo.go # UserRepo implementation
|
│ │ ├── user_repo.go # UserRepo
|
||||||
│ │ ├── session_repo.go # SessionRepo implementation
|
│ │ ├── session_repo.go # SessionRepo
|
||||||
│ │ ├── acl_repo.go # ACLRepo implementation
|
│ │ ├── acl_repo.go # ACLRepo
|
||||||
│ │ ├── audit_repo.go # AuditRepo implementation
|
│ │ ├── audit_repo.go # AuditRepo
|
||||||
│ │ ├── mime_repo.go # MimeRepo implementation
|
│ │ ├── mime_repo.go # MimeRepo
|
||||||
│ │ └── filter_parser.go # DSL → SQL WHERE clause builder
|
│ │ ├── duplicate_repo.go # DuplicatePairRepo + DismissalRepo
|
||||||
|
│ │ └── filter_parser.go # Filter DSL → SQL WHERE clause builder
|
||||||
│ │
|
│ │
|
||||||
│ ├── storage/ # File storage adapter
|
│ ├── storage/ # File storage adapter
|
||||||
│ │ └── disk.go # FileStorage implementation (read/write/delete on disk)
|
│ │ └── disk.go # FileStorage on disk: originals + thumbnail/preview cache
|
||||||
|
│ │ # (vipsthumbnail / ffmpeg / pure-Go imaging)
|
||||||
|
│ │
|
||||||
|
│ ├── imagehash/ # Perceptual hashing (64-bit dHash) for near-duplicate detection
|
||||||
|
│ │ └── imagehash.go
|
||||||
|
│ │
|
||||||
|
│ ├── integration/ # End-to-end HTTP tests against a disposable Postgres
|
||||||
|
│ │ └── server_test.go
|
||||||
│ │
|
│ │
|
||||||
│ └── config/ # Configuration
|
│ └── config/ # Configuration
|
||||||
│ └── config.go # Struct + loader from env vars
|
│ └── config.go # Config struct + loader from env vars
|
||||||
│
|
│
|
||||||
├── migrations/ # SQL migration files (goose format)
|
├── migrations/ # SQL migration files (goose format), embedded via go:embed
|
||||||
│ ├── 001_init_schemas.sql
|
│ ├── 001_init_schemas.sql
|
||||||
│ ├── 002_core_tables.sql
|
│ ├── 002_core_tables.sql
|
||||||
│ ├── 003_data_tables.sql
|
│ ├── 003_data_tables.sql
|
||||||
│ ├── 004_acl_tables.sql
|
│ ├── 004_acl_tables.sql
|
||||||
│ ├── 005_activity_tables.sql
|
│ ├── 005_activity_tables.sql
|
||||||
│ ├── 006_indexes.sql
|
│ ├── 006_indexes.sql
|
||||||
│ └── 007_seed_data.sql
|
│ ├── 007_seed_data.sql
|
||||||
|
│ └── embed.go # //go:embed *.sql → migrations.FS
|
||||||
│
|
│
|
||||||
├── go.mod
|
├── go.mod
|
||||||
└── go.sum
|
└── go.sum
|
||||||
@@ -126,6 +153,7 @@ handler → service → port (interfaces) ← db/postgres / storage
|
|||||||
- **db/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
|
- **db/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
|
||||||
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
|
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
|
||||||
- **storage/**: imports domain/ and port/. Implements FileStorage.
|
- **storage/**: imports domain/ and port/. Implements FileStorage.
|
||||||
|
- **imagehash/**: leaf package (stdlib + image libs); used by service/ and storage/.
|
||||||
|
|
||||||
No layer may import a layer above it. No circular dependencies.
|
No layer may import a layer above it. No circular dependencies.
|
||||||
|
|
||||||
@@ -133,138 +161,126 @@ No layer may import a layer above it. No circular dependencies.
|
|||||||
|
|
||||||
### Dependency Injection (Wiring)
|
### Dependency Injection (Wiring)
|
||||||
|
|
||||||
Manual wiring in `cmd/server/main.go`. No DI frameworks.
|
Manual wiring in `cmd/server/main.go`. No DI frameworks. Constructors take their
|
||||||
|
collaborators explicitly; the shape below matches the real signatures.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Pseudocode
|
// Pseudocode — see cmd/server/main.go for the exact calls.
|
||||||
pool := postgres.NewPool(cfg.DatabaseURL)
|
pool := postgres.NewPool(ctx, cfg.DatabaseURL)
|
||||||
goose.Up(pool, migrations)
|
goose.Up(stdlib.OpenDBFromPool(pool), ".") // migrations.FS embedded
|
||||||
|
|
||||||
// Repos (all from internal/db/postgres/)
|
|
||||||
fileRepo := postgres.NewFileRepo(pool)
|
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
diskStore := storage.NewDiskStorage(cfg.FilesPath)
|
diskStorage := storage.NewDiskStorage(
|
||||||
|
cfg.FilesPath, cfg.ThumbsCachePath,
|
||||||
|
cfg.ThumbWidth, cfg.ThumbHeight, cfg.PreviewWidth, cfg.PreviewHeight,
|
||||||
|
cfg.ThumbMaxPixels, cfg.ThumbConcurrency,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repos (all from internal/db/postgres/)
|
||||||
|
fileRepo := postgres.NewFileRepo(pool)
|
||||||
|
// … tag, tagRule, category, pool, user, session, acl, audit, mime,
|
||||||
|
// duplicatePair, dismissal repos + transactor
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
aclSvc := service.NewACLService(aclRepo, objectTypeRepo)
|
authSvc := service.NewAuthService(userRepo, sessionRepo,
|
||||||
auditSvc := service.NewAuditService(auditRepo, actionTypeRepo)
|
cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL, cfg.ContentTokenTTL)
|
||||||
fileSvc := service.NewFileService(fileRepo, mimeRepo, tagRepo, diskStore, aclSvc, auditSvc)
|
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc)
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
// ...
|
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
||||||
|
dupSvc := service.NewDuplicateService(fileRepo, duplicatePairRepo, dismissalRepo,
|
||||||
|
aclSvc, auditSvc, transactor, cfg.DuplicateHashThreshold)
|
||||||
|
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage,
|
||||||
|
aclSvc, auditSvc, tagSvc, transactor, cfg.ImportPath)
|
||||||
|
// … category, pool, user services
|
||||||
|
|
||||||
// Handlers
|
// Bootstrap the initial admin from env (idempotent).
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
userSvc.EnsureAdmin(ctx, cfg.AdminUsername, cfg.AdminPassword)
|
||||||
// ...
|
|
||||||
|
|
||||||
router := handler.NewRouter(cfg, fileHandler, tagHandler, ...)
|
// Handlers → router (also wires trusted proxies + optional static SPA dir)
|
||||||
router.Run(cfg.ListenAddr)
|
router, _ := handler.NewRouter(authMiddleware, authHandler, fileHandler,
|
||||||
|
duplicateHandler, tagHandler, categoryHandler, poolHandler,
|
||||||
|
userHandler, aclHandler, auditHandler, cfg.StaticDir, cfg.TrustedProxies)
|
||||||
|
srv.ListenAndServe()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Context Propagation
|
### Context Propagation
|
||||||
|
|
||||||
Every service method receives `context.Context` as the first argument.
|
Every service method receives `context.Context` as the first argument.
|
||||||
The handler extracts user info from JWT (via middleware) and puts it
|
The auth middleware parses the JWT and puts the caller's identity (user id,
|
||||||
into context. Services read the current user from context for ACL checks
|
admin flag, session id) into the context. Services read it for ACL checks and
|
||||||
and audit logging.
|
audit logging.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// middleware.go
|
// handler/middleware.go
|
||||||
func (m *AuthMiddleware) Handle(c *gin.Context) {
|
claims := parseJWT(c.GetHeader("Authorization"))
|
||||||
claims := parseJWT(c.GetHeader("Authorization"))
|
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
|
||||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin)
|
c.Request = c.Request.WithContext(ctx)
|
||||||
c.Request = c.Request.WithContext(ctx)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// domain/context.go
|
// domain/context.go
|
||||||
type ctxKey int
|
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context
|
||||||
const userKey ctxKey = iota
|
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int)
|
||||||
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { ... }
|
|
||||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) { ... }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Transaction Management
|
### Transaction Management
|
||||||
|
|
||||||
Repository interfaces include a `Transactor`:
|
The `Transactor` port lets services compose multiple repo calls atomically.
|
||||||
|
The postgres implementation stores the active `pgx.Tx` in the context; repo
|
||||||
|
methods pick it up via a conn-or-tx helper, so the same method works inside or
|
||||||
|
outside a transaction.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// port/repository.go
|
// port/repository.go
|
||||||
type Transactor interface {
|
type Transactor interface {
|
||||||
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
The postgres implementation wraps `pgxpool.Pool.BeginTx`. Inside `fn`,
|
// service/file_service.go (sketch)
|
||||||
all repo calls use the transaction from context. This allows services
|
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
|
||||||
to compose multiple repo calls in a single transaction:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// service/file_service.go
|
|
||||||
func (s *FileService) Upload(ctx context.Context, input UploadInput) (*domain.File, error) {
|
|
||||||
return s.tx.WithTx(ctx, func(ctx context.Context) error {
|
return s.tx.WithTx(ctx, func(ctx context.Context) error {
|
||||||
file, err := s.fileRepo.Create(ctx, ...) // uses tx
|
created, err := s.files.Create(ctx, f) // uses tx from ctx
|
||||||
if err != nil { return err }
|
// apply initial tags, etc., in the same tx
|
||||||
for _, tagID := range input.TagIDs {
|
return err
|
||||||
s.tagRepo.AddFileTag(ctx, file.ID, tagID) // same tx
|
|
||||||
}
|
|
||||||
s.auditRepo.Log(ctx, ...) // same tx
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### ACL Check Pattern
|
### ACL Check Pattern
|
||||||
|
|
||||||
ACL logic is centralized in `ACLService`. Other services call it before
|
ACL logic is centralized in `ACLService`. Other services call it before any
|
||||||
any data mutation or retrieval:
|
mutation or retrieval. The model is private-by-default: admins see everything;
|
||||||
|
otherwise a `public` flag, creator ownership, or an explicit `acl.permissions`
|
||||||
|
grant is required.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// service/acl_service.go
|
// service/acl_service.go (shape)
|
||||||
func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uuid.UUID) error {
|
func (s *ACLService) CanView(ctx context.Context, userID int16, isAdmin bool,
|
||||||
userID, isAdmin := domain.UserFromContext(ctx)
|
creatorID int16, isPublic bool, objectType int16, objectID uuid.UUID) (bool, error)
|
||||||
if isAdmin { return nil }
|
func (s *ACLService) CanEdit(ctx context.Context, userID int16, isAdmin bool,
|
||||||
// Check is_public on the object
|
creatorID int16, objectType int16, objectID uuid.UUID) (bool, error)
|
||||||
// If not public, check creator_id == userID
|
|
||||||
// If not creator, check acl.permissions
|
|
||||||
// Return domain.ErrForbidden if none match
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Mapping
|
### Error Mapping
|
||||||
|
|
||||||
Domain errors → HTTP status codes (handled in handler/response.go):
|
Domain errors → HTTP status codes (handled in handler/response.go):
|
||||||
|
|
||||||
| Domain Error | HTTP Status | Error Code |
|
| Domain Error | HTTP Status | Error Code |
|
||||||
|-----------------------|-------------|-------------------|
|
| ------------------ | ----------- | ---------------- |
|
||||||
| ErrNotFound | 404 | not_found |
|
| ErrNotFound | 404 | not_found |
|
||||||
| ErrForbidden | 403 | forbidden |
|
| ErrForbidden | 403 | forbidden |
|
||||||
| ErrUnauthorized | 401 | unauthorized |
|
| ErrUnauthorized | 401 | unauthorized |
|
||||||
| ErrConflict | 409 | conflict |
|
| ErrConflict | 409 | conflict |
|
||||||
| ErrValidation | 400 | validation_error |
|
| ErrValidation | 400 | validation_error |
|
||||||
| ErrUnsupportedMIME | 415 | unsupported_mime |
|
| ErrUnsupportedMIME | 415 | unsupported_mime |
|
||||||
| (unexpected) | 500 | internal_error |
|
| (unexpected) | 500 | internal_error |
|
||||||
|
|
||||||
### Filter DSL
|
### Filter DSL
|
||||||
|
|
||||||
The DSL parser lives in `db/postgres/filter_parser.go` because it produces
|
The DSL parser lives in `db/postgres/filter_parser.go` because it produces SQL
|
||||||
SQL WHERE clauses — it is a PostgreSQL-specific adapter concern.
|
WHERE clauses — a PostgreSQL-specific adapter concern. The service layer passes
|
||||||
The service layer passes the raw DSL string to the repository; the
|
the raw DSL string down; the repository parses it and builds the query. For a
|
||||||
repository parses it and builds the query.
|
different DBMS, a corresponding parser would live in `db/<dbms>/filter_parser.go`.
|
||||||
|
|
||||||
For a different DBMS, a corresponding parser would live in
|
|
||||||
`db/<dbms>/filter_parser.go`.
|
|
||||||
|
|
||||||
The interface:
|
|
||||||
```go
|
```go
|
||||||
// port/repository.go
|
|
||||||
type FileRepo interface {
|
|
||||||
List(ctx context.Context, params FileListParams) (*domain.FilePage, error)
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// domain/file.go
|
// domain/file.go
|
||||||
type FileListParams struct {
|
type FileListParams struct {
|
||||||
Filter string // raw DSL string
|
Filter string // raw DSL string
|
||||||
@@ -279,42 +295,38 @@ type FileListParams struct {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The DSL grammar itself is documented in `openapi.yaml` (the `filter` query
|
||||||
|
parameter), so the contract stays in one place.
|
||||||
|
|
||||||
### JWT Structure
|
### JWT Structure
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
UserID int16 `json:"uid"`
|
UserID int16 `json:"uid"`
|
||||||
IsAdmin bool `json:"adm"`
|
IsAdmin bool `json:"adm"`
|
||||||
SessionID int `json:"sid"`
|
SessionID int `json:"sid"`
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Access token: short-lived (15 min). Refresh token: long-lived (30 days),
|
Access token: short-lived (15 min default). Refresh token: long-lived (30 days
|
||||||
stored as hash in `activity.sessions.token_hash`.
|
default), rotated on use, stored as a hash in `activity.sessions`. A separate
|
||||||
|
**content token** (default 6 h) is a single-file capability minted for media
|
||||||
|
URLs, so a long video keeps streaming past access-token expiry — see
|
||||||
|
`CONTENT_TOKEN_TTL` in `.env.example`.
|
||||||
|
|
||||||
|
### Perceptual Duplicate Detection
|
||||||
|
|
||||||
|
Images are dHash-ed inline on upload (`internal/imagehash`); video hashes are
|
||||||
|
backfilled by the `dedup` CLI (ffmpeg stays off the upload path). A rescan
|
||||||
|
rebuilds `data.duplicate_pairs` by inserting every pair within
|
||||||
|
`DUPLICATE_HASH_THRESHOLD` Hamming distance (BK-tree lookups, not O(N²)); the
|
||||||
|
duplicates API then groups pairs into connected-component clusters. See
|
||||||
|
`service/duplicate_service.go` and `service/duplicate_index.go`.
|
||||||
|
|
||||||
### Configuration (.env)
|
### Configuration (.env)
|
||||||
|
|
||||||
```env
|
Every variable the server reads is documented in `.env.example` (1:1 with
|
||||||
# Server
|
`config.Config`). Required at startup: `JWT_SECRET`, `ADMIN_PASSWORD`,
|
||||||
LISTEN_ADDR=:42776
|
`DATABASE_URL`, `FILES_PATH`, `THUMBS_CACHE_PATH`, `IMPORT_PATH`. Everything
|
||||||
JWT_SECRET=<random-32-bytes>
|
else has a sensible default (see `config.go`).
|
||||||
JWT_ACCESS_TTL=15m
|
|
||||||
JWT_REFRESH_TTL=720h
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgres://user:pass@host:5432/tanabata?sslmode=disable
|
|
||||||
|
|
||||||
# Storage
|
|
||||||
FILES_PATH=/data/files
|
|
||||||
THUMBS_CACHE_PATH=/data/thumbs
|
|
||||||
|
|
||||||
# Thumbnails
|
|
||||||
THUMB_WIDTH=160
|
|
||||||
THUMB_HEIGHT=160
|
|
||||||
PREVIEW_WIDTH=1920
|
|
||||||
PREVIEW_HEIGHT=1080
|
|
||||||
|
|
||||||
# Import
|
|
||||||
IMPORT_PATH=/data/import
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# Tanabata File Manager — Requirements
|
||||||
|
|
||||||
|
> Product requirements for Tanabata File Manager (TFM). Architecture and code
|
||||||
|
> layout are described separately in [ARCHITECTURE.md](ARCHITECTURE.md),
|
||||||
|
> [GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md) and
|
||||||
|
> [FRONTEND_STRUCTURE.md](FRONTEND_STRUCTURE.md).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Tanabata File Manager (TFM) is a multi-user, tag-based web file manager. It runs
|
||||||
|
on a client–server architecture and is operated entirely through a web
|
||||||
|
interface. Its goal is centralized, server-side storage of files with access and
|
||||||
|
management from both desktop and mobile browsers. The application is primarily
|
||||||
|
oriented toward **images and video**.
|
||||||
|
|
||||||
|
The app is a PWA that can be installed on a desktop or a phone. Files managed by
|
||||||
|
Tanabata are stored flat in a single directory; each file's on-disk name equals
|
||||||
|
its UUID in the database.
|
||||||
|
|
||||||
|
Support for additional database engines is planned for future versions.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
- **File** — a single file on the server. It may carry any number of tags and
|
||||||
|
belong to any number of pools. It has a creator and optional access settings
|
||||||
|
(a user — which may be null, making the file public — plus read and edit
|
||||||
|
permission flags). It has an original name and metadata (key–value, including
|
||||||
|
all EXIF data).
|
||||||
|
- **Tag** — a label on a file. It may be attached to any number of files and
|
||||||
|
belong to at most one category. It has a name, a description, key–value
|
||||||
|
metadata, and may define auto-tag rules.
|
||||||
|
- **Auto-tag (tag rule)** — a rule stating that when tag A is attached to a file,
|
||||||
|
tag B is attached to the same file automatically.
|
||||||
|
- **Category** — an entity that logically groups several tags. It has a name, a
|
||||||
|
description, and key–value metadata.
|
||||||
|
- **Pool** — a logical grouping of files. It has a name, a description, and
|
||||||
|
key–value metadata. Files in a pool can be sorted automatically or arranged in
|
||||||
|
a user-defined manual order.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### 1. File management
|
||||||
|
|
||||||
|
1. Browse the file list (lazy load, pagination).
|
||||||
|
2. Filter files by tags and metadata.
|
||||||
|
3. View and edit sort settings (persisted per user).
|
||||||
|
4. Multi-select files (Ctrl, Shift) and act on the selection:
|
||||||
|
1. Attach / detach tags.
|
||||||
|
2. Copy / paste tags.
|
||||||
|
3. Add to a pool.
|
||||||
|
4. View and edit access settings.
|
||||||
|
5. Delete (with a confirmation prompt).
|
||||||
|
5. View a single file.
|
||||||
|
6. Single-file actions:
|
||||||
|
1. Attach / detach tags.
|
||||||
|
2. Copy / paste tags.
|
||||||
|
3. Add to a pool.
|
||||||
|
4. View and edit access settings.
|
||||||
|
5. Replace the file (upload new content under the same ID).
|
||||||
|
6. Delete (with a confirmation prompt).
|
||||||
|
7. Browse files gallery-style (prev/next paging through the viewer).
|
||||||
|
8. Upload new files through the web UI (form or drag-and-drop onto the list).
|
||||||
|
9. Import new files from a folder on the server.
|
||||||
|
10. Near-duplicate detection for images and video:
|
||||||
|
1. Show groups (clusters) of duplicates.
|
||||||
|
2. Dismiss false duplicates (the app remembers that file A is _not_ a
|
||||||
|
duplicate of file B).
|
||||||
|
3. Choose which duplicate to keep and which to delete.
|
||||||
|
4. Choose, per field, which duplicate the surviving file inherits it from.
|
||||||
|
11. Trash:
|
||||||
|
1. Browse trashed files.
|
||||||
|
2. Restore from trash.
|
||||||
|
3. Delete permanently.
|
||||||
|
|
||||||
|
### 2. Tag management
|
||||||
|
|
||||||
|
1. Browse the tag list (lazy load, pagination).
|
||||||
|
2. Search by name.
|
||||||
|
3. View and edit sort settings (persisted per user).
|
||||||
|
4. Multi-select tags (Ctrl, Shift) and act on the selection:
|
||||||
|
1. Assign auto-tag rules.
|
||||||
|
2. Change category.
|
||||||
|
3. Delete (with a confirmation prompt).
|
||||||
|
5. View a single tag.
|
||||||
|
6. Single-tag actions:
|
||||||
|
1. Edit name, description, and metadata (key–value).
|
||||||
|
2. Change category.
|
||||||
|
3. Assign auto-tag rules.
|
||||||
|
4. Delete (with a confirmation prompt).
|
||||||
|
7. Create a tag:
|
||||||
|
1. Enter name, description, and metadata (key–value).
|
||||||
|
2. Assign a category (optional).
|
||||||
|
3. Assign auto-tag rules.
|
||||||
|
|
||||||
|
### 3. Category management
|
||||||
|
|
||||||
|
1. Browse the category list (lazy load, pagination).
|
||||||
|
2. Search by name.
|
||||||
|
3. View and edit sort settings (persisted per user).
|
||||||
|
4. Multi-select categories (Ctrl, Shift) and act on the selection:
|
||||||
|
1. View shared tags and tags attached to some (but not all) of them.
|
||||||
|
2. Attach / detach tags.
|
||||||
|
3. Delete (with a confirmation prompt).
|
||||||
|
5. View a single category.
|
||||||
|
6. Single-category actions:
|
||||||
|
1. Edit name, description, and metadata (key–value).
|
||||||
|
2. View attached tags.
|
||||||
|
3. Attach / detach tags.
|
||||||
|
4. Delete (with a confirmation prompt).
|
||||||
|
7. Create a category:
|
||||||
|
1. Enter name, description, and metadata (key–value).
|
||||||
|
2. Attach tags.
|
||||||
|
|
||||||
|
### 4. Pool management
|
||||||
|
|
||||||
|
1. Browse the pool list (lazy load, pagination).
|
||||||
|
2. Search by name.
|
||||||
|
3. View and edit sort settings (persisted per user).
|
||||||
|
4. Multi-select pools (Ctrl, Shift) and act on the selection:
|
||||||
|
1. View and edit access settings.
|
||||||
|
2. Delete (with a confirmation prompt).
|
||||||
|
5. View a single pool.
|
||||||
|
6. Single-pool actions:
|
||||||
|
1. Edit name, description, and metadata (key–value).
|
||||||
|
2. View and edit access settings.
|
||||||
|
3. View all files in the pool.
|
||||||
|
4. Filter the pool's files by tags.
|
||||||
|
5. Change the file sort setting (including disabling automatic sorting).
|
||||||
|
6. Reorder files manually (when automatic sorting is disabled).
|
||||||
|
7. Delete (with a confirmation prompt).
|
||||||
|
7. Create a pool:
|
||||||
|
1. Enter name, description, and metadata (key–value).
|
||||||
|
2. Attach files.
|
||||||
|
|
||||||
|
### 5. User settings
|
||||||
|
|
||||||
|
1. Username.
|
||||||
|
2. Password.
|
||||||
|
3. Sessions:
|
||||||
|
1. Terminate a session.
|
||||||
|
4. Path to the server folder scanned during file import.
|
||||||
|
|
||||||
|
### 6. Server administration (admin panel)
|
||||||
|
|
||||||
|
1. Users:
|
||||||
|
1. Browse the list.
|
||||||
|
2. View a single user.
|
||||||
|
3. Create.
|
||||||
|
4. Delete.
|
||||||
|
5. Block / unblock.
|
||||||
|
6. Set role (reader / editor).
|
||||||
|
|
||||||
|
### 7. Audit logging (in the database)
|
||||||
|
|
||||||
|
Log the following user actions:
|
||||||
|
|
||||||
|
1. File views.
|
||||||
|
2. Changes to file access settings.
|
||||||
|
3. Create / edit / delete of a file, tag, category, pool, or file–tag relation.
|
||||||
|
4. Create / block / unblock / delete of a user.
|
||||||
|
5. User role changes.
|
||||||
|
6. User login / logout.
|
||||||
|
7. Session termination.
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
1. The interface must be as simple and convenient as possible: everything needed
|
||||||
|
should be at hand, reachable in the fewest possible actions.
|
||||||
|
2. The interface must adapt to both desktop and mobile devices.
|
||||||
|
3. The interface must offer dark and light themes.
|
||||||
|
4. Use PWA technology, including a button that fully resets the PWA (except the
|
||||||
|
cache) and reloads it from the server.
|
||||||
|
5. Allow selected files to be cached and viewed offline in the installed PWA.
|
||||||
|
6. First-run setup must require minimal effort: automatic database migration, a
|
||||||
|
ready-made Docker Compose file, and a `.env` file with the configurable
|
||||||
|
installation parameters.
|
||||||
|
7. Use a Domain-Driven Design approach on the API server.
|
||||||
|
8. Reject files whose MIME type is not present in the database (no DB entry — no
|
||||||
|
support).
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
## О проекте
|
|
||||||
|
|
||||||
Tanabata File Manager или сокращенно TFM — многопользовательский веб-файловый менеджер, организующий файлы по тегам. Работает на клиент-серверной архитектуре, управляется через веб-интерфейс. Главная цель проекта — обеспечить централизованное хранение файлов на сервере, доступ к ним и управление ими через веб как с компьютера, так и со смартфона. В первую очередь данное приложение ориентировано на изображения и видео.
|
|
||||||
|
|
||||||
## Общая архитектура
|
|
||||||
|
|
||||||
- File storage
|
|
||||||
- Relational database (PostgreSQL)
|
|
||||||
- REST API service (Go)
|
|
||||||
- Frontend (SvelteKit)
|
|
||||||
|
|
||||||
Приложение предполагается разворачивать внутри контейнера Docker. Фронтенд и бэкенд - в одном контейнере, СУБД - отдельно (на моем сервере планируется подключать к СУБД на хосте). Все файлы, управляемые Танабатой, будут храниться кучей в одной папке. Имя файла на диске совпадает с его UUID в БД.
|
|
||||||
|
|
||||||
Приложение является PWA, которое можно установить на компьютер или смартфон.
|
|
||||||
|
|
||||||
В будущих версиях планируется введение поддержки других СУБД.
|
|
||||||
|
|
||||||
## Основные понятия
|
|
||||||
|
|
||||||
**Файл** — один файл на сервере. Может иметь сколько угодно тегов, может принадлежать скольким угодно пулам. Имеет автора, а также может иметь настройки доступа (пользователь (может быть null - таким образом можно делать файл публичным), флаг права на чтение, флаг права на изменение). Имеет оригинальное название и метаданные (ключ-значение, в том числе все данные EXIF).
|
|
||||||
|
|
||||||
**Тег** — метка файла. Может быть привязан к скольким угодно файлам, может быть привязан к одной категории. Имеет название, описание, метаданные (ключ-значение). Может иметь автотеги.
|
|
||||||
|
|
||||||
**Автотег** — правило, согласно которому при привязке к файлу условного тега А к этому же файлу автоматически привязывается условный тег Б.
|
|
||||||
|
|
||||||
**Категория** — сущность, логически объединяющая собой несколько тегов. Имеет название, описание, метаданные (ключ-значение).
|
|
||||||
|
|
||||||
**Пул** — логическое объединение файлов. Имеет название, описание, метаданные (ключ-значение). Файлы внутри могут быть как отсортированы автоматически, так и расположены в порядке, заданном пользователем вручную.
|
|
||||||
|
|
||||||
## Функциональные требования
|
|
||||||
|
|
||||||
1. Управление файлами
|
|
||||||
1. Просмотр списка файлов (lazy load, pagination)
|
|
||||||
2. Фильтрация файлов по тегам и метаданным
|
|
||||||
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
|
|
||||||
4. Выделение нескольких файлов (Ctrl, Shift) и действия с ними
|
|
||||||
1. Привязка/отвязка тегов
|
|
||||||
2. Копирование/вставка тегов
|
|
||||||
3. Добавление в пул
|
|
||||||
4. Просмотр и редактирование настроек доступа
|
|
||||||
5. Удаление (с запросом подтверждения)
|
|
||||||
5. Просмотр одного файла
|
|
||||||
6. Действия с одним файлом
|
|
||||||
1. Привязка/отвязка тегов
|
|
||||||
2. Копирование/вставка тегов
|
|
||||||
3. Добавление в пул
|
|
||||||
4. Просмотр и редактирование настроек доступа
|
|
||||||
5. Замена файла (загрузка нового под таким же ID)
|
|
||||||
6. Удаление (с запросом подтверждения)
|
|
||||||
7. Листание файлов, как в галерее
|
|
||||||
8. Загрузка новых файлов через веб-интерфейс (через форму или drag-n-drop прямо на список)
|
|
||||||
9. Импорт новых файлов из папки на сервере
|
|
||||||
10. Выявление дубликатов, в частности, изображений и видео
|
|
||||||
1. Отображение групп дубликатов
|
|
||||||
2. Возможность отвязывания фальшивых дубликатов (чтобы приложение запомнило, что изображение А не является дубликатом изображения Б)
|
|
||||||
3. Возможность выбора дубликата для удаления/сохранения
|
|
||||||
4. Возможность выбора, какие поля от какого дубликата подтягивать
|
|
||||||
11. Корзина
|
|
||||||
1. Просмотр файлов в корзине
|
|
||||||
2. Восстановление из корзины
|
|
||||||
3. Окончательное удаление
|
|
||||||
2. Управление тегами
|
|
||||||
1. Просмотр списка тегов (lazy load, pagination)
|
|
||||||
2. Поиск по названию
|
|
||||||
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
|
|
||||||
4. Выделение нескольких тегов (Ctrl, Shift) и действия с ними
|
|
||||||
1. Назначение автотегов
|
|
||||||
2. Изменение категории
|
|
||||||
3. Удаление (с запросом подтверждения)
|
|
||||||
5. Просмотр одного тега
|
|
||||||
6. Действия с одним тегом
|
|
||||||
1. Редактирование названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Изменение категории
|
|
||||||
3. Назначение автотегов
|
|
||||||
4. Удаление (с запросом подтверждения)
|
|
||||||
7. Создание тега
|
|
||||||
1. Внесение названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Назначение категории (опционально)
|
|
||||||
3. Назначение автотегов
|
|
||||||
3. Управление категориями
|
|
||||||
1. Просмотр списка категорий (lazy load, pagination)
|
|
||||||
2. Поиск по названию
|
|
||||||
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
|
|
||||||
4. Выделение нескольких категорий (Ctrl, Shift) и действия с ними
|
|
||||||
1. Просмотр привязанных общих тегов и тегов, привязанных к некоторым, но не ко всем
|
|
||||||
2. Привязка/отвязка тегов
|
|
||||||
3. Удаление (с запросом подтверждения)
|
|
||||||
5. Просмотр одной категории
|
|
||||||
6. Действия с одной категорией
|
|
||||||
1. Редактирование названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Просмотр привязанных тегов
|
|
||||||
3. Привязка/отвязка тегов
|
|
||||||
4. Удаление (с запросом подтверждения)
|
|
||||||
7. Создание категории
|
|
||||||
1. Внесение названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Привязка тегов
|
|
||||||
4. Управление пулами
|
|
||||||
1. Просмотр списка пулов (lazy load, pagination)
|
|
||||||
2. Поиск по названию
|
|
||||||
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
|
|
||||||
4. Выделение нескольких пулов (Ctrl, Shift) и действия с ними
|
|
||||||
1. Просмотр и редактирование настроек доступа
|
|
||||||
2. Удаление (с запросом подтверждения)
|
|
||||||
5. Просмотр одного пула
|
|
||||||
6. Действия с одним пулом
|
|
||||||
1. Редактирование названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Просмотр и редактирование настроек доступа
|
|
||||||
3. Просмотр всех файлов, входящих в пул
|
|
||||||
4. Фильтрация файлов по тегам
|
|
||||||
5. Изменение настройки сортировки файлов (в том числе можно отключить автоматическую сортировку)
|
|
||||||
6. Ручное изменение порядка файлов (при отключенной сортировке)
|
|
||||||
7. Удаление (с запросом подтверждения)
|
|
||||||
7. Создание категории
|
|
||||||
1. Внесение названия, описания и метаданных (ключ-значение)
|
|
||||||
2. Привязка тегов
|
|
||||||
5. Управление пользовательскими настройками
|
|
||||||
1. Имя пользователя
|
|
||||||
2. Пароль
|
|
||||||
3. Сессии
|
|
||||||
1. Завершение сессии
|
|
||||||
4. Путь к папке на сервере, которая будет сканироваться при импорта файлов
|
|
||||||
6. Управление настройками сервера (админка)
|
|
||||||
1. Пользователи
|
|
||||||
1. Просмотр списка
|
|
||||||
2. Просмотр одного
|
|
||||||
3. Создание
|
|
||||||
4. Удаление
|
|
||||||
5. Блокировка/разблокировка
|
|
||||||
6. Установка роли (читатель/редактор)
|
|
||||||
7. Журналирование пользовательских действий в БД
|
|
||||||
1. Просмотры файлов
|
|
||||||
2. Смены настроек доступа к файлам
|
|
||||||
3. Создание/редактирование/удаление файла, тега, категории, пула, связи файл-тег
|
|
||||||
4. Создание/блокировка/разблокировка/удаление пользователя
|
|
||||||
5. Смена роли пользователя
|
|
||||||
6. Авторизация/логаут пользователя
|
|
||||||
7. Завершение сессии
|
|
||||||
|
|
||||||
## Нефункциональные требования
|
|
||||||
|
|
||||||
1. Интерфейс должен быть максимально простым и удобным, все необходимое должно быть под рукой, доступным за минимальное количество действий
|
|
||||||
2. Интерфейс должен быть адаптирован под десктоп и под мобильные устройства
|
|
||||||
3. Интерфейс должен иметь темную и светлую темы
|
|
||||||
4. Использование технологии PWA (также должна быть кнопка, при нажатии которой PWA будет полностью сбрасываться (кроме кэша) и заново загружаться с сервера)
|
|
||||||
5. Возможность сохранять некоторые файлы в кэш и просматривать их оффлайн при использовании установленного PWA
|
|
||||||
6. При первичном запуске приложение должно требовать минимума действий: автоматическая миграция БД, заранее готовый файл docker compose, файл .env с настраиваемыми параметрами установки
|
|
||||||
7. Использование подхода DDD для сервера API
|
|
||||||
8. Не принимать файлы, чей MIME отсутствует в БД (нет в БД — нет поддержки)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "3.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
||||||
|
|||||||
+34
-3
@@ -5,8 +5,9 @@ info:
|
|||||||
REST API for Tanabata File Manager — a multi-user, tag-based web file manager.
|
REST API for Tanabata File Manager — a multi-user, tag-based web file manager.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
All endpoints except `POST /auth/login` require a Bearer JWT token
|
All endpoints require a Bearer JWT token in the `Authorization` header,
|
||||||
in the `Authorization` header.
|
except `POST /auth/login`, `POST /auth/refresh` (which carry their own
|
||||||
|
credentials) and `GET /health`.
|
||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
- **Files**: cursor-based (`cursor` parameter, returned in `next_cursor`).
|
- **Files**: cursor-based (`cursor` parameter, returned in `next_cursor`).
|
||||||
@@ -32,7 +33,7 @@ info:
|
|||||||
|
|
||||||
Example: `{t=uuid1,&,!,t=uuid2}` → has tag1 AND NOT tag2.
|
Example: `{t=uuid1,&,!,t=uuid2}` → has tag1 AND NOT tag2.
|
||||||
Example: `{r=1,&,m~image%}` → needs review AND is an image.
|
Example: `{r=1,&,m~image%}` → needs review AND is an image.
|
||||||
version: 1.0.0
|
version: 3.0.0
|
||||||
license:
|
license:
|
||||||
name: Proprietary
|
name: Proprietary
|
||||||
|
|
||||||
@@ -59,12 +60,42 @@ tags:
|
|||||||
description: User management (admin)
|
description: User management (admin)
|
||||||
- name: Audit
|
- name: Audit
|
||||||
description: Audit log (admin)
|
description: Audit log (admin)
|
||||||
|
- name: System
|
||||||
|
description: Service health and liveness
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Paths
|
# Paths
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
paths:
|
paths:
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# System
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
/health:
|
||||||
|
# Served at the server root, outside the /api/v1 prefix — override the
|
||||||
|
# global server so the documented path is /health, not /api/v1/health.
|
||||||
|
servers:
|
||||||
|
- url: /
|
||||||
|
get:
|
||||||
|
tags: [System]
|
||||||
|
summary: Health check
|
||||||
|
description: |
|
||||||
|
Liveness probe. Requires no authentication and is used by the container
|
||||||
|
HEALTHCHECK. Always returns 200 while the process is serving.
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service is healthy.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [status]
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Auth
|
# Auth
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user