Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-21 03:28:56 +02:00
commit b5f1da92d9
28 changed files with 1159 additions and 56 deletions

View file

@ -1 +1 @@
0.13.0
0.14.0

View file

@ -0,0 +1,39 @@
"""first-run install wizard state
Revision ID: 0025_install_wizard
Revises: 0024_google_email_verified
Create Date: 2026-06-19
Adds app_state.configured + setup_token_hash for the first-run install wizard. Existing installs
(any users present) are marked configured so they never see the wizard; a genuinely fresh DB (no
users) starts unconfigured and boots into setup mode.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0025_install_wizard"
down_revision: Union[str, None] = "0024_google_email_verified"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"app_state",
sa.Column("configured", sa.Boolean(), nullable=False, server_default="false"),
)
op.add_column("app_state", sa.Column("setup_token_hash", sa.String(length=64), nullable=True))
# Ensure the singleton row exists and mark it configured, then revert to unconfigured only if
# this is a genuinely fresh DB (no users yet). Net: configured = (the instance already has users).
op.execute(
"INSERT INTO app_state (id, sync_paused, configured) VALUES (1, false, true) "
"ON CONFLICT (id) DO UPDATE SET configured = true"
)
op.execute("UPDATE app_state SET configured = false WHERE NOT EXISTS (SELECT 1 FROM users)")
def downgrade() -> None:
op.drop_column("app_state", "setup_token_hash")
op.drop_column("app_state", "configured")

View file

@ -75,14 +75,40 @@ log = logging.getLogger("subfeed.auth")
router = APIRouter(prefix="/auth", tags=["auth"])
oauth = OAuth()
oauth.register(
# The Google OAuth client is built lazily from the CURRENT credentials (DB override via sysconfig,
# else env), so the install wizard / admin can set or change them at runtime without a restart. The
# authlib registry is rebuilt only when the creds change (cheap, first use after a change).
_oauth = OAuth()
_oauth_creds: tuple[str, str] | None = None
def google_oauth(db: Session):
"""The configured authlib Google client, or None when no credentials are set (Google sign-in
is then disabled email+password still works)."""
global _oauth, _oauth_creds
cid = sysconfig.get_str(db, "google_client_id")
csec = sysconfig.get_str(db, "google_client_secret")
if not cid or not csec:
return None
if _oauth_creds != (cid, csec):
_oauth = OAuth()
_oauth.register(
name="google",
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
client_id=cid,
client_secret=csec,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": BASE_SCOPES},
)
_oauth_creds = (cid, csec)
return _oauth.google
def google_enabled(db: Session) -> bool:
"""Whether Sign in with Google is available (both client id + secret resolve to non-empty)."""
return bool(
sysconfig.get_str(db, "google_client_id")
and sysconfig.get_str(db, "google_client_secret")
)
def has_read_scope(user: User) -> bool:
@ -178,11 +204,16 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
@router.get("/login")
async def login(request: Request):
async def login(request: Request, db: Session = Depends(get_db)):
# access_type=offline ensures a refresh_token on first authorization. We avoid
# prompt=consent so returning users get a quick sign-in; the stored refresh token
# is kept when Google doesn't re-issue one (see the callback).
return await oauth.google.authorize_redirect(
client = google_oauth(db)
if client is None:
# Google sign-in isn't configured on this instance — land back on the login page (the UI
# hides the button, so this is just a safety net for a stale/direct hit).
return RedirectResponse(url="/")
return await client.authorize_redirect(
request,
settings.oauth_redirect_url,
access_type="offline",
@ -198,8 +229,11 @@ async def callback(
background: BackgroundTasks,
db: Session = Depends(get_db),
):
client = google_oauth(db)
if client is None:
return RedirectResponse(url="/")
try:
token = await oauth.google.authorize_access_token(request)
token = await client.authorize_access_token(request)
except OAuthError as exc:
raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}")
@ -661,15 +695,22 @@ def require_human(user: User = Depends(current_user)) -> User:
@router.get("/link")
async def link_google(request: Request, user: User = Depends(current_user)):
async def link_google(
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
the identity scopes YouTube access is granted separately via /auth/upgrade. The callback
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
if user.is_demo:
return RedirectResponse(url="/")
client = google_oauth(db)
if client is None:
return RedirectResponse(url="/?link=error")
request.session["oauth_link_uid"] = user.id
request.session["oauth_link_explicit"] = True
return await oauth.google.authorize_redirect(
return await client.authorize_redirect(
request,
settings.oauth_redirect_url,
access_type="offline",
@ -705,7 +746,10 @@ def set_password(
@router.get("/upgrade")
async def upgrade(
request: Request, access: str = "read", user: User = Depends(current_user)
request: Request,
access: str = "read",
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
read-only (enough to build the feed); `access=write` additionally grants management
@ -716,11 +760,14 @@ async def upgrade(
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
if user.is_demo:
return RedirectResponse(url="/")
client = google_oauth(db)
if client is None:
return RedirectResponse(url="/")
# Attach the grant to THIS account in the callback. Without it a password account (no
# google_sub) would get a brand-new, separate Google user created instead of being linked.
request.session["oauth_link_uid"] = user.id
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
return await oauth.google.authorize_redirect(
return await client.authorize_redirect(
request,
settings.oauth_redirect_url,
access_type="offline",
@ -739,3 +786,13 @@ async def me(user: User = Depends(current_user)) -> dict:
"avatar_url": user.avatar_url,
"role": user.role,
}
@router.get("/config")
def auth_config(db: Session = Depends(get_db)) -> dict:
"""Public: which sign-in options this instance offers, so the login page can hide what's off
(e.g. no Google button when Google OAuth isn't configured)."""
return {
"google_enabled": google_enabled(db),
"allow_registration": sysconfig.get_bool(db, "allow_registration"),
}

View file

@ -20,12 +20,13 @@ if not _subfeed_logger.handlers:
log = logging.getLogger("subfeed.app")
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app import auth
from app import auth, state
from app.config import settings
from app.db import SessionLocal
from app.routes import (
admin,
channels,
@ -37,6 +38,7 @@ from app.routes import (
playlists,
quota,
scheduler as scheduler_routes,
setup as setup_routes,
sync,
tags,
version,
@ -47,6 +49,17 @@ from app.scheduler import shutdown_scheduler, start_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Siftlode starting up")
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
# the wizard URL so the operator can open it (the app runs in setup mode until they finish).
with SessionLocal() as db:
if not state.is_configured(db):
token = state.rotate_setup_token(db)
url = f"{settings.app_base}/setup?token={token}"
log.warning(
"FIRST-RUN SETUP REQUIRED — this instance isn't configured yet.\n"
" Open the install wizard (internal access only):\n %s",
url,
)
start_scheduler()
try:
yield
@ -73,6 +86,27 @@ if settings.frontend_origin:
allow_headers=["*"],
)
# Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or
# /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render.
_SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version")
@app.middleware("http")
async def setup_gate(request, call_next):
"""Lock the app to the install wizard until it's configured. Skips the DB entirely once setup
completed in this process (the cached fast-path), so there's no steady-state overhead."""
if not state.setup_complete():
path = request.url.path
if path.startswith(("/api/", "/auth/")) and not path.startswith(_SETUP_OPEN_PREFIXES):
with SessionLocal() as db:
configured = state.is_configured_cached(db)
if not configured:
return JSONResponse(
{"detail": "This instance isn't set up yet."}, status_code=503
)
return await call_next(request)
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(sync.router)
@ -87,6 +121,7 @@ app.include_router(config_routes.router)
app.include_router(scheduler_routes.router)
app.include_router(quota.router)
app.include_router(version.router)
app.include_router(setup_routes.router)
# The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa"

View file

@ -383,6 +383,14 @@ class AppState(Base):
# Admin override for the maintenance job's rolling re-validation batch (videos re-checked
# per run). NULL = use the config/env default (settings.maintenance_revalidate_batch).
maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer)
# First-run install wizard. `configured` is False on a fresh instance (the app runs in
# setup mode: only the wizard works) and flipped True when the wizard finishes. While
# unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard;
# the raw token + URL are printed to the container logs on each startup.
configured: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
setup_token_hash: Mapped[str | None] = mapped_column(String(64))
class SchedulerSetting(Base):

View file

@ -2,7 +2,14 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import current_user, has_read_scope, has_write_scope, is_allowed, purge_user
from app.auth import (
current_user,
google_enabled,
has_read_scope,
has_write_scope,
is_allowed,
purge_user,
)
from app.db import get_db
from app.models import Invite, User
@ -80,6 +87,9 @@ def get_me(
"is_demo": user.is_demo,
"has_google": user.google_sub is not None,
"has_password": user.password_hash is not None,
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
# YouTube-access affordances and the onboarding nudge when it isn't configured).
"google_enabled": google_enabled(db),
"can_read": has_read_scope(user),
"can_write": has_write_scope(user),
"pending_invites": pending_invites,

109
backend/app/routes/setup.py Normal file
View file

@ -0,0 +1,109 @@
"""First-run install wizard.
`GET /api/setup/status` is public (the SPA uses it to decide whether to show the wizard). Every
mutating step goes through `require_setup`: it rejects once the instance is configured AND requires
the one-time setup token (printed to the container logs at first boot, carried by the wizard as the
X-Setup-Token header). The wizard steps themselves are added in epic 6c.
"""
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import email as email_mod
from app import state, sysconfig
from app.auth import PASSWORD_MIN_LEN
from app.db import get_db
from app.models import User
from app.security import hash_password
router = APIRouter(prefix="/api/setup", tags=["setup"])
def require_setup(
x_setup_token: str | None = Header(default=None),
db: Session = Depends(get_db),
) -> Session:
"""Gate for setup steps. Returns the db session for the handler to reuse."""
if state.is_configured(db):
raise HTTPException(status_code=409, detail="This instance is already set up.")
if not state.check_setup_token(db, x_setup_token):
raise HTTPException(status_code=403, detail="Invalid or missing setup token.")
return db
@router.get("/status")
def setup_status(db: Session = Depends(get_db)) -> dict:
"""Public: whether the instance still needs first-run setup."""
return {"configured": state.is_configured(db)}
@router.get("/info")
def setup_info(db: Session = Depends(require_setup)) -> dict:
"""Capabilities the wizard adapts to: whether secrets (Google creds, SMTP password) can be
stored they need TOKEN_ENCRYPTION_KEY (set in env), so on a box without it the wizard hides
those steps and the operator stays on email+password only."""
return {"secrets_manageable": sysconfig.secrets_manageable()}
@router.post("/admin")
def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict:
"""Create (or update) the first administrator. Active + verified immediately — the operator is
configuring the instance, so there's no email-verification / approval gate here."""
email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or ""
if "@" not in email:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
)
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is None:
user = User(email=email, role="admin")
db.add(user)
user.password_hash = hash_password(password)
user.role = "admin"
user.is_active = True
user.email_verified = True
db.commit()
return {"ok": True, "email": email}
@router.post("/config")
def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict:
"""Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be in the
sysconfig registry; empty values are skipped (left at the default)."""
saved: list[str] = []
for key, value in (payload or {}).items():
if sysconfig.spec(key) is None:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
if value is None or value == "":
continue
try:
sysconfig.set_value(db, key, value)
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
saved.append(key)
return {"saved": saved}
@router.post("/test-email")
def setup_test_email(payload: dict, db: Session = Depends(require_setup)) -> dict:
"""Send a test email to confirm the SMTP settings the wizard just saved."""
to = (payload.get("to") or "").strip()
if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not email_mod.send_test(to):
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
return {"ok": True}
@router.post("/finish")
def setup_finish(db: Session = Depends(require_setup)) -> dict:
"""Complete setup: require that an admin exists, then mark the instance configured (which
invalidates the setup token and disables the wizard)."""
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
if not has_admin:
raise HTTPException(status_code=400, detail="Create the admin account first.")
state.mark_configured(db)
return {"ok": True}

View file

@ -1,5 +1,7 @@
"""Global admin-controlled app state (e.g. pausing background sync)."""
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
import hashlib
import logging
import secrets
from sqlalchemy.orm import Session
@ -12,6 +14,10 @@ log = logging.getLogger("subfeed.state")
MAINTENANCE_BATCH_MIN = 100
MAINTENANCE_BATCH_MAX = 100_000
# Once configuration completes in this process it never reverts, so cache it to skip the per-request
# DB hit in the setup-mode gate (see main.setup_gate). Starts False; refreshed from the DB.
_configured_cache = False
def _row(db: Session) -> AppState:
row = db.get(AppState, 1)
@ -34,6 +40,62 @@ def set_sync_paused(db: Session, paused: bool) -> None:
log.info("Background sync %s", "paused" if paused else "resumed")
# --- First-run install wizard ------------------------------------------------------------
def is_configured(db: Session) -> bool:
return _row(db).configured
def setup_complete() -> bool:
"""Fast, DB-free check for the request gate: True once configuration finished this process."""
return _configured_cache
def is_configured_cached(db: Session) -> bool:
"""Configured check that promotes the module cache once true (configuration is one-way)."""
global _configured_cache
if _configured_cache:
return True
if is_configured(db):
_configured_cache = True
return _configured_cache
def mark_configured(db: Session) -> None:
"""Finish setup: flip the instance to configured and invalidate the setup token."""
global _configured_cache
row = _row(db)
row.configured = True
row.setup_token_hash = None
db.add(row)
db.commit()
_configured_cache = True
log.info("Instance marked configured; setup wizard disabled.")
def _hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def rotate_setup_token(db: Session) -> str:
"""Generate a fresh one-time setup token, persist only its hash, and return the raw token
(logged at startup so the admin can open the wizard)."""
raw = secrets.token_urlsafe(24)
row = _row(db)
row.setup_token_hash = _hash_token(raw)
db.add(row)
db.commit()
return raw
def check_setup_token(db: Session, raw: str | None) -> bool:
"""Whether `raw` matches the stored setup-token hash (constant-time)."""
if not raw:
return False
stored = _row(db).setup_token_hash
return stored is not None and secrets.compare_digest(stored, _hash_token(raw))
def get_maintenance_batch(db: Session) -> int:
"""Effective maintenance re-validation batch: the admin override if set, else the
env/config default."""

View file

@ -50,6 +50,10 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
# install wizard / Configuration page so no restart is needed when the creds change. ---
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
# --- Access / registration ---
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
)

