Merge: promote dev to prod
This commit is contained in:
commit
b5f1da92d9
28 changed files with 1159 additions and 56 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.13.0
|
0.14.0
|
||||||
|
|
|
||||||
39
backend/alembic/versions/0025_install_wizard.py
Normal file
39
backend/alembic/versions/0025_install_wizard.py
Normal 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")
|
||||||
|
|
@ -75,14 +75,40 @@ log = logging.getLogger("subfeed.auth")
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
oauth = OAuth()
|
# The Google OAuth client is built lazily from the CURRENT credentials (DB override via sysconfig,
|
||||||
oauth.register(
|
# else env), so the install wizard / admin can set or change them at runtime without a restart. The
|
||||||
name="google",
|
# authlib registry is rebuilt only when the creds change (cheap, first use after a change).
|
||||||
client_id=settings.google_client_id,
|
_oauth = OAuth()
|
||||||
client_secret=settings.google_client_secret,
|
_oauth_creds: tuple[str, str] | None = None
|
||||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
||||||
client_kwargs={"scope": BASE_SCOPES},
|
|
||||||
)
|
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=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:
|
def has_read_scope(user: User) -> bool:
|
||||||
|
|
@ -178,11 +204,16 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@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
|
# 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
|
# 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).
|
# 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,
|
request,
|
||||||
settings.oauth_redirect_url,
|
settings.oauth_redirect_url,
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
|
|
@ -198,8 +229,11 @@ async def callback(
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
client = google_oauth(db)
|
||||||
|
if client is None:
|
||||||
|
return RedirectResponse(url="/")
|
||||||
try:
|
try:
|
||||||
token = await oauth.google.authorize_access_token(request)
|
token = await client.authorize_access_token(request)
|
||||||
except OAuthError as exc:
|
except OAuthError as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}")
|
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")
|
@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
|
"""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
|
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."""
|
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
return RedirectResponse(url="/")
|
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_uid"] = user.id
|
||||||
request.session["oauth_link_explicit"] = True
|
request.session["oauth_link_explicit"] = True
|
||||||
return await oauth.google.authorize_redirect(
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
settings.oauth_redirect_url,
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
|
|
@ -705,7 +746,10 @@ def set_password(
|
||||||
|
|
||||||
@router.get("/upgrade")
|
@router.get("/upgrade")
|
||||||
async def 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
|
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
||||||
read-only (enough to build the feed); `access=write` additionally grants management
|
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.
|
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
return RedirectResponse(url="/")
|
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
|
# 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.
|
# google_sub) would get a brand-new, separate Google user created instead of being linked.
|
||||||
request.session["oauth_link_uid"] = user.id
|
request.session["oauth_link_uid"] = user.id
|
||||||
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
||||||
return await oauth.google.authorize_redirect(
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
settings.oauth_redirect_url,
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
|
|
@ -739,3 +786,13 @@ async def me(user: User = Depends(current_user)) -> dict:
|
||||||
"avatar_url": user.avatar_url,
|
"avatar_url": user.avatar_url,
|
||||||
"role": user.role,
|
"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"),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ if not _subfeed_logger.handlers:
|
||||||
|
|
||||||
log = logging.getLogger("subfeed.app")
|
log = logging.getLogger("subfeed.app")
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app import auth
|
from app import auth, state
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.db import SessionLocal
|
||||||
from app.routes import (
|
from app.routes import (
|
||||||
admin,
|
admin,
|
||||||
channels,
|
channels,
|
||||||
|
|
@ -37,6 +38,7 @@ from app.routes import (
|
||||||
playlists,
|
playlists,
|
||||||
quota,
|
quota,
|
||||||
scheduler as scheduler_routes,
|
scheduler as scheduler_routes,
|
||||||
|
setup as setup_routes,
|
||||||
sync,
|
sync,
|
||||||
tags,
|
tags,
|
||||||
version,
|
version,
|
||||||
|
|
@ -47,6 +49,17 @@ from app.scheduler import shutdown_scheduler, start_scheduler
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
log.info("Siftlode starting up")
|
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()
|
start_scheduler()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
|
|
@ -73,6 +86,27 @@ if settings.frontend_origin:
|
||||||
allow_headers=["*"],
|
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(health.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(sync.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(scheduler_routes.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
app.include_router(version.router)
|
app.include_router(version.router)
|
||||||
|
app.include_router(setup_routes.router)
|
||||||
|
|
||||||
# The built SPA (populated by the Docker frontend build stage).
|
# The built SPA (populated by the Docker frontend build stage).
|
||||||
STATIC_DIR = Path(__file__).parent / "static_spa"
|
STATIC_DIR = Path(__file__).parent / "static_spa"
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,14 @@ class AppState(Base):
|
||||||
# Admin override for the maintenance job's rolling re-validation batch (videos re-checked
|
# 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).
|
# per run). NULL = use the config/env default (settings.maintenance_revalidate_batch).
|
||||||
maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer)
|
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):
|
class SchedulerSetting(Base):
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,14 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
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.db import get_db
|
||||||
from app.models import Invite, User
|
from app.models import Invite, User
|
||||||
|
|
||||||
|
|
@ -80,6 +87,9 @@ def get_me(
|
||||||
"is_demo": user.is_demo,
|
"is_demo": user.is_demo,
|
||||||
"has_google": user.google_sub is not None,
|
"has_google": user.google_sub is not None,
|
||||||
"has_password": user.password_hash 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_read": has_read_scope(user),
|
||||||
"can_write": has_write_scope(user),
|
"can_write": has_write_scope(user),
|
||||||
"pending_invites": pending_invites,
|
"pending_invites": pending_invites,
|
||||||
|
|
|
||||||
109
backend/app/routes/setup.py
Normal file
109
backend/app/routes/setup.py
Normal 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}
|
||||||
|
|
@ -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 logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -12,6 +14,10 @@ log = logging.getLogger("subfeed.state")
|
||||||
MAINTENANCE_BATCH_MIN = 100
|
MAINTENANCE_BATCH_MIN = 100
|
||||||
MAINTENANCE_BATCH_MAX = 100_000
|
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:
|
def _row(db: Session) -> AppState:
|
||||||
row = db.get(AppState, 1)
|
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")
|
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:
|
def get_maintenance_batch(db: Session) -> int:
|
||||||
"""Effective maintenance re-validation batch: the admin override if set, else the
|
"""Effective maintenance re-validation batch: the admin override if set, else the
|
||||||
env/config default."""
|
env/config default."""
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||||
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
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) ---
|
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
||||||
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
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 ---
|
# --- Access / registration ---
|
||||||
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ class YouTubeClient:
|
||||||
resp = self._http.post(
|
resp = self._http.post(
|
||||||
GOOGLE_TOKEN_URL,
|
GOOGLE_TOKEN_URL,
|
||||||
data={
|
data={
|
||||||
"client_id": settings.google_client_id,
|
"client_id": sysconfig.get_str(self.db, "google_client_id"),
|
||||||
"client_secret": settings.google_client_secret,
|
"client_secret": sysconfig.get_str(self.db, "google_client_secret"),
|
||||||
"refresh_token": refresh,
|
"refresh_token": refresh,
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
67
docker-compose.selfhost.yml
Normal file
67
docker-compose.selfhost.yml
Normal 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
97
docs/self-hosting.md
Normal 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.
|
||||||
|
|
@ -22,6 +22,7 @@ import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||||
import { useConfirm } from "./components/ConfirmProvider";
|
import { useConfirm } from "./components/ConfirmProvider";
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
import type { PrefsController } from "./components/SettingsPanel";
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
|
import SetupWizard from "./components/SetupWizard";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import NavSidebar from "./components/NavSidebar";
|
import NavSidebar from "./components/NavSidebar";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
|
|
@ -265,6 +266,15 @@ export default function App() {
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
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
|
// 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.
|
// 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
|
// 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({
|
const meQuery = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: api.me,
|
queryFn: api.me,
|
||||||
|
enabled: setupQuery.isSuccess && !needsSetup,
|
||||||
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
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
|
// (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.
|
// 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)
|
if (meQuery.isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
|
// 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.
|
// the server until Save. Group order is fixed where known so logically related settings (e.g.
|
||||||
// Email/SMTP) stay together.
|
// 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";
|
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
export default function ConfigPanel() {
|
export default function ConfigPanel() {
|
||||||
|
|
|
||||||
|
|
@ -389,34 +389,40 @@ function SignInMethods({ me }: { me: Me }) {
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
|
"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 (
|
return (
|
||||||
<Section title={t("settings.account.signInMethods")}>
|
<Section title={t("settings.account.signInMethods")}>
|
||||||
<div className="flex items-start justify-between gap-3 py-2">
|
{showGoogle && (
|
||||||
<div className="min-w-0">
|
<div className="flex items-start justify-between gap-3 py-2">
|
||||||
<div className="text-sm font-medium">{t("settings.account.googleLink.title")}</div>
|
<div className="min-w-0">
|
||||||
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
<div className="text-sm font-medium">{t("settings.account.googleLink.title")}</div>
|
||||||
{me.has_google
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||||
? t("settings.account.googleLink.connectedHint")
|
{me.has_google
|
||||||
: t("settings.account.googleLink.connectHint")}
|
? t("settings.account.googleLink.connectedHint")
|
||||||
</p>
|
: t("settings.account.googleLink.connectHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{me.has_google ? (
|
||||||
|
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||||
|
{t("settings.account.googleLink.connected")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = "/auth/link";
|
||||||
|
}}
|
||||||
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
||||||
|
>
|
||||||
|
{t("settings.account.googleLink.connect")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{me.has_google ? (
|
)}
|
||||||
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
|
||||||
{t("settings.account.googleLink.connected")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = "/auth/link";
|
|
||||||
}}
|
|
||||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
|
||||||
>
|
|
||||||
{t("settings.account.googleLink.connect")}
|
|
||||||
</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="flex items-start justify-between gap-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium">{t("settings.account.password.title")}</div>
|
<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")}
|
{t("settings.account.demoNotice")}
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
) : (
|
) : me.google_enabled ? (
|
||||||
|
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
|
||||||
<Section title={t("settings.account.youtubeAccess")}>
|
<Section title={t("settings.account.youtubeAccess")}>
|
||||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||||
{t("settings.account.youtubeAccessIntro")}
|
{t("settings.account.youtubeAccessIntro")}
|
||||||
|
|
@ -549,7 +556,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
{t("settings.account.walkMeThrough")}
|
{t("settings.account.walkMeThrough")}
|
||||||
</button>
|
</button>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{!me.is_demo && (
|
{!me.is_demo && (
|
||||||
<Section title={t("settings.account.dangerZone")}>
|
<Section title={t("settings.account.dangerZone")}>
|
||||||
|
|
|
||||||
273
frontend/src/components/SetupWizard.tsx
Normal file
273
frontend/src/components/SetupWizard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -243,6 +243,13 @@ function AuthCard() {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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
|
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
|
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
|
||||||
|
|
@ -423,12 +430,14 @@ function AuthCard() {
|
||||||
{t("welcome.auth.or")}
|
{t("welcome.auth.or")}
|
||||||
<span className="h-px flex-1 bg-border" />
|
<span className="h-px flex-1 bg-border" />
|
||||||
</div>
|
</div>
|
||||||
<a
|
{googleEnabled && (
|
||||||
href="/auth/login"
|
<a
|
||||||
className="block text-center px-4 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent transition"
|
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>
|
{t("welcome.auth.google")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{!showDemo ? (
|
{!showDemo ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDemo(true)}
|
onClick={() => setShowDemo(true)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"access": "Zugang",
|
"access": "Zugang",
|
||||||
"email": "E-Mail / SMTP",
|
"email": "E-Mail / SMTP",
|
||||||
"youtube": "YouTube-API",
|
"youtube": "YouTube-API",
|
||||||
|
"google": "Google-Anmeldung",
|
||||||
"quota": "Kontingent",
|
"quota": "Kontingent",
|
||||||
"backfill": "Nachladen (Backfill)",
|
"backfill": "Nachladen (Backfill)",
|
||||||
"shorts": "Shorts-Prüfung",
|
"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)." },
|
"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." },
|
"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." },
|
"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." }
|
"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",
|
"save": "Speichern",
|
||||||
|
|
|
||||||
50
frontend/src/i18n/locales/de/setup.json
Normal file
50
frontend/src/i18n/locales/de/setup.json
Normal 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."
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"access": "Access",
|
"access": "Access",
|
||||||
"email": "Email / SMTP",
|
"email": "Email / SMTP",
|
||||||
"youtube": "YouTube API",
|
"youtube": "YouTube API",
|
||||||
|
"google": "Google sign-in",
|
||||||
"quota": "Quota",
|
"quota": "Quota",
|
||||||
"backfill": "Backfill",
|
"backfill": "Backfill",
|
||||||
"shorts": "Shorts probe",
|
"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)." },
|
"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." },
|
"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." },
|
"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." }
|
"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",
|
"save": "Save",
|
||||||
|
|
|
||||||
50
frontend/src/i18n/locales/en/setup.json
Normal file
50
frontend/src/i18n/locales/en/setup.json
Normal 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."
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"access": "Hozzáférés",
|
"access": "Hozzáférés",
|
||||||
"email": "E-mail / SMTP",
|
"email": "E-mail / SMTP",
|
||||||
"youtube": "YouTube API",
|
"youtube": "YouTube API",
|
||||||
|
"google": "Google bejelentkezés",
|
||||||
"quota": "Kvóta",
|
"quota": "Kvóta",
|
||||||
"backfill": "Letöltés (backfill)",
|
"backfill": "Letöltés (backfill)",
|
||||||
"shorts": "Shorts-vizsgálat",
|
"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)." },
|
"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." },
|
"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ó." },
|
"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." }
|
"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",
|
"save": "Mentés",
|
||||||
|
|
|
||||||
50
frontend/src/i18n/locales/hu/setup.json
Normal file
50
frontend/src/i18n/locales/hu/setup.json
Normal 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."
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ export interface Me {
|
||||||
is_demo: boolean;
|
is_demo: boolean;
|
||||||
has_google: boolean;
|
has_google: boolean;
|
||||||
has_password: boolean;
|
has_password: boolean;
|
||||||
|
google_enabled: boolean;
|
||||||
can_read: boolean;
|
can_read: boolean;
|
||||||
can_write: boolean;
|
can_write: boolean;
|
||||||
pending_invites: number;
|
pending_invites: number;
|
||||||
|
|
@ -210,6 +211,13 @@ export function setUnauthorizedHandler(fn: (() => void) | null): void {
|
||||||
onUnauthorized = fn;
|
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> {
|
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
|
||||||
const method = opts.method ?? "GET";
|
const method = opts.method ?? "GET";
|
||||||
const canRetry = cfg.idempotent ?? method === "GET";
|
const canRetry = cfg.idempotent ?? method === "GET";
|
||||||
|
|
@ -635,6 +643,22 @@ export const api = {
|
||||||
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
|
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) ---
|
// --- auth: email + password (errors handled inline via quiet) ---
|
||||||
register: (email: string, password: string): Promise<{ status: string }> =>
|
register: (email: string, password: string): Promise<{ status: string }> =>
|
||||||
req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
|
req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,16 @@
|
||||||
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
|
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
|
||||||
export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed";
|
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.
|
// The shared demo account can never connect YouTube, so never nudge it to.
|
||||||
if (me.is_demo) return false;
|
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;
|
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
||||||
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
|
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,19 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.13.0",
|
||||||
date: "2026-06-19",
|
date: "2026-06-19",
|
||||||
|
|
|
||||||
52
install.ps1
Normal file
52
install.ps1
Normal 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
53
install.sh
Executable 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 "==================================================================="
|
||||||
Loading…
Add table
Add a link
Reference in a new issue