Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ BACKUP_SFTP_HOST=
BACKUP_SFTP_PORT=22
BACKUP_SFTP_USER=
# Distinct from gecko-notes', so the two apps' snapshots do not share a path.
BACKUP_SFTP_REMOTE_PATH=/gam
BACKUP_SFTP_REMOTE_PATH=/GAM/restic
# Optional plain-file mirror beside the restic repository: SFTP chroot path, e.g.
# /GAM/mirror. Empty = off. See ops/backup/README.md step 13.
BACKUP_MIRROR_PATH=
BACKUP_CRON_SCHEDULE=0 * * * *
BACKUP_KEEP_HOURLY=48
BACKUP_KEEP_DAILY=14
Expand Down
25 changes: 24 additions & 1 deletion backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from jose import JWTError, jwt
from jose.exceptions import ExpiredSignatureError
from pydantic import BaseModel
from sqlmodel import Session
from sqlmodel import Session, select

from app.config import settings
from app.database import get_session
Expand Down Expand Up @@ -144,8 +144,26 @@ def _origin_allowed_for_cookie(request: Request) -> bool:
return any(origin_host == _host_of(allowed) for allowed in settings.cors_origins)


def _read_only_user(session: Session) -> UserCtx:
"""The one person a read-only archive acts as.

Matched by id first, then by username, so `READ_ONLY_USER` can be whichever the
operator has to hand. Exact matches only, for the reason `is_admin` gives. An entry
matching no row is used as an id as given: the archive then shows an empty library,
which says plainly that the setting is wrong.
"""
wanted = settings.read_only_user.strip()
record = session.get(User, wanted)
if record is None:
record = session.exec(select(User).where(User.username == wanted)).first()
if record is None:
return UserCtx(id=wanted, username=wanted)
return UserCtx(id=record.id, username=record.username)