View file

@ -66,8 +66,8 @@ class YouTubeClient:
resp = self._http.post(
GOOGLE_TOKEN_URL,
data={
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"client_id": sysconfig.get_str(self.db, "google_client_id"),
"client_secret": sysconfig.get_str(self.db, "google_client_secret"),
"refresh_token": refresh,
"grant_type": "refresh_token",
},

View file

@ -0,0 +1,67 @@
# Self-hosting Siftlode — pulls the prebuilt image, no source build needed.
#
# 1. Run ./install.sh (or install.ps1 on Windows) once — it generates a .env with secrets.
# 2. docker compose -f docker-compose.selfhost.yml up -d
# 3. Open the setup wizard at the URL printed in the logs:
# docker compose -f docker-compose.selfhost.yml logs api | grep SETUP
#
# The .env only needs four values (the install script generates all of them):
# POSTGRES_PASSWORD, SECRET_KEY, TOKEN_ENCRYPTION_KEY, OAUTH_REDIRECT_URL
# Everything else — the admin account, Google sign-in, SMTP — is set in the web wizard on
# first run. See docs/self-hosting.md.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-subfeed}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set (run install.sh)}
POSTGRES_DB: ${POSTGRES_DB:-subfeed}
volumes:
- siftlode_pgdata:/var/lib/postgresql/data
networks: [internal]
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-subfeed} -d ${POSTGRES_DB:-subfeed}"]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped
api:
image: forge.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest}
env_file:
- .env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-subfeed}
# This instance owns the background scheduler (single writer).
SCHEDULER_ENABLED: "true"
depends_on:
db:
condition: service_healthy
networks: [internal]
ports:
# Reachable on the host's LAN at http://<host>:8080. Put a reverse proxy (Caddy/Nginx) in
# front for HTTPS / public exposure — and set OAUTH_REDIRECT_URL to the https URL.
- "${HTTP_PORT:-8080}:8000"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
volumes:
siftlode_pgdata:
networks:
internal:

