add auth
This commit is contained in:
16
.env.example
16
.env.example
@@ -2,3 +2,19 @@
|
||||
# Library paths you configure in the app must be subdirectories of this path.
|
||||
# Inside the container, this maps to /media.
|
||||
MEDIA_ROOT=/mnt/nas
|
||||
|
||||
# Authentication settings
|
||||
# SECRET_KEY is used to sign JWT tokens. Leave unset to auto-generate one
|
||||
# (all sessions will be invalidated on restart).
|
||||
SECRET_KEY=
|
||||
|
||||
# Admin user created on first startup.
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
|
||||
# JWT token expiry in days (default: 30).
|
||||
ACCESS_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# Gitea container registry owner (username or org). Used by docker-compose.yml
|
||||
# to pull pre-built images. Defaults to "gpatti".
|
||||
OWNER=gpatti
|
||||
|
||||
93
.gitea/workflows/container-publish.yml
Normal file
93
.gitea/workflows/container-publish.yml
Normal file
@@ -0,0 +1,93 @@
|
||||
name: Container Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
run: |
|
||||
REGISTRY=git.gpatti.com
|
||||
OWNER="${{ gitea.repository_owner }}"
|
||||
IMAGE_NAME="${REGISTRY}/${OWNER}/medialore-backend"
|
||||
|
||||
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "registry=${REGISTRY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ "${{ gitea.ref_type }}" == "tag" ]]; then
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "tags=${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | \
|
||||
docker login "${{ steps.meta.outputs.registry }}" \
|
||||
-u "${{ secrets.REGISTRY_USER }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: https://github.com/docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
needs: []
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
run: |
|
||||
REGISTRY=git.gpatti.com
|
||||
OWNER="${{ gitea.repository_owner }}"
|
||||
IMAGE_NAME="${REGISTRY}/${OWNER}/medialore-frontend"
|
||||
|
||||
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "registry=${REGISTRY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ "${{ gitea.ref_type }}" == "tag" ]]; then
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "tags=${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | \
|
||||
docker login "${{ steps.meta.outputs.registry }}" \
|
||||
-u "${{ secrets.REGISTRY_USER }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: https://github.com/docker/build-push-action@v6
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
107
AGENTS.md
107
AGENTS.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
MediaLore is a **self-hosted media library browser** for images and videos. It provides a web UI for browsing, searching, tagging, and viewing media with thumbnail previews and doom-scroll/TikTok-style consumption.
|
||||
MediaLore is a **self-hosted media library browser** for images and videos. It provides a web UI for browsing, searching, tagging, and viewing media with thumbnail previews and doom-scroll/TikTok-style consumption. **All access requires authentication** — users must log in before accessing any media or UI pages.
|
||||
|
||||
**Monorepo** with two independent apps:
|
||||
- `backend/` — Python FastAPI server
|
||||
@@ -15,6 +15,7 @@ MediaLore is a **self-hosted media library browser** for images and videos. It p
|
||||
| Backend | Python 3.12+, FastAPI, Uvicorn |
|
||||
| Database | SQLite via SQLAlchemy (async aiosqlite), WAL mode |
|
||||
| Migrations | Alembic |
|
||||
| Auth | JWT (python-jose) + bcrypt password hashing |
|
||||
| Thumbnails | Pillow (images), ffmpeg (videos) — generated on-demand |
|
||||
| File Watch | watchdog — live filesystem monitoring |
|
||||
| Frontend | React 19, TypeScript 6, Vite 8, React Router 7, TanStack Query v5 |
|
||||
@@ -25,7 +26,10 @@ MediaLore is a **self-hosted media library browser** for images and videos. It p
|
||||
```
|
||||
medialore-web-app/
|
||||
├── docker-compose.yml # Orchestration (backend, frontend, volumes)
|
||||
├── .env.example # MEDIA_ROOT env var template
|
||||
├── .env.example # Environment variable template
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ └── container-publish.yml # CI: build & push Docker images
|
||||
├── backend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── pyproject.toml # Python package config (hatchling build)
|
||||
@@ -35,11 +39,13 @@ medialore-web-app/
|
||||
│ │ └── versions/
|
||||
│ └── app/
|
||||
│ ├── main.py # FastAPI app, CORS, lifespan, router registration
|
||||
│ ├── config.py # Pydantic Settings (DATABASE_URL, MEDIA_ROOT, THUMBNAIL_DIR)
|
||||
│ ├── config.py # Pydantic Settings (DATABASE_URL, MEDIA_ROOT, etc.)
|
||||
│ ├── database.py # SQLAlchemy async engine, session, WAL pragma
|
||||
│ ├── models.py # ORM models: Library, MediaItem, Tag, media_item_tags
|
||||
│ ├── schemas.py # Pydantic request/response schemas
|
||||
│ ├── models.py # ORM models: Library, MediaItem, Tag, User, media_item_tags
|
||||
│ ├── schemas.py # Pydantic request/response schemas (incl. auth)
|
||||
│ ├── auth.py # bcrypt hashing, JWT create/decode, auth dependencies
|
||||
│ ├── routers/
|
||||
│ │ ├── auth.py # Login, token validation, user CRUD (admin)
|
||||
│ │ ├── libraries.py # Library CRUD, browse, doom-scroll, scan-status, rescan
|
||||
│ │ ├── media.py # Media item get, file serve, thumbnail, tag assignment
|
||||
│ │ ├── tags.py # Tag CRUD, grouped by category
|
||||
@@ -67,7 +73,12 @@ medialore-web-app/
|
||||
├── index.css # CSS custom properties for light/dark themes
|
||||
├── api/
|
||||
│ └── client.ts # Typed fetch wrapper; all endpoint functions
|
||||
├── auth/
|
||||
│ ├── AuthTypes.ts # AuthUser, AuthState interfaces, AuthContext
|
||||
│ ├── AuthContext.tsx # AuthProvider component (login/logout/token mgmt)
|
||||
│ └── useAuth.ts # useAuth hook
|
||||
├── pages/
|
||||
│ ├── LoginPage.tsx
|
||||
│ ├── SearchPage.tsx
|
||||
│ ├── BrowserPage.tsx
|
||||
│ ├── SettingsPage.tsx
|
||||
@@ -127,10 +138,14 @@ docker compose up -d
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|------------------|-------------------------------------|------------------------------|
|
||||
|-------------------------|-------------------------------------|------------------------------|
|
||||
| `DATABASE_URL` | `sqlite+aiosqlite:////data/medialore.db` | SQLite connection string |
|
||||
| `MEDIA_ROOT` | `/media` | Root path for media libraries |
|
||||
| `THUMBNAIL_DIR` | `/data/thumbnails` | Cached thumbnail storage |
|
||||
| `SECRET_KEY` | auto-generated | JWT signing key |
|
||||
| `ADMIN_USERNAME` | `admin` | Initial admin user |
|
||||
| `ADMIN_PASSWORD` | (required) | Initial admin password |
|
||||
| `ACCESS_TOKEN_EXPIRE_DAYS` | `30` | JWT token lifetime in days |
|
||||
|
||||
Pydantic Settings reads from `.env` at startup (via `model_config = {"env_file": ".env"}`). For Docker, these are set in `docker-compose.yml`.
|
||||
|
||||
@@ -144,13 +159,26 @@ SQLite with **WAL mode** and a 10-second busy timeout (see `app/database.py:14-1
|
||||
- **media_items**: `id`, `library_id` (FK), `rel_path`, `filename`, `file_hash` (SHA-256), `media_type` ("image" or "video"), `size_bytes`, `missing`, `created_at`, `updated_at` — unique on `(library_id, rel_path)`
|
||||
- **tags**: `id`, `name`, `category` — unique on `(name, category)`
|
||||
- **media_item_tags**: `media_item_id` (FK), `tag_id` (FK), composite PK
|
||||
- **users**: `id`, `username` (unique), `password_hash`, `is_admin`, `created_at`
|
||||
|
||||
**Migrations**: Use Alembic. The initial migration is at `backend/alembic/versions/0001_initial_schema.py`.
|
||||
**Migrations**: Use Alembic. The initial migration is at `backend/alembic/versions/0001_initial_schema.py` and the users table migration at `0002_add_users_table.py`.
|
||||
|
||||
## API Conventions
|
||||
|
||||
All endpoints are prefixed with `/api`. All responses are JSON except `/api/media/:id/file` (binary stream) and `/api/media/:id/thumbnail` (JPEG).
|
||||
|
||||
**All endpoints require authentication** except `POST /api/auth/login`. The `Authorization: Bearer <token>` header must be included. File and thumbnail endpoints also accept `?token=` as a query parameter (for `<img>`/`<video>` tags that can't send headers).
|
||||
|
||||
### Auth Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|-------------------|--------|-----------------------------|
|
||||
| `POST` | `/auth/login` | Public | Returns JWT `access_token` |
|
||||
| `GET` | `/auth/me` | User | Current user info |
|
||||
| `GET` | `/auth/users` | Admin | List all users |
|
||||
| `POST` | `/auth/users` | Admin | Create a new user |
|
||||
| `DELETE` | `/auth/users/{id}` | Admin | Delete a user (not self) |
|
||||
|
||||
### Async Database Access
|
||||
|
||||
Routers use FastAPI's `Depends` injection with the `get_db` async generator from `database.py`:
|
||||
@@ -163,6 +191,37 @@ async def endpoint(db: AsyncSession = Depends(get_db)):
|
||||
...
|
||||
```
|
||||
|
||||
### Authentication Dependency
|
||||
|
||||
All protected endpoints use `get_current_user` (from `app/auth.py`) as a FastAPI dependency:
|
||||
|
||||
```python
|
||||
from app.auth import get_current_user
|
||||
from app.models import User
|
||||
|
||||
@router.get("/...")
|
||||
async def endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
...
|
||||
```
|
||||
|
||||
Admin-only endpoints chain `get_current_admin_user`:
|
||||
|
||||
```python
|
||||
from app.auth import get_current_admin_user
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
...
|
||||
):
|
||||
```
|
||||
|
||||
File and thumbnail endpoints use `get_current_user_from_query_or_header` which accepts the token via `Authorization` header or `?token=` query parameter.
|
||||
|
||||
### Frontend API Client
|
||||
|
||||
All API calls go through the typed `api` object in `frontend/src/api/client.ts`. Use `useQuery` / `useMutation` from TanStack Query. Never call `fetch` directly from components.
|
||||
@@ -186,6 +245,8 @@ const { data } = useQuery({
|
||||
- Service modules handle business logic; routers handle HTTP concerns
|
||||
- Background scanning runs via `asyncio.create_task`; watchers run in separate threads via `watchdog.Observer`
|
||||
- Logging: use `logging.getLogger(__name__)` — the `app` namespace has a StreamHandler configured in `main.py`
|
||||
- Auth: use `bcrypt` directly (not passlib) for password hashing; `python-jose` for JWT tokens
|
||||
- All protected endpoints must include `_user: User = Depends(get_current_user)` as a parameter
|
||||
|
||||
### Frontend (TypeScript/React)
|
||||
- **ESLint flat config** with strict TypeScript-ESLint rules
|
||||
@@ -193,8 +254,10 @@ const { data } = useQuery({
|
||||
- TypeScript interfaces for all API types live in `client.ts`
|
||||
- Components use `useState`/`useEffect`/`useQuery` hooks, not class components
|
||||
- Theme state persisted in `localStorage` key `"theme"`, applied as `data-theme` attribute
|
||||
- Auth token persisted in `localStorage` key `"token"`, managed via `AuthProvider` context
|
||||
- Responsive via `window.matchMedia("(max-width: 767px)")` listener
|
||||
- File/thumbnail URLs constructed with `api.media.fileUrl(id)` and `api.media.thumbnailUrl(id)` (not fetched)
|
||||
- API client automatically injects `Authorization: Bearer <token>` header; handles 401 by clearing token
|
||||
|
||||
### TypeScript Configuration
|
||||
- `tsconfig.app.json`: strict mode, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`
|
||||
@@ -211,16 +274,42 @@ const { data } = useQuery({
|
||||
## Docker / Deployment
|
||||
|
||||
- `docker compose up -d` starts both services
|
||||
- Backend runs on port 8000 (internal), frontend Nginx on port 80 (mapped to 8085 on host)
|
||||
- Images are pulled from the Gitea container registry (`git.gpatti.com/<owner>/medialore-backend` and `...frontend`)
|
||||
- Backend runs on port 8000 (internal), frontend Nginx on port 80 (mapped to 8080 on host)
|
||||
- Nginx proxies `/api/` to `http://backend:8000`
|
||||
- Media paths from the host are volume-mounted into the container at `/media`
|
||||
- Persistent data (DB, thumbnails) stored in Docker volume `medialore-data`
|
||||
- Set `OWNER` in `.env` to control which registry namespace images are pulled from
|
||||
|
||||
### CI/CD (Gitea Actions)
|
||||
|
||||
The workflow at `.gitea/workflows/container-publish.yml` automatically builds and pushes Docker images to the Gitea container registry on push to `main` or version tags (`v*`).
|
||||
|
||||
**Required secrets** (set in repo Settings → Actions → Secrets):
|
||||
| Secret | Purpose |
|
||||
|--------|---------|
|
||||
| `REGISTRY_USER` | Gitea username for docker login |
|
||||
| `REGISTRY_TOKEN` | Gitea PAT with `read:packages` and `write:packages` scopes |
|
||||
|
||||
To pull images from the private registry on a host, run `docker login git.gpatti.com` first with the same credentials.
|
||||
|
||||
### Local Development (without registry)
|
||||
|
||||
For local development without pulling from the registry, switch docker-compose.yml back to local builds:
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
build: ./backend # instead of image: ...
|
||||
frontend:
|
||||
build: ./frontend # instead of image: ...
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **No auth/authentication** — CORS allows all origins, no login system
|
||||
- **Authentication is required** for all endpoints and UI pages except `/api/auth/login`. JWT tokens are signed with `SECRET_KEY` (auto-generated if unset, so set it for persistent sessions). File/thumbnail endpoints also accept `?token=` as a query parameter since `<img>`/`<video>` tags can't send headers.
|
||||
- Thumbnails are generated on first request and cached to disk (`THUMBNAIL_DIR`)
|
||||
- The scanner computes SHA-256 hashes to detect moved/renamed files and avoid re-processing
|
||||
- File watcher uses watchdog with per-library `PollingObserver` (necessary for Docker volumes)
|
||||
- `media_items.missing` flag is set when a scanned file no longer exists on disk
|
||||
- The frontend `build` command runs `tsc -b` first, which checks types across project references — type errors will block the build
|
||||
- Admin user is created automatically on first startup from `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars; additional users can be created via the Settings page by an admin
|
||||
|
||||
29
backend/alembic/versions/0002_add_users_table.py
Normal file
29
backend/alembic/versions/0002_add_users_table.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""add users table
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-06-28
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("username", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("password_hash", sa.String(), nullable=False),
|
||||
sa.Column("is_admin", sa.Boolean(), default=False),
|
||||
sa.Column("created_at", sa.DateTime(), default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("users")
|
||||
81
backend/app/auth.py
Normal file
81
backend/app/auth.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
|
||||
security = HTTPBearer()
|
||||
_optional_security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.access_token_expire_days)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
||||
except JWTError:
|
||||
raise HTTPException(401, "Invalid or expired token")
|
||||
|
||||
|
||||
async def _resolve_user(token_str: str, db: AsyncSession) -> User:
|
||||
payload = decode_access_token(token_str)
|
||||
user_id_str: str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise HTTPException(401, "Invalid token payload")
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(401, "Invalid token payload")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(401, "User not found")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
return await _resolve_user(credentials.credentials, db)
|
||||
|
||||
|
||||
async def get_current_user_from_query_or_header(
|
||||
token: str | None = Query(default=None),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_optional_security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
if credentials:
|
||||
token_str = credentials.credentials
|
||||
elif token:
|
||||
token_str = token
|
||||
else:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return await _resolve_user(token_str, db)
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin privileges required")
|
||||
return current_user
|
||||
@@ -1,15 +1,34 @@
|
||||
import secrets
|
||||
import logging
|
||||
from pydantic_settings import BaseSettings
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "sqlite+aiosqlite:////data/medialore.db"
|
||||
media_root: str = "/media"
|
||||
thumbnail_dir: str = "/data/thumbnails"
|
||||
|
||||
secret_key: str = ""
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = ""
|
||||
access_token_expire_days: int = 30
|
||||
|
||||
model_config = {"env_file": ".env"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
if not settings.secret_key:
|
||||
settings.secret_key = secrets.token_urlsafe(32)
|
||||
log.warning(
|
||||
"SECRET_KEY not set — generated random key: %s. "
|
||||
"All sessions will be invalidated on restart. "
|
||||
"Set SECRET_KEY in .env for persistent sessions.",
|
||||
settings.secret_key,
|
||||
)
|
||||
|
||||
THUMBNAIL_DIR = Path(settings.thumbnail_dir)
|
||||
THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
# uvicorn's dictConfig only configures uvicorn.* loggers; the root logger
|
||||
# ends up with no handler, so app.* records are silently discarded.
|
||||
@@ -17,18 +18,45 @@ _app_logger.propagate = False
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from app.database import engine, Base
|
||||
from app.routers import libraries, media, tags, search
|
||||
from app.config import settings
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import libraries, media, tags, search, auth
|
||||
from app.services import watcher as watcher_service
|
||||
from app.auth import hash_password
|
||||
import app.models # noqa: F401 — registers models with Base.metadata
|
||||
|
||||
|
||||
async def _ensure_admin_user():
|
||||
from app.models import User
|
||||
|
||||
if not settings.admin_password:
|
||||
log.warning("ADMIN_PASSWORD not set — no admin user will be created.")
|
||||
return
|
||||
|
||||
async with SessionLocal() as db:
|
||||
result = await db.execute(select(User).where(User.username == settings.admin_username))
|
||||
if result.scalars().first():
|
||||
log.info("Admin user '%s' already exists.", settings.admin_username)
|
||||
return
|
||||
|
||||
user = User(
|
||||
username=settings.admin_username,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
log.info("Created admin user '%s'.", settings.admin_username)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
log.info("Creating database tables...")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
await _ensure_admin_user()
|
||||
|
||||
log.info("Starting library watchers...")
|
||||
await watcher_service.start_all()
|
||||
|
||||
@@ -54,6 +82,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(libraries.router, prefix="/api")
|
||||
app.include_router(media.router, prefix="/api")
|
||||
app.include_router(tags.router, prefix="/api")
|
||||
|
||||
@@ -54,3 +54,13 @@ class Tag(Base):
|
||||
category: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
items: Mapped[list["MediaItem"]] = relationship("MediaItem", secondary=media_item_tags, back_populates="tags")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String, nullable=False)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
76
backend/app/routers/auth.py
Normal file
76
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import LoginRequest, TokenResponse, UserOut, UserCreate
|
||||
from app.auth import (
|
||||
verify_password,
|
||||
hash_password,
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
get_current_admin_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.username == body.username))
|
||||
user = result.scalars().first()
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(401, "Invalid username or password")
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
result = await db.execute(select(User).order_by(User.username))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserOut, status_code=201)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
existing = await db.execute(select(User).where(User.username == body.username))
|
||||
if existing.scalars().first():
|
||||
raise HTTPException(409, "Username already exists")
|
||||
user = User(
|
||||
username=body.username,
|
||||
password_hash=hash_password(body.password),
|
||||
is_admin=False,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=204)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "Cannot delete your own account")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
@@ -5,15 +5,19 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Library, MediaItem
|
||||
from app.models import Library, MediaItem, User
|
||||
from app.schemas import LibraryCreate, LibraryOut, MediaItemOut, BrowseResult, BrowseEntry
|
||||
from app.services import scanner, watcher as watcher_service
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/libraries", tags=["libraries"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[LibraryOut])
|
||||
async def list_libraries(db: AsyncSession = Depends(get_db)):
|
||||
async def list_libraries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library))
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -23,6 +27,7 @@ async def create_library(
|
||||
body: LibraryCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
path = Path(body.path)
|
||||
if not path.is_dir():
|
||||
@@ -44,7 +49,10 @@ async def create_library(
|
||||
|
||||
|
||||
@router.get("/{library_id}/scan-status")
|
||||
async def get_scan_status(library_id: int):
|
||||
async def get_scan_status(
|
||||
library_id: int,
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
return {"scanning": scanner.is_scanning(library_id)}
|
||||
|
||||
|
||||
@@ -53,6 +61,7 @@ async def rescan_library(
|
||||
library_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library).where(Library.id == library_id))
|
||||
lib = result.scalars().first()
|
||||
@@ -69,6 +78,7 @@ async def doom_scroll(
|
||||
library_id: int,
|
||||
path: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library).where(Library.id == library_id))
|
||||
if not result.scalars().first():
|
||||
@@ -87,7 +97,11 @@ async def doom_scroll(
|
||||
|
||||
|
||||
@router.delete("/{library_id}", status_code=204)
|
||||
async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_library(
|
||||
library_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library).where(Library.id == library_id))
|
||||
lib = result.scalars().first()
|
||||
if not lib:
|
||||
@@ -98,7 +112,12 @@ async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{library_id}/browse", response_model=BrowseResult)
|
||||
async def browse_library(library_id: int, path: str = "", db: AsyncSession = Depends(get_db)):
|
||||
async def browse_library(
|
||||
library_id: int,
|
||||
path: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Library).where(Library.id == library_id))
|
||||
lib = result.scalars().first()
|
||||
if not lib:
|
||||
|
||||
@@ -5,9 +5,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Library, MediaItem, Tag
|
||||
from app.models import Library, MediaItem, Tag, User
|
||||
from app.schemas import MediaItemOut, TagIdList
|
||||
from app.services.thumbnails import get_or_create_thumbnail
|
||||
from app.auth import get_current_user, get_current_user_from_query_or_header
|
||||
|
||||
router = APIRouter(prefix="/media", tags=["media"])
|
||||
|
||||
@@ -33,7 +34,11 @@ def _resolve_safe(lib: Library, item: MediaItem) -> Path:
|
||||
|
||||
|
||||
@router.get("/{media_id}", response_model=MediaItemOut)
|
||||
async def get_media_item(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_media_item(
|
||||
media_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(MediaItem).where(MediaItem.id == media_id)
|
||||
)
|
||||
@@ -46,7 +51,11 @@ async def get_media_item(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{media_id}/file")
|
||||
async def serve_file(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def serve_file(
|
||||
media_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user_from_query_or_header),
|
||||
):
|
||||
item, lib = await _get_item_and_lib(media_id, db)
|
||||
if item.missing:
|
||||
raise HTTPException(404, "File is missing from disk")
|
||||
@@ -57,7 +66,11 @@ async def serve_file(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{media_id}/thumbnail")
|
||||
async def serve_thumbnail(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def serve_thumbnail(
|
||||
media_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user_from_query_or_header),
|
||||
):
|
||||
item, lib = await _get_item_and_lib(media_id, db)
|
||||
abs_path = _resolve_safe(lib, item)
|
||||
thumb = get_or_create_thumbnail(media_id, str(abs_path), item.media_type)
|
||||
@@ -67,7 +80,12 @@ async def serve_thumbnail(media_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/{media_id}/tags", response_model=MediaItemOut)
|
||||
async def set_tags(media_id: int, body: TagIdList, db: AsyncSession = Depends(get_db)):
|
||||
async def set_tags(
|
||||
media_id: int,
|
||||
body: TagIdList,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(MediaItem).where(MediaItem.id == media_id))
|
||||
item = result.scalars().first()
|
||||
if not item:
|
||||
|
||||
@@ -4,8 +4,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import MediaItem, media_item_tags
|
||||
from app.models import MediaItem, media_item_tags, User
|
||||
from app.schemas import MediaItemOut
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
@@ -16,6 +17,7 @@ async def search(
|
||||
tags: str = Query(default=""),
|
||||
library_id: int | None = Query(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
stmt = (
|
||||
select(MediaItem)
|
||||
|
||||
@@ -3,14 +3,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Tag
|
||||
from app.models import Tag, User
|
||||
from app.schemas import TagCreate, TagOut, TagsByCategory
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TagsByCategory])
|
||||
async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
async def list_tags(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Tag).order_by(Tag.category, Tag.name))
|
||||
tags = result.scalars().all()
|
||||
|
||||
@@ -22,7 +26,11 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("", response_model=TagOut, status_code=201)
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_tag(
|
||||
body: TagCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
existing = await db.execute(
|
||||
select(Tag).where(Tag.name == body.name, Tag.category == body.category)
|
||||
)
|
||||
@@ -36,7 +44,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
async def delete_tag(tag_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_tag(
|
||||
tag_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
tag = result.scalars().first()
|
||||
if not tag:
|
||||
|
||||
@@ -86,3 +86,28 @@ class SearchResult(BaseModel):
|
||||
|
||||
class TagIdList(BaseModel):
|
||||
tag_ids: list[int]
|
||||
|
||||
|
||||
# --- Auth ---
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@@ -12,6 +12,8 @@ dependencies = [
|
||||
"watchdog>=4.0",
|
||||
"Pillow>=10.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"python-jose[cryptography]>=3.3",
|
||||
"bcrypt>=4.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
image: git.gpatti.com/${OWNER:-gpatti}/medialore-backend:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- medialore-data:/data
|
||||
@@ -9,8 +9,11 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
|
||||
- THUMBNAIL_DIR=/data/thumbnails
|
||||
- SECRET_KEY=${SECRET_KEY:-}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||
frontend:
|
||||
build: ./frontend
|
||||
image: git.gpatti.com/${OWNER:-gpatti}/medialore-frontend:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route, NavLink, Navigate, useNavigate } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||
import { api, type Library } from "./api/client";
|
||||
import { AuthProvider } from "./auth/AuthContext";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import BrowserPage from "./pages/BrowserPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import SearchPage from "./pages/SearchPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
|
||||
<span style={{ color: "var(--text-secondary)" }}>Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [dark, setDark] = useState(
|
||||
() => document.documentElement.getAttribute("data-theme") === "dark"
|
||||
@@ -22,9 +43,12 @@ function useTheme() {
|
||||
}
|
||||
|
||||
function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void; dark: boolean; onClose?: () => void }) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { data: libraries = [] } = useQuery<Library[]>({
|
||||
queryKey: ["libraries"],
|
||||
queryFn: api.libraries.list,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const linkStyle = ({ isActive }: { isActive: boolean }) => ({
|
||||
@@ -37,6 +61,11 @@ function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void;
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
width: 220,
|
||||
@@ -76,12 +105,24 @@ function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void;
|
||||
>
|
||||
{dark ? "☀ Light mode" : "☾ Dark mode"}
|
||||
</button>
|
||||
{user && (
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)", padding: "4px 12px 0" }}>
|
||||
{user.username}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{ textAlign: "left", border: "none", background: "transparent", padding: "6px 12px", color: "var(--danger)", borderRadius: 4 }}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { dark, toggle } = useTheme();
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -97,6 +138,15 @@ function AppShell() {
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", background: "var(--bg)", color: "var(--text)" }}>
|
||||
{/* Mobile hamburger button */}
|
||||
@@ -129,10 +179,11 @@ function AppShell() {
|
||||
|
||||
<main style={{ flex: 1, overflow: "auto", background: "var(--bg)", paddingTop: isMobile ? 48 : 0 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<SearchPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/library/:libraryId" element={<BrowserPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
|
||||
<Route path="/search" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
|
||||
<Route path="/library/:libraryId" element={<ProtectedRoute><BrowserPage /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
@@ -142,9 +193,11 @@ function AppShell() {
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const BASE = "/api";
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("token");
|
||||
}
|
||||
|
||||
export interface Library {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -42,11 +46,28 @@ export interface BrowseResult {
|
||||
entries: BrowseEntry[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
...init,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
@@ -56,6 +77,23 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ access_token: string; token_type: string }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
me: () => request<AuthUser>("/auth/me"),
|
||||
listUsers: () => request<AuthUser[]>("/auth/users"),
|
||||
createUser: (username: string, password: string) =>
|
||||
request<AuthUser>("/auth/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
deleteUser: (id: number) =>
|
||||
request<void>(`/auth/users/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
|
||||
libraries: {
|
||||
list: () => request<Library[]>("/libraries"),
|
||||
create: (name: string, path: string) =>
|
||||
@@ -77,8 +115,16 @@ export const api = {
|
||||
|
||||
media: {
|
||||
get: (id: number) => request<MediaItem>(`/media/${id}`),
|
||||
fileUrl: (id: number) => `${BASE}/media/${id}/file`,
|
||||
thumbnailUrl: (id: number) => `${BASE}/media/${id}/thumbnail`,
|
||||
fileUrl: (id: number) => {
|
||||
const token = getToken();
|
||||
const base = `${BASE}/media/${id}/file`;
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
},
|
||||
thumbnailUrl: (id: number) => {
|
||||
const token = getToken();
|
||||
const base = `${BASE}/media/${id}/thumbnail`;
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
},
|
||||
setTags: (id: number, tagIds: number[]) =>
|
||||
request<MediaItem>(`/media/${id}/tags`, {
|
||||
method: "PUT",
|
||||
|
||||
86
frontend/src/auth/AuthContext.tsx
Normal file
86
frontend/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { AuthContext, type AuthUser } from "./AuthTypes";
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function fetchMe(token: string) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/auth/me`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem("token"));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function validateToken() {
|
||||
if (!token) return;
|
||||
const u = await fetchMe(token);
|
||||
if (cancelled) return;
|
||||
if (u) {
|
||||
setUser(u);
|
||||
} else {
|
||||
localStorage.removeItem("token");
|
||||
setToken(null);
|
||||
}
|
||||
}
|
||||
|
||||
validateToken().finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const newToken = data.access_token;
|
||||
localStorage.setItem("token", newToken);
|
||||
setToken(newToken);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem("token");
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
19
frontend/src/auth/AuthTypes.ts
Normal file
19
frontend/src/auth/AuthTypes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthState | null>(null);
|
||||
10
frontend/src/auth/useAuth.ts
Normal file
10
frontend/src/auth/useAuth.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthContext, type AuthState } from "./AuthTypes";
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
100
frontend/src/pages/LoginPage.tsx
Normal file
100
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (isAuthenticated) {
|
||||
navigate("/", { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100vh",
|
||||
background: "var(--bg)",
|
||||
}}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
width: 320,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: 32,
|
||||
borderRadius: 8,
|
||||
background: "var(--bg-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: 24, color: "var(--text)", textAlign: "center" }}>
|
||||
MediaLore
|
||||
</h1>
|
||||
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: "var(--danger)", margin: 0, fontSize: 13 }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
padding: "10px 0",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Library } from "../api/client";
|
||||
import { api, type Library, type AuthUser } from "../api/client";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
|
||||
function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) => void }) {
|
||||
const qc = useQueryClient();
|
||||
@@ -48,6 +49,91 @@ function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) =>
|
||||
);
|
||||
}
|
||||
|
||||
function UserManagement() {
|
||||
const qc = useQueryClient();
|
||||
const { user: currentUser } = useAuth();
|
||||
const { data: users = [] } = useQuery<AuthUser[]>({
|
||||
queryKey: ["users"],
|
||||
queryFn: api.auth.listUsers,
|
||||
});
|
||||
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [userError, setUserError] = useState("");
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => api.auth.createUser(newUsername, newPassword),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["users"] });
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
setUserError("");
|
||||
},
|
||||
onError: (e: Error) => setUserError(e.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.auth.deleteUser(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||
onError: (e: Error) => setUserError(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: "var(--text)", marginTop: 32 }}>Users</h2>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); createMutation.mutate(); }}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
|
||||
>
|
||||
<input
|
||||
placeholder="Username"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{userError && <p style={{ color: "var(--danger)", margin: 0, fontSize: 13 }}>{userError}</p>}
|
||||
<button type="submit" disabled={createMutation.isPending} style={{ background: "var(--accent)", color: "#fff", border: "none" }}>
|
||||
{createMutation.isPending ? "Creating…" : "Add User"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{users.map((u) => (
|
||||
<li key={u.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text)" }}>{u.username}</strong>
|
||||
{u.is_admin && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, color: "var(--accent)", fontWeight: 600 }}>
|
||||
ADMIN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{u.id !== currentUser?.id && (
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(u.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
style={{ color: "var(--danger)", background: "transparent", border: "none" }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: libraries = [] } = useQuery<Library[]>({
|
||||
@@ -97,6 +183,8 @@ export default function SettingsPage() {
|
||||
<LibraryRow key={lib.id} lib={lib} onRemove={(id) => deleteMutation.mutate(id)} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<UserManagement />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user