auth #4

Merged
gpatti merged 3 commits from auth into main 2026-08-03 20:12:03 +00:00
24 changed files with 972 additions and 46 deletions

View File

@@ -2,3 +2,19 @@
# Library paths you configure in the app must be subdirectories of this path. # Library paths you configure in the app must be subdirectories of this path.
# Inside the container, this maps to /media. # Inside the container, this maps to /media.
MEDIA_ROOT=/mnt/nas 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

View 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 }}

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ frontend/dist/
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/ .venv/
.DS_Store

115
AGENTS.md
View File

@@ -2,7 +2,7 @@
## Project Overview ## 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: **Monorepo** with two independent apps:
- `backend/` — Python FastAPI server - `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 | | Backend | Python 3.12+, FastAPI, Uvicorn |
| Database | SQLite via SQLAlchemy (async aiosqlite), WAL mode | | Database | SQLite via SQLAlchemy (async aiosqlite), WAL mode |
| Migrations | Alembic | | Migrations | Alembic |
| Auth | JWT (python-jose) + bcrypt password hashing |
| Thumbnails | Pillow (images), ffmpeg (videos) — generated on-demand | | Thumbnails | Pillow (images), ffmpeg (videos) — generated on-demand |
| File Watch | watchdog — live filesystem monitoring | | File Watch | watchdog — live filesystem monitoring |
| Frontend | React 19, TypeScript 6, Vite 8, React Router 7, TanStack Query v5 | | 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/ medialore-web-app/
├── docker-compose.yml # Orchestration (backend, frontend, volumes) ├── 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/ ├── backend/
│ ├── Dockerfile │ ├── Dockerfile
│ ├── pyproject.toml # Python package config (hatchling build) │ ├── pyproject.toml # Python package config (hatchling build)
@@ -35,11 +39,13 @@ medialore-web-app/
│ │ └── versions/ │ │ └── versions/
│ └── app/ │ └── app/
│ ├── main.py # FastAPI app, CORS, lifespan, router registration │ ├── 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 │ ├── database.py # SQLAlchemy async engine, session, WAL pragma
│ ├── models.py # ORM models: Library, MediaItem, Tag, media_item_tags │ ├── models.py # ORM models: Library, MediaItem, Tag, User, media_item_tags
│ ├── schemas.py # Pydantic request/response schemas │ ├── schemas.py # Pydantic request/response schemas (incl. auth)
│ ├── auth.py # bcrypt hashing, JWT create/decode, auth dependencies
│ ├── routers/ │ ├── routers/
│ │ ├── auth.py # Login, token validation, user CRUD (admin)
│ │ ├── libraries.py # Library CRUD, browse, doom-scroll, scan-status, rescan │ │ ├── libraries.py # Library CRUD, browse, doom-scroll, scan-status, rescan
│ │ ├── media.py # Media item get, file serve, thumbnail, tag assignment │ │ ├── media.py # Media item get, file serve, thumbnail, tag assignment
│ │ ├── tags.py # Tag CRUD, grouped by category │ │ ├── tags.py # Tag CRUD, grouped by category
@@ -67,7 +73,12 @@ medialore-web-app/
├── index.css # CSS custom properties for light/dark themes ├── index.css # CSS custom properties for light/dark themes
├── api/ ├── api/
│ └── client.ts # Typed fetch wrapper; all endpoint functions │ └── 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/ ├── pages/
│ ├── LoginPage.tsx
│ ├── SearchPage.tsx │ ├── SearchPage.tsx
│ ├── BrowserPage.tsx │ ├── BrowserPage.tsx
│ ├── SettingsPage.tsx │ ├── SettingsPage.tsx
@@ -126,11 +137,15 @@ docker compose up -d
## Environment Variables ## Environment Variables
| Variable | Default | Purpose | | Variable | Default | Purpose |
|------------------|-------------------------------------|------------------------------| |-------------------------|-------------------------------------|------------------------------|
| `DATABASE_URL` | `sqlite+aiosqlite:////data/medialore.db` | SQLite connection string | | `DATABASE_URL` | `sqlite+aiosqlite:////data/medialore.db` | SQLite connection string |
| `MEDIA_ROOT` | `/media` | Root path for media libraries | | `MEDIA_ROOT` | `/media` | Root path for media libraries |
| `THUMBNAIL_DIR` | `/data/thumbnails` | Cached thumbnail storage | | `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`. 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)` - **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)` - **tags**: `id`, `name`, `category` — unique on `(name, category)`
- **media_item_tags**: `media_item_id` (FK), `tag_id` (FK), composite PK - **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 ## 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 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 ### Async Database Access
Routers use FastAPI's `Depends` injection with the `get_db` async generator from `database.py`: 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 ### 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. 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 - Service modules handle business logic; routers handle HTTP concerns
- Background scanning runs via `asyncio.create_task`; watchers run in separate threads via `watchdog.Observer` - 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` - 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) ### Frontend (TypeScript/React)
- **ESLint flat config** with strict TypeScript-ESLint rules - **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` - TypeScript interfaces for all API types live in `client.ts`
- Components use `useState`/`useEffect`/`useQuery` hooks, not class components - Components use `useState`/`useEffect`/`useQuery` hooks, not class components
- Theme state persisted in `localStorage` key `"theme"`, applied as `data-theme` attribute - 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 - Responsive via `window.matchMedia("(max-width: 767px)")` listener
- File/thumbnail URLs constructed with `api.media.fileUrl(id)` and `api.media.thumbnailUrl(id)` (not fetched) - 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 ### TypeScript Configuration
- `tsconfig.app.json`: strict mode, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly` - `tsconfig.app.json`: strict mode, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`
@@ -211,16 +274,42 @@ const { data } = useQuery({
## Docker / Deployment ## Docker / Deployment
- `docker compose up -d` starts both services - `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` - Nginx proxies `/api/` to `http://backend:8000`
- Media paths from the host are volume-mounted into the container at `/media` - Media paths from the host are volume-mounted into the container at `/media`
- Persistent data (DB, thumbnails) stored in Docker volume `medialore-data` - 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 ## 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`) - 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 - 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) - 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 - `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 - 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

View 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
View 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

View File

@@ -1,15 +1,34 @@
import secrets
import logging
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pathlib import Path from pathlib import Path
log = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:////data/medialore.db" database_url: str = "sqlite+aiosqlite:////data/medialore.db"
media_root: str = "/media" media_root: str = "/media"
thumbnail_dir: str = "/data/thumbnails" 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"} model_config = {"env_file": ".env"}
settings = Settings() 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 = Path(settings.thumbnail_dir)
THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True) THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)