97
docs/self-hosting.md Normal file
View file

@ -0,0 +1,97 @@
# Self-hosting Siftlode
Run your own private Siftlode instance with Docker. You don't need the source code — the app runs
from a prebuilt image, and everything user-facing (your admin account, Google sign-in, email) is
configured in a web wizard on first start. There's no editing of config files by hand.
## What you need
- A machine with **Docker** and the **Docker Compose plugin** (Docker Desktop on Windows/macOS,
or Docker Engine on Linux).
- A few hundred MB of disk and ~1 GB RAM free.
- Optional: a domain name + reverse proxy if you want HTTPS / public access (see below).
## 1. Get the files
Download these into a new, empty folder:
- `docker-compose.selfhost.yml`
- `install.sh` (Linux/macOS) **or** `install.ps1` (Windows)
The app image is published at `forge.b1fr0st.eu/peter/siftlode` (public — no login to pull).
## 2. Run the installer
**Linux / macOS:**
```bash
chmod +x install.sh
./install.sh
```
**Windows (PowerShell):**
```powershell
./install.ps1
```
The installer asks for the **public URL** where the instance will be reached (just press Enter for
`http://localhost:8080` to try it locally). It then:
- generates the secrets it needs (`SECRET_KEY`, `TOKEN_ENCRYPTION_KEY`, a database password) into a
local `.env` file — keep that file private,
- pulls the image and starts the app + database,
- prints the **setup wizard URL**, which looks like `…/setup?token=…`.
## 3. Finish in the web wizard
Open the printed setup URL in your browser. The one-time token in it means only you (with access to
the server logs) can run setup. Then click through:
1. **Admin account** — your email + a password. This is how you'll sign in.
2. **Google sign-in** *(optional)* — paste a Google OAuth client ID + secret to enable "Sign in with
Google" and pulling your YouTube subscriptions. Skip it to use email + password only.
3. **Email / SMTP** *(optional)* — an SMTP server so the app can send verification and notification
emails. Skip it — without email, new registrations are simply approved by you (the admin) instead.
4. **Finish** — the wizard disappears, the instance is now configured, and you land on the sign-in
page. Log in with the admin account you just created.
That's it. You can change any of the optional settings later under the admin **Configuration** page.
> The setup wizard only exists until you finish it. After that, the setup routes are disabled and
> the token is invalidated — there's no setup surface left on a configured instance.
## Getting a Google OAuth client (optional)
Only needed for "Sign in with Google" / YouTube access. In the
[Google Cloud Console](https://console.cloud.google.com/): create a project → **APIs & Services →
Credentials → Create credentials → OAuth client ID** → *Web application*. Add your instance's
`…/auth/callback` URL as an **Authorized redirect URI**, then copy the **client ID** and **secret**
into the wizard's Google step. (Enable the **YouTube Data API v3** for the project too.)
## HTTPS / public access
The app is served on port `8080` over plain HTTP, which is fine for a LAN or a quick trial. For
public access, put a reverse proxy (Caddy, Nginx, Traefik…) in front to terminate TLS, and set the
**public URL** in the installer to your `https://…` address (this also marks the session cookie
secure). If you've already run the installer, edit `OAUTH_REDIRECT_URL` in `.env` to the https
callback URL and `docker compose -f docker-compose.selfhost.yml up -d`.
## Day-to-day
```bash
# Update to the latest release
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
# Logs / status
docker compose -f docker-compose.selfhost.yml logs -f api
docker compose -f docker-compose.selfhost.yml ps
# Stop
docker compose -f docker-compose.selfhost.yml down
```
Your data (accounts, subscriptions, playlists, the video catalog) lives in the `siftlode_pgdata`
Docker volume — back that up to keep your instance's state. Database migrations run automatically
when the app starts, so updating is just pull + up.

View file

@ -22,6 +22,7 @@ import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
@ -265,6 +266,15 @@ export default function App() {
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const queryClient = useQueryClient();
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
// We check this before anything else and render the wizard; `me` is held back until we know the
// instance is set up (otherwise it would 503 against the setup-mode lock).
const setupQuery = useQuery({
queryKey: ["setup-status"],
queryFn: api.setupStatus,
staleTime: Infinity,
});
const needsSetup = setupQuery.data?.configured === false;
// Poll the session so an account suspended/deleted by an admin is noticed even with no
// interaction (option 1): the refetch 401s → the 401 handler below drops to the login page.
// Only poll while signed in (data present) — polling on the public login page is pointless and
@ -272,6 +282,7 @@ export default function App() {
const meQuery = useQuery({
queryKey: ["me"],
queryFn: api.me,
enabled: setupQuery.isSuccess && !needsSetup,
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
@ -445,6 +456,13 @@ export default function App() {
// (we can't show our in-app confirm there), and unsaved prefs are local-only — they
// revert to the saved server baseline on the next load — so there's nothing to lose.
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
if (setupQuery.isLoading)
return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
);
if (needsSetup) return <SetupWizard />;
if (meQuery.isLoading)
return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>

View file

@ -12,7 +12,7 @@ import Tabs, { usePersistedTab } from "./Tabs";
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
const GROUP_ORDER = ["access", "email", "youtube", "quota", "backfill", "shorts", "batch"];
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch"];
type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() {

View file

@ -389,8 +389,13 @@ function SignInMethods({ me }: { me: Me }) {
const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
// Offer Google only when this instance has Google OAuth configured (or the account is already
// linked — then we still show its "Connected" status even if the instance creds were removed).
const showGoogle = me.google_enabled || me.has_google;
return (
<Section title={t("settings.account.signInMethods")}>
{showGoogle && (
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.googleLink.title")}</div>
@ -415,8 +420,9 @@ function SignInMethods({ me }: { me: Me }) {
</button>
)}
</div>
)}
<div className="border-t border-border mt-1 pt-1">
<div className={showGoogle ? "border-t border-border mt-1 pt-1" : ""}>
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.password.title")}</div>
@ -517,7 +523,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
{t("settings.account.demoNotice")}
</p>
</Section>
) : (
) : me.google_enabled ? (
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1">
{t("settings.account.youtubeAccessIntro")}
@ -549,7 +556,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
{t("settings.account.walkMeThrough")}
</button>
</Section>
)}
) : null}
{!me.is_demo && (
<Section title={t("settings.account.dangerZone")}>

View file

@ -0,0 +1,273 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
import { api } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
// setup token comes from the URL the app printed to the container logs (?token=…); every step
// posts it back as the X-Setup-Token header. On finish the instance flips to configured, the
// wizard disappears, and the operator signs in with the admin account they just created.
type StepId = "intro" | "admin" | "google" | "smtp" | "finish";
const PASSWORD_MIN = 10;
export default function SetupWizard() {
const { t, i18n } = useTranslation();
const [token] = useState(() => new URLSearchParams(window.location.search).get("token") ?? "");
const [info, setInfo] = useState<{ secrets_manageable: boolean } | null>(null);
const [tokenError, setTokenError] = useState(false);
useEffect(() => {
if (!token) {
setTokenError(true);
return;
}
api.setupInfo(token).then(setInfo).catch(() => setTokenError(true));
}, [token]);
// Step state.
const [stepIdx, setStepIdx] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// Admin account.
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// Google OAuth (optional).
const [gid, setGid] = useState("");
const [gsecret, setGsecret] = useState("");
// SMTP (optional).
const [smtp, setSmtp] = useState({ host: "", port: "587", user: "", from: "", password: "" });
const [testTo, setTestTo] = useState("");
const [testState, setTestState] = useState<"idle" | "sending" | "ok" | "fail">("idle");
if (tokenError) return <Centered><TokenError /></Centered>;
if (!info) return <Centered><Loader2 className="w-6 h-6 animate-spin text-muted" /></Centered>;
// Steps adapt to the instance: Google + SMTP need encrypted secret storage (TOKEN_ENCRYPTION_KEY).
const steps: StepId[] = [
"intro",
"admin",
...(info.secrets_manageable ? (["google", "smtp"] as StepId[]) : []),
"finish",
];
const step = steps[stepIdx];
const go = (delta: number) => {
setError("");
setStepIdx((i) => Math.min(steps.length - 1, Math.max(0, i + delta)));
};
// Each step's "Next" action persists what it collected, then advances. Optional steps with empty
// fields just advance (skip). Errors surface inline (the setup calls use the quiet flag).
async function next() {
setError("");
setBusy(true);
try {
if (step === "admin") {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
throw { detail: t("setup.admin.invalidEmail") };
if (password.length < PASSWORD_MIN)
throw { detail: t("setup.admin.shortPassword", { n: PASSWORD_MIN }) };
await api.setupAdmin(token, email, password);
} else if (step === "google" && (gid.trim() || gsecret.trim())) {
if (!gid.trim() || !gsecret.trim()) throw { detail: t("setup.google.bothRequired") };
await api.setupConfig(token, { google_client_id: gid.trim(), google_client_secret: gsecret.trim() });
} else if (step === "smtp" && smtp.host.trim()) {
await api.setupConfig(token, {
smtp_host: smtp.host.trim(),
smtp_port: Number(smtp.port) || 587,
smtp_user: smtp.user.trim(),
smtp_from: smtp.from.trim(),
smtp_password: smtp.password,
});
} else if (step === "finish") {
await api.setupFinish(token);
window.location.href = "/"; // configured now → reloads into the login page
return;
}
go(1);
} catch (e: any) {
setError(e?.detail ?? t("setup.genericError"));
} finally {
setBusy(false);
}
}
async function sendTest() {
setTestState("sending");
try {
// Persist the SMTP fields first so the test uses them, then send.
if (smtp.host.trim())
await api.setupConfig(token, {
smtp_host: smtp.host.trim(),
smtp_port: Number(smtp.port) || 587,
smtp_user: smtp.user.trim(),
smtp_from: smtp.from.trim(),
smtp_password: smtp.password,
});
await api.setupTestEmail(token, testTo.trim());
setTestState("ok");
} catch {
setTestState("fail");
}
}
const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
return (
<Centered>
<div className="w-full max-w-lg">
<div className="flex items-center justify-between mb-4">
<div className="text-xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</div>
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
</div>
{/* Step progress dots */}
<div className="flex items-center gap-1.5 mb-4">
{steps.map((s, i) => (
<div
key={s}
className={`h-1.5 flex-1 rounded-full transition ${
i <= stepIdx ? "bg-accent" : "bg-border"
}`}
/>
))}
</div>
<div className="glass rounded-2xl p-6">
{step === "intro" && (
<Section title={t("setup.intro.title")} desc={t("setup.intro.body")} />
)}
{step === "admin" && (
<Section title={t("setup.admin.title")} desc={t("setup.admin.desc")}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("setup.admin.email")}
autoComplete="username"
className={inputCls}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("setup.admin.password", { n: PASSWORD_MIN })}
autoComplete="new-password"
className={inputCls}
/>
</Section>
)}
{step === "google" && (
<Section title={t("setup.google.title")} desc={t("setup.google.desc")}>
<input value={gid} onChange={(e) => setGid(e.target.value)} placeholder={t("setup.google.clientId")} className={inputCls} />
<input value={gsecret} onChange={(e) => setGsecret(e.target.value)} placeholder={t("setup.google.clientSecret")} className={inputCls} />
<p className="text-xs text-muted">{t("setup.google.skipHint")}</p>
</Section>
)}
{step === "smtp" && (
<Section title={t("setup.smtp.title")} desc={t("setup.smtp.desc")}>
<input value={smtp.host} onChange={(e) => setSmtp({ ...smtp, host: e.target.value })} placeholder={t("setup.smtp.host")} className={inputCls} />
<div className="flex gap-2">
<input value={smtp.port} onChange={(e) => setSmtp({ ...smtp, port: e.target.value })} placeholder={t("setup.smtp.port")} className={`${inputCls} w-24`} />
<input value={smtp.user} onChange={(e) => setSmtp({ ...smtp, user: e.target.value })} placeholder={t("setup.smtp.user")} className={inputCls} />
</div>
<input value={smtp.from} onChange={(e) => setSmtp({ ...smtp, from: e.target.value })} placeholder={t("setup.smtp.from")} className={inputCls} />
<input type="password" value={smtp.password} onChange={(e) => setSmtp({ ...smtp, password: e.target.value })} placeholder={t("setup.smtp.password")} className={inputCls} />
{smtp.host.trim() && (
<div className="flex items-center gap-2 pt-1">
<input value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder={t("setup.smtp.testTo")} className={inputCls} />
<button
type="button"
onClick={sendTest}
disabled={testState === "sending" || !testTo.trim()}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
{t("setup.smtp.test")}
</button>
</div>
)}
{testState === "ok" && <p className="text-xs text-emerald-500">{t("setup.smtp.testOk")}</p>}
{testState === "fail" && <p className="text-xs text-red-400">{t("setup.smtp.testFail")}</p>}
<p className="text-xs text-muted">{t("setup.smtp.skipHint")}</p>
</Section>
)}
{step === "finish" && (
<Section title={t("setup.finish.title")} desc={t("setup.finish.body")} />
)}
{error && <p className="text-sm text-red-400 mt-3">{error}</p>}
<div className="flex items-center justify-between mt-6">
<button
onClick={() => go(-1)}
disabled={stepIdx === 0 || busy}
className="glass-card glass-hover inline-flex items-center gap-1 px-3 py-2 rounded-xl text-sm disabled:opacity-40 transition"
>
<ChevronLeft className="w-4 h-4" />
{t("setup.nav.back")}
</button>
<button
onClick={next}
disabled={busy}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-50 transition"
>
{busy ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : step === "finish" ? (
<Check className="w-4 h-4" />
) : null}
{step === "finish" ? t("setup.nav.finish") : t("setup.nav.next")}
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</Centered>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-bg text-fg grid place-items-center p-4">{children}</div>
);
}
function Section({
title,
desc,
children,
}: {
title: string;
desc: string;
children?: React.ReactNode;
}) {
return (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted leading-relaxed mt-1">{desc}</p>
</div>
{children}
</div>
);
}
function TokenError() {
const { t } = useTranslation();
return (
<div className="max-w-md glass rounded-2xl p-6 text-center">
<h2 className="text-lg font-semibold">{t("setup.token.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2">{t("setup.token.body")}</p>
</div>
);
}

View file

@ -243,6 +243,13 @@ function AuthCard() {
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// Whether to offer "Continue with Google" — hidden when the instance has no Google OAuth
// configured. Optimistic (most instances have it); the rare Google-less instance just sees it
// disappear once the config loads.
const [googleEnabled, setGoogleEnabled] = useState(true);
useEffect(() => {
api.authConfig().then((c) => setGoogleEnabled(c.google_enabled)).catch(() => {});
}, []);
const [done, setDone] = useState<string>(""); // success/info message replacing the form
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
@ -423,12 +430,14 @@ function AuthCard() {
{t("welcome.auth.or")}
<span className="h-px flex-1 bg-border" />
</div>
{googleEnabled && (
<a
href="/auth/login"
className="block text-center px-4 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent transition"
>
{t("welcome.auth.google")}
</a>
)}
{!showDemo ? (
<button
onClick={() => setShowDemo(true)}

View file

@ -5,6 +5,7 @@
"access": "Zugang",
"email": "E-Mail / SMTP",
"youtube": "YouTube-API",
"google": "Google-Anmeldung",
"quota": "Kontingent",
"backfill": "Nachladen (Backfill)",
"shorts": "Shorts-Prüfung",
@ -25,6 +26,8 @@
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." },
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." },
"youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." },
"google_client_id": { "label": "Google-Client-ID", "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." },
"google_client_secret": { "label": "Google-Client-Secret", "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." },
"allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." }
},
"save": "Speichern",

View file

@ -0,0 +1,50 @@
{
"intro": {
"title": "Willkommen — richten wir Siftlode ein",
"body": "Ein paar schnelle Schritte, und deine Instanz ist bereit: Erstelle dein Admin-Konto und verbinde optional die Google-Anmeldung und E-Mail. All das kannst du später in den Admin-Einstellungen ändern."
},
"admin": {
"title": "Admin-Konto erstellen",
"desc": "Dies ist das erste Konto und ein Administrator. Nach der Einrichtung meldest du dich mit dieser E-Mail und diesem Passwort an.",
"email": "E-Mail-Adresse",
"password": "Passwort (min. {{n}} Zeichen)",
"invalidEmail": "Gib eine gültige E-Mail-Adresse ein.",
"shortPassword": "Das Passwort muss mindestens {{n}} Zeichen lang sein."
},
"google": {
"title": "Mit Google anmelden (optional)",
"desc": "Füge deine Google-OAuth-Client-ID und das Secret ein, um die Google-Anmeldung und den YouTube-Zugriff zu aktivieren. Beide leer lassen zum Überspringen — E-Mail+Passwort funktioniert auch ohne.",
"clientId": "Google-Client-ID",
"clientSecret": "Google-Client-Secret",
"bothRequired": "Gib Client-ID und Secret ein oder lass beide leer zum Überspringen.",
"skipHint": "Leer lassen, um dies später auf der Admin-Konfigurationsseite einzurichten."
},
"smtp": {
"title": "E-Mail / SMTP (optional)",
"desc": "Richte einen SMTP-Server ein, damit Siftlode Bestätigungs-, Reset- und Benachrichtigungs-E-Mails senden kann. Host leer lassen zum Überspringen — ohne E-Mail werden Registrierungen stattdessen vom Admin automatisch freigegeben.",
"host": "SMTP-Host (z. B. smtp.gmail.com)",
"port": "Port",
"user": "Benutzername",
"from": "Absenderadresse (optional)",
"password": "SMTP-Passwort",
"testTo": "Test-E-Mail senden an…",
"test": "Test senden",
"testOk": "Test-E-Mail gesendet — sieh im Postfach nach.",
"testFail": "Senden fehlgeschlagen — prüfe die Einstellungen.",
"skipHint": "Leer lassen, um dies später auf der Admin-Konfigurationsseite einzurichten."
},
"finish": {
"title": "Alles bereit!",
"body": "Klicke auf Fertig, um die Einrichtung abzuschließen. Der Assistent verschwindet und du gelangst zur Anmeldeseite — melde dich mit dem soeben erstellten Admin-Konto an."
},
"nav": {
"back": "Zurück",
"next": "Weiter",
"finish": "Fertig"
},
"token": {
"title": "Setup-Link erforderlich",
"body": "Öffne den Assistenten über den Link, der beim ersten Start in den Container-Logs ausgegeben wird (sieht aus wie …/setup?token=…). Er stellt sicher, dass nur der Betreiber diese Instanz einrichten kann."
},
"genericError": "Etwas ist schiefgelaufen — bitte versuche es erneut."
}

View file

@ -5,6 +5,7 @@
"access": "Access",
"email": "Email / SMTP",
"youtube": "YouTube API",
"google": "Google sign-in",
"quota": "Quota",
"backfill": "Backfill",
"shorts": "Shorts probe",
@ -25,6 +26,8 @@
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." },
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." },
"youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." },
"google_client_id": { "label": "Google client ID", "hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)." },
"google_client_secret": { "label": "Google client secret", "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." },
"allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." }
},
"save": "Save",

View file

@ -0,0 +1,50 @@
{
"intro": {
"title": "Welcome — let's set up Siftlode",
"body": "A few quick steps to get your instance ready: create your admin account, and optionally connect Google sign-in and email. You can change all of this later from the admin settings."
},
"admin": {
"title": "Create your admin account",
"desc": "This is the first account and an administrator. You'll sign in with this email and password once setup is done.",
"email": "Email address",
"password": "Password (min. {{n}} characters)",
"invalidEmail": "Enter a valid email address.",
"shortPassword": "Password must be at least {{n}} characters."
},
"google": {
"title": "Sign in with Google (optional)",
"desc": "Paste your Google OAuth client ID and secret to enable Google sign-in and YouTube access. Leave both empty to skip — email+password works without it.",
"clientId": "Google client ID",
"clientSecret": "Google client secret",
"bothRequired": "Enter both the client ID and secret, or leave both empty to skip.",
"skipHint": "Leave empty to set this up later from the admin Configuration page."
},
"smtp": {
"title": "Email / SMTP (optional)",
"desc": "Configure an SMTP server so Siftlode can send verification, reset and notification emails. Leave the host empty to skip — without email, registrations are auto-approved by the admin instead.",
"host": "SMTP host (e.g. smtp.gmail.com)",
"port": "Port",
"user": "Username",
"from": "From address (optional)",
"password": "SMTP password",
"testTo": "Send a test email to…",
"test": "Send test",
"testOk": "Test email sent — check the inbox.",
"testFail": "Couldn't send — check the settings.",
"skipHint": "Leave empty to set this up later from the admin Configuration page."
},
"finish": {
"title": "All set!",
"body": "Click Finish to complete setup. The wizard will disappear and you'll be taken to the sign-in page — log in with the admin account you just created."
},
"nav": {
"back": "Back",
"next": "Next",
"finish": "Finish"
},
"token": {
"title": "Setup link needed",
"body": "Open the setup wizard using the link printed in the container logs at first start (it looks like …/setup?token=…). It's required so only the operator can configure this instance."
},
"genericError": "Something went wrong — please try again."
}

View file

@ -5,6 +5,7 @@
"access": "Hozzáférés",
"email": "E-mail / SMTP",
"youtube": "YouTube API",
"google": "Google bejelentkezés",
"quota": "Kvóta",
"backfill": "Letöltés (backfill)",
"shorts": "Shorts-vizsgálat",
@ -25,6 +26,8 @@
"enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." },
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." },
"youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." },
"google_client_id": { "label": "Google kliens-azonosító", "hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)." },
"google_client_secret": { "label": "Google kliens-titok", "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." },
"allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." }
},
"save": "Mentés",