def current_user(
request: Request,
session: Session = Depends(get_session),
authorization: Annotated[Optional[str], Header()] = None,
gecko_session: Annotated[Optional[str], Cookie()] = None,
) -> UserCtx:
Expand All @@ -155,6 +173,11 @@ def current_user(
explicitly rather than read off the request so it shows up in the OpenAPI schema
and can be overridden in tests.
"""
# Before any token is looked at: an archive cannot verify one (it deliberately does
# not hold production's secret — see docs/deployment.md), and has nobody to ask.
if settings.read_only and settings.read_only_user:
return _read_only_user(session)

token: Optional[str] = None
# Which transport carried it, because it changes what a failure *means*. A cookie
# was minted by Notes moments ago and handed over by the browser; if it will not
Expand Down
25 changes: 24 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pathlib import Path
from typing import List

from pydantic import Field, field_validator
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

REPO_ROOT = Path(__file__).resolve().parents[2]
Expand Down Expand Up @@ -128,6 +128,18 @@ class Settings(BaseSettings):
generation_base_max_edge: int = 2048
generation_max_data_uri_mb: int = 8

# ─── read-only archive ───────────────────────────────────────────────────
# For a copy of the app run against the plain-file mirror the backup sidecar keeps
# on the NAS (ops/backup/README.md, "Plain-file mirror"). The database is opened
# read-only, every non-GET request is refused, and no worker starts — restart
# recovery and the stale sweep both write.
read_only: bool = False
# Who the archive acts as, by id or username. Instead of signing in: Notes only
# redirects back to *.geckopico.com, and an archive that needs Notes up to open is
# no use on the day production is down. So an archive must never be reachable from
# anywhere but a trusted network.
read_only_user: str = ""

# ─── development ─────────────────────────────────────────────────────────
# Bypasses token verification and acts as this user id. Refused unless
# `environment` is "development", so it cannot be switched on in production by
Expand All @@ -146,6 +158,17 @@ def _reject_weak_secret(cls, value: str) -> str:
)
return value

@model_validator(mode="after")
def _read_only_user_needs_read_only(self) -> "Settings":
# The same two-condition rule as dev auth, but refused outright rather than
# ignored: READ_ONLY_USER turns sign-in off, and a production .env that picked
# it up from an archive's should stop the app, not quietly keep it running.
if self.read_only_user and not self.read_only:
raise ValueError(
"READ_ONLY_USER disables sign-in and is only allowed with READ_ONLY=true."
)
return self

@property
def resolved_database_url(self) -> str:
if self.database_url:
Expand Down
35 changes: 32 additions & 3 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import Iterator

from sqlalchemy import event
from sqlalchemy.pool import NullPool
from sqlmodel import Session, create_engine

from app.config import settings
Expand All @@ -19,7 +20,28 @@

DATABASE_URL = settings.resolved_database_url

if DATABASE_URL.startswith("sqlite:///"):

def read_only_url(url: str) -> str:
"""The same SQLite file, opened so that no write can reach it.

`mode=ro` rather than relying on the `:ro` bind mount alone: on a read-only mount
SQLite still opens read-write and fails on the first write with "attempt to write a
readonly database" — after the request has already done half its work. `mode=ro`
fails it at the statement, and keeps a mirror mounted read-write safe as well.

Needs a file in rollback-journal mode. A WAL file opened `mode=ro` still has to
create its `-shm`, which a read-only mount cannot hold; the mirror converts its
copy before uploading it (ops/backup/backup.sh).
"""
if not url.startswith("sqlite:///"):
return url
path = url.replace("sqlite:///", "", 1)
return f"sqlite:///file:{path}?mode=ro&uri=true"


if settings.read_only:
DATABASE_URL = read_only_url(DATABASE_URL)
elif DATABASE_URL.startswith("sqlite:///"):
Path(DATABASE_URL.replace("sqlite:///", "", 1)).parent.mkdir(parents=True, exist_ok=True)

engine = create_engine(
Expand All @@ -28,6 +50,11 @@
# it. Background workers run on their own threads and open their own sessions,
# so the check has to come off.
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
# A read-only archive's database is replaced under it on every mirror run, by a
# rename. A pooled connection holds the old file open and goes on reading it, so
# the archive would show the day it started until somebody restarted it. A fresh
# connection per session costs a file open, which SQLite does in microseconds.
**({"poolclass": NullPool} if settings.read_only else {}),
echo=False,
)

Expand All @@ -43,8 +70,10 @@ def _set_sqlite_pragmas(dbapi_connection, connection_record):
which is the right trade for a single-host media library.
"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
# Both are writes to the file header, so a read-only connection refuses them.
if not settings.read_only:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()

Expand Down
51 changes: 44 additions & 7 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import shutil
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
Expand Down Expand Up @@ -47,7 +47,8 @@

@asynccontextmanager
async def lifespan(_app: FastAPI):
os.makedirs(settings.resolved_media_dir, exist_ok=True)
if not settings.read_only:
os.makedirs(settings.resolved_media_dir, exist_ok=True)
if ffmpeg_available():
logger.info("ffmpeg available: %s", ffmpeg_version())
else:
Expand All @@ -70,10 +71,15 @@ async def lifespan(_app: FastAPI):

log_if_behind(engine)

# Picks up anything a restart interrupted, and starts the worker threads.
from app.jobs import enrichment
# Picks up anything a restart interrupted, and starts the worker threads. Not in a
# read-only archive: recovery would requeue whatever production had running when
# the mirror was taken, and every step of a job is a write.
if settings.read_only:
logger.info("Read-only mode: the database is opened read-only and no workers start.")
else:
from app.jobs import enrichment

enrichment.start()
enrichment.start()

yield

Expand All @@ -84,6 +90,30 @@ async def lifespan(_app: FastAPI):
# gecko-notes deployment, where the app container is never reachable directly.
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")

_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})


@app.middleware("http")
async def _refuse_writes_when_read_only(request: Request, call_next):
"""In a read-only archive, refuse every write before any router sees it.

The read-only database would refuse them anyway, but only as a 500 from partway
through a handler, after an upload had already been streamed to a disk that
cannot hold it. Registered before CORS so the refusal still carries CORS headers.
"""
if settings.read_only and request.method.upper() not in _SAFE_METHODS:
return JSONResponse(
status_code=403,
content={
"detail": {
"code": "read_only",
"message": "This is a read-only archive copy. Make changes in the live app.",
}
},
)
return await call_next(request)


if not settings.cors_origins:
logger.warning("CORS_ORIGIN is not set — cross-origin API requests will be blocked.")

Expand Down Expand Up @@ -141,7 +171,9 @@ def client_config() -> DataResponse[ClientConfig]:
`NOTES_BASE_URL` could be set in `.env`, plumbed through docker-compose, and still
have no effect on where the login button went.
"""
return DataResponse(data=ClientConfig(notes_base_url=settings.notes_base_url))
return DataResponse(
data=ClientConfig(notes_base_url=settings.notes_base_url, read_only=settings.read_only)
)


@app.get("/api/me", response_model=DataResponse[dict])
Expand All @@ -155,9 +187,14 @@ def me(
authenticated request would put a SELECT — and periodically a write — in front of
every upload and every media byte, for a row nothing on those paths reads.
"""
from app.models.user import User
from app.services.users import ensure_user

record = ensure_user(session, user)
# Read, not upserted, in an archive: the write would only fail — harmlessly, but
# with a warning on every page load.
record = session.get(User, user.id) if settings.read_only else None
if record is None:
record = ensure_user(session, user)
return DataResponse(
data={
"id": record.id,
Expand Down
3 changes: 3 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ class ClientConfig(BaseModel):
"""

notes_base_url: str
# So the frontend can say it is an archive. Public by nature: anyone who can reach
# an archive can already see that writes are refused.
read_only: bool = False


class HealthResponse(BaseModel):
Expand Down
14 changes: 12 additions & 2 deletions backend/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,18 @@
# which the orchestrator can see, and a half-migrated app cannot tell you.
set -e

echo "Running database migrations..."
alembic upgrade head
# Never against a read-only archive. Its database is a mirror of production's: migrating
# it would fail on the read-only mount, and if it did not, the next mirror run would put
# production's schema back over it.
case "$(echo "${READ_ONLY:-}" | tr '[:upper:]' '[:lower:]')" in
1|true|yes|on)
echo "Read-only mode: skipping migrations."
;;
*)
echo "Running database migrations..."
alembic upgrade head
;;
esac

echo "Starting API..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 "$@"
2 changes: 1 addition & 1 deletion backend/tests/test_client_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@ def test_it_exposes_nothing_but_what_it_promises(client):
"""
data = client.get("/api/config").json()["data"]

assert set(data) == {"notes_base_url"}
assert set(data) == {"notes_base_url", "read_only"}
assert settings.jwt_secret_key not in str(data)
Loading
Loading