View File

@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import select
# uvicorn's dictConfig only configures uvicorn.* loggers; the root logger # uvicorn's dictConfig only configures uvicorn.* loggers; the root logger
# ends up with no handler, so app.* records are silently discarded. # ends up with no handler, so app.* records are silently discarded.
@@ -17,18 +18,45 @@ _app_logger.propagate = False
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from app.database import engine, Base from app.config import settings
from app.routers import libraries, media, tags, search 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.services import watcher as watcher_service
from app.auth import hash_password
import app.models # noqa: F401 — registers models with Base.metadata 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
log.info("Creating database tables...") log.info("Creating database tables...")
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
await _ensure_admin_user()
log.info("Starting library watchers...") log.info("Starting library watchers...")
await watcher_service.start_all() await watcher_service.start_all()
@@ -54,6 +82,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(auth.router, prefix="/api")
app.include_router(libraries.router, prefix="/api") app.include_router(libraries.router, prefix="/api")
app.include_router(media.router, prefix="/api") app.include_router(media.router, prefix="/api")
app.include_router(tags.router, prefix="/api") app.include_router(tags.router, prefix="/api")

View File

@@ -54,3 +54,13 @@ class Tag(Base):
category: Mapped[str] = mapped_column(String, nullable=False) category: Mapped[str] = mapped_column(String, nullable=False)
items: Mapped[list["MediaItem"]] = relationship("MediaItem", secondary=media_item_tags, back_populates="tags") 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)

View 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()

View File

@@ -5,15 +5,19 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import get_db 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.schemas import LibraryCreate, LibraryOut, MediaItemOut, BrowseResult, BrowseEntry
from app.services import scanner, watcher as watcher_service from app.services import scanner, watcher as watcher_service
from app.auth import get_current_user
router = APIRouter(prefix="/libraries", tags=["libraries"]) router = APIRouter(prefix="/libraries", tags=["libraries"])
@router.get("", response_model=list[LibraryOut]) @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)) result = await db.execute(select(Library))
return result.scalars().all() return result.scalars().all()
@@ -23,6 +27,7 @@ async def create_library(
body: LibraryCreate, body: LibraryCreate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
): ):
path = Path(body.path) path = Path(body.path)
if not path.is_dir(): if not path.is_dir():
@@ -44,7 +49,10 @@ async def create_library(
@router.get("/{library_id}/scan-status") @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)} return {"scanning": scanner.is_scanning(library_id)}
@@ -53,6 +61,7 @@ async def rescan_library(
library_id: int, library_id: int,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
): ):
result = await db.execute(select(Library).where(Library.id == library_id)) result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first() lib = result.scalars().first()
@@ -69,6 +78,7 @@ async def doom_scroll(
library_id: int, library_id: int,
path: str = "", path: str = "",
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
): ):
result = await db.execute(select(Library).where(Library.id == library_id)) result = await db.execute(select(Library).where(Library.id == library_id))
if not result.scalars().first(): if not result.scalars().first():
@@ -87,7 +97,11 @@ async def doom_scroll(
@router.delete("/{library_id}", status_code=204) @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)) result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first() lib = result.scalars().first()
if not lib: 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) @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)) result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first() lib = result.scalars().first()
if not lib: if not lib:

View File

@@ -5,9 +5,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.database import get_db 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.schemas import MediaItemOut, TagIdList
from app.services.thumbnails import get_or_create_thumbnail 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"]) 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) @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( result = await db.execute(
select(MediaItem).where(MediaItem.id == media_id) 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") @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) item, lib = await _get_item_and_lib(media_id, db)
if item.missing: if item.missing:
raise HTTPException(404, "File is missing from disk") 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") @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) item, lib = await _get_item_and_lib(media_id, db)
abs_path = _resolve_safe(lib, item) abs_path = _resolve_safe(lib, item)
thumb = get_or_create_thumbnail(media_id, str(abs_path), item.media_type) 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) @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)) result = await db.execute(select(MediaItem).where(MediaItem.id == media_id))
item = result.scalars().first() item = result.scalars().first()
if not item: if not item:

View File

@@ -4,8 +4,9 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import get_db 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.schemas import MediaItemOut
from app.auth import get_current_user
router = APIRouter(prefix="/search", tags=["search"]) router = APIRouter(prefix="/search", tags=["search"])
@@ -16,6 +17,7 @@ async def search(
tags: str = Query(default=""), tags: str = Query(default=""),
library_id: int | None = Query(default=None), library_id: int | None = Query(default=None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
): ):
stmt = ( stmt = (
select(MediaItem) select(MediaItem)

View File

@@ -3,14 +3,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.database import get_db 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.schemas import TagCreate, TagOut, TagsByCategory
from app.auth import get_current_user
router = APIRouter(prefix="/tags", tags=["tags"]) router = APIRouter(prefix="/tags", tags=["tags"])
@router.get("", response_model=list[TagsByCategory]) @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)) result = await db.execute(select(Tag).order_by(Tag.category, Tag.name))
tags = result.scalars().all() 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) @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( existing = await db.execute(
select(Tag).where(Tag.name == body.name, Tag.category == body.category) 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) @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)) result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalars().first() tag = result.scalars().first()
if not tag: if not tag:

View File

@@ -86,3 +86,28 @@ class SearchResult(BaseModel):
class TagIdList(BaseModel): class TagIdList(BaseModel):
tag_ids: list[int] 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

View File

@@ -12,6 +12,8 @@ dependencies = [
"watchdog>=4.0", "watchdog>=4.0",
"Pillow>=10.0", "Pillow>=10.0",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"python-jose[cryptography]>=3.3",
"bcrypt>=4.0",
] ]
[build-system] [build-system]

View File

@@ -1,16 +1,19 @@
services: services:
backend: backend:
build: ./backend image: git.gpatti.com/${OWNER:-gpatti}/medialore-backend:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- medialore-data:/data - medialore-data:/data
- /data/smb/adult/Images:/media/Images # set volumes for media
- /data/smb/adult/Video Clips:/media/Video Clips - data:/media
environment: environment:
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db - DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
- THUMBNAIL_DIR=/data/thumbnails - THUMBNAIL_DIR=/data/thumbnails
- SECRET_KEY=${SECRET_KEY:-}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
frontend: frontend:
build: ./frontend image: git.gpatti.com/${OWNER:-gpatti}/medialore-frontend:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:80" - "8080:80"

View File