View file

@ -0,0 +1,50 @@
{
"intro": {
"title": "Üdv — állítsuk be a Siftlode-ot",
"body": "Néhány gyors lépés, és kész a példányod: hozd létre az admin fiókodat, és igény szerint kösd be a Google-bejelentkezést és az e-mailt. Mindezt később az admin beállításokból módosíthatod."
},
"admin": {
"title": "Admin fiók létrehozása",
"desc": "Ez az első fiók, egyben adminisztrátor. A telepítés után ezzel az e-maillel és jelszóval jelentkezel be.",
"email": "E-mail-cím",
"password": "Jelszó (min. {{n}} karakter)",
"invalidEmail": "Adj meg érvényes e-mail-címet.",
"shortPassword": "A jelszó legalább {{n}} karakter legyen."
},
"google": {
"title": "Google bejelentkezés (opcionális)",
"desc": "Illeszd be a Google OAuth kliens-azonosítót és -titkot a Google-bejelentkezés és a YouTube-hozzáférés engedélyezéséhez. Hagyd üresen mindkettőt a kihagyáshoz — e-mail+jelszó enélkül is működik.",
"clientId": "Google kliens-azonosító",
"clientSecret": "Google kliens-titok",
"bothRequired": "Add meg a kliens-azonosítót és a -titkot is, vagy hagyd üresen mindkettőt a kihagyáshoz.",
"skipHint": "Hagyd üresen, ha később, az admin Configuration oldalon állítanád be."
},
"smtp": {
"title": "E-mail / SMTP (opcionális)",
"desc": "Állíts be egy SMTP-szervert, hogy a Siftlode tudjon verifikációs, reset- és értesítő e-mailt küldeni. Hagyd üresen a hostot a kihagyáshoz — e-mail nélkül a regisztrációkat az admin hagyja jóvá automatikusan.",
"host": "SMTP-host (pl. smtp.gmail.com)",
"port": "Port",
"user": "Felhasználónév",
"from": "Feladó cím (opcionális)",
"password": "SMTP-jelszó",
"testTo": "Teszt-e-mail ide…",
"test": "Teszt küldése",
"testOk": "Teszt-e-mail elküldve — nézd meg a postafiókot.",
"testFail": "Nem sikerült elküldeni — ellenőrizd a beállításokat.",
"skipHint": "Hagyd üresen, ha később, az admin Configuration oldalon állítanád be."
},
"finish": {
"title": "Minden kész!",
"body": "Kattints a Befejezésre a telepítés lezárásához. A varázsló eltűnik, és a bejelentkező oldalra kerülsz — lépj be az imént létrehozott admin fiókkal."
},
"nav": {
"back": "Vissza",
"next": "Tovább",
"finish": "Befejezés"
},
"token": {
"title": "Setup-link szükséges",
"body": "Nyisd meg a varázslót a konténer logjaiba az első indításkor kiírt linkkel (így néz ki: …/setup?token=…). Ez azért kell, hogy csak az üzemeltető tudja beállítani a példányt."
},
"genericError": "Valami hiba történt — próbáld újra."
}

