chore: initial project structure
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
# Tanabata File Manager — Frontend Structure
|
||||
|
||||
## Stack
|
||||
|
||||
- **Framework**: SvelteKit (SPA mode, `ssr: false`)
|
||||
- **Language**: TypeScript
|
||||
- **CSS**: Tailwind CSS + CSS custom properties (hybrid)
|
||||
- **API types**: Auto-generated via openapi-typescript
|
||||
- **PWA**: Service worker + web manifest
|
||||
- **Font**: Epilogue (variable weight)
|
||||
- **Package manager**: npm
|
||||
|
||||
## Monorepo Layout
|
||||
|
||||
```
|
||||
tanabata/
|
||||
├── backend/ ← Go project (go.mod in here)
|
||||
│ ├── cmd/
|
||||
│ ├── internal/
|
||||
│ ├── migrations/
|
||||
│ ├── go.mod
|
||||
│ └── go.sum
|
||||
│
|
||||
├── frontend/ ← SvelteKit project (package.json in here)
|
||||
│ └── (see below)
|
||||
│
|
||||
├── openapi.yaml ← Shared API contract (root level)
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
`openapi.yaml` lives at repository root — both backend and frontend
|
||||
reference it. The frontend generates types from it; the backend
|
||||
validates its handlers against it.
|
||||
|
||||
## Frontend Directory Layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── package.json
|
||||
├── svelte.config.js
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── tailwind.config.ts
|
||||
├── postcss.config.js
|
||||
│
|
||||
├── src/
|
||||
│ ├── app.html # Shell HTML (PWA meta, font preload)
|
||||
│ ├── app.css # Tailwind directives + CSS custom properties
|
||||
│ ├── hooks.server.ts # Server hooks (not used in SPA mode)
|
||||
│ ├── hooks.client.ts # Client hooks (global error handling)
|
||||
│ │
|
||||
│ ├── lib/ # Shared code ($lib/ alias)
|
||||
│ │ │
|
||||
│ │ ├── api/ # API client layer
|
||||
│ │ │ ├── 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/
|
||||
├── favicon.png
|
||||
├── favicon.ico
|
||||
├── manifest.webmanifest # PWA manifest (name, icons, theme_color)
|
||||
├── images/
|
||||
│ ├── tanabata-left.png # Login page decorations (from current design)
|
||||
│ ├── tanabata-right.png
|
||||
│ └── icons/ # PWA icons (192×192, 512×512, etc.)
|
||||
└── fonts/
|
||||
└── Epilogue-VariableFont_wght.ttf
|
||||
```
|
||||
|
||||
## Key Architecture Decisions
|
||||
|
||||
### CSS Hybrid: Tailwind + Custom Properties
|
||||
|
||||
Theme colors defined as CSS custom properties in `app.css`:
|
||||
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-bg-primary: #312F45;
|
||||
--color-bg-secondary: #181721;
|
||||
--color-bg-elevated: #111118;
|
||||
--color-accent: #9592B5;
|
||||
--color-accent-hover: #7D7AA4;
|
||||
--color-text-primary: #f0f0f0;
|
||||
--color-text-muted: #9999AD;
|
||||
--color-danger: #DB6060;
|
||||
--color-info: #4DC7ED;
|
||||
--color-warning: #F5E872;
|
||||
--color-tag-default: #444455;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--color-bg-primary: #f5f5f5;
|
||||
--color-bg-secondary: #ffffff;
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
Tailwind references them in `tailwind.config.ts`:
|
||||
|
||||
```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">`.
|
||||
Complex cases use scoped `<style>` inside `.svelte` files.
|
||||
|
||||
### API Client Pattern
|
||||
|
||||
`client.ts` — thin wrapper around fetch:
|
||||
|
||||
```ts
|
||||
// $lib/api/client.ts
|
||||
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 = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
upload: <T>(path: string, formData: FormData) =>
|
||||
request<T>(path, { method: 'POST', body: formData, headers: {} }),
|
||||
};
|
||||
```
|
||||
|
||||
Domain-specific modules use it:
|
||||
|
||||
```ts
|
||||
// $lib/api/files.ts
|
||||
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
|
||||
|
||||
Script in `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
||||
"dev": "npm run generate:types && vite dev",
|
||||
"build": "npm run generate:types && vite build"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Friendly aliases in `types.ts`:
|
||||
|
||||
```ts
|
||||
import type { components } from './schema';
|
||||
|
||||
export type File = components['schemas']['File'];
|
||||
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
|
||||
|
||||
`svelte.config.js`:
|
||||
|
||||
```js
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
export default {
|
||||
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).
|
||||
In development, Vite dev server proxies `/api` to the Go backend.
|
||||
|
||||
### PWA
|
||||
|
||||
`service-worker.ts` handles:
|
||||
- App shell caching (HTML, CSS, JS, fonts)
|
||||
- User-pinned file caching (explicit, via UI button)
|
||||
- Cache versioning and cleanup on update
|
||||
- Reset function (clear all caches except pinned files)
|
||||
@@ -0,0 +1,320 @@
|
||||
# Tanabata File Manager — Go Project Structure
|
||||
|
||||
## Stack
|
||||
|
||||
- **Router**: Gin
|
||||
- **Database**: pgx v5 (pgxpool)
|
||||
- **Migrations**: goose v3 + go:embed (auto-migrate on startup)
|
||||
- **Auth**: JWT (golang-jwt/jwt/v5)
|
||||
- **Config**: environment variables via .env (joho/godotenv)
|
||||
- **Logging**: slog (stdlib, Go 1.21+)
|
||||
- **Validation**: go-playground/validator/v10
|
||||
- **EXIF**: rwcarlsen/goexif or dsoprea/go-exif
|
||||
- **Image processing**: disintegration/imaging (thumbnails, previews)
|
||||
- **Architecture**: Clean Architecture (domain → service → repository/handler)
|
||||
|
||||
## Monorepo Layout
|
||||
|
||||
```
|
||||
tanabata/
|
||||
├── backend/ ← Go project
|
||||
├── frontend/ ← SvelteKit project
|
||||
├── openapi.yaml ← Shared API contract
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Backend Directory Layout
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Entrypoint: config → DB → migrate → wire → run
|
||||
│
|
||||
├── internal/
|
||||
│ │
|
||||
│ ├── domain/ # Pure business entities & value objects
|
||||
│ │ ├── file.go # File, FileFilter, FilePage
|
||||
│ │ ├── tag.go # Tag, TagRule
|
||||
│ │ ├── category.go # Category
|
||||
│ │ ├── pool.go # Pool, PoolFile
|
||||
│ │ ├── user.go # User, Session
|
||||
│ │ ├── acl.go # Permission, ObjectType
|
||||
│ │ ├── audit.go # AuditEntry, ActionType
|
||||
│ │ └── errors.go # Domain error types (ErrNotFound, ErrForbidden, etc.)
|
||||
│ │
|
||||
│ ├── port/ # Interfaces (ports) — contracts between layers
|
||||
│ │ ├── repository.go # FileRepo, TagRepo, CategoryRepo, PoolRepo,
|
||||
│ │ │ # UserRepo, SessionRepo, ACLRepo, AuditRepo,
|
||||
│ │ │ # MimeRepo, TagRuleRepo
|
||||
│ │ └── storage.go # FileStorage interface (disk operations)
|
||||
│ │
|
||||
│ ├── service/ # Business logic (use cases)
|
||||
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace,
|
||||
│ │ │ # import, filter/list, duplicate detection
|
||||
│ │ ├── tag_service.go # CRUD + auto-tag application logic
|
||||
│ │ ├── category_service.go # CRUD (thin, delegates to repo + ACL + audit)
|
||||
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
|
||||
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, session management
|
||||
│ │ ├── acl_service.go # Permission checks, grant/revoke
|
||||
│ │ ├── audit_service.go # Log actions, query audit log
|
||||
│ │ └── user_service.go # Profile update, admin CRUD, block/unblock
|
||||
│ │
|
||||
│ ├── handler/ # HTTP layer (Gin handlers)
|
||||
│ │ ├── router.go # Route registration, middleware wiring
|
||||
│ │ ├── middleware.go # Auth middleware (JWT extraction → context)
|
||||
│ │ ├── request.go # Common request parsing helpers
|
||||
│ │ ├── response.go # Error/success response builders
|
||||
│ │ ├── file_handler.go # /files endpoints
|
||||
│ │ ├── tag_handler.go # /tags endpoints
|
||||
│ │ ├── category_handler.go # /categories endpoints
|
||||
│ │ ├── pool_handler.go # /pools endpoints
|
||||
│ │ ├── auth_handler.go # /auth endpoints
|
||||
│ │ ├── acl_handler.go # /acl endpoints
|
||||
│ │ ├── user_handler.go # /users endpoints
|
||||
│ │ └── audit_handler.go # /audit endpoints
|
||||
│ │
|
||||
│ ├── db/ # Database adapters
|
||||
│ │ ├── db.go # Common helpers: pagination, repo factory, transactor base
|
||||
│ │ └── postgres/ # PostgreSQL implementation
|
||||
│ │ ├── postgres.go # pgxpool init, tx-from-context helpers
|
||||
│ │ ├── file_repo.go # FileRepo implementation
|
||||
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo implementation
|
||||
│ │ ├── category_repo.go # CategoryRepo implementation
|
||||
│ │ ├── pool_repo.go # PoolRepo implementation
|
||||
│ │ ├── user_repo.go # UserRepo implementation
|
||||
│ │ ├── session_repo.go # SessionRepo implementation
|
||||
│ │ ├── acl_repo.go # ACLRepo implementation
|
||||
│ │ ├── audit_repo.go # AuditRepo implementation
|
||||
│ │ ├── mime_repo.go # MimeRepo implementation
|
||||
│ │ └── filter_parser.go # DSL → SQL WHERE clause builder
|
||||
│ │
|
||||
│ ├── storage/ # File storage adapter
|
||||
│ │ └── disk.go # FileStorage implementation (read/write/delete on disk)
|
||||
│ │
|
||||
│ └── config/ # Configuration
|
||||
│ └── config.go # Struct + loader from env vars
|
||||
│
|
||||
├── migrations/ # SQL migration files (goose format)
|
||||
│ ├── 001_init_schemas.sql
|
||||
│ ├── 002_core_tables.sql
|
||||
│ ├── 003_data_tables.sql
|
||||
│ ├── 004_acl_tables.sql
|
||||
│ ├── 005_activity_tables.sql
|
||||
│ ├── 006_indexes.sql
|
||||
│ └── 007_seed_data.sql
|
||||
│
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
## Layer Dependency Rules
|
||||
|
||||
```
|
||||
handler → service → port (interfaces) ← db/postgres / storage
|
||||
↓
|
||||
domain (entities, value objects, errors)
|
||||
```
|
||||
|
||||
- **domain/**: zero imports from other internal packages. Only stdlib.
|
||||
- **port/**: imports only domain/. Defines interfaces.
|
||||
- **service/**: imports domain/ and port/. Never imports db/ or handler/.
|
||||
- **handler/**: imports domain/ and service/. Never imports db/.
|
||||
- **db/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
|
||||
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
|
||||
- **storage/**: imports domain/ and port/. Implements FileStorage.
|
||||
|
||||
No layer may import a layer above it. No circular dependencies.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Dependency Injection (Wiring)
|
||||
|
||||
Manual wiring in `cmd/server/main.go`. No DI frameworks.
|
||||
|
||||
```go
|
||||
// Pseudocode
|
||||
pool := postgres.NewPool(cfg.DatabaseURL)
|
||||
goose.Up(pool, migrations)
|
||||
|
||||
// Repos (all from internal/db/postgres/)
|
||||
fileRepo := postgres.NewFileRepo(pool)
|
||||
tagRepo := postgres.NewTagRepo(pool)
|
||||
// ...
|
||||
|
||||
// Storage
|
||||
diskStore := storage.NewDiskStorage(cfg.FilesPath)
|
||||
|
||||
// Services
|
||||
aclSvc := service.NewACLService(aclRepo, objectTypeRepo)
|
||||
auditSvc := service.NewAuditService(auditRepo, actionTypeRepo)
|
||||
fileSvc := service.NewFileService(fileRepo, mimeRepo, tagRepo, diskStore, aclSvc, auditSvc)
|
||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc)
|
||||
// ...
|
||||
|
||||
// Handlers
|
||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
||||
// ...
|
||||
|
||||
router := handler.NewRouter(cfg, fileHandler, tagHandler, ...)
|
||||
router.Run(cfg.ListenAddr)
|
||||
```
|
||||
|
||||
### Context Propagation
|
||||
|
||||
Every service method receives `context.Context` as the first argument.
|
||||
The handler extracts user info from JWT (via middleware) and puts it
|
||||
into context. Services read the current user from context for ACL checks
|
||||
and audit logging.
|
||||
|
||||
```go
|
||||
// middleware.go
|
||||
func (m *AuthMiddleware) Handle(c *gin.Context) {
|
||||
claims := parseJWT(c.GetHeader("Authorization"))
|
||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// domain/context.go
|
||||
type ctxKey int
|
||||
const userKey ctxKey = iota
|
||||
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { ... }
|
||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) { ... }
|
||||
```
|
||||
|
||||
### Transaction Management
|
||||
|
||||
Repository interfaces include a `Transactor`:
|
||||
|
||||
```go
|
||||
// port/repository.go
|
||||
type Transactor interface {
|
||||
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
||||
}
|
||||
```
|
||||
|
||||
The postgres implementation wraps `pgxpool.Pool.BeginTx`. Inside `fn`,
|
||||
all repo calls use the transaction from context. This allows services
|
||||
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 {
|
||||
file, err := s.fileRepo.Create(ctx, ...) // uses tx
|
||||
if err != nil { return err }
|
||||
for _, tagID := range input.TagIDs {
|
||||
s.tagRepo.AddFileTag(ctx, file.ID, tagID) // same tx
|
||||
}
|
||||
s.auditRepo.Log(ctx, ...) // same tx
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### ACL Check Pattern
|
||||
|
||||
ACL logic is centralized in `ACLService`. Other services call it before
|
||||
any data mutation or retrieval:
|
||||
|
||||
```go
|
||||
// service/acl_service.go
|
||||
func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uuid.UUID) error {
|
||||
userID, isAdmin := domain.UserFromContext(ctx)
|
||||
if isAdmin { return nil }
|
||||
// Check is_public on the object
|
||||
// If not public, check creator_id == userID
|
||||
// If not creator, check acl.permissions
|
||||
// Return domain.ErrForbidden if none match
|
||||
}
|
||||
```
|
||||
|
||||
### Error Mapping
|
||||
|
||||
Domain errors → HTTP status codes (handled in handler/response.go):
|
||||
|
||||
| Domain Error | HTTP Status | Error Code |
|
||||
|-----------------------|-------------|-------------------|
|
||||
| ErrNotFound | 404 | not_found |
|
||||
| ErrForbidden | 403 | forbidden |
|
||||
| ErrUnauthorized | 401 | unauthorized |
|
||||
| ErrConflict | 409 | conflict |
|
||||
| ErrValidation | 400 | validation_error |
|
||||
| ErrUnsupportedMIME | 415 | unsupported_mime |
|
||||
| (unexpected) | 500 | internal_error |
|
||||
|
||||
### Filter DSL
|
||||
|
||||
The DSL parser lives in `db/postgres/filter_parser.go` because it produces
|
||||
SQL WHERE clauses — it is a PostgreSQL-specific adapter concern.
|
||||
The service layer passes the raw DSL string to the repository; the
|
||||
repository parses it and builds the query.
|
||||
|
||||
For a different DBMS, a corresponding parser would live in
|
||||
`db/<dbms>/filter_parser.go`.
|
||||
|
||||
The interface:
|
||||
```go
|
||||
// port/repository.go
|
||||
type FileRepo interface {
|
||||
List(ctx context.Context, params FileListParams) (*domain.FilePage, error)
|
||||
// ...
|
||||
}
|
||||
|
||||
// domain/file.go
|
||||
type FileListParams struct {
|
||||
Filter string // raw DSL string
|
||||
Sort string
|
||||
Order string
|
||||
Cursor string
|
||||
Anchor *uuid.UUID
|
||||
Direction string // "forward" or "backward"
|
||||
Limit int
|
||||
Trash bool
|
||||
Search string
|
||||
}
|
||||
```
|
||||
|
||||
### JWT Structure
|
||||
|
||||
```go
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserID int16 `json:"uid"`
|
||||
IsAdmin bool `json:"adm"`
|
||||
SessionID int `json:"sid"`
|
||||
}
|
||||
```
|
||||
|
||||
Access token: short-lived (15 min). Refresh token: long-lived (30 days),
|
||||
stored as hash in `activity.sessions.token_hash`.
|
||||
|
||||
### Configuration (.env)
|
||||
|
||||
```env
|
||||
# Server
|
||||
LISTEN_ADDR=:8080
|
||||
JWT_SECRET=<random-32-bytes>
|
||||
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,148 @@
|
||||
## О проекте
|
||||
|
||||
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 отсутствует в БД (нет в БД — нет поддержки)
|
||||
Reference in New Issue
Block a user