@@ -1,13 +1,34 @@
import { useEffect, useState } from "react"; 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 { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { api, type Library } from "./api/client"; import { api, type Library } from "./api/client";
import { AuthProvider } from "./auth/AuthContext";
import { useAuth } from "./auth/useAuth";
import BrowserPage from "./pages/BrowserPage"; import BrowserPage from "./pages/BrowserPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import SearchPage from "./pages/SearchPage"; import SearchPage from "./pages/SearchPage";
import LoginPage from "./pages/LoginPage";
const queryClient = new QueryClient(); 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() { function useTheme() {
const [dark, setDark] = useState( const [dark, setDark] = useState(
() => document.documentElement.getAttribute("data-theme") === "dark" () => document.documentElement.getAttribute("data-theme") === "dark"
@@ -22,9 +43,12 @@ function useTheme() {
} }
function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void; dark: boolean; onClose?: () => void }) { function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void; dark: boolean; onClose?: () => void }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { data: libraries = [] } = useQuery<Library[]>({ const { data: libraries = [] } = useQuery<Library[]>({
queryKey: ["libraries"], queryKey: ["libraries"],
queryFn: api.libraries.list, queryFn: api.libraries.list,
enabled: !!user,
}); });
const linkStyle = ({ isActive }: { isActive: boolean }) => ({ const linkStyle = ({ isActive }: { isActive: boolean }) => ({
@@ -37,6 +61,11 @@ function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void;
fontWeight: isActive ? 600 : 400, fontWeight: isActive ? 600 : 400,
}); });
function handleLogout() {
logout();
navigate("/login");
}
return ( return (
<nav style={{ <nav style={{
width: 220, width: 220,
@@ -76,12 +105,24 @@ function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void;
> >
{dark ? "☀ Light mode" : "☾ Dark mode"} {dark ? "☀ Light mode" : "☾ Dark mode"}
</button> </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> </div>
</nav> </nav>
); );
} }
function AppShell() { function AppShell() {
const { isAuthenticated } = useAuth();
const { dark, toggle } = useTheme(); const { dark, toggle } = useTheme();
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768); const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -97,6 +138,15 @@ function AppShell() {
return () => mq.removeEventListener("change", handler); return () => mq.removeEventListener("change", handler);
}, []); }, []);
if (!isAuthenticated) {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
}
return ( return (
<div style={{ display: "flex", height: "100vh", background: "var(--bg)", color: "var(--text)" }}> <div style={{ display: "flex", height: "100vh", background: "var(--bg)", color: "var(--text)" }}>
{/* Mobile hamburger button */} {/* Mobile hamburger button */}
@@ -129,10 +179,11 @@ function AppShell() {
<main style={{ flex: 1, overflow: "auto", background: "var(--bg)", paddingTop: isMobile ? 48 : 0 }}> <main style={{ flex: 1, overflow: "auto", background: "var(--bg)", paddingTop: isMobile ? 48 : 0 }}>
<Routes> <Routes>
<Route path="/" element={<SearchPage />} /> <Route path="/" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
<Route path="/search" element={<SearchPage />} /> <Route path="/search" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
<Route path="/library/:libraryId" element={<BrowserPage />} /> <Route path="/library/:libraryId" element={<ProtectedRoute><BrowserPage /></ProtectedRoute>} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/login" element={<LoginPage />} />
</Routes> </Routes>
</main> </main>
</div> </div>
@@ -142,9 +193,11 @@ function AppShell() {
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <AuthProvider>
<AppShell /> <BrowserRouter>
</BrowserRouter> <AppShell />
</BrowserRouter>
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@@ -1,5 +1,9 @@
const BASE = "/api"; const BASE = "/api";
function getToken(): string | null {
return localStorage.getItem("token");
}
export interface Library { export interface Library {
id: number; id: number;
name: string; name: string;
@@ -42,11 +46,28 @@ export interface BrowseResult {
entries: BrowseEntry[]; 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> { 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}`, { const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" }, headers,
...init, ...init,
}); });
if (res.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
throw new Error("Session expired");
}
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new Error(`${res.status}: ${text}`); throw new Error(`${res.status}: ${text}`);
@@ -56,6 +77,23 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} }
export const api = { 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: { libraries: {
list: () => request<Library[]>("/libraries"), list: () => request<Library[]>("/libraries"),
create: (name: string, path: string) => create: (name: string, path: string) =>
@@ -77,8 +115,16 @@ export const api = {
media: { media: {
get: (id: number) => request<MediaItem>(`/media/${id}`), get: (id: number) => request<MediaItem>(`/media/${id}`),
fileUrl: (id: number) => `${BASE}/media/${id}/file`, fileUrl: (id: number) => {
thumbnailUrl: (id: number) => `${BASE}/media/${id}/thumbnail`, 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[]) => setTags: (id: number, tagIds: number[]) =>
request<MediaItem>(`/media/${id}/tags`, { request<MediaItem>(`/media/${id}/tags`, {
method: "PUT", method: "PUT",

View 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>
);
}

View 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);

View 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;
}

View 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>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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 }) { function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) => void }) {
const qc = useQueryClient(); 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() { export default function SettingsPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { data: libraries = [] } = useQuery<Library[]>({ const { data: libraries = [] } = useQuery<Library[]>({
@@ -97,6 +183,8 @@ export default function SettingsPage() {
<LibraryRow key={lib.id} lib={lib} onRemove={(id) => deleteMutation.mutate(id)} /> <LibraryRow key={lib.id} lib={lib} onRemove={(id) => deleteMutation.mutate(id)} />
))} ))}
</ul> </ul>
<UserManagement />
</div> </div>
); );
} }