View file

@ -11,6 +11,7 @@ export interface Me {
is_demo: boolean;
has_google: boolean;
has_password: boolean;
google_enabled: boolean;
can_read: boolean;
can_write: boolean;
pending_invites: number;
@ -210,6 +211,13 @@ export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn;
}
// Headers for install-wizard calls: the one-time setup token plus the JSON content type (req
// replaces — not merges — its default headers when opts.headers is given, so include both).
const setupHeaders = (token: string) => ({
"Content-Type": "application/json",
"X-Setup-Token": token,
});
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET";
@ -635,6 +643,22 @@ export const api = {
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
}),
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
authConfig: (): Promise<{ google_enabled: boolean; allow_registration: boolean }> =>
req("/auth/config"),
// --- first-run install wizard (token from the setup URL printed to the container logs) ---
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> =>
req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }),
setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> =>
req("/api/setup/admin", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) }, { quiet: true }),
setupConfig: (token: string, values: Record<string, unknown>): Promise<{ saved: string[] }> =>
req("/api/setup/config", { method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) }, { quiet: true }),
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
req("/api/setup/test-email", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) }, { quiet: true }),
setupFinish: (token: string): Promise<{ ok: boolean }> =>
req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }),
// --- auth: email + password (errors handled inline via quiet) ---
register: (email: string, password: string): Promise<{ status: string }> =>
req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),

View file

@ -15,9 +15,16 @@
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed";
export function shouldAutoOpenOnboarding(me: { can_read: boolean; is_demo?: boolean }): boolean {
export function shouldAutoOpenOnboarding(me: {
can_read: boolean;
is_demo?: boolean;
google_enabled?: boolean;
}): boolean {
// The shared demo account can never connect YouTube, so never nudge it to.
if (me.is_demo) return false;
// YouTube connect needs Google OAuth configured on this instance. A self-host instance may run
// email+password only — then there's nothing to connect, so don't nudge.
if (me.google_enabled === false) return false;
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
}

View file

@ -14,6 +14,19 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.14.0",
date: "2026-06-21",
summary:
"Run your own Siftlode: a guided first-run setup and a one-command self-host package.",
features: [
"A first-run setup wizard: the very first time a fresh instance is opened, a guided wizard walks you through creating the first admin account and (optionally) entering your Google and email credentials — no editing config files by hand. Once finished, the instance is marked configured and the wizard steps aside.",
"Google sign-in is now optional: if an instance hasn't been given Google credentials, the Google buttons and YouTube-account features are simply hidden, so a self-hoster can run the app with email-and-password accounts only.",
],
chores: [
"A one-command self-host package: a ready-made compose file with a bundled database plus install scripts for Linux/macOS and Windows that generate the needed secrets, start everything and print the setup URL. The app now ships as a prebuilt image, so hosts pull the exact released artifact instead of building it.",
],
},
{
version: "0.13.0",
date: "2026-06-19",

52
install.ps1 Normal file
View file

@ -0,0 +1,52 @@
# Siftlode self-host installer (Windows / PowerShell). Generates secrets into .env, starts the
# stack from the prebuilt image, and prints the first-run setup-wizard URL. Re-running is safe: an
# existing .env is left untouched. Requires Docker Desktop (with the compose plugin).
$ErrorActionPreference = "Stop"
$Port = if ($env:HTTP_PORT) { $env:HTTP_PORT } else { "8080" }
function New-RandBytes([int]$n) {
$b = New-Object byte[] $n
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($b)
return $b
}
if (Test-Path .env) {
Write-Host ".env already exists — leaving it untouched (delete it to re-generate)."
} else {
$url = Read-Host "Public URL where this instance will be reached [http://localhost:$Port]"
if ([string]::IsNullOrWhiteSpace($url)) { $url = "http://localhost:$Port" }
$url = $url.TrimEnd('/')
$secretKey = (New-RandBytes 32 | ForEach-Object { $_.ToString("x2") }) -join ""
$fernet = [Convert]::ToBase64String((New-RandBytes 32)).Replace('+', '-').Replace('/', '_')
$pgPass = (New-RandBytes 16 | ForEach-Object { $_.ToString("x2") }) -join ""
@"
# Generated by install.ps1 — keep secret, never commit. Re-run after deleting to reset.
POSTGRES_PASSWORD=$pgPass
SECRET_KEY=$secretKey
TOKEN_ENCRYPTION_KEY=$fernet
OAUTH_REDIRECT_URL=$url/auth/callback
"@ | Set-Content -Path .env -Encoding ascii
Write-Host "Wrote .env (secrets generated)."
}
Write-Host "Pulling and starting Siftlode..."
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
Write-Host "Waiting for the app to come up..."
foreach ($i in 1..30) {
try { Invoke-WebRequest "http://localhost:$Port/healthz" -UseBasicParsing -TimeoutSec 3 | Out-Null; break } catch { Start-Sleep 2 }
}
Write-Host ""
Write-Host "==================================================================="
Write-Host " Siftlode is up. Open the first-run setup wizard at:"
$logs = docker compose -f docker-compose.selfhost.yml logs api 2>$null
$match = ($logs | Select-String -Pattern "https?://\S*setup\?token=[A-Za-z0-9_-]+" -AllMatches |
ForEach-Object { $_.Matches.Value } | Select-Object -Last 1)
if ($match) { Write-Host " $match" }
else { Write-Host " (not found yet — run: docker compose -f docker-compose.selfhost.yml logs api | Select-String 'setup?token=')" }
Write-Host "==================================================================="

53
install.sh Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Siftlode self-host installer (Linux/macOS). Generates secrets into .env, starts the stack from
# the prebuilt image, and prints the first-run setup-wizard URL. Re-running is safe: an existing
# .env is left untouched. Requires Docker (with the compose plugin) and openssl.
set -euo pipefail
COMPOSE="docker compose -f docker-compose.selfhost.yml"
PORT="${HTTP_PORT:-8080}"
if [ -f .env ]; then
echo ".env already exists — leaving it untouched (delete it to re-generate)."
else
printf "Public URL where this instance will be reached [http://localhost:%s]: " "$PORT"
read -r URL
URL="${URL:-http://localhost:$PORT}"
URL="${URL%/}"
# Secrets — openssl only, no Python needed. The Fernet key is urlsafe base64 of 32 bytes.
SECRET_KEY="$(openssl rand -hex 32)"
TOKEN_ENCRYPTION_KEY="$(openssl rand -base64 32 | tr '+/' '-_')"
POSTGRES_PASSWORD="$(openssl rand -hex 16)"
cat > .env <<EOF
# Generated by install.sh — keep secret (chmod 600), never commit. Re-run after deleting to reset.
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
SECRET_KEY=$SECRET_KEY
TOKEN_ENCRYPTION_KEY=$TOKEN_ENCRYPTION_KEY
OAUTH_REDIRECT_URL=$URL/auth/callback
EOF
chmod 600 .env
echo "Wrote .env (secrets generated)."
fi
echo "Pulling and starting Siftlode…"
$COMPOSE pull
$COMPOSE up -d
echo "Waiting for the app to come up…"
for _ in $(seq 1 30); do
curl -fsS -o /dev/null "http://localhost:$PORT/healthz" 2>/dev/null && break
sleep 2
done
echo
echo "==================================================================="
echo " Siftlode is up. Open the first-run setup wizard at:"
URL_LINE="$($COMPOSE logs api 2>/dev/null | grep -o 'http[s]*://[^ ]*setup?token=[A-Za-z0-9_-]*' | tail -1)"
if [ -n "$URL_LINE" ]; then
echo " $URL_LINE"
else
echo " (not found yet — run: $COMPOSE logs api | grep 'setup?token=')"
fi
echo "==================================================================="