Add hosted multi-user mode: accounts, MySQL, Docker, brand identity

Converts the single-user localhost tool into a hosted application while
leaving local mode intact.

The pipeline moves out of serve.py into app/services/{analysis,exporters}.py,
which import only the standard library and ppcbudget. serve.py becomes a thin
shim over them, so it still runs offline with no dependency beyond openpyxl and
there is one copy of the analysis rather than two. ppcbudget and run_report.py
are otherwise untouched.

app/ is a FastAPI application serving the same dashboard behind sign-in:

- Open signup with email confirmation, forgot/reset, argon2id hashing, and
  opaque DB-backed session cookies (HttpOnly, SameSite=Lax, Secure derived from
  APP_BASE_URL so TLS is later a config change, not a rewrite).
- Signup and forgot-password answer identically whether or not an address
  exists; login gives one generic message for unknown, wrong, locked and
  disabled alike. Email tokens are HMAC'd at rest and spent by a single atomic
  UPDATE, and /verify and /reset are inert pages that POST the token -- a mail
  scanner following the link cannot burn it.
- Routes are def, not async def: the pipeline is CPU-bound and would otherwise
  block the event loop. A semaphore bounds concurrent analyses.
- The process-wide SESSION global becomes a workspace per account, with
  per-workspace locks, size caps and idle eviction. The old code held one lock
  for the whole analysis; per-user locks fix that by construction.
- Accounts live in three oob_-prefixed tables. The connection URL is built with
  URL.create, since the password contains ? and # and a hand-built DSN
  truncates it there.
- Minimal /admin page for enabling, disabling and signing accounts out.

Hardening: server-side upload extension and size limits, an Origin guard on
writes, correlation-id 500s instead of echoed exceptions, and a CSP. Every page
script is external because script-src 'self' blocks inline blocks.

Restyled to the Utopia Brands guidelines. Brand swatches sit verbatim in the
--u-* properties and everything derives from them, including the Excel report.
--red and --amber are deliberately not from the guide: it covers identity, not
function, and has no warning colour, but this dashboard exists to show
campaigns going dark. The four data colours were checked for colour-vision
separation across every pair on both surfaces, and the day heatmap is now one
hue getting darker rather than a rainbow.

The claim that nothing is uploaded anywhere stays true only for local mode, so
the dashboard copy and the README now say so.

Tests: tests/test_auth_smoke.py covers the account lifecycle plus upload,
analyse and export against SQLite with mail captured, so it needs no MySQL and
no network. tests/synthetic.py builds a small stand-in export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat/hosted-auth-mysql-docker
bahawal.baloch 2026-08-12 13:42:00 +05:00
parent 54d41b12a4
commit b7cc1bac71
49 changed files with 3830 additions and 262 deletions

41
.dockerignore Normal file
View File

@ -0,0 +1,41 @@
.git
.gitignore
# Secrets arrive at runtime via env_file. Baking them into a layer would leave
# them extractable from the image forever.
.env
.env.*
!.env.example
__pycache__/
*.py[cod]
.venv/
venv/
env/
ENV/
# Customer exports; also keeps the build context small.
data/
reports/
*.xlsx
*.xlsm
*.xls
*.csv
~$*
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
Dockerfile
.dockerignore
docker-compose.yml
README.md

54
.env.example Normal file
View File

@ -0,0 +1,54 @@
# Copy to .env and fill in. .env is gitignored; this file is not.
# Only hosted mode reads any of this -- serve.py and run_report.py ignore it entirely.
# ─── Application ───────────────────────────────────────────────────
# The public origin. Verification and reset links in emails are built from this,
# so if it is wrong every emailed link points somewhere unreachable. No trailing slash.
APP_BASE_URL=http://localhost:8000
# python -c "import secrets; print(secrets.token_urlsafe(48))"
# Rotating this signs everyone out and invalidates all pending email links.
SECRET_KEY=change-me-to-at-least-32-random-characters
# Leave false while serving plain HTTP. If this is true over http:// the browser
# silently discards the session cookie: login appears to work and every later
# request 401s. Blank means "derive it from APP_BASE_URL", which is usually right.
COOKIE_SECURE=false
ENV=prod
LOG_LEVEL=INFO
# ─── Database - MySQL ──────────────────────────────────────────────
MYSQL_HOST=your-instance.rds.amazonaws.com
MYSQL_PORT=3306
MYSQL_USER=your_user
MYSQL_PASSWORD='your password; quote it if it has # or ? in it'
MYSQL_DATABASE=your_database
MYSQL_SLOW_QUERY_MS=500
MYSQL_POOL_SIZE=10
MYSQL_POOL_RECYCLE=3600
# ─── Default admin (seeded on startup) ─────────────────────────────
# Created pre-verified on first boot. The password is NOT overwritten on later
# boots, so rotating it in the app sticks. Set ADMIN_RESET_PASSWORD=true to force it.
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-me
ADMIN_RESET_PASSWORD=false
# ─── Email ─────────────────────────────────────────────────────────
# "api" posts to EMAIL_ENDPOINT. "console" just logs the message and its link,
# which is how you exercise signup/verify/reset locally without sending real mail.
EMAIL_PROVIDER=api
EMAIL_ENDPOINT=http://your-mail-host:8000/mail/send
EMAIL_API_KEY=your-key
EMAIL_FROM_NAME=PPC Dashboard
# ─── Signup ────────────────────────────────────────────────────────
SIGNUP_ENABLED=true
# Comma-separated. Blank means any domain may register.
SIGNUP_ALLOWED_DOMAINS=
# ─── Limits ────────────────────────────────────────────────────────
# Blank WORKSPACE_ROOT means the system temp dir. The container sets /srv/work.
WORKSPACE_ROOT=
MAX_CONCURRENT_ANALYSES=2

47
Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# Hosted mode. The local serve.py is not what runs here -- see the CMD.
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /srv/app
# Created before the COPYs so --chown has someone to point at.
RUN groupadd --system app && useradd --system --gid app --uid 10001 --home /srv/app app
# Dependencies first: this layer stays cached until requirements.txt changes.
# argon2-cffi, cryptography and openpyxl all ship manylinux wheels, so slim
# needs no compiler. If a future pin lacks one, add a builder stage rather than
# putting gcc in the runtime image.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=app:app ppcbudget/ ./ppcbudget/
COPY --chown=app:app app/ ./app/
COPY --chown=app:app web/ ./web/
COPY --chown=app:app tests/ ./tests/
# Carried along only so `docker compose exec app python run_report.py` works for
# debugging. serve.py is never the container's entrypoint, so its localhost bind
# and its webbrowser.open are unreachable here.
COPY --chown=app:app run_report.py serve.py ./
# Scratch space for per-user uploads, writable by the non-root user.
RUN mkdir -p /srv/work && chown app:app /srv/work
ENV WORKSPACE_ROOT=/srv/work
USER app
EXPOSE 8000
# No curl in the image: the interpreter is already here and is one fewer thing
# to patch. /healthz deliberately does not touch MySQL -- see app/routers/pages.py.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).status == 200 else 1)"]
# One worker is load-bearing: uploads and the last analysis live in this
# process's memory, so a second worker would answer half the requests without
# them. Add --proxy-headers --forwarded-allow-ips=<proxy ip> only once a trusted
# proxy is in front; with the port exposed directly, it would let a client spoof
# X-Forwarded-For and walk past the per-IP rate limits.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

131
README.md
View File

@ -3,11 +3,14 @@
Finds the campaigns that keep running out of budget, how long they were dark, Finds the campaigns that keep running out of budget, how long they were dark,
how often it happened, and what it plausibly cost. how often it happened, and what it plausibly cost.
Two ways to use it. Both run the same analysis code, so they can never Three ways to use it. All of them run the same analysis code — `ppcbudget/`
disagree — the dashboard just makes it explorable and the report makes it via `app/services/analysis.py` — so they can never disagree. The dashboard
shareable. makes it explorable, the report makes it shareable, and the hosted version
makes it available to a team.
## The dashboard ## Local mode — your files never leave your machine
### The dashboard
```bash ```bash
python3 serve.py python3 serve.py
@ -18,16 +21,24 @@ Sort and filter by any column, click a campaign for its full timeline and
outage list, and download the Excel or CSV version from the header. outage list, and download the Excel or CSV version from the header.
Add `--preload` to pick up whatever is already sitting in `data/` on startup. Add `--preload` to pick up whatever is already sitting in `data/` on startup.
The server binds to localhost only; nothing is uploaded anywhere. The server binds to localhost only; nothing is uploaded anywhere, and there are
no accounts, no database and nothing to install beyond `openpyxl`.
## The Excel report ### The Excel report
```bash ```bash
python3 run_report.py python3 run_report.py
``` ```
Put your exports in `data/` first. The report lands in `reports/`. Nothing to Put your exports in `data/` first. The report lands in `reports/`. Same
install — it uses `openpyxl`, which you already have. dependencies as above: just `openpyxl`.
## Hosted mode — accounts, on a server
The hosted version is the same dashboard with sign-in in front of it. **Files
you upload here do go to the server**, where they are analysed and then deleted
when you sign out or go idle; nobody else with an account can see them. Accounts
live in MySQL. See [Running the hosted version](#running-the-hosted-version).
## Loading more than one day ## Loading more than one day
@ -156,16 +167,110 @@ zero would become a fact the moment someone summed the column.
budget, so paused minutes are removed from both the loss total and the budget, so paused minutes are removed from both the loss total and the
in-budget denominator that sets the spend rate. in-budget denominator that sets the spend rate.
## Running the hosted version
The hosted app is FastAPI (`app/`), serving the same dashboard behind sign-in.
Accounts, sessions and email tokens live in MySQL in three `oob_`-prefixed
tables. Uploads and the last analysis stay in memory and in a scratch directory,
keyed by user — they are not written to the database and do not survive a
restart.
### Configuration
Copy `.env.example` to `.env` and fill it in. Four settings have no sensible
default and matter more than the rest:
| Setting | Why it matters |
| --- | --- |
| `APP_BASE_URL` | Verification and reset links are built from it. Point it at wherever people actually reach the app, or every emailed link is dead. No trailing slash. |
| `SECRET_KEY` | Keys the session-cookie and email-token fingerprints. Generate with `python -c "import secrets; print(secrets.token_urlsafe(48))"`. Rotating it signs everyone out and voids pending email links. |
| `COOKIE_SECURE` | Leave `false` over plain HTTP. Set it `true` there and the browser silently discards the session cookie, so signing in appears to do nothing. Blank derives it from `APP_BASE_URL`. |
| `EMAIL_PROVIDER` | `api` posts to `EMAIL_ENDPOINT`; `console` just logs the message and its link, which is how to exercise signup and reset without sending real mail. |
`ADMIN_EMAIL` and `ADMIN_PASSWORD` seed an administrator on first startup,
already confirmed. Later startups repair the account's flags but leave the
password alone, so rotating it in the app sticks — set `ADMIN_RESET_PASSWORD=true`
for one boot if you need to force it back.
### With Docker
```bash
docker compose up -d --build
docker compose ps # healthy
curl -s localhost:8000/readyz # {"db":"ok"} proves it can reach MySQL
```
The compose file starts the app only; the database is whatever `MYSQL_HOST`
points at. The EC2 instance's security group has to be allowed inbound on 3306
at RDS.
### Without Docker
```bash
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python -m app.db # create the tables, seed the admin
.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1
```
**One worker is load-bearing.** Uploads and the last analysis live in the
process's memory, so a second worker would answer half the requests without
them. Growing past one box means moving that state out of memory first.
### Branding
The dashboard, the auth pages and the Excel report all follow the Utopia Brands
guidelines. The brand swatches live verbatim in the `--u-*` custom properties at
the top of `web/styles.css` and everything else derives from them; the Excel
palette is the matching set of constants at the top of `ppcbudget/excelout.py`.
Two colours are deliberately **not** from the guide. The guide covers identity,
not function, and has no warning colour — but this dashboard exists to show
campaigns going dark, so out-of-budget has to read as a problem. `--red` and
`--amber` are tuned to sit against the brand greens and are only ever used to
encode state.
Headings are set in Belleza and body copy in Neue Montreal, per the guide's
typeface hierarchy. Neither font file ships with the repository — see
[web/fonts/README.md](web/fonts/README.md) for how to add them. Until they are
there, both fall back to close system faces and the `@font-face` request for
Belleza 404s harmlessly.
The mark in the top bar is a placeholder built from the two brand primaries.
Replace it with the real emblem when the SVG is available rather than redrawing
it — `.mark` in `web/styles.css`.
### Schema changes
Tables are created with `create_all` on startup. That creates missing tables but
never alters existing ones, so the first time a column is added you either run
the `ALTER` by hand or adopt Alembic then. Three tables did not justify a
migrations tree up front.
### Before it faces the internet
The app is built so TLS is a configuration change rather than a rewrite: set
`APP_BASE_URL=https://…` and the session cookie picks up `Secure` on its own.
Until then, passwords and session cookies cross the network in the clear. If you
put a proxy in front, add `--proxy-headers --forwarded-allow-ips=<proxy ip>` to
the uvicorn command — but not while port 8000 is exposed directly, where it
would let a client spoof `X-Forwarded-For` and walk past the per-IP rate limits.
## Tests ## Tests
```bash ```bash
python3 tests/test_golden.py python3 tests/test_golden.py # the analysis
python3 tests/test_auth_smoke.py # the hosted app
``` ```
34 checks: frozen totals from the reference export, structural invariants, `test_golden.py` is 34 checks: frozen totals from the reference export,
overlapping-export handling, action classification, and edge cases. The structural invariants, overlapping-export handling, action classification, and
important ones are `test_chain_breaks_canary` and edge cases. The important ones are `test_chain_breaks_canary` and
`test_amazon_pacing_rows_are_not_actions`. `test_amazon_pacing_rows_are_not_actions`. It needs the reference export in
`data/`; the 15 synthetic-fixture checks run without it.
`test_auth_smoke.py` is 53 checks covering the whole account lifecycle plus
upload, analyse and export. It runs against in-memory SQLite with mail captured
rather than sent, so it needs no MySQL, no network and no configuration.
The export is written newest-first, so rows sharing the same minute are also The export is written newest-first, so rows sharing the same minute are also
newest-first and must be reversed before the state machine walks them. Sorting newest-first and must be reversed before the state machine walks them. Sorting

11
app/__init__.py Normal file
View File

@ -0,0 +1,11 @@
"""Hosted, multi-user front end for the out-of-budget analysis.
`app.services` holds the analysis and export code, and imports nothing but the
standard library and `ppcbudget` -- that is what lets serve.py keep running
offline with no third-party dependencies beyond openpyxl.
Everything else in this package is the web application: FastAPI routers, the
MySQL-backed account store, and the per-user workspaces.
"""
__version__ = "2.0.0"

66
app/bootstrap.py Normal file
View File

@ -0,0 +1,66 @@
"""Seed the administrator account named in the environment."""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.models import User, utcnow
from app.security import hash_password
log = logging.getLogger(__name__)
def seed_admin(db: Session, s: Settings) -> User | None:
"""Idempotent. Creates the admin pre-verified on first run; on later runs it
repairs the flags but leaves the password alone -- otherwise every restart
would revert a rotated password and the stale .env value would be a
permanent way in. ADMIN_RESET_PASSWORD=true is the deliberate override."""
if not (s.ADMIN_EMAIL and s.ADMIN_PASSWORD):
log.info("ADMIN_EMAIL/ADMIN_PASSWORD not set; skipping admin seed")
return None
email = s.ADMIN_EMAIL.strip().lower()
user = db.scalar(select(User).where(User.email == email))
if user is None:
user = User(
email=email,
name="Administrator",
password_hash=hash_password(s.ADMIN_PASSWORD.get_secret_value()),
is_admin=True,
is_active=True,
email_verified_at=utcnow(),
)
db.add(user)
db.commit()
log.info("seeded admin account %s", email)
return user
changed = []
if not user.is_admin:
user.is_admin = True
changed.append("is_admin")
if not user.is_active:
user.is_active = True
changed.append("is_active")
if user.email_verified_at is None:
user.email_verified_at = utcnow()
changed.append("email_verified_at")
if user.locked_until is not None or user.failed_login_count:
user.locked_until = None
user.failed_login_count = 0
changed.append("lockout cleared")
if s.ADMIN_RESET_PASSWORD:
user.password_hash = hash_password(s.ADMIN_PASSWORD.get_secret_value())
changed.append("password reset from ADMIN_PASSWORD")
if changed:
db.commit()
log.info("admin account %s updated: %s", email, ", ".join(changed))
else:
log.info("admin account %s already present", email)
return user

133
app/config.py Normal file
View File

@ -0,0 +1,133 @@
"""Every knob the hosted app has, read once from the environment.
Secrets are `SecretStr` so they render as `**********` in tracebacks and in any
accidental `print(settings)`. The validator refuses to start on the two
misconfigurations that otherwise fail silently and confusingly at runtime.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
def _unquote(value: str) -> str:
"""Drop one matched pair of surrounding quotes.
`docker run --env-file` passes values through literally, so a .env line
reading MYSQL_PASSWORD='secret' arrives with the quotes still attached and
the database answers "Access denied" with nothing to explain why. dotenv and
`docker compose env_file:` both strip them, so this only ever fires on the
raw docker run path -- but that failure is expensive to diagnose and this is
cheap. A value that genuinely begins and ends with the same quote character
would need it doubled.
"""
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
return value
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8",
extra="ignore", case_sensitive=False,
)
# ---- application -------------------------------------------------------
APP_NAME: str = "PPC Out-of-Budget Dashboard"
# Verification and reset links are built from this. If it is wrong, every
# emailed link points somewhere the recipient cannot reach.
APP_BASE_URL: str = "http://localhost:8000"
SECRET_KEY: SecretStr
ENV: Literal["dev", "prod"] = "prod"
LOG_LEVEL: str = "INFO"
# ---- database ----------------------------------------------------------
MYSQL_HOST: str
MYSQL_PORT: int = 3306
MYSQL_USER: str
MYSQL_PASSWORD: SecretStr
MYSQL_DATABASE: str
MYSQL_POOL_SIZE: int = 5
MYSQL_POOL_RECYCLE: int = 3600
MYSQL_SLOW_QUERY_MS: int = 500
# ---- sessions & auth ---------------------------------------------------
SESSION_COOKIE_NAME: str = "oob_session"
SESSION_TTL_DAYS: int = 14
# None means "derive from APP_BASE_URL". Setting it True over plain http
# makes the browser silently drop the cookie -- login then does nothing.
COOKIE_SECURE: bool | None = None
VERIFY_TOKEN_TTL_HOURS: int = 24
RESET_TOKEN_TTL_MINUTES: int = 60
MAX_FAILED_LOGINS: int = 5
LOCKOUT_MINUTES: int = 15
MIN_PASSWORD_LENGTH: int = 10
MAX_PASSWORD_LENGTH: int = 128
SIGNUP_ENABLED: bool = True
SIGNUP_ALLOWED_DOMAINS: str = "" # comma separated; blank means any
# ---- admin bootstrap ---------------------------------------------------
ADMIN_EMAIL: str | None = None
ADMIN_PASSWORD: SecretStr | None = None
# Off by default: otherwise every restart reverts a rotated password and the
# stale .env value becomes a permanent backdoor.
ADMIN_RESET_PASSWORD: bool = False
# ---- email -------------------------------------------------------------
EMAIL_PROVIDER: Literal["api", "console"] = "api"
EMAIL_ENDPOINT: str | None = None
EMAIL_API_KEY: SecretStr | None = None
EMAIL_FROM_NAME: str = "PPC Dashboard"
EMAIL_TIMEOUT_S: float = 10.0
# ---- workspaces & limits ----------------------------------------------
WORKSPACE_ROOT: str | None = None # None -> system temp dir
MAX_UPLOAD_BYTES: int = 200 * 1024 * 1024 # one file
MAX_WORKSPACE_BYTES: int = 400 * 1024 * 1024 # per user, cumulative
MAX_TOTAL_WORKSPACE_BYTES: int = 4 * 1024 ** 3
MAX_ACTIVE_WORKSPACES: int = 20
WORKSPACE_IDLE_TTL_MIN: int = 30
MAX_CONCURRENT_ANALYSES: int = 2
@field_validator("MYSQL_PASSWORD", "ADMIN_PASSWORD", "EMAIL_API_KEY",
"SECRET_KEY", mode="before")
@classmethod
def _strip_quotes(cls, v):
return _unquote(v) if isinstance(v, str) else v
@property
def base_url(self) -> str:
return self.APP_BASE_URL.rstrip("/")
@property
def cookie_secure(self) -> bool:
if self.COOKIE_SECURE is not None:
return self.COOKIE_SECURE
return self.base_url.startswith("https://")
@property
def allowed_signup_domains(self) -> set[str]:
return {d.strip().lower() for d in self.SIGNUP_ALLOWED_DOMAINS.split(",") if d.strip()}
@model_validator(mode="after")
def _check(self) -> "Settings":
if len(self.SECRET_KEY.get_secret_value()) < 32:
raise ValueError(
"SECRET_KEY must be at least 32 characters. Generate one with: "
'python -c "import secrets; print(secrets.token_urlsafe(48))"'
)
if self.EMAIL_PROVIDER == "api" and not (self.EMAIL_ENDPOINT and self.EMAIL_API_KEY):
raise ValueError(
"EMAIL_PROVIDER=api needs EMAIL_ENDPOINT and EMAIL_API_KEY. "
"Set EMAIL_PROVIDER=console to log emails instead of sending them."
)
return self
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()

124
app/db.py Normal file
View File

@ -0,0 +1,124 @@
"""Engine, session factory, and schema creation.
The connection URL is built with `URL.create`, never an f-string. The staged
MySQL password contains `?` and `#`, which start the query and fragment
components of a URL -- a hand-built DSN silently truncates the password there
and MySQL answers "Access denied" with nothing to suggest why.
"""
from __future__ import annotations
import logging
import time
from collections.abc import Iterator
from sqlalchemy import create_engine, event, text
from sqlalchemy.engine import URL, Engine
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session, sessionmaker
from app.config import Settings, get_settings
from app.models import Base
log = logging.getLogger(__name__)
slow_log = logging.getLogger("app.db.slow")
def build_url(s: Settings) -> URL:
return URL.create(
drivername="mysql+pymysql",
username=s.MYSQL_USER,
password=s.MYSQL_PASSWORD.get_secret_value(), # raw; URL.create encodes it
host=s.MYSQL_HOST,
port=s.MYSQL_PORT,
database=s.MYSQL_DATABASE, # hyphens are legal in a URL path segment
query={"charset": "utf8mb4"},
)
def build_engine(s: Settings) -> Engine:
engine = create_engine(
build_url(s),
pool_size=s.MYSQL_POOL_SIZE,
max_overflow=5,
pool_recycle=s.MYSQL_POOL_RECYCLE, # must stay under RDS wait_timeout
pool_pre_ping=True, # survives idle kills and failovers
pool_timeout=10,
future=True,
)
_install_slow_query_log(engine, s.MYSQL_SLOW_QUERY_MS)
return engine
def _install_slow_query_log(engine: Engine, threshold_ms: int) -> None:
"""Warn about queries slower than the threshold. RDS is across a network,
so this is worth having. Logs the statement only -- the bind parameters
hold password hashes, token fingerprints and email addresses."""
@event.listens_for(engine, "before_cursor_execute")
def _start(conn, cursor, statement, parameters, context, executemany):
conn.info.setdefault("_q_start", []).append(time.perf_counter())
@event.listens_for(engine, "after_cursor_execute")
def _end(conn, cursor, statement, parameters, context, executemany):
stack = conn.info.get("_q_start")
if not stack:
return
elapsed_ms = (time.perf_counter() - stack.pop()) * 1000
if elapsed_ms >= threshold_ms:
slow_log.warning("slow query %.0fms: %s", elapsed_ms,
" ".join(statement.split())[:400])
_settings = get_settings()
engine: Engine = build_engine(_settings)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False,
future=True)
def get_db() -> Iterator[Session]:
"""FastAPI dependency. One session per request, always closed."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def create_schema(bind: Engine | None = None) -> None:
"""Create any missing tables.
Note what this does not do: it never ALTERs an existing table. Adding a
column later means running the ALTER by hand or adopting Alembic then.
"""
target = bind or engine
try:
Base.metadata.create_all(bind=target)
except (OperationalError, ProgrammingError) as exc:
log.critical(
"Could not create tables in %r as %r. Check the database exists, the "
"credentials are right, this host can reach it, and the user holds "
"CREATE privileges. Underlying error: %s",
_settings.MYSQL_DATABASE, _settings.MYSQL_USER, exc,
)
raise
def ping(bind: Engine | None = None) -> None:
"""Raise if the database is unreachable. Used by /readyz."""
with (bind or engine).connect() as conn:
conn.execute(text("SELECT 1"))
if __name__ == "__main__": # python -m app.db -- create tables and seed the admin
from app.bootstrap import seed_admin
logging.basicConfig(level=_settings.LOG_LEVEL,
format="%(levelname)s %(name)s: %(message)s")
log.info("connecting to %s:%s/%s as %s", _settings.MYSQL_HOST, _settings.MYSQL_PORT,
_settings.MYSQL_DATABASE, _settings.MYSQL_USER)
ping()
create_schema()
log.info("tables present: %s", ", ".join(sorted(Base.metadata.tables)))
with SessionLocal() as db:
seed_admin(db, _settings)

73
app/deps.py Normal file
View File

@ -0,0 +1,73 @@
"""Request-scoped dependencies: the database session and the signed-in user."""
from __future__ import annotations
from datetime import timedelta
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import get_db
from app.models import AuthSession, User, utcnow
from app.security import fingerprint, session_expiry
# Written at most this often, so ordinary asset requests do not each cost a write.
_LAST_SEEN_RESOLUTION = timedelta(minutes=5)
def client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def current_user(request: Request,
db: Session = Depends(get_db),
s: Settings = Depends(get_settings)) -> User | None:
"""Resolve the session cookie, or None. Never raises -- pages that merely
render differently for anonymous visitors depend on that."""
raw = request.cookies.get(s.SESSION_COOKIE_NAME)
if not raw:
return None
now = utcnow()
sess = db.scalar(select(AuthSession).where(AuthSession.token_hash == fingerprint(raw, s)))
if sess is None:
return None
if sess.expires_at <= now:
db.execute(delete(AuthSession).where(AuthSession.id == sess.id))
db.commit()
return None
user = db.get(User, sess.user_id)
if user is None or not user.is_active:
return None
dirty = False
if now - sess.last_seen_at > _LAST_SEEN_RESOLUTION:
sess.last_seen_at = now
dirty = True
# Rolling expiry: past the halfway mark, extend rather than sign the user
# out mid-session. The cookie's own max-age is refreshed in the same place
# it was set, so this only needs to move the server-side row.
if sess.expires_at - now < timedelta(days=s.SESSION_TTL_DAYS) / 2:
sess.expires_at = session_expiry(s, now)
dirty = True
if dirty:
db.commit()
request.state.session_id = sess.id
return user
def require_user(user: User | None = Depends(current_user)) -> User:
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Please sign in.")
return user
def require_admin(user: User = Depends(require_user)) -> User:
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN,
"That area is for administrators.")
return user

80
app/errors.py Normal file
View File

@ -0,0 +1,80 @@
"""Error rendering.
Two jobs. First, answer a 401 the way the caller can use: an HTML navigation
gets a redirect to the sign-in page, a fetch gets JSON. Without this, clicking
Excel export on an expired session dumps raw JSON into a new tab.
Second, stop leaking internals. The local serve.py answers 500s with
`f"{type(exc).__name__}: {exc}"`, which is fine for a tool on your own laptop
and not fine on a shared server.
"""
from __future__ import annotations
import logging
import uuid
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
log = logging.getLogger(__name__)
def wants_html(request: Request) -> bool:
accept = request.headers.get("accept") or ""
# fetch() sends */* or application/json; a browser navigation asks for HTML
# explicitly and first.
return "text/html" in accept
def _login_redirect(request: Request) -> RedirectResponse:
nxt = request.url.path
if request.url.query:
nxt = f"{nxt}?{request.url.query}"
return RedirectResponse(f"/login?next={nxt}", status_code=status.HTTP_303_SEE_OTHER)
def install(app: FastAPI) -> None:
@app.exception_handler(HTTPException)
async def http_exception(request: Request, exc: HTTPException):
if exc.status_code == status.HTTP_401_UNAUTHORIZED and wants_html(request):
return _login_redirect(request)
payload = {"error": exc.detail}
if isinstance(exc.detail, dict):
payload = exc.detail
return JSONResponse(payload, status_code=exc.status_code,
headers=getattr(exc, "headers", None))
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError):
# Literal 422: the Starlette constant for it was renamed, and pinning to
# either spelling would tie this file to a version range.
return JSONResponse({"error": _readable(exc)}, status_code=422)
@app.exception_handler(ValueError)
async def value_error(request: Request, exc: ValueError):
# The pipeline raises ValueError with deliberate user-facing copy
# ("No change-history files uploaded yet."). Pass it straight through.
return JSONResponse({"error": str(exc)}, status_code=status.HTTP_400_BAD_REQUEST)
@app.exception_handler(Exception)
async def unhandled(request: Request, exc: Exception):
ref = uuid.uuid4().hex[:12]
log.exception("unhandled error ref=%s on %s %s", ref, request.method,
request.url.path)
return JSONResponse(
{"error": "Something went wrong on our side.", "ref": ref},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _readable(exc: RequestValidationError) -> str:
"""Turn pydantic's error list into one sentence a person can act on."""
for err in exc.errors():
field = ".".join(str(p) for p in err.get("loc", ()) if p not in ("body", "query"))
msg = err.get("msg", "is not valid")
if field:
return f"{field}: {msg}"
return msg
return "That request was not valid."

161
app/main.py Normal file
View File

@ -0,0 +1,161 @@
"""The hosted application.
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1
One worker is load-bearing, not a leftover: uploads and the last analysis live
in this process's memory, so with two workers a user's upload and their analyse
request could land in different processes.
"""
from __future__ import annotations
import logging
import tempfile
import threading
import time
from contextlib import asynccontextmanager
from datetime import timedelta
from pathlib import Path
from urllib.parse import urlparse
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import delete
from app import errors
from app.bootstrap import seed_admin
from app.config import get_settings
from app.db import SessionLocal, create_schema
from app.models import AuthSession, EmailToken, utcnow
from app.routers import admin, analysis, auth, pages
from app.services.ratelimit import limiter
from app.services.store import WorkspaceStore, set_store, store_of
log = logging.getLogger(__name__)
settings = get_settings()
WEB = Path(__file__).resolve().parent.parent / "web"
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
SWEEP_INTERVAL_S = 60
TOKEN_RETENTION = timedelta(days=7)
_STARTED = time.monotonic()
def _workspace_root() -> Path:
base = settings.WORKSPACE_ROOT or tempfile.gettempdir()
return Path(base) / "oob-workspaces"
def _sweeper(store: WorkspaceStore, stop: threading.Event) -> None:
"""Housekeeping, in a thread rather than an asyncio task: removing a few
hundred megabytes of uploads is blocking work and would stall the loop."""
while not stop.wait(SWEEP_INTERVAL_S):
try:
store.sweep()
limiter.prune()
with SessionLocal() as db:
now = utcnow()
db.execute(delete(AuthSession).where(AuthSession.expires_at < now))
db.execute(delete(EmailToken).where(
EmailToken.expires_at < now - TOKEN_RETENTION))
db.commit()
except Exception: # noqa: BLE001 - never kill the sweeper
log.exception("sweep failed")
@asynccontextmanager
async def lifespan(app: FastAPI):
logging.basicConfig(level=settings.LOG_LEVEL,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log.info("%s starting", settings.APP_NAME)
log.info("base url %s, cookie secure=%s", settings.base_url, settings.cookie_secure)
if not settings.cookie_secure and settings.base_url.startswith("https://"):
log.warning("COOKIE_SECURE is off but APP_BASE_URL is https -- "
"session cookies will travel without the Secure flag")
create_schema()
with SessionLocal() as db:
seed_admin(db, settings)
store = WorkspaceStore(
_workspace_root(),
idle_ttl_s=settings.WORKSPACE_IDLE_TTL_MIN * 60,
max_workspaces=settings.MAX_ACTIVE_WORKSPACES,
max_total_bytes=settings.MAX_TOTAL_WORKSPACE_BYTES,
)
store.start()
set_store(store)
stop = threading.Event()
thread = threading.Thread(target=_sweeper, args=(store, stop),
name="workspace-sweeper", daemon=True)
thread.start()
log.info("ready on %s", settings.base_url)
try:
yield
finally:
stop.set()
thread.join(timeout=5)
store.dispose_all()
log.info("stopped")
app = FastAPI(title=settings.APP_NAME, lifespan=lifespan,
docs_url=None, redoc_url=None, openapi_url=None)
@app.middleware("http")
async def guard(request: Request, call_next):
"""Body-size ceiling, cross-origin write guard, and security headers."""
if request.method not in SAFE_METHODS:
declared = request.headers.get("content-length")
if declared and declared.isdigit() and int(declared) > settings.MAX_UPLOAD_BYTES:
# Refuse before reading the body. Starlette has no size limit of
# its own, so without this a large POST is fully buffered first.
return JSONResponse(
{"error": "That file is too large."},
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
# SameSite=Lax already blocks cross-site cookie-bearing writes; this
# covers the multipart upload route, which a plain HTML form can forge.
origin = request.headers.get("origin") or request.headers.get("referer")
if origin:
host = urlparse(origin).netloc.lower()
allowed = {urlparse(settings.base_url).netloc.lower(),
(request.headers.get("host") or "").lower()}
if host and host not in allowed:
return JSONResponse({"error": "Bad origin."},
status_code=status.HTTP_403_FORBIDDEN)
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
# Reset and verification tokens ride in the query string, so no Referer.
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
"script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
if request.url.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store"
return response
errors.install(app)
app.include_router(pages.router)
app.include_router(auth.router)
app.include_router(analysis.router)
app.include_router(admin.router)
# Component-wise containment, ETags and 304s for free. The local serve.py does
# this with a str.startswith prefix check, which a sibling directory would pass.
app.mount("/static", StaticFiles(directory=WEB), name="static")
@app.get("/statusz", include_in_schema=False)
def statusz() -> dict:
"""Cheap operational counters. No account data, so no auth needed."""
store = store_of()
return {"workspaces": len(store), "bytes": store.total_bytes(),
"uptime_s": int(time.monotonic() - _STARTED)}

106
app/models.py Normal file
View File

@ -0,0 +1,106 @@
"""Account tables.
Every table is prefixed `oob_`. The target database is shared and `create_all`
silently skips a table that already exists, so an unprefixed `users` could
quietly bind the app to somebody else's table.
Column types are deliberately portable -- Integer/String/Boolean/DateTime, no
MySQL-specific types -- so the test suite can run the whole auth flow against
in-memory SQLite without a database server.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import (Boolean, DateTime, ForeignKey, Index, Integer, String,
UniqueConstraint)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
VERIFY_EMAIL = "verify_email"
PASSWORD_RESET = "password_reset"
def utcnow() -> datetime:
"""Naive UTC. MySQL DATETIME carries no timezone, so storing an aware value
would round-trip as naive anyway and the comparisons would start raising."""
return datetime.now(timezone.utc).replace(tzinfo=None)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "oob_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), nullable=False) # lowercased on write
name: Mapped[str] = mapped_column(String(120), nullable=False, default="")
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# NULL means unverified. A timestamp is more useful than a bool here.
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
failed_login_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False,
default=utcnow, onupdate=utcnow)
__table_args__ = (UniqueConstraint("email", name="uq_oob_users_email"),)
@property
def is_verified(self) -> bool:
return self.email_verified_at is not None
class AuthSession(Base):
"""A signed-in browser.
Named AuthSession, not Session, so it cannot be confused with
sqlalchemy.orm.Session in a type annotation or an import.
"""
__tablename__ = "oob_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("oob_users.id", ondelete="CASCADE"), nullable=False)
# HMAC of the cookie value, never the value itself: a read-only database
# leak then yields nothing anyone can log in with.
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
ip: Mapped[str | None] = mapped_column(String(45), nullable=True) # 45 = IPv6 text max
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
__table_args__ = (
UniqueConstraint("token_hash", name="uq_oob_sessions_hash"),
Index("ix_oob_sessions_user", "user_id"),
Index("ix_oob_sessions_expires", "expires_at"),
)
class EmailToken(Base):
"""One-shot link sent by email: address verification or password reset."""
__tablename__ = "oob_email_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("oob_users.id", ondelete="CASCADE"), nullable=False)
purpose: Mapped[str] = mapped_column(String(32), nullable=False)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
requested_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
__table_args__ = (
UniqueConstraint("token_hash", name="uq_oob_email_tokens_hash"),
Index("ix_oob_email_tokens_user_purpose", "user_id", "purpose"),
Index("ix_oob_email_tokens_expires", "expires_at"),
)

1
app/routers/__init__.py Normal file
View File

@ -0,0 +1 @@
"""HTTP routes."""

96
app/routers/admin.py Normal file
View File

@ -0,0 +1,96 @@
"""Administrator endpoints: see who has an account, and turn them off."""
from __future__ import annotations
import logging
from datetime import timedelta
from fastapi import (APIRouter, BackgroundTasks, Depends, HTTPException, Request,
status)
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import get_db
from app.deps import client_ip, require_admin
from app.models import VERIFY_EMAIL, AuthSession, User
from app.routers.auth import _mint, _send_verification
from app.schemas import Ok, UserOut
from app.services.store import store_of
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"],
dependencies=[Depends(require_admin)])
@router.get("/users")
def list_users(db: Session = Depends(get_db)) -> dict:
users = db.scalars(select(User).order_by(User.created_at.desc())).all()
live = {uid for (uid,) in db.execute(
select(AuthSession.user_id).group_by(AuthSession.user_id))}
return {
"users": [
{**UserOut.of(u).model_dump(mode="json"), "signed_in": u.id in live}
for u in users
],
"sessions": db.scalar(select(func.count()).select_from(AuthSession)) or 0,
"workspaces": len(store_of()),
}
def _target(db: Session, user_id: int) -> User:
user = db.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such account.")
return user
@router.post("/users/{user_id}/deactivate", response_model=Ok)
def deactivate(user_id: int, admin: User = Depends(require_admin),
db: Session = Depends(get_db)) -> Ok:
user = _target(db, user_id)
if user.id == admin.id:
raise HTTPException(status.HTTP_400_BAD_REQUEST,
"You cannot deactivate your own account.")
user.is_active = False
# Signing them out is the point; leaving live sessions would make the
# button look like it worked while they carried on using the dashboard.
db.execute(delete(AuthSession).where(AuthSession.user_id == user.id))
db.commit()
store_of().drop(user.id)
log.info("admin %s deactivated %s", admin.email, user.email)
return Ok(message=f"{user.email} can no longer sign in.")
@router.post("/users/{user_id}/activate", response_model=Ok)
def activate(user_id: int, admin: User = Depends(require_admin),
db: Session = Depends(get_db)) -> Ok:
user = _target(db, user_id)
user.is_active = True
user.failed_login_count = 0
user.locked_until = None
db.commit()
log.info("admin %s reactivated %s", admin.email, user.email)
return Ok(message=f"{user.email} can sign in again.")
@router.post("/users/{user_id}/resend-verification", response_model=Ok)
def resend(user_id: int, request: Request, bg: BackgroundTasks,
db: Session = Depends(get_db),
s: Settings = Depends(get_settings)) -> Ok:
user = _target(db, user_id)
if user.is_verified:
return Ok(message=f"{user.email} is already confirmed.")
raw = _mint(db, user, VERIFY_EMAIL, timedelta(hours=s.VERIFY_TOKEN_TTL_HOURS),
client_ip(request))
bg.add_task(_send_verification, user, raw, s)
return Ok(message=f"A new confirmation link is on its way to {user.email}.")
@router.post("/users/{user_id}/sign-out", response_model=Ok)
def sign_out(user_id: int, db: Session = Depends(get_db)) -> Ok:
user = _target(db, user_id)
db.execute(delete(AuthSession).where(AuthSession.user_id == user.id))
db.commit()
store_of().drop(user.id)
return Ok(message=f"{user.email} has been signed out everywhere.")

113
app/routers/analysis.py Normal file
View File

@ -0,0 +1,113 @@
"""Upload, analyse, export -- the original dashboard API, now per user.
Every route here is `def`, not `async def`, on purpose. The pipeline is
synchronous and CPU-bound: one analysis holds a core for seconds to tens of
seconds. FastAPI runs `def` handlers in a worker thread, so a long run cannot
stall the event loop and everyone else's requests with it.
"""
from __future__ import annotations
import logging
import threading
from fastapi import (APIRouter, Depends, File, Form, HTTPException, Response,
UploadFile, status)
from app.config import Settings, get_settings
from app.deps import require_user
from app.models import User
from app.schemas import Ok, SettingsIn
from app.services import exporters
from app.services.analysis import run_analysis
from app.services.ratelimit import limiter
from app.services.store import UploadTooLarge, UserWorkspace, store_of
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["analysis"])
# Bounds peak memory. The default worker threadpool is 40 threads; without this
# a burst of uploads could start 40 concurrent pipelines and exhaust the box.
_slots = threading.BoundedSemaphore(get_settings().MAX_CONCURRENT_ANALYSES)
def workspace(user: User = Depends(require_user)) -> UserWorkspace:
return store_of().get(user.id)
@router.get("/state")
def state(user: User = Depends(require_user),
ws: UserWorkspace = Depends(workspace)) -> dict:
with ws.lock:
payload = ws.names()
payload["user"] = {"email": user.email, "name": user.name, "is_admin": user.is_admin}
return payload
@router.post("/upload")
def upload(file: UploadFile = File(...), kind: str = Form("history"),
ws: UserWorkspace = Depends(workspace),
s: Settings = Depends(get_settings)) -> dict:
if kind not in ("history", "perf"):
raise ValueError("Unknown upload kind.")
try:
with ws.lock:
saved = ws.add(file.filename or "upload.xlsx", file.file, kind,
s.MAX_UPLOAD_BYTES, s.MAX_WORKSPACE_BYTES)
except UploadTooLarge as exc:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, str(exc)) from exc
finally:
file.file.close()
return {"ok": True, "name": saved.name.split("_", 1)[-1]}
@router.post("/analyze")
def analyze(body: SettingsIn | None = None,
user: User = Depends(require_user),
ws: UserWorkspace = Depends(workspace)) -> dict:
wait = limiter.check("analyze:user", str(user.id))
if wait:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS,
"That is a lot of runs in a short time. Give it a minute.",
headers={"Retry-After": str(int(wait))})
if not _slots.acquire(blocking=False):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE,
"The server is analysing other reports right now. "
"Try again in a moment.",
headers={"Retry-After": "30"})
try:
settings_in = (body or SettingsIn()).model_dump()
with ws.lock:
# Released before the run so the previous result is not held
# alongside the new one at the peak of the build.
ws.last = None
result = run_analysis(ws.history, ws.perf, settings_in)
ws.last = result.artifacts
return result.payload
finally:
_slots.release()
@router.post("/clear", response_model=Ok)
def clear(ws: UserWorkspace = Depends(workspace)) -> Ok:
with ws.lock:
ws.clear()
return Ok()
@router.get("/export")
def export(format: str = "xlsx", ws: UserWorkspace = Depends(workspace)) -> Response:
with ws.lock:
last = ws.last
if not last:
raise ValueError("Analyse some files first.")
stamp = exporters.export_stamp(last)
if format == "csv":
body, mime, name = (exporters.csv_bytes(last), exporters.CSV_MIME,
f"ppc-budget_{stamp}.csv")
else:
body, mime, name = (exporters.xlsx_bytes(last), exporters.XLSX_MIME,
f"ppc-budget-report_{stamp}.xlsx")
return Response(body, media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{name}"'})

310
app/routers/auth.py Normal file
View File

@ -0,0 +1,310 @@
"""Sign up, sign in, verify an address, reset a password.
Two rules run through all of it.
Nothing here tells a caller whether an address has an account. Signup and
forgot-password always answer the same way; login answers with one generic
message whether the address is unknown, the password wrong, the account locked,
or the account disabled.
Nothing here consumes a token on GET. Mail scanners -- Outlook Safe Links,
Defender, Proofpoint -- fetch every URL in an inbound message, so a link that
acts on GET is spent before the recipient ever clicks it. The pages are static;
they read the token from the URL and POST it.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from fastapi import (APIRouter, BackgroundTasks, Depends, HTTPException, Request,
Response, status)
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import get_db
from app.deps import client_ip, current_user, require_user
from app.models import (PASSWORD_RESET, VERIFY_EMAIL, AuthSession, EmailToken,
User, utcnow)
from app.schemas import EmailIn, LoginIn, Ok, ResetIn, SignupIn, TokenIn, UserOut
from app.security import (burn_cpu, clear_session_cookie, fingerprint,
hash_password, new_token, session_expiry,
set_session_cookie, verify_password)
from app.services import email_service
from app.services.ratelimit import limiter
from app.services.store import store_of
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
BAD_CREDENTIALS = "Email or password is incorrect."
CHECK_INBOX = "Check your email to finish setting up your account."
RESET_SENT = "If that address has an account, a reset link is on its way."
BAD_LINK = "That link has expired or has already been used."
# --------------------------------------------------------------------- helpers
def _limit(bucket: str, subject: str) -> None:
wait = limiter.check(bucket, subject)
if wait:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS,
"Too many attempts. Try again in a few minutes.",
headers={"Retry-After": str(int(wait))})
def _mint(db: Session, user: User, purpose: str, ttl: timedelta,
ip: str | None) -> str:
"""Issue a one-shot token, retiring any earlier unused one of this purpose
so a mailbox cannot accumulate a stack of live links."""
db.execute(
update(EmailToken)
.where(EmailToken.user_id == user.id, EmailToken.purpose == purpose,
EmailToken.used_at.is_(None))
.values(used_at=utcnow())
)
raw = new_token()
db.add(EmailToken(user_id=user.id, purpose=purpose, token_hash=fingerprint(raw),
expires_at=utcnow() + ttl, requested_ip=ip))
db.commit()
return raw
def _consume(db: Session, raw: str, purpose: str) -> User:
"""Spend a token, or refuse. One UPDATE, so two simultaneous clicks cannot
both win: whichever loses sees rowcount 0."""
now = utcnow()
result = db.execute(
update(EmailToken)
.where(EmailToken.token_hash == fingerprint(raw),
EmailToken.purpose == purpose,
EmailToken.used_at.is_(None),
EmailToken.expires_at > now)
.values(used_at=now)
)
if result.rowcount != 1:
db.rollback()
raise HTTPException(status.HTTP_400_BAD_REQUEST, BAD_LINK)
token = db.scalar(select(EmailToken).where(EmailToken.token_hash == fingerprint(raw)))
user = db.get(User, token.user_id) if token else None
if user is None:
db.rollback()
raise HTTPException(status.HTTP_400_BAD_REQUEST, BAD_LINK)
db.commit()
return user
def _start_session(db: Session, user: User, request: Request,
response: Response, s: Settings) -> None:
now = utcnow()
raw = new_token()
db.add(AuthSession(
user_id=user.id, token_hash=fingerprint(raw, s),
created_at=now, last_seen_at=now, expires_at=session_expiry(s, now),
ip=client_ip(request)[:45],
user_agent=(request.headers.get("user-agent") or "")[:255] or None,
))
user.last_login_at = now
user.failed_login_count = 0
user.locked_until = None
db.commit()
set_session_cookie(response, raw, s)
def _send_verification(user: User, raw: str, s: Settings) -> None:
email_service.send_verification_email(
user.email, user.name, f"{s.base_url}/verify?token={raw}")
def _domain_allowed(email: str, s: Settings) -> bool:
allowed = s.allowed_signup_domains
return not allowed or email.rsplit("@", 1)[-1].lower() in allowed
# ---------------------------------------------------------------------- routes
@router.post("/signup", response_model=Ok)
def signup(body: SignupIn, request: Request, bg: BackgroundTasks,
db: Session = Depends(get_db), s: Settings = Depends(get_settings)) -> Ok:
if not s.SIGNUP_ENABLED:
raise HTTPException(status.HTTP_403_FORBIDDEN,
"New accounts are not being created right now.")
ip = client_ip(request)
_limit("signup:ip", ip)
email = body.email.strip().lower()
if not _domain_allowed(email, s):
allowed = ", ".join(sorted(s.allowed_signup_domains))
raise HTTPException(status.HTTP_403_FORBIDDEN,
f"Accounts are limited to these domains: {allowed}.")
existing = db.scalar(select(User).where(User.email == email))
if existing is not None:
# Same answer as a fresh signup. The real owner is told what happened;
# whoever submitted the form learns nothing.
if existing.is_verified:
bg.add_task(email_service.send_account_exists_email, existing.email,
existing.name, f"{s.base_url}/login", f"{s.base_url}/forgot")
else:
raw = _mint(db, existing, VERIFY_EMAIL,
timedelta(hours=s.VERIFY_TOKEN_TTL_HOURS), ip)
bg.add_task(_send_verification, existing, raw, s)
return Ok(message=CHECK_INBOX)
user = User(email=email, name=body.name, password_hash=hash_password(body.password))
db.add(user)
try:
db.commit()
except IntegrityError:
# Lost a race against a simultaneous signup for the same address.
db.rollback()
return Ok(message=CHECK_INBOX)
raw = _mint(db, user, VERIFY_EMAIL, timedelta(hours=s.VERIFY_TOKEN_TTL_HOURS), ip)
bg.add_task(_send_verification, user, raw, s)
log.info("account created: %s", email)
return Ok(message=CHECK_INBOX)
@router.post("/login")
def login(body: LoginIn, request: Request, response: Response,
db: Session = Depends(get_db), s: Settings = Depends(get_settings)) -> dict:
email = body.email.strip().lower()
_limit("login:ip", client_ip(request))
_limit("login:email", email)
user = db.scalar(select(User).where(User.email == email))
if user is None:
burn_cpu() # match the timing of a real verify
raise HTTPException(status.HTTP_401_UNAUTHORIZED, BAD_CREDENTIALS)
now = utcnow()
# Locked accounts get the same message as a wrong password. Saying "locked"
# would confirm the address exists and hand out a way to grief a colleague.
if user.locked_until and user.locked_until > now:
burn_cpu()
raise HTTPException(status.HTTP_401_UNAUTHORIZED, BAD_CREDENTIALS)
ok, rehashed = verify_password(user.password_hash, body.password)
if not ok:
user.failed_login_count += 1
if user.failed_login_count >= s.MAX_FAILED_LOGINS:
over = user.failed_login_count - s.MAX_FAILED_LOGINS
minutes = min(60, s.LOCKOUT_MINUTES * (2 ** over))
user.locked_until = now + timedelta(minutes=minutes)
log.warning("account %s locked for %d minutes", email, minutes)
db.commit()
raise HTTPException(status.HTTP_401_UNAUTHORIZED, BAD_CREDENTIALS)
if not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, BAD_CREDENTIALS)
if not user.is_verified:
# The one place the answer is specific. It only reaches someone who
# already has valid credentials, and without it an unverified user has
# no way forward.
user.failed_login_count = 0
db.commit()
raise HTTPException(
status.HTTP_403_FORBIDDEN,
{"error": "Confirm your email address before signing in.",
"code": "email_not_verified"})
if rehashed:
user.password_hash = rehashed
_start_session(db, user, request, response, s)
limiter.reset("login:email", email)
return {"ok": True, "user": UserOut.of(user).model_dump(mode="json")}
@router.post("/logout", response_model=Ok)
def logout(request: Request, response: Response,
db: Session = Depends(get_db), s: Settings = Depends(get_settings)) -> Ok:
raw = request.cookies.get(s.SESSION_COOKIE_NAME)
if raw:
db.execute(delete(AuthSession).where(AuthSession.token_hash == fingerprint(raw, s)))
db.commit()
clear_session_cookie(response, s)
return Ok(message="Signed out.")
@router.get("/me")
def me(user: User = Depends(require_user)) -> dict:
return {"user": UserOut.of(user).model_dump(mode="json")}
@router.post("/verify", response_model=Ok)
def verify(body: TokenIn, db: Session = Depends(get_db)) -> Ok:
user = _consume(db, body.token, VERIFY_EMAIL)
if not user.is_verified:
user.email_verified_at = utcnow()
db.commit()
log.info("email verified: %s", user.email)
return Ok(message="Your email address is confirmed. You can sign in now.")
@router.post("/resend-verification", response_model=Ok)
def resend_verification(body: EmailIn, request: Request, bg: BackgroundTasks,
db: Session = Depends(get_db),
s: Settings = Depends(get_settings)) -> Ok:
email = body.email.strip().lower()
_limit("resend:email", email)
user = db.scalar(select(User).where(User.email == email))
if user is not None and not user.is_verified and user.is_active:
raw = _mint(db, user, VERIFY_EMAIL, timedelta(hours=s.VERIFY_TOKEN_TTL_HOURS),
client_ip(request))
bg.add_task(_send_verification, user, raw, s)
return Ok(message=CHECK_INBOX)
@router.post("/forgot", response_model=Ok)
def forgot(body: EmailIn, request: Request, bg: BackgroundTasks,
db: Session = Depends(get_db), s: Settings = Depends(get_settings)) -> Ok:
email = body.email.strip().lower()
_limit("forgot:ip", client_ip(request))
_limit("forgot:email", email)
user = db.scalar(select(User).where(User.email == email))
if user is None or not user.is_active:
burn_cpu() # keep the timing indistinguishable
return Ok(message=RESET_SENT)
raw = _mint(db, user, PASSWORD_RESET,
timedelta(minutes=s.RESET_TOKEN_TTL_MINUTES), client_ip(request))
bg.add_task(email_service.send_password_reset_email, user.email, user.name,
f"{s.base_url}/reset?token={raw}")
return Ok(message=RESET_SENT)
@router.post("/reset", response_model=Ok)
def reset(body: ResetIn, bg: BackgroundTasks, db: Session = Depends(get_db)) -> Ok:
user = _consume(db, body.token, PASSWORD_RESET)
user.password_hash = hash_password(body.password)
# Controlling the mailbox proves the address, so an unverified account
# becomes verified here rather than stranding the user.
if not user.is_verified:
user.email_verified_at = utcnow()
user.failed_login_count = 0
user.locked_until = None
# Every other browser is signed out. This is the point of server-side
# sessions: a stolen cookie dies with the password that leaked it.
db.execute(delete(AuthSession).where(AuthSession.user_id == user.id))
db.commit()
store_of().drop(user.id)
bg.add_task(email_service.send_password_changed_email, user.email, user.name)
log.info("password reset completed: %s", user.email)
return Ok(message="Your password has been changed. Sign in with it now.")
@router.get("/session")
def session_state(user: User | None = Depends(current_user)) -> dict:
"""Whether this browser is signed in. Never 401s -- the sign-in page itself
uses it to bounce an already-authenticated visitor onward."""
return {"authenticated": user is not None,
"user": UserOut.of(user).model_dump(mode="json") if user else None}

102
app/routers/pages.py Normal file
View File

@ -0,0 +1,102 @@
"""The HTML pages, plus the two health endpoints.
There is no template engine. The only thing a template would inject is the
`next` parameter and a status message, both of which the page reads from
location.search -- so these are static files and the JS does the rest.
"""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from app import db as db_module
from app.deps import current_user
from app.models import User
log = logging.getLogger(__name__)
router = APIRouter(tags=["pages"])
WEB = Path(__file__).resolve().parent.parent.parent / "web"
NO_STORE = {"Cache-Control": "no-store"}
def page(name: str) -> FileResponse:
return FileResponse(WEB / name, media_type="text/html; charset=utf-8",
headers=NO_STORE)
@router.get("/", include_in_schema=False)
def index(user: User | None = Depends(current_user)) -> Response:
# Redirect on the server. Gating in JS would flash the whole app shell
# before bouncing, and would show nothing at all with scripting disabled.
if user is None:
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
return page("index.html")
@router.get("/login", include_in_schema=False)
def login_page(request: Request, user: User | None = Depends(current_user)) -> Response:
if user is not None:
nxt = request.query_params.get("next") or "/"
if not nxt.startswith("/") or nxt.startswith("//"):
nxt = "/" # only ever redirect within this site
return RedirectResponse(nxt, status_code=status.HTTP_303_SEE_OTHER)
return page("login.html")
@router.get("/signup", include_in_schema=False)
def signup_page(user: User | None = Depends(current_user)) -> Response:
if user is not None:
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
return page("signup.html")
@router.get("/forgot", include_in_schema=False)
def forgot_page() -> Response:
return page("forgot.html")
# Both of these are deliberately inert: they render, read the token out of the
# query string, and POST it. A mail scanner that GETs every link in a message
# therefore cannot spend the token before the recipient opens it.
@router.get("/reset", include_in_schema=False)
def reset_page() -> Response:
return page("reset.html")
@router.get("/verify", include_in_schema=False)
def verify_page() -> Response:
return page("verify.html")
@router.get("/admin", include_in_schema=False)
def admin_page(user: User | None = Depends(current_user)) -> Response:
if user is None:
return RedirectResponse("/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
if not user.is_admin:
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
return page("admin.html")
@router.get("/healthz", include_in_schema=False)
def healthz() -> JSONResponse:
"""Liveness only. Deliberately does not touch MySQL: if it did, a thirty
second RDS failover would restart the container instead of just failing the
handful of routes that actually need the database."""
return JSONResponse({"status": "ok"}, headers=NO_STORE)
@router.get("/readyz", include_in_schema=False)
def readyz() -> JSONResponse:
try:
db_module.ping()
except Exception as exc: # noqa: BLE001 - report, don't raise
log.warning("readiness check failed: %s", exc)
return JSONResponse({"db": "unreachable"},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
headers=NO_STORE)
return JSONResponse({"db": "ok"}, headers=NO_STORE)

84
app/schemas.py Normal file
View File

@ -0,0 +1,84 @@
"""Request and response bodies.
Declaring these as pydantic models is also a CSRF control: FastAPI then rejects
form-encoded content types with a 422, and an HTML form can only produce those.
A cross-site form therefore cannot reach any of these endpoints even before the
SameSite cookie attribute is considered.
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, field_validator
from app.config import get_settings
def _password_field() -> Field:
s = get_settings()
return Field(min_length=s.MIN_PASSWORD_LENGTH, max_length=s.MAX_PASSWORD_LENGTH)
class SignupIn(BaseModel):
email: EmailStr
password: str = _password_field()
name: str = Field(default="", max_length=120)
@field_validator("name")
@classmethod
def _tidy(cls, v: str) -> str:
return v.strip()
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=256)
class EmailIn(BaseModel):
email: EmailStr
class TokenIn(BaseModel):
token: str = Field(min_length=8, max_length=256)
class ResetIn(TokenIn):
password: str = _password_field()
class SettingsIn(BaseModel):
"""The modelling assumptions the dashboard sends with /api/analyze.
`roas` is a string because the settings dialog sends "" for "use the
account average", and the pipeline already treats falsy as absent.
"""
roas: str | float | None = None
haircut: float = Field(default=0.7, ge=0, le=1)
cap: float = Field(default=3, gt=0, le=100)
merge_gap: int = Field(default=5, ge=0, le=720)
class UserOut(BaseModel):
id: int
email: str
name: str
is_admin: bool
is_active: bool
verified: bool
created_at: datetime | None = None
last_login_at: datetime | None = None
@classmethod
def of(cls, user) -> "UserOut":
return cls(id=user.id, email=user.email, name=user.name,
is_admin=user.is_admin, is_active=user.is_active,
verified=user.is_verified, created_at=user.created_at,
last_login_at=user.last_login_at)
class Ok(BaseModel):
ok: bool = True
message: str | None = None

94
app/security.py Normal file
View File

@ -0,0 +1,94 @@
"""Password hashing, opaque token minting, and the session cookie."""
from __future__ import annotations
import hashlib
import hmac
import logging
import secrets
from datetime import datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import (InvalidHashError, VerificationError,
VerifyMismatchError)
from fastapi import Response
from app.config import Settings, get_settings
log = logging.getLogger(__name__)
# OWASP's second recommended argon2id profile: 19 MiB, t=2, p=1. Sized so that
# a handful of concurrent logins cannot exhaust a 2 GB container's memory.
_ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1,
hash_len=32, salt_len=16)
# Verified on the unknown-email path so response time does not distinguish
# "no such account" from "wrong password".
_DUMMY_HASH = _ph.hash("timing-equalisation-placeholder")
def hash_password(raw: str) -> str:
return _ph.hash(raw)
def verify_password(stored: str, raw: str) -> tuple[bool, str | None]:
"""(ok, replacement_hash). The replacement is set when the stored hash was
made with weaker parameters than the current ones."""
try:
_ph.verify(stored, raw)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False, None
try:
return True, (_ph.hash(raw) if _ph.check_needs_rehash(stored) else None)
except Exception: # noqa: BLE001 - a rehash failure must not block login
return True, None
def burn_cpu() -> None:
"""Spend the same time a real verify would, and discard the result."""
try:
_ph.verify(_DUMMY_HASH, "x")
except Exception: # noqa: BLE001 - always mismatches, by design
pass
# --------------------------------------------------------------------- tokens
def new_token() -> str:
"""256 bits of URL-safe randomness. Used for both session cookies and the
one-shot links sent by email."""
return secrets.token_urlsafe(32)
def fingerprint(raw: str, s: Settings | None = None) -> str:
"""What gets stored. Keyed with SECRET_KEY so a leaked table of hashes is
not enough to forge one, and so rotating the key invalidates everything."""
key = (s or get_settings()).SECRET_KEY.get_secret_value().encode()
return hmac.new(key, raw.encode(), hashlib.sha256).hexdigest()
# --------------------------------------------------------------------- cookie
def set_session_cookie(response: Response, raw_token: str, s: Settings) -> None:
response.set_cookie(
key=s.SESSION_COOKIE_NAME,
value=raw_token,
max_age=s.SESSION_TTL_DAYS * 86400,
httponly=True, # app.js builds a lot of markup; XSS must not reach this
samesite="lax", # strict would break links clicked from a webmail tab
secure=s.cookie_secure,
path="/",
# No domain: host-only. A Domain attribute on a bare IP is invalid and
# the browser drops the cookie without saying so.
)
def clear_session_cookie(response: Response, s: Settings) -> None:
response.delete_cookie(
key=s.SESSION_COOKIE_NAME, path="/",
httponly=True, samesite="lax", secure=s.cookie_secure,
)
def session_expiry(s: Settings, now: datetime) -> datetime:
return now + timedelta(days=s.SESSION_TTL_DAYS)

6
app/services/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""Framework-free services.
`analysis` and `exporters` deliberately import nothing beyond the standard
library and `ppcbudget`, so that the local single-user serve.py can use them
without pulling in FastAPI, SQLAlchemy or anything else.
"""

95
app/services/analysis.py Normal file
View File

@ -0,0 +1,95 @@
"""The analysis pipeline, lifted out of serve.py so more than one caller can use it.
This module imports only the standard library and `ppcbudget`. Keep it that way:
serve.py depends on it staying installable with nothing but openpyxl.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ppcbudget import actions as actions_mod
from ppcbudget import aggregate, metrics, payload, perfjoin
from ppcbudget.ingest import dedupe_events, load_history
from ppcbudget.scoring import check_invariants, score_all
@dataclass
class AnalysisResult:
"""What one run produced.
`payload` is the compact JSON the dashboard renders. `artifacts` is the
scored data itself, kept because the Excel and CSV exports rebuild their
output from it rather than from the payload.
"""
payload: dict
artifacts: dict
def run_analysis(history: Sequence[Path], perf: Path | None,
settings_in: Mapping[str, Any]) -> AnalysisResult:
"""Run the pipeline over everything uploaded so far."""
if not history:
raise ValueError("No change-history files uploaded yet.")
events, metas, qas, skipped = [], [], [], []
for path in history:
try:
evs, meta, qa = load_history(path)
except (ValueError, KeyError, OSError) as exc:
skipped.append(f"{path.name}: {exc}")
continue
events.extend(evs)
metas.append(meta)
qas.append(qa)
if not events:
detail = " ".join(skipped) or "no readable rows"
raise ValueError(f"None of the files could be read as a change-history export. {detail}")
events, overlap_rows = dedupe_events(events)
days = score_all(events, merge_gap_min=int(settings_in.get("merge_gap", 5)))
if not days:
raise ValueError("No campaigns had budget-state changes, so there is nothing to score.")
join_report = None
roas_source = "account_average"
if perf:
records, join_report = perfjoin.load_performance(perf)
perfjoin.apply_to(days, records, join_report)
roas_source = "campaign"
account_roas = next((m.roas for m in metas if m.roas), None)
roas_override = settings_in.get("roas")
settings = metrics.ModelSettings(
roas=float(roas_override) if roas_override else (account_roas or 4.0),
roas_source="override" if roas_override else roas_source,
haircut=float(settings_in.get("haircut", metrics.DEFAULT_ROAS_HAIRCUT)),
cap_multiple=float(settings_in.get("cap", metrics.DEFAULT_CAP_MULTIPLE)),
)
metrics.apply(days, settings)
totals = metrics.summarize(days)
rollups = aggregate.rollup(days)
date_keys = sorted({d.date_key for d in days})
scored_names = {d.campaign for d in days}
acts = actions_mod.build(events, date_keys, scored_names)
act_summary = actions_mod.summarize(acts)
data = payload.build(days, totals, rollups, qas, metas, settings, date_keys,
join_report, overlap_rows, acts, act_summary)
problems = check_invariants(days)
data["invariants"] = {"checked": len(days), "failed": problems[:5]}
data["skipped"] = skipped
artifacts = {
"days": days, "totals": totals, "rollups": rollups, "qas": qas,
"metas": metas, "settings": settings, "date_keys": date_keys,
"join_report": join_report, "overlap_rows": overlap_rows,
"actions": acts,
}
return AnalysisResult(payload=data, artifacts=artifacts)

View File

@ -0,0 +1,149 @@
"""Outbound email.
`_send` never raises into a request path. A mail outage should leave the user
with "check your inbox" and a resend button, not a 500 -- and on the
forgot-password route, raising would also reveal whether the address existed.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Sequence
from html import escape
import httpx
from app.config import get_settings
log = logging.getLogger(__name__)
def _shell(name: str, heading: str, body_html: str) -> str:
"""Plain, table-free HTML. Mail clients are not browsers."""
greeting = f"Hi {escape(name)}," if name else "Hi,"
return (
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
'font-size:15px;line-height:1.55;color:#111827;max-width:520px">'
f"<h2 style=\"font-size:18px;margin:0 0 14px\">{escape(heading)}</h2>"
f"<p style=\"margin:0 0 12px\">{greeting}</p>"
f"{body_html}"
'<p style="margin:22px 0 0;font-size:12.5px;color:#6b7280">'
"You are receiving this because someone used this address on the "
"PPC out-of-budget dashboard. If that was not you, ignore this message."
"</p></div>"
)
def _button(url: str, label: str) -> str:
safe = escape(url, quote=True)
return (
f'<p style="margin:0 0 18px"><a href="{safe}" '
'style="display:inline-block;background:#111827;color:#fff;text-decoration:none;'
f'padding:10px 18px;border-radius:8px;font-weight:600">{escape(label)}</a></p>'
'<p style="margin:0 0 6px;font-size:12.5px;color:#6b7280">'
"If the button does not work, paste this into your browser:</p>"
f'<p style="margin:0;font-size:12.5px;word-break:break-all">{escape(url)}</p>'
)
def _send(to: str, subject: str, html: str,
attachment: str | Sequence[str] | None = None) -> bool:
"""Deliver one message. Returns True on success, False on any failure.
The endpoint declares `to` as an array and attachments as `files` (plural).
httpx sends a single string for `to` as one repeated field, which the API
wraps back into a list, so one recipient needs no special handling.
"""
s = get_settings()
if s.EMAIL_PROVIDER == "console":
log.info("EMAIL (console provider)\n to: %s\n subject: %s\n body:\n%s",
to, subject, html)
return True
data = {"to": to, "subject": subject, "body": html, "content_type": "html"}
paths = [attachment] if isinstance(attachment, str) else list(attachment or ())
handles = []
try:
# The field name has to be "files"; anything else is accepted and then
# silently dropped, and the response comes back with attachments: [].
for path in paths:
handle = open(path, "rb")
handles.append(handle)
files = [("files", (os.path.basename(p), h))
for p, h in zip(paths, handles)] or None
resp = httpx.post(
s.EMAIL_ENDPOINT,
headers={"Authorization": f"Bearer {s.EMAIL_API_KEY.get_secret_value()}"},
data=data, files=files, timeout=s.EMAIL_TIMEOUT_S,
)
if resp.status_code >= 300: # the API answers 202 on success
log.error("email API returned %s for %s: %s",
resp.status_code, to, resp.text[:300])
return False
return True
except (httpx.HTTPError, OSError) as exc:
log.error("could not send email to %s: %s", to, exc)
return False
finally:
for handle in handles:
handle.close()
def send_email(to: str, subject: str, html: str,
attachment: str | Sequence[str] | None = None) -> bool:
"""Public wrapper for arbitrary messages. Prefer the named helpers below."""
return _send(to, subject, html, attachment)
def send_verification_email(to: str, name: str, verify_url: str) -> bool:
hours = get_settings().VERIFY_TOKEN_TTL_HOURS
body = (
"<p style=\"margin:0 0 18px\">Confirm this address to finish setting up your "
"account on the PPC out-of-budget dashboard.</p>"
+ _button(verify_url, "Confirm my email")
+ f'<p style="margin:16px 0 0;font-size:12.5px;color:#6b7280">'
f"This link works once and expires in {hours} hours.</p>"
)
return _send(to, "Confirm your email address",
_shell(name, "One more step", body))
def send_password_reset_email(to: str, name: str, reset_url: str) -> bool:
minutes = get_settings().RESET_TOKEN_TTL_MINUTES
body = (
'<p style="margin:0 0 18px">Use the button below to choose a new password.</p>'
+ _button(reset_url, "Choose a new password")
+ f'<p style="margin:16px 0 0;font-size:12.5px;color:#6b7280">'
f"This link works once and expires in {minutes} minutes. If you did not "
"ask for it, nothing has changed and you can ignore this.</p>"
)
return _send(to, "Reset your password", _shell(name, "Password reset", body))
def send_account_exists_email(to: str, name: str, login_url: str, forgot_url: str) -> bool:
"""Sent when someone signs up with an address that already has an account.
Signup answers identically either way, so this is what tells the real owner
what happened without telling the requester whether the address exists.
"""
body = (
'<p style="margin:0 0 12px">Someone just tried to create an account with this '
"address, but you already have one.</p>"
+ _button(login_url, "Sign in")
+ '<p style="margin:18px 0 0;font-size:13px">Forgotten your password? '
f'<a href="{escape(forgot_url, quote=True)}">Reset it here</a>.</p>'
)
return _send(to, "You already have an account",
_shell(name, "You already have an account", body))
def send_password_changed_email(to: str, name: str) -> bool:
body = ('<p style="margin:0 0 12px">Your password has just been changed, and every '
"browser that was signed in has been signed out.</p>"
'<p style="margin:0">If this was not you, reset your password immediately '
"and tell whoever runs this dashboard.</p>")
return _send(to, "Your password was changed",
_shell(name, "Your password was changed", body))

85
app/services/exporters.py Normal file
View File

@ -0,0 +1,85 @@
"""CSV and Excel renderings of an analysis, lifted out of serve.py.
Same rule as `analysis`: standard library and `ppcbudget` only.
"""
from __future__ import annotations
import csv
import io
import shutil
import tempfile
from datetime import date
from pathlib import Path
from ppcbudget import excelout
CSV_MIME = "text/csv; charset=utf-8"
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
def export_stamp(artifacts: dict) -> str:
"""The suffix both filenames carry: last day analysed, then today."""
return f"{artifacts['date_keys'][-1]}_{date.today():%Y%m%d}"
def csv_bytes(artifacts: dict) -> bytes:
"""One row per campaign-day. BOM-prefixed so Excel opens it as UTF-8."""
return _csv(artifacts["days"], artifacts.get("actions") or {}).encode("utf-8-sig")
def xlsx_bytes(artifacts: dict) -> bytes:
"""The full formatted workbook, built in a temp dir and read back."""
out = Path(tempfile.mkdtemp()) / f"ppc-budget-report_{export_stamp(artifacts)}.xlsx"
try:
excelout.write_report(out, artifacts["days"], artifacts["totals"],
artifacts["rollups"], artifacts["qas"], artifacts["metas"],
artifacts["settings"], artifacts["date_keys"],
artifacts["join_report"], artifacts.get("overlap_rows", 0),
artifacts.get("actions"))
return out.read_bytes()
finally:
shutil.rmtree(out.parent, ignore_errors=True)
def _csv(days, actions: dict) -> str:
buf = io.StringIO()
w = csv.writer(buf, lineterminator="\r\n")
w.writerow([
"date", "campaign", "eligible_hours", "in_budget_hours", "out_of_budget_hours",
"paused_hours", "pct_of_active_day", "budget_cap_hits", "distinct_outages",
"first_out", "last_recovery", "ended_out", "daily_budget", "budget_source",
"spend_rate_per_hour", "lost_spend", "lost_sales", "capped", "severity",
"diagnosis", "confidence", "uncertainty_hours",
"last_action", "days_since_action", "what_changed_last", "actions_in_window",
])
for d in sorted(days, key=lambda x: (-x.severity, x.campaign)):
lost = d.lost or {}
w.writerow([
d.date_key, d.campaign, f"{d.eligible_min / 60:.2f}", f"{d.in_hours:.2f}",
f"{d.oob_hours:.2f}", f"{d.paused_hours:.2f}", f"{d.oob_share:.4f}",
d.episodes_raw, d.episodes_merged,
excelout.hhmm(d.first_oob_min), excelout.hhmm(d.last_recovery_min),
"yes" if d.closed_oob else "no",
# Deliberately blank, never 0, when unobserved.
f"{d.budget.time_weighted:.2f}" if d.budget.time_weighted else "",
d.budget.source,
f"{lost['spend_rate_per_hour']:.4f}" if lost.get("spend_rate_per_hour") else "",
f"{lost['lost_spend']:.2f}" if lost.get("lost_spend") is not None else "",
f"{lost['lost_sales']:.2f}" if lost.get("lost_sales") is not None else "",
"yes" if lost.get("capped") else "",
f"{d.severity:.1f}", d.diagnosis, d.confidence,
f"{d.oob_uncertainty_min / 60:.2f}" if d.chain_breaks else "",
*_action_columns(actions.get(d.campaign)),
])
return buf.getvalue()
def _action_columns(act) -> tuple:
"""Last meaningful action, or an explicit statement that there was none."""
if act is None:
return ("not observed", "", "", "")
return (act.summary,
"" if act.days_since is None else act.days_since,
act.last_label or "",
act.count or "")

59
app/services/ratelimit.py Normal file
View File

@ -0,0 +1,59 @@
"""A sliding-window rate limiter that needs no infrastructure.
The app is pinned to a single worker by its in-memory workspaces, so an
in-process limiter is exactly as effective as Redis would be here, and one
fewer thing to run.
"""
from __future__ import annotations
import threading
import time
from collections import defaultdict, deque
# key -> (limit, window seconds)
BUDGETS: dict[str, tuple[int, float]] = {
"login:ip": (20, 600),
"login:email": (10, 600),
"signup:ip": (5, 3600),
"forgot:ip": (5, 3600),
"forgot:email": (3, 3600),
"resend:email": (3, 3600),
"analyze:user": (10, 300),
}
class SlidingWindow:
def __init__(self) -> None:
self._hits: dict[str, deque[float]] = defaultdict(deque)
self._lock = threading.Lock()
def check(self, bucket: str, subject: str) -> float:
"""Seconds to wait, or 0.0 if the call is allowed. Records the hit."""
limit, window = BUDGETS[bucket]
key = f"{bucket}:{subject}"
now = time.monotonic()
with self._lock:
q = self._hits[key]
while q and now - q[0] > window:
q.popleft()
if len(q) >= limit:
return max(1.0, window - (now - q[0]))
q.append(now)
return 0.0
def reset(self, bucket: str, subject: str) -> None:
"""Forget a subject's history -- called after a successful login."""
with self._lock:
self._hits.pop(f"{bucket}:{subject}", None)
def prune(self) -> None:
"""Drop empty queues so the dictionary cannot grow without bound."""
now = time.monotonic()
with self._lock:
for key in [k for k, q in self._hits.items()
if not q or now - q[-1] > max(w for _, w in BUDGETS.values())]:
self._hits.pop(key, None)
limiter = SlidingWindow()

226
app/services/store.py Normal file
View File

@ -0,0 +1,226 @@
"""Per-user upload workspaces.
The local serve.py keeps one process-wide Session; hosted mode keeps one of
these per signed-in user. Two lock levels, and the order matters: the store
lock only ever guards the dictionary, never file I/O and never the analysis
itself, so one user's thirty-second run cannot block another user's file list.
"""
from __future__ import annotations
import logging
import re
import shutil
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import BinaryIO
log = logging.getLogger(__name__)
ALLOWED_SUFFIXES = (".xlsx", ".xlsm", ".csv", ".tsv")
_UNSAFE = re.compile(r"[^A-Za-z0-9._-]")
_CHUNK = 1 << 20
class UploadTooLarge(Exception):
"""Raised when a file, or the workspace total, would exceed its cap."""
def safe_name(raw: str, index: int) -> str:
"""A filename that cannot escape the workspace directory.
serve.py used `Path(name).name`, which is Windows-shaped: on Linux a
backslash is an ordinary filename character, so 'a\\b.xlsx' comes back whole
and '..' survives intact. Strip to an explicit allowlist instead.
"""
base = PurePosixPath(raw.replace("\\", "/")).name
base = _UNSAFE.sub("_", base).lstrip(".")[:100]
if not base:
base = "upload.xlsx"
if not base.lower().endswith(ALLOWED_SUFFIXES):
raise ValueError("Only .xlsx, .xlsm, .csv and .tsv files can be analysed.")
return f"{index}_{base}"
@dataclass
class UserWorkspace:
"""One user's uploads and their most recent analysis."""
user_id: int
dir: Path
history: list[Path] = field(default_factory=list)
perf: Path | None = None
last: dict | None = None # AnalysisResult.artifacts
bytes_used: int = 0
touched: float = field(default_factory=time.monotonic)
lock: threading.Lock = field(default_factory=threading.Lock)
def touch(self) -> None:
self.touched = time.monotonic()
def add(self, filename: str, src: BinaryIO, kind: str,
max_file_bytes: int, max_total_bytes: int) -> Path:
"""Stream an upload to disk, enforcing both caps as it goes.
The size is checked while writing rather than from Content-Length: that
header is absent under chunked encoding, and it is the client's claim
either way.
"""
target = self.dir / safe_name(filename, len(self.history))
written = 0
try:
with target.open("wb") as fh:
while chunk := src.read(_CHUNK):
written += len(chunk)
if written > max_file_bytes:
raise UploadTooLarge("That file is too large.")
if self.bytes_used + written > max_total_bytes:
raise UploadTooLarge(
"That would use more working space than one account is "
"allowed. Start over to clear the files you have loaded.")
fh.write(chunk)
except BaseException:
target.unlink(missing_ok=True)
raise
if not written:
target.unlink(missing_ok=True)
raise ValueError("That file was empty.")
self.bytes_used += written
if kind == "perf":
# Replacing the performance report leaves the old file behind on
# disk; it is bounded by the workspace cap and cleared on reset.
self.perf = target
else:
self.history.append(target)
return target
def names(self) -> dict:
"""What /api/state reports: original filenames, index prefix removed."""
return {
"history": [p.name.split("_", 1)[-1] for p in self.history],
"perf": self.perf.name.split("_", 1)[-1] if self.perf else None,
}
def clear(self) -> None:
shutil.rmtree(self.dir, ignore_errors=True)
self.dir.mkdir(parents=True, exist_ok=True)
self.history.clear()
self.perf = None
self.last = None
self.bytes_used = 0
def dispose(self) -> None:
self.last = None
shutil.rmtree(self.dir, ignore_errors=True)
class WorkspaceStore:
"""Workspaces keyed by user id, with idle and size eviction."""
def __init__(self, root: Path, *, idle_ttl_s: float, max_workspaces: int,
max_total_bytes: int) -> None:
self.root = root
self.idle_ttl_s = idle_ttl_s
self.max_workspaces = max_workspaces
self.max_total_bytes = max_total_bytes
self._spaces: dict[int, UserWorkspace] = {}
self._lock = threading.RLock()
def start(self) -> None:
"""Begin from a clean slate. Nothing here is meant to outlive a restart."""
shutil.rmtree(self.root, ignore_errors=True)
self.root.mkdir(parents=True, exist_ok=True)
def get(self, user_id: int) -> UserWorkspace:
with self._lock:
ws = self._spaces.get(user_id)
if ws is None:
path = self.root / f"u{user_id}"
shutil.rmtree(path, ignore_errors=True)
path.mkdir(parents=True, exist_ok=True)
try:
path.chmod(0o700)
except OSError: # no-op on Windows
pass
ws = UserWorkspace(user_id=user_id, dir=path)
self._spaces[user_id] = ws
ws.touch()
return ws
def peek(self, user_id: int) -> UserWorkspace | None:
with self._lock:
return self._spaces.get(user_id)
def drop(self, user_id: int) -> None:
with self._lock:
ws = self._spaces.pop(user_id, None)
if ws is not None:
ws.dispose()
def total_bytes(self) -> int:
with self._lock:
return sum(w.bytes_used for w in self._spaces.values())
def sweep(self) -> int:
"""Evict idle workspaces, then the least recently used ones until the
count and byte budgets are satisfied. Returns how many were dropped."""
now = time.monotonic()
with self._lock:
doomed = [uid for uid, w in self._spaces.items()
if now - w.touched > self.idle_ttl_s]
survivors = sorted(
((uid, w) for uid, w in self._spaces.items() if uid not in doomed),
key=lambda kv: kv[1].touched,
)
while len(survivors) > self.max_workspaces:
uid, _ = survivors.pop(0)
doomed.append(uid)
total = sum(w.bytes_used for _, w in survivors)
while total > self.max_total_bytes and survivors:
uid, w = survivors.pop(0)
total -= w.bytes_used
doomed.append(uid)
evicted = [self._spaces.pop(uid) for uid in doomed if uid in self._spaces]
# Deliberately outside the lock: rmtree of a few hundred megabytes
# would otherwise stall every other request touching the store.
for ws in evicted:
ws.dispose()
if evicted:
log.info("evicted %d workspace(s)", len(evicted))
return len(evicted)
def dispose_all(self) -> None:
with self._lock:
spaces, self._spaces = list(self._spaces.values()), {}
for ws in spaces:
ws.dispose()
shutil.rmtree(self.root, ignore_errors=True)
def __len__(self) -> int:
with self._lock:
return len(self._spaces)
# The process-wide store. Created in the app lifespan, which is also why this
# app must run with a single worker: a second process would have its own store
# and a user's upload and analysis could land in different ones.
_store: WorkspaceStore | None = None
def set_store(store: WorkspaceStore) -> None:
global _store
_store = store
def store_of() -> WorkspaceStore:
if _store is None:
raise RuntimeError("Workspace store not initialised; the app lifespan sets it.")
return _store

36
docker-compose.yml Normal file
View File

@ -0,0 +1,36 @@
# The database is the existing RDS instance, so there is no mysql service here.
# The EC2 host's security group must be allowed inbound on 3306 at RDS.
services:
app:
build: .
image: oob-dashboard:latest
container_name: oob-dashboard
env_file: .env
environment:
# Overrides .env: inside the container the workspace lives on the volume.
WORKSPACE_ROOT: /srv/work
ports:
- "8000:8000"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).status == 200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
volumes:
# A named volume, not tmpfs: tmpfs is RAM, and a few hundred megabytes of
# uploads would come straight out of the memory budget that the analysis
# itself needs. The app wipes this directory on startup instead.
- workspaces:/srv/work
# An OOM then kills and restarts this container rather than the host.
mem_limit: 2g
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
workspaces:

View File

@ -26,23 +26,28 @@ from .scoring import CampaignDay
# ------------------------------------------------------------------- palette # ------------------------------------------------------------------- palette
NAVY = "1F3864" # Utopia Brands. The report is the artefact that leaves the building, so it
SLATE = "44546A" # carries the same identity as the dashboard: deep green chrome on pale mint
# panels. RED and AMBER are functional, not brand -- the guide has no warning
# colour and a report about campaigns going dark needs one.
DEEP = "004D43" # brand primary, deep green
MIST = "EAFFF4" # brand primary, pale mint
SLATE = "4A6B64"
WHITE = "FFFFFF" WHITE = "FFFFFF"
GREEN = "16A34A" GREEN = "0F7A5C"
AMBER = "B45309" AMBER = "A2600A"
RED = "C0392B" RED = "C9401F"
GREY = "9E9E9E" GREY = "7C9A92"
PANEL = "F4F6FA" PANEL = MIST
HDR_FILL = PatternFill("solid", fgColor=NAVY) HDR_FILL = PatternFill("solid", fgColor=DEEP)
HDR_FONT = Font(color=WHITE, bold=True, size=10) HDR_FONT = Font(color=WHITE, bold=True, size=10)
TITLE_FONT = Font(color=NAVY, bold=True, size=20) TITLE_FONT = Font(color=DEEP, bold=True, size=20)
SUB_FONT = Font(color=SLATE, size=10, italic=True) SUB_FONT = Font(color=SLATE, size=10, italic=True)
SECTION = Font(color=NAVY, bold=True, size=12) SECTION = Font(color=DEEP, bold=True, size=12)
TILE_FILL = PatternFill("solid", fgColor=PANEL) TILE_FILL = PatternFill("solid", fgColor=PANEL)
KPI_LABEL = Font(color=SLATE, size=9, bold=True) KPI_LABEL = Font(color=SLATE, size=9, bold=True)
KPI_VALUE = Font(color=NAVY, size=18, bold=True) KPI_VALUE = Font(color=DEEP, size=18, bold=True)
KPI_ALARM = Font(color=RED, size=18, bold=True) KPI_ALARM = Font(color=RED, size=18, bold=True)
KPI_NOTE = Font(color=GREY, size=8, italic=True) KPI_NOTE = Font(color=GREY, size=8, italic=True)
RUN_FONT = Font(color=GREEN, size=10) RUN_FONT = Font(color=GREEN, size=10)
@ -50,29 +55,34 @@ LOST_FONT = Font(color=RED, size=10, bold=True)
UNPRICED = Font(color=GREY, size=9, italic=True) UNPRICED = Font(color=GREY, size=9, italic=True)
# Per-date sub-columns run narrow, so they get their own smaller type. # Per-date sub-columns run narrow, so they get their own smaller type.
DAY_RUN_FONT = Font(color=GREEN, size=9) DAY_RUN_FONT = Font(color=GREEN, size=9)
DAY_LOST_FONT = Font(color="7F1D1D", size=9, bold=True) DAY_LOST_FONT = Font(color="8F2810", size=9, bold=True)
DAY_PAUSE_FONT = Font(color=GREY, size=9) DAY_PAUSE_FONT = Font(color=GREY, size=9)
EDGE = Side(style="thin", color="C9D2E3") EDGE = Side(style="thin", color="CFE8DC")
BOX = Border(left=EDGE, right=EDGE, top=EDGE, bottom=EDGE) BOX = Border(left=EDGE, right=EDGE, top=EDGE, bottom=EDGE)
# Hour-of-day heat, reused so 60k cells share nine fill objects. # Hour-of-day heat, reused so 60k cells share nine fill objects. The first entry
# is the "no time lost" bucket; the rest are one warm hue getting steadily
# darker, matching heatColor() in web/app.js so the report and the dashboard
# shade the same hour the same way.
HEAT = [PatternFill("solid", fgColor=c) for c in HEAT = [PatternFill("solid", fgColor=c) for c in
("E8F5E9", "FFF9C4", "FFECB3", "FFE0B2", "FFCCBC", ("D3F6E8", "FDECE7", "FBD7CD", "F9BFAE", "F7A58C",
"FFAB91", "FF8A65", "EF5350", "C62828")] "F4886A", "F2542D", "D8431F", "B53617")]
PAUSED_FILL = PatternFill("solid", fgColor="E0E0E0") PAUSED_FILL = PatternFill("solid", fgColor="DDE5E3")
NA_FILL = PatternFill("solid", fgColor="F5F5F5") NA_FILL = PatternFill("solid", fgColor="F2F8F5")
HEAT_FONT = Font(size=7, color="616161") HEAT_FONT = Font(size=7, color=SLATE)
HEAT_FONT_DARK = Font(size=7, color=WHITE) HEAT_FONT_DARK = Font(size=7, color=WHITE)
# Same tints the dashboard's diagnosis pills use, so a reader moving between
# the two sees one scheme.
DIAGNOSIS_FILL = { DIAGNOSIS_FILL = {
"Structurally underfunded": PatternFill("solid", fgColor="FFCDD2"), "Structurally underfunded": PatternFill("solid", fgColor="FFE4DC"),
"Exhausts early": PatternFill("solid", fgColor="FFE0B2"), "Exhausts early": PatternFill("solid", fgColor="FDEFD0"),
"Pacing thrash": PatternFill("solid", fgColor="E1BEE7"), "Pacing thrash": PatternFill("solid", fgColor="E8E9FF"),
"Evening cap": PatternFill("solid", fgColor="FFF9C4"), "Evening cap": PatternFill("solid", fgColor="FEF7E4"),
"Intermittent": PatternFill("solid", fgColor="E3F2FD"), "Intermittent": PatternFill("solid", fgColor="F1FFD7"),
"Healthy": PatternFill("solid", fgColor="C8E6C9"), "Healthy": PatternFill("solid", fgColor="D3F6E8"),
"Mostly paused": PatternFill("solid", fgColor="ECEFF1"), "Mostly paused": PatternFill("solid", fgColor="E4F5EC"),
} }
# A duration is a fraction of a day; [h] lets a total exceed 24 hours. # A duration is a fraction of a day; [h] lets a total exceed 24 hours.
@ -137,9 +147,9 @@ def _as_table(ws, name: str, last_row: int, last_col: int, first_row: int = 1) -
ws.add_table(table) ws.add_table(table)
STALE_FILL = PatternFill("solid", fgColor="FFCDD2") STALE_FILL = PatternFill("solid", fgColor="FFE4DC")
WARM_FILL = PatternFill("solid", fgColor="FFF3CD") WARM_FILL = PatternFill("solid", fgColor="FDEFD0")
STALE_FONT = Font(color="7F1D1D", size=10, bold=True) STALE_FONT = Font(color="8F2810", size=10, bold=True)
def _action_cells(ws, row: int, col: int, act) -> None: def _action_cells(ws, row: int, col: int, act) -> None:
@ -373,9 +383,9 @@ def _sheet_campaigns_multi(wb: Workbook, rollups: list[CampaignRollup],
for j in range(len(shown_dates)): for j in range(len(shown_dates)):
letter = get_column_letter(base + 2 + j * 3) letter = get_column_letter(base + 2 + j * 3)
ws.conditional_formatting.add(f"{letter}2:{letter}{last_row}", ColorScaleRule( ws.conditional_formatting.add(f"{letter}2:{letter}{last_row}", ColorScaleRule(
start_type="num", start_value=0, start_color="E8F5E9", start_type="num", start_value=0, start_color="D3F6E8",
mid_type="num", mid_value=0.5, mid_color="FFCC80", mid_type="num", mid_value=0.5, mid_color="F7A58C",
end_type="num", end_value=1, end_color="C62828")) end_type="num", end_value=1, end_color="8F2810"))
_as_table(ws, "Campaigns", last_row, last_col) _as_table(ws, "Campaigns", last_row, last_col)
ws.freeze_panes = "B2" ws.freeze_panes = "B2"
@ -676,7 +686,7 @@ def _sheet_method(wb: Workbook, settings: ModelSettings, metas: list[WorkbookMet
] ]
row = 4 row = 4
for name, text in entries: for name, text in entries:
ws.cell(row=row, column=1, value=name).font = Font(bold=True, color=NAVY, size=10) ws.cell(row=row, column=1, value=name).font = Font(bold=True, color=DEEP, size=10)
ws.cell(row=row, column=1).alignment = Alignment(vertical="top") ws.cell(row=row, column=1).alignment = Alignment(vertical="top")
c = ws.cell(row=row, column=2, value=text) c = ws.cell(row=row, column=2, value=text)
c.alignment = Alignment(wrap_text=True, vertical="top") c.alignment = Alignment(wrap_text=True, vertical="top")
@ -684,7 +694,7 @@ def _sheet_method(wb: Workbook, settings: ModelSettings, metas: list[WorkbookMet
row += 1 row += 1
row += 1 row += 1
ws.cell(row=row, column=1, value="Generated").font = Font(bold=True, color=NAVY, size=10) ws.cell(row=row, column=1, value="Generated").font = Font(bold=True, color=DEEP, size=10)
ws.cell(row=row, column=2, ws.cell(row=row, column=2,
value=f"{datetime.now():%Y-%m-%d %H:%M} from " value=f"{datetime.now():%Y-%m-%d %H:%M} from "
+ ", ".join(m.path.name for m in metas)) + ", ".join(m.path.name for m in metas))

View File

@ -12,11 +12,17 @@ from .ingest import QaReport, WorkbookMeta
from .metrics import ModelSettings, Totals, hourly_starvation from .metrics import ModelSettings, Totals, hourly_starvation
from .scoring import IN, NA, OOB, PAUSED, CampaignDay from .scoring import IN, NA, OOB, PAUSED, CampaignDay
# The timeline strips are one gradient per campaign-day, so these four values
# are baked into the payload and have to hold up on both the light and the dark
# surface. Checked for colour-vision separation across every pair, not just
# neighbours: the tightest is paused against in-budget at dE 9.3 (protan) and
# 15.8 with normal vision. Paused is deliberately a cool neutral -- tinting it
# green to match the brand put it right on top of in-budget.
TRACK_COLOR = { TRACK_COLOR = {
IN: "#16a34a", IN: "#0f9a74", # brand deep green lifted to read on a dark surface
OOB: "#dc2626", OOB: "#f2542d", # functional warning; the brand guide has no such colour
PAUSED: "#9ca3af", PAUSED: "#9aa6ad", # nothing is happening, so it recedes
NA: "#e5e7eb", NA: "#cbdbd4", # not yet created: near-absent, labelled in the legend
} }
STATE_LABEL = {IN: "In budget", OOB: "Out of budget", PAUSED: "Paused", NA: "Not yet created"} STATE_LABEL = {IN: "In budget", OOB: "Out of budget", PAUSED: "Paused", NA: "Not yet created"}

25
requirements.txt Normal file
View File

@ -0,0 +1,25 @@
# Hosted mode only. serve.py and run_report.py need nothing but openpyxl.
# --- web ---
fastapi>=0.115,<1.0
uvicorn[standard]>=0.34,<1.0
python-multipart>=0.0.20 # Starlette needs it to parse multipart uploads
# --- data ---
sqlalchemy>=2.0.36,<3.0
pymysql>=1.1.1 # pure-python driver, so the image needs no compiler
cryptography>=43.0 # PyMySQL needs it for caching_sha2_password (RDS MySQL 8 default)
# --- config / validation ---
pydantic>=2.9,<3
pydantic-settings>=2.6,<3 # pulls in python-dotenv
email-validator>=2.2 # required by pydantic EmailStr
# --- auth ---
argon2-cffi>=23.1 # not passlib: its last release is 2020 and it breaks on bcrypt>=4.1
# --- outbound ---
httpx>=0.27 # multipart POST to EMAIL_ENDPOINT
# --- analysis ---
openpyxl>=3.1.5

139
serve.py
View File

@ -7,8 +7,9 @@ Opens http://localhost:8765 in your browser. Drag change-history exports onto
the page and the dashboard appears. Everything runs on this machine -- the the page and the dashboard appears. Everything runs on this machine -- the
server binds to localhost only and nothing is uploaded anywhere. server binds to localhost only and nothing is uploaded anywhere.
The analysis is the same code the Excel report uses, so the two can never This is the single-user local mode: no accounts, no database, no dependencies
disagree. beyond openpyxl. The hosted multi-user version is `app.main`, run under uvicorn;
both call the same pipeline in app/services, so the two can never disagree.
""" """
from __future__ import annotations from __future__ import annotations
@ -20,16 +21,13 @@ import tempfile
import threading import threading
import traceback import traceback
import webbrowser import webbrowser
from datetime import date
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from ppcbudget import actions as actions_mod from app.services import exporters
from ppcbudget import aggregate, excelout, metrics, payload, perfjoin from app.services.analysis import run_analysis
from ppcbudget.ingest import dedupe_events, load_history
from ppcbudget.scoring import check_invariants, score_all
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
WEB = HERE / "web" WEB = HERE / "web"
@ -76,66 +74,9 @@ SESSION = Session()
def analyze(settings_in: dict) -> dict: def analyze(settings_in: dict) -> dict:
"""Run the pipeline over everything uploaded so far.""" """Run the pipeline over everything uploaded so far."""
if not SESSION.history: result = run_analysis(SESSION.history, SESSION.perf, settings_in)
raise ValueError("No change-history files uploaded yet.") SESSION.last = result.artifacts
return result.payload
events, metas, qas, skipped = [], [], [], []
for path in SESSION.history:
try:
evs, meta, qa = load_history(path)
except (ValueError, KeyError, OSError) as exc:
skipped.append(f"{path.name}: {exc}")
continue
events.extend(evs)
metas.append(meta)
qas.append(qa)
if not events:
detail = " ".join(skipped) or "no readable rows"
raise ValueError(f"None of the files could be read as a change-history export. {detail}")
events, overlap_rows = dedupe_events(events)
days = score_all(events, merge_gap_min=int(settings_in.get("merge_gap", 5)))
if not days:
raise ValueError("No campaigns had budget-state changes, so there is nothing to score.")
join_report = None
roas_source = "account_average"
if SESSION.perf:
records, join_report = perfjoin.load_performance(SESSION.perf)
perfjoin.apply_to(days, records, join_report)
roas_source = "campaign"
account_roas = next((m.roas for m in metas if m.roas), None)
roas_override = settings_in.get("roas")
settings = metrics.ModelSettings(
roas=float(roas_override) if roas_override else (account_roas or 4.0),
roas_source="override" if roas_override else roas_source,
haircut=float(settings_in.get("haircut", metrics.DEFAULT_ROAS_HAIRCUT)),
cap_multiple=float(settings_in.get("cap", metrics.DEFAULT_CAP_MULTIPLE)),
)
metrics.apply(days, settings)
totals = metrics.summarize(days)
rollups = aggregate.rollup(days)
date_keys = sorted({d.date_key for d in days})
scored_names = {d.campaign for d in days}
acts = actions_mod.build(events, date_keys, scored_names)
act_summary = actions_mod.summarize(acts)
data = payload.build(days, totals, rollups, qas, metas, settings, date_keys,
join_report, overlap_rows, acts, act_summary)
problems = check_invariants(days)
data["invariants"] = {"checked": len(days), "failed": problems[:5]}
data["skipped"] = skipped
SESSION.last = {
"days": days, "totals": totals, "rollups": rollups, "qas": qas,
"metas": metas, "settings": settings, "date_keys": date_keys,
"join_report": join_report, "overlap_rows": overlap_rows,
"actions": acts,
}
return data
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@ -236,71 +177,17 @@ class Handler(BaseHTTPRequestHandler):
if not last: if not last:
self._error("Analyse some files first.") self._error("Analyse some files first.")
return return
stamp = f"{last['date_keys'][-1]}_{date.today():%Y%m%d}" stamp = exporters.export_stamp(last)
if fmt == "csv": if fmt == "csv":
body = _csv(last["days"], last.get("actions") or {}).encode("utf-8-sig") self._send(HTTPStatus.OK, exporters.csv_bytes(last), exporters.CSV_MIME,
self._send(HTTPStatus.OK, body, "text/csv; charset=utf-8",
{"Content-Disposition": {"Content-Disposition":
f'attachment; filename="ppc-budget_{stamp}.csv"'}) f'attachment; filename="ppc-budget_{stamp}.csv"'})
return return
out = Path(tempfile.mkdtemp()) / f"ppc-budget-report_{stamp}.xlsx" self._send(HTTPStatus.OK, exporters.xlsx_bytes(last), exporters.XLSX_MIME,
excelout.write_report(out, last["days"], last["totals"], last["rollups"], {"Content-Disposition":
last["qas"], last["metas"], last["settings"], f'attachment; filename="ppc-budget-report_{stamp}.xlsx"'})
last["date_keys"], last["join_report"],
last.get("overlap_rows", 0), last.get("actions"))
body = out.read_bytes()
shutil.rmtree(out.parent, ignore_errors=True)
self._send(HTTPStatus.OK, body,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
{"Content-Disposition": f'attachment; filename="{out.name}"'})
def _csv(days, actions: dict) -> str:
import csv
import io
buf = io.StringIO()
w = csv.writer(buf, lineterminator="\r\n")
w.writerow([
"date", "campaign", "eligible_hours", "in_budget_hours", "out_of_budget_hours",
"paused_hours", "pct_of_active_day", "budget_cap_hits", "distinct_outages",
"first_out", "last_recovery", "ended_out", "daily_budget", "budget_source",
"spend_rate_per_hour", "lost_spend", "lost_sales", "capped", "severity",
"diagnosis", "confidence", "uncertainty_hours",
"last_action", "days_since_action", "what_changed_last", "actions_in_window",
])
for d in sorted(days, key=lambda x: (-x.severity, x.campaign)):
lost = d.lost or {}
w.writerow([
d.date_key, d.campaign, f"{d.eligible_min / 60:.2f}", f"{d.in_hours:.2f}",
f"{d.oob_hours:.2f}", f"{d.paused_hours:.2f}", f"{d.oob_share:.4f}",
d.episodes_raw, d.episodes_merged,
excelout.hhmm(d.first_oob_min), excelout.hhmm(d.last_recovery_min),
"yes" if d.closed_oob else "no",
# Deliberately blank, never 0, when unobserved.
f"{d.budget.time_weighted:.2f}" if d.budget.time_weighted else "",
d.budget.source,
f"{lost['spend_rate_per_hour']:.4f}" if lost.get("spend_rate_per_hour") else "",
f"{lost['lost_spend']:.2f}" if lost.get("lost_spend") is not None else "",
f"{lost['lost_sales']:.2f}" if lost.get("lost_sales") is not None else "",
"yes" if lost.get("capped") else "",
f"{d.severity:.1f}", d.diagnosis, d.confidence,
f"{d.oob_uncertainty_min / 60:.2f}" if d.chain_breaks else "",
*_action_columns(actions.get(d.campaign)),
])
return buf.getvalue()
def _action_columns(act) -> tuple:
"""Last meaningful action, or an explicit statement that there was none."""
if act is None:
return ("not observed", "", "", "")
return (act.summary,
"" if act.days_since is None else act.days_since,
act.last_label or "",
act.count or "")
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:

72
tests/synthetic.py Normal file
View File

@ -0,0 +1,72 @@
"""A tiny change-history export, shaped like Amazon's.
The golden tests run against a real export that cannot be committed. This one
is small and synthetic: enough for the hosted app's tests to upload something
and get a real analysis back, without depending on customer data.
"""
from __future__ import annotations
from pathlib import Path
import openpyxl
HEADER = ["Change level type", "Change level name", "Campaign", "Change type",
"From", "To", "From (numeric)", "To (numeric)", "Date and time (ISO)"]
DATE = "2026-08-06"
ROWS = [
# Out of budget mid-morning, never recovers.
("Campaign", "Chronic Offender", "Chronic Offender", "Campaign created", "", "", None, None, f"{DATE}T00:00:00"),
("Campaign", "Chronic Offender", "Chronic Offender", "Campaign daily budget", "$50.00", "$50.00", 50, 50, f"{DATE}T00:01:00"),
("Campaign", "Chronic Offender", "Chronic Offender", "Campaign status", "Out of budget", "In budget", None, None, f"{DATE}T00:02:00"),
("Campaign", "Chronic Offender", "Chronic Offender", "Campaign status", "In budget", "Out of budget", None, None, f"{DATE}T09:30:00"),
# Flaps in the evening.
("Campaign", "Evening Cap", "Evening Cap", "Campaign daily budget", "$120.00", "$120.00", 120, 120, f"{DATE}T00:01:00"),
("Campaign", "Evening Cap", "Evening Cap", "Campaign status", "Out of budget", "In budget", None, None, f"{DATE}T00:03:00"),
("Campaign", "Evening Cap", "Evening Cap", "Campaign status", "In budget", "Out of budget", None, None, f"{DATE}T18:00:00"),
("Campaign", "Evening Cap", "Evening Cap", "Campaign status", "Out of budget", "In budget", None, None, f"{DATE}T19:00:00"),
("Campaign", "Evening Cap", "Evening Cap", "Campaign status", "In budget", "Out of budget", None, None, f"{DATE}T21:00:00"),
# Healthy, with a human budget edit so "last action" has something to find.
("Campaign", "Healthy One", "Healthy One", "Campaign status", "Out of budget", "In budget", None, None, f"{DATE}T00:04:00"),
("Campaign", "Healthy One", "Healthy One", "Campaign daily budget", "$80.00", "$95.00", 80, 95, f"{DATE}T11:15:00"),
# Paused for the afternoon.
("Campaign", "Half Paused", "Half Paused", "Campaign status", "Out of budget", "In budget", None, None, f"{DATE}T00:05:00"),
("Campaign", "Half Paused", "Half Paused", "Campaign status", "Delivering", "Paused", None, None, f"{DATE}T12:00:00"),
# Account level, no campaign: a row the reader is meant to drop.
("Account", "", "", "Campaign status", "In budget", "Out of budget", None, None, f"{DATE}T13:00:00"),
]
CAMPAIGNS = 4 # campaign-days the pipeline should score
def build(out: Path) -> Path:
wb = openpyxl.Workbook()
history = wb.active
history.title = "History"
history.append(HEADER)
for row in ROWS:
history.append(list(row))
meta = wb.create_sheet("Extraction Metadata")
for pair in (("Account", "Synthetic Test Account"),
("Marketplace", "United States"),
("Date range", f"{DATE} to {DATE}"),
("Extraction run ID", "synthetic-0001"),
("Status", "Complete"),
("Rows expected", len(ROWS)),
("Rows exported", len(ROWS)),
("Duplicate rows skipped", 0),
("Pages processed", 1)):
meta.append(list(pair))
summary = wb.create_sheet("Summary Metrics")
summary.append(["Metric", "Value"])
for pair in (("Spend", 1500.0), ("Sales", 6000.0), ("ROAS", 4.0),
("Impressions", 250000)):
summary.append(list(pair))
out.parent.mkdir(parents=True, exist_ok=True)
wb.save(out)
return out

328
tests/test_auth_smoke.py Normal file
View File

@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""End-to-end checks for the hosted app.
python3 tests/test_auth_smoke.py
Runs against in-memory SQLite with email routed to a capture list, so it needs
no MySQL, no network, and sends no mail. Every model column is a portable type
precisely so this is possible.
Plain asserts and a main(), matching tests/test_golden.py -- no pytest.
"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
WORK = Path(tempfile.mkdtemp(prefix="oob-authtest-"))
# Must be set before app.config reads the environment.
os.environ["EMAIL_PROVIDER"] = "console"
os.environ["APP_BASE_URL"] = "http://testserver"
os.environ["WORKSPACE_ROOT"] = str(WORK)
os.environ.setdefault("SECRET_KEY", "test-secret-key-at-least-32-characters-long")
os.environ.setdefault("MYSQL_HOST", "unused-in-this-test")
os.environ.setdefault("MYSQL_USER", "unused")
os.environ.setdefault("MYSQL_PASSWORD", "unused")
os.environ.setdefault("MYSQL_DATABASE", "unused")
os.environ.setdefault("ADMIN_EMAIL", "admin@example.com")
os.environ.setdefault("ADMIN_PASSWORD", "admin-password-1")
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
import app.db as db_module # noqa: E402
# Point the app at SQLite before anything imports SessionLocal from it.
_sqlite = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
db_module.engine = _sqlite
db_module.SessionLocal = sessionmaker(bind=_sqlite, autoflush=False,
expire_on_commit=False, future=True)
from fastapi.testclient import TestClient # noqa: E402
from app.config import get_settings # noqa: E402
from app.main import app # noqa: E402
from app.services import email_service # noqa: E402
from tests import synthetic # noqa: E402
SENT: list[tuple[str, str, str]] = []
email_service._send = lambda to, subject, html, attachment=None: (
SENT.append((to, subject, html)) or True)
FIXTURE = synthetic.build(WORK / "amazon-ads-history_synthetic.xlsx")
GENERIC_LOGIN_ERROR = "Email or password is incorrect."
PASSED: list[str] = []
FAILED: list[str] = []
def check(label: str, condition: bool, detail: object = "") -> None:
if condition:
PASSED.append(label)
print(f" pass {label}")
else:
FAILED.append(label)
print(f" FAIL {label}" + (f" <- {detail}" if detail else ""))
def link_from_mail(prefix: str) -> str:
"""Pull the most recent link with this prefix out of the captured HTML."""
for _to, _subject, html in reversed(SENT):
i = html.find(prefix)
if i >= 0:
return html[i:].split('"')[0].split("<")[0].strip()
raise AssertionError(f"no email contained a {prefix!r} link")
def token_from_mail(prefix: str) -> str:
return link_from_mail(prefix).split("token=")[1]
def verify_new_user(client: TestClient, email: str, password: str, name: str = "") -> None:
client.post("/api/auth/signup",
json={"email": email, "password": password, "name": name})
client.post("/api/auth/verify",
json={"token": token_from_mail("http://testserver/verify?token=")})
def main() -> int:
with TestClient(app, base_url="http://testserver") as c:
_anonymous(c)
_signup_and_verify(c)
_login(c)
_dashboard(c)
_csrf_and_admin_gate(c)
_isolation(c)
_logout(c)
_forgot_and_reset(c)
_lockout(c)
_admin(c)
print(f"\n{len(PASSED)}/{len(PASSED) + len(FAILED)} passed")
if FAILED:
print("failed: " + ", ".join(FAILED))
return 1 if FAILED else 0
# --------------------------------------------------------------------- checks
def _anonymous(c: TestClient) -> None:
r = c.get("/", follow_redirects=False)
check("anonymous / redirects to the sign-in page",
r.status_code == 303 and r.headers["location"] == "/login", r.status_code)
check("healthz is up", c.get("/healthz").status_code == 200)
check("readyz reports the database", c.get("/readyz").json().get("db") == "ok")
check("anonymous api call is 401", c.get("/api/state").status_code == 401)
r = c.get("/api/state", headers={"Accept": "text/html"}, follow_redirects=False)
check("a browser navigation gets redirected, not JSON",
r.status_code == 303 and "/login" in r.headers["location"], r.status_code)
def _signup_and_verify(c: TestClient) -> None:
body = {"email": "alice@example.com", "password": "correct-horse-1", "name": "Alice"}
first = c.post("/api/auth/signup", json=body)
check("signup succeeds", first.status_code == 200, first.text)
repeat = c.post("/api/auth/signup", json=body)
check("signing up an existing address answers identically",
repeat.status_code == first.status_code and repeat.json() == first.json(),
repeat.text)
r = c.post("/api/auth/signup", json={"email": "x@example.com", "password": "short"})
check("a short password is refused", r.status_code == 422, r.status_code)
token = token_from_mail("http://testserver/verify?token=")
r = c.get(f"/verify?token={token}")
check("the verify page is served as html",
r.status_code == 200 and "text/html" in r.headers["content-type"])
r = c.post("/api/auth/login",
json={"email": "alice@example.com", "password": "correct-horse-1"})
check("signing in before confirming is refused with a reason",
r.status_code == 403 and r.json().get("code") == "email_not_verified", r.text)
r = c.post("/api/auth/verify", json={"token": token})
check("loading the page did not spend the token; posting it does",
r.status_code == 200, r.text)
r = c.post("/api/auth/verify", json={"token": token})
check("a verification token works only once", r.status_code == 400, r.status_code)
def _login(c: TestClient) -> None:
r = c.post("/api/auth/login",
json={"email": "alice@example.com", "password": "wrong-password"})
check("a wrong password is refused without detail",
r.status_code == 401 and r.json()["error"] == GENERIC_LOGIN_ERROR, r.text)
r = c.post("/api/auth/login",
json={"email": "nobody@example.com", "password": "wrong-password"})
check("an unknown address gets the very same message",
r.status_code == 401 and r.json()["error"] == GENERIC_LOGIN_ERROR, r.text)
r = c.post("/api/auth/login",
json={"email": "alice@example.com", "password": "correct-horse-1"})
check("signing in works", r.status_code == 200, r.text)
cookie = r.headers.get("set-cookie", "")
check("the session cookie is HttpOnly", "httponly" in cookie.lower(), cookie)
check("the session cookie is SameSite=Lax", "samesite=lax" in cookie.lower(), cookie)
check("the session cookie is not Secure over plain http",
"secure" not in cookie.lower(), cookie)
state = c.get("/api/state")
check("the api answers once signed in", state.status_code == 200, state.text)
check("the state carries who is signed in",
state.json()["user"]["email"] == "alice@example.com")
check("the dashboard itself is served", c.get("/").status_code == 200)
def _dashboard(c: TestClient) -> None:
with FIXTURE.open("rb") as fh:
r = c.post("/api/upload", files={"file": (FIXTURE.name, fh)},
data={"kind": "history"})
check("an export uploads", r.status_code == 200, r.text)
check("the uploaded file is listed",
c.get("/api/state").json()["history"] == [FIXTURE.name])
r = c.post("/api/analyze", json={"haircut": 0.7, "cap": 3, "merge_gap": 5})
check("the analysis runs", r.status_code == 200, r.text[:200])
payload = r.json()
check("the payload has campaigns in it", bool(payload.get("campaigns")),
sorted(payload)[:6])
r = c.get("/api/export?format=csv")
check("the csv export downloads",
r.status_code == 200 and r.content[:3] == b"\xef\xbb\xbf", r.status_code)
check("the csv is sent as an attachment",
"attachment" in r.headers.get("content-disposition", ""))
r = c.get("/api/export?format=xlsx")
check("the excel export downloads",
r.status_code == 200 and r.content[:2] == b"PK", r.status_code)
r = c.post("/api/upload", files={"file": ("payload.exe", b"x" * 32)},
data={"kind": "history"})
check("the file type is enforced on the server, not just in the browser",
r.status_code == 400, r.status_code)
r = c.post("/api/clear")
check("starting over empties the workspace",
r.status_code == 200 and c.get("/api/state").json()["history"] == [])
def _csrf_and_admin_gate(c: TestClient) -> None:
r = c.post("/api/clear", headers={"Origin": "https://evil.example"})
check("a write from another origin is refused", r.status_code == 403, r.status_code)
check("an ordinary member cannot reach the admin api",
c.get("/api/admin/users").status_code == 403)
r = c.get("/admin", follow_redirects=False)
check("an ordinary member is bounced off the admin page",
r.status_code == 303 and r.headers["location"] == "/", r.status_code)
def _isolation(c: TestClient) -> None:
with TestClient(app, base_url="http://testserver") as other:
verify_new_user(other, "bob@example.com", "another-good-1", "Bob")
other.post("/api/auth/login",
json={"email": "bob@example.com", "password": "another-good-1"})
with FIXTURE.open("rb") as fh:
other.post("/api/upload", files={"file": ("bobs-file.xlsx", fh)},
data={"kind": "history"})
check("the second account sees its own upload",
other.get("/api/state").json()["history"] == ["bobs-file.xlsx"])
check("the first account does not see it",
c.get("/api/state").json()["history"] == [])
check("and has no analysis to export from the other's data",
other.get("/api/export?format=csv").status_code == 400)
def _logout(c: TestClient) -> None:
check("signing out works", c.post("/api/auth/logout").status_code == 200)
check("the session is dead afterwards", c.get("/api/state").status_code == 401)
def _forgot_and_reset(c: TestClient) -> None:
before = len(SENT)
known = c.post("/api/auth/forgot", json={"email": "alice@example.com"})
unknown = c.post("/api/auth/forgot", json={"email": "ghost@example.com"})
check("forgot-password answers the same for a known and unknown address",
known.status_code == unknown.status_code and known.json() == unknown.json())
check("but only the real address was actually emailed", len(SENT) == before + 1)
token = token_from_mail("http://testserver/reset?token=")
r = c.post("/api/auth/reset",
json={"token": token, "password": "brand-new-secret-9"})
check("the password is reset", r.status_code == 200, r.text)
r = c.post("/api/auth/reset",
json={"token": token, "password": "yet-another-one-1"})
check("a reset token works only once", r.status_code == 400, r.status_code)
r = c.post("/api/auth/login",
json={"email": "alice@example.com", "password": "correct-horse-1"})
check("the old password stops working", r.status_code == 401)
r = c.post("/api/auth/login",
json={"email": "alice@example.com", "password": "brand-new-secret-9"})
check("the new password works", r.status_code == 200, r.text)
c.post("/api/auth/logout")
def _lockout(c: TestClient) -> None:
s = get_settings()
for _ in range(s.MAX_FAILED_LOGINS + 1):
r = c.post("/api/auth/login",
json={"email": "bob@example.com", "password": "definitely-wrong"})
check("a locked account is not announced as locked",
r.status_code == 401 and r.json()["error"] == GENERIC_LOGIN_ERROR, r.text)
r = c.post("/api/auth/login",
json={"email": "bob@example.com", "password": "another-good-1"})
check("even the right password is refused while locked",
r.status_code == 401, r.status_code)
def _admin(c: TestClient) -> None:
from app.bootstrap import seed_admin
s = get_settings()
with db_module.SessionLocal() as db:
seed_admin(db, s)
r = c.post("/api/auth/login",
json={"email": s.ADMIN_EMAIL,
"password": s.ADMIN_PASSWORD.get_secret_value()})
check("the seeded admin can sign in", r.status_code == 200, r.text)
r = c.get("/api/admin/users")
check("the admin can list accounts",
r.status_code == 200 and len(r.json()["users"]) >= 3, r.text[:200])
check("the admin page renders", c.get("/admin").status_code == 200)
users = r.json()["users"]
bob = next(u for u in users if u["email"] == "bob@example.com")
check("the admin can clear a lockout",
c.post(f"/api/admin/users/{bob['id']}/activate").status_code == 200)
check("the admin can disable an account",
c.post(f"/api/admin/users/{bob['id']}/deactivate").status_code == 200)
r = c.post("/api/auth/login",
json={"email": "bob@example.com", "password": "another-good-1"})
check("a disabled account cannot sign in", r.status_code == 401, r.status_code)
me = c.get("/api/auth/me").json()["user"]
check("the admin cannot disable themselves",
c.post(f"/api/admin/users/{me['id']}/deactivate").status_code == 400)
if __name__ == "__main__":
try:
raise SystemExit(main())
finally:
import shutil
shutil.rmtree(WORK, ignore_errors=True)

52
web/admin.html Normal file
View File

@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accounts &middot; Out-of-Budget Dashboard</title>
<link rel="stylesheet" href="/static/styles.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>📉</text></svg>">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="mark"></span>
<div>
<h1>Accounts</h1>
<p>Who can sign in to this dashboard</p>
</div>
</div>
<div class="topbar-actions">
<a href="/"><button type="button" class="ghost">Dashboard</button></a>
<button type="button" class="ghost danger" id="signout">Sign out</button>
</div>
</header>
<main class="admin">
<div class="panel">
<div class="panel-head">
<div>
<h2>Accounts</h2>
<p class="sub" id="summary">Loading&hellip;</p>
</div>
</div>
<p id="note" class="msg" hidden></p>
<div style="overflow-x:auto">
<table class="utable">
<thead>
<tr>
<th>Email</th><th>Name</th><th>Status</th><th>Role</th>
<th>Last signed in</th><th></th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
</main>
<script src="/static/auth.js"></script>
<script src="/static/page-admin.js"></script>
</body>
</html>

View File

@ -59,12 +59,13 @@ function renderFileList() {
} }
async function upload(file, kind) { async function upload(file, kind) {
const body = await file.arrayBuffer(); // FormData rather than a raw body: the filename travels in the part header,
const res = await fetch('/api/upload', { // so no X-Filename escaping, and the server can stream it to disk instead of
method: 'POST', // holding the whole thing in memory.
headers: { 'X-Filename': encodeURIComponent(file.name).replace(/%20/g, ' '), 'X-Kind': kind }, const form = new FormData();
body, form.append('file', file);
}); form.append('kind', kind);
const res = await A.api('/api/upload', { method: 'POST', body: form });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Upload failed'); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Upload failed');
state.files.push({ name: file.name, kind }); state.files.push({ name: file.name, kind });
renderFileList(); renderFileList();
@ -92,7 +93,7 @@ async function analyze() {
let i = 0; let i = 0;
const tick = setInterval(() => { $('loading-text').textContent = steps[++i % steps.length]; }, 900); const tick = setInterval(() => { $('loading-text').textContent = steps[++i % steps.length]; }, 900);
try { try {
const res = await fetch('/api/analyze', { const res = await A.api('/api/analyze', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state.settings), body: JSON.stringify(state.settings),
@ -160,15 +161,20 @@ function renderAnswer(t, m) {
`able to run &mdash; and <b class="out">${hrs(a.out)}</b> shut off because it hit its daily budget.` + `able to run &mdash; and <b class="out">${hrs(a.out)}</b> shut off because it hit its daily budget.` +
(a.paused > 0.05 ? ` A further ${hrs(a.paused)} it was paused, which costs nothing.` : ''); (a.paused > 0.05 ? ` A further ${hrs(a.paused)} it was paused, which costs nothing.` : '');
// Same four values as TRACK_COLOR in ppcbudget/payload.py -- the day bar and
// the timeline strips have to agree.
const segs = [ const segs = [
['running', a.running, '#16a34a', 'Running'], ['running', a.running, '#0f9a74', 'Running'],
['out', a.out, '#dc2626', 'Out of budget'], ['out', a.out, '#f2542d', 'Out of budget'],
['paused', a.paused, '#9ca3af', 'Paused'], ['paused', a.paused, '#9aa6ad', 'Paused'],
['na', a.na, '#e5e7eb', 'Not yet created'], ['na', a.na, '#cbdbd4', 'Not yet created'],
].filter(([, v]) => v > 0.01); ].filter(([, v]) => v > 0.01);
$('daybar').innerHTML = segs.map(([, v, color, label]) => // The two recessive states are pale enough that the default white label
`<span style="width:${(v / 24) * 100}%;background:${color}" // disappears on them, so those segments get dark text instead.
$('daybar').innerHTML = segs.map(([key, v, color, label]) =>
`<span class="${key === 'na' || key === 'paused' ? 'on-pale' : ''}"
style="width:${(v / 24) * 100}%;background:${color}"
title="${label}: ${hrs(v)}">${(v / 24) > 0.13 ? hrs(v) : ''}</span>`).join(''); title="${label}: ${hrs(v)}">${(v / 24) > 0.13 ? hrs(v) : ''}</span>`).join('');
$('daykeys').innerHTML = segs.map(([, v, color, label]) => $('daykeys').innerHTML = segs.map(([, v, color, label]) =>
`<div><i class="sw" style="background:${color}"></i>${label} <b>${hrs(v)}</b></div>`).join(''); `<div><i class="sw" style="background:${color}"></i>${label} <b>${hrs(v)}</b></div>`).join('');
@ -326,12 +332,15 @@ function setColumns(mode, multi) {
wrap.classList.toggle('multi', mode === 'day' && multi); wrap.classList.toggle('multi', mode === 'day' && multi);
} }
/** Colour for a day, on the same green-to-red scale as the hour heatmap. */ /** Colour for a day: nothing lost is the in-budget green, and everything above
* that rides one warm ramp. A single hue getting steadily darker, rather than
* the yellow-orange-red rainbow it replaced -- with one hue, "worse" is
* readable from the depth of the colour alone. */
function heatColor(lostHours, eligibleHours) { function heatColor(lostHours, eligibleHours) {
const f = eligibleHours > 0 ? Math.min(1, lostHours / eligibleHours) : 0; const f = eligibleHours > 0 ? Math.min(1, lostHours / eligibleHours) : 0;
if (f <= 0.005) return '#16a34a'; if (f <= 0.005) return '#0f9a74';
const ramp = ['#fff9c4', '#ffecb3', '#ffe0b2', '#ffccbc', '#ffab91', const ramp = ['#fdece7', '#fbd7cd', '#f9bfae', '#f7a58c', '#f4886a',
'#ff8a65', '#ef5350', '#dc2626', '#b71c1c']; '#f2542d', '#d8431f', '#b53617', '#8f2810'];
return ramp[Math.min(ramp.length - 1, Math.floor(f * ramp.length))]; return ramp[Math.min(ramp.length - 1, Math.floor(f * ramp.length))];
} }
@ -692,8 +701,13 @@ window.addEventListener('drop', (e) => e.preventDefault());
$('btn-analyze').addEventListener('click', analyze); $('btn-analyze').addEventListener('click', analyze);
$('btn-error-back').addEventListener('click', () => stage(state.data ? 'dash' : 'upload')); $('btn-error-back').addEventListener('click', () => stage(state.data ? 'dash' : 'upload'));
$('btn-signout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
location.replace('/login');
});
$('btn-reset').addEventListener('click', async () => { $('btn-reset').addEventListener('click', async () => {
await fetch('/api/clear', { method: 'POST' }); await A.api('/api/clear', { method: 'POST' });
state.data = null; state.files = []; state.diagnoses.clear(); state.data = null; state.files = []; state.diagnoses.clear();
state.search = ''; $('search').value = ''; state.pricedOnly = false; $('only-priced').checked = false; state.search = ''; $('search').value = ''; state.pricedOnly = false; $('only-priced').checked = false;
state.staleOnly = false; $('only-stale').checked = false; state.staleOnly = false; $('only-stale').checked = false;
@ -701,8 +715,28 @@ $('btn-reset').addEventListener('click', async () => {
stage('upload'); stage('upload');
}); });
$('btn-csv').addEventListener('click', () => { location.href = '/api/export?format=csv'; }); // Fetched rather than navigated to, so a refusal renders in the UI instead of
$('btn-xlsx').addEventListener('click', () => { location.href = '/api/export?format=xlsx'; }); // dumping JSON into a new tab. The session cookie rides along either way.
async function download(format) {
const res = await A.api('/api/export?format=' + format);
if (!res.ok) {
fail((await res.json().catch(() => ({}))).error || 'That export failed.');
return;
}
const name = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') || '');
const url = URL.createObjectURL(await res.blob());
const a = document.createElement('a');
a.href = url;
a.download = name ? name[1] : 'ppc-budget-report.' + format;
document.body.appendChild(a);
a.click();
a.remove();
// Revoked on a later tick: Safari has not finished reading it synchronously.
setTimeout(() => URL.revokeObjectURL(url), 30000);
}
$('btn-csv').addEventListener('click', () => download('csv'));
$('btn-xlsx').addEventListener('click', () => download('xlsx'));
$('search').addEventListener('input', (ev) => { state.search = ev.target.value; applyFilters(); }); $('search').addEventListener('input', (ev) => { state.search = ev.target.value; applyFilters(); });
$('only-priced').addEventListener('change', (ev) => { state.pricedOnly = ev.target.checked; applyFilters(); }); $('only-priced').addEventListener('change', (ev) => { state.pricedOnly = ev.target.checked; applyFilters(); });
@ -775,8 +809,15 @@ $('settings').addEventListener('close', (ev) => {
analyze(); analyze();
}); });
// Pick up anything preloaded from data/ on startup. // Pick up whatever this account already has loaded. Routed through A.api so an
fetch('/api/state').then((r) => r.json()).then((s) => { // expired session redirects to the sign-in page rather than silently rendering
// an empty dashboard.
A.api('/api/state').then((r) => r.json()).then((s) => {
if (s.user) {
$('whoami').textContent = s.user.name || s.user.email;
$('whoami').title = s.user.email;
$('link-admin').hidden = !s.user.is_admin;
}
state.files = s.history.map((n) => ({ name: n, kind: 'history' })); state.files = s.history.map((n) => ({ name: n, kind: 'history' }));
if (s.perf) state.files.push({ name: s.perf, kind: 'perf' }); if (s.perf) state.files.push({ name: s.perf, kind: 'perf' });
renderFileList(); renderFileList();

83
web/auth.js Normal file
View File

@ -0,0 +1,83 @@
'use strict';
// Shared by the auth pages and by app.js. Plain script, no modules -- same
// style as app.js, and it has to work when loaded before it.
const A = {
qs(key) {
return new URLSearchParams(location.search).get(key);
},
// Redirect a 401 to the sign-in page instead of letting the caller render an
// empty, broken screen. Everything else is handed back for normal handling.
guard(res) {
if (res.status === 401) {
const here = location.pathname + location.search;
location.replace('/login?next=' + encodeURIComponent(here));
throw new Error('not signed in');
}
return res;
},
async api(url, opts) {
return A.guard(await fetch(url, opts));
},
async postJSON(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
let json = {};
try { json = await res.json(); } catch (_) { /* empty or non-JSON body */ }
if (!res.ok) {
const err = new Error(json.error || 'That did not work. Try again.');
err.status = res.status;
err.code = json.code;
throw err;
}
return json;
},
msg(el, text, kind) {
if (!el) return;
el.textContent = text || '';
el.className = 'msg' + (kind ? ' ' + kind : '');
el.hidden = !text;
},
// Only ever bounce to a path on this site: '//evil.example' is a protocol
// relative URL and would leave it.
safeNext(raw) {
if (!raw || !raw.startsWith('/') || raw.startsWith('//')) return '/';
return raw;
},
busy(button, on, labelWhenBusy) {
if (!button) return;
if (on) {
button.dataset.label = button.textContent;
button.textContent = labelWhenBusy || 'Working…';
} else if (button.dataset.label) {
button.textContent = button.dataset.label;
}
button.disabled = on;
},
// Every auth page shares one submit shape: disable, call, show the outcome.
wire(form, button, note, handler) {
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
A.msg(note, '');
A.busy(button, true);
try {
await handler();
} catch (err) {
A.msg(note, String(err.message || err), 'err');
} finally {
A.busy(button, false);
}
});
},
};

35
web/fonts/README.md Normal file
View File

@ -0,0 +1,35 @@
# Brand fonts
These files are not in the repository. Drop them here and the app picks them up
with no code change — `web/styles.css` already declares the `@font-face` rules
and the fallback stacks.
| File | Face | Where it is used | Source |
| --- | --- | --- | --- |
| `Belleza-Regular.woff2` | Belleza | headings (`h1`, `h2`) | Google Fonts, SIL Open Font License |
| `NeueMontreal-Regular.woff2` | Neue Montreal | body copy, sub-headings | Pangram Pangram, commercial licence |
| `NeueMontreal-Medium.woff2` | Neue Montreal Medium | emphasis | as above |
| `NeueMontreal-Bold.woff2` | Neue Montreal Bold | strong emphasis | as above |
Belleza is already wired up. For Neue Montreal, uncomment the `@font-face`
block at the top of `web/styles.css` once the files are here.
## They have to be self-hosted
The app sends `Content-Security-Policy: default-src 'self'`, so a stylesheet
that pulls fonts from `fonts.gstatic.com` is blocked by the browser and the
page silently falls back. Converting to `.woff2` and serving them from this
directory is the only route.
To convert from `.ttf`/`.otf`:
```bash
pip install fonttools brotli
python -c "from fontTools.ttLib import TTFont; f=TTFont('Belleza-Regular.ttf'); f.flavor='woff2'; f.save('Belleza-Regular.woff2')"
```
## Until then
Headings fall back to Optima → Candara → Gill Sans → Trebuchet MS, and body
copy to the system UI sans. Both are chosen to sit close to the real faces, so
the layout does not shift when the files arrive.

42
web/forgot.html Normal file
View File

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reset your password &middot; Out-of-Budget Dashboard</title>
<link rel="stylesheet" href="/static/styles.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>📉</text></svg>">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="mark"></span>
<div>
<h1>Out-of-Budget Dashboard</h1>
<p>Amazon Ads change history &rarr; which campaigns keep going dark</p>
</div>
</div>
</header>
<main class="auth">
<div class="panel">
<h2>Reset your password</h2>
<p class="sub">We will email you a link to choose a new one.</p>
<form id="form" novalidate>
<label>Email
<input type="email" id="email" autocomplete="username" required autofocus>
</label>
<button type="submit" class="primary" id="submit">Send the link</button>
</form>
<p id="note" class="msg" hidden></p>
<p class="alt"><a href="/login">Back to sign in</a></p>
</div>
</main>
<script src="/static/auth.js"></script>
<script src="/static/page-forgot.js"></script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>PPC Out-of-Budget Dashboard</title> <title>PPC Out-of-Budget Dashboard</title>
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/static/styles.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>📉</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>📉</text></svg>">
</head> </head>
<body> <body>
@ -22,6 +22,10 @@
<button id="btn-csv" class="ghost" hidden>CSV</button> <button id="btn-csv" class="ghost" hidden>CSV</button>
<button id="btn-xlsx" class="ghost" hidden>Excel</button> <button id="btn-xlsx" class="ghost" hidden>Excel</button>
<button id="btn-reset" class="ghost danger" hidden>Start over</button> <button id="btn-reset" class="ghost danger" hidden>Start over</button>
<!-- Outside the set that stage() toggles, so these stay visible throughout. -->
<span id="whoami" class="muted" style="align-self:center;font-size:12px"></span>
<a id="link-admin" href="/admin" hidden><button type="button" class="ghost">Accounts</button></a>
<button id="btn-signout" class="ghost">Sign out</button>
</div> </div>
</header> </header>
@ -40,7 +44,8 @@
<h2>Drop your change-history exports here</h2> <h2>Drop your change-history exports here</h2>
<p>One file or a whole week of them. <button type="button" class="linklike" id="pick">Choose files</button> <p>One file or a whole week of them. <button type="button" class="linklike" id="pick">Choose files</button>
&mdash; or drop a folder.</p> &mdash; or drop a folder.</p>
<p class="fineprint">Runs entirely on this machine. Nothing is uploaded anywhere.</p> <p class="fineprint">Your files are uploaded to this server, analysed, and deleted
when you sign out or go idle. Nobody else using this dashboard can see them.</p>
<input type="file" id="file-input" multiple accept=".xlsx,.xlsm" hidden> <input type="file" id="file-input" multiple accept=".xlsx,.xlsm" hidden>
</div> </div>
@ -136,10 +141,10 @@
</div> </div>
</div> </div>
<p class="legend"> <p class="legend">
<span><i class="sw" style="background:#16a34a"></i>In budget</span> <span><i class="sw" style="background:#0f9a74"></i>In budget</span>
<span><i class="sw" style="background:#dc2626"></i>Out of budget</span> <span><i class="sw" style="background:#f2542d"></i>Out of budget</span>
<span><i class="sw" style="background:#9ca3af"></i>Paused</span> <span><i class="sw" style="background:#9aa6ad"></i>Paused</span>
<span><i class="sw" style="background:#e5e7eb"></i>Not yet created</span> <span><i class="sw" style="background:#cbdbd4"></i>Not yet created</span>
<span class="muted">Timeline runs midnight to midnight, left to right.</span> <span class="muted">Timeline runs midnight to midnight, left to right.</span>
</p> </p>
</div> </div>
@ -198,6 +203,7 @@
</form> </form>
</dialog> </dialog>
<script src="/app.js"></script> <script src="/static/auth.js"></script>
<script src="/static/app.js"></script>
</body> </body>
</html> </html>

53
web/login.html Normal file
View File

@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in &middot; Out-of-Budget Dashboard</title>
<link rel="stylesheet" href="/static/styles.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>📉</text></svg>">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="mark"></span>
<div>
<h1>Out-of-Budget Dashboard</h1>
<p>Amazon Ads change history &rarr; which campaigns keep going dark</p>
</div>
</div>
</header>
<main class="auth">
<div class="panel">
<h2>Sign in</h2>
<p class="sub">Use the address you signed up with.</p>
<form id="form" novalidate>
<label>Email
<input type="email" id="email" autocomplete="username" required autofocus>
</label>
<label>Password
<input type="password" id="password" autocomplete="current-password" required>
</label>
<button type="submit" class="primary" id="submit">Sign in</button>
</form>
<p id="note" class="msg" hidden></p>
<p class="alt" id="resend-wrap" hidden>
<button type="button" class="linklike" id="resend">Resend the confirmation email</button>
</p>
<p class="alt">
<a href="/forgot">Forgot your password?</a> &nbsp;&middot;&nbsp;
<a href="/signup">Create an account</a>
</p>
</div>
</main>
<script src="/static/auth.js"></script>
<script src="/static/page-login.js"></script>
</body>
</html>

80
web/page-admin.js Normal file
View File

@ -0,0 +1,80 @@
'use strict';
const $ = (id) => document.getElementById(id);
let me = null;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function when(iso) {
if (!iso) return '<span class="muted">never</span>';
// Timestamps are stored as naive UTC, so say so before parsing.
const d = new Date(/[Zz+]|\d-\d\d:\d\d$/.test(iso) ? iso : iso + 'Z');
return esc(d.toLocaleString());
}
function statusPill(u) {
if (!u.is_active) return '<span class="pill tag-off">disabled</span>';
if (!u.verified) return '<span class="pill tag-wait">unconfirmed</span>';
return '<span class="pill tag-on">active</span>';
}
function actions(u) {
const out = [];
if (!u.verified) out.push(`<button data-act="resend-verification" data-id="${u.id}">Resend</button>`);
if (u.signed_in) out.push(`<button data-act="sign-out" data-id="${u.id}">Sign out</button>`);
if (u.is_active) {
// Disabling yourself would lock you out of this page immediately.
if (!me || u.id !== me.id) {
out.push(`<button class="danger" data-act="deactivate" data-id="${u.id}">Disable</button>`);
}
} else {
out.push(`<button data-act="activate" data-id="${u.id}">Enable</button>`);
}
return out.join('');
}
async function load() {
const res = await A.api('/api/admin/users');
if (res.status === 403) { location.replace('/'); return; }
const data = await res.json();
$('summary').textContent =
`${data.users.length} account${data.users.length === 1 ? '' : 's'}, ` +
`${data.sessions} live session${data.sessions === 1 ? '' : 's'}, ` +
`${data.workspaces} workspace${data.workspaces === 1 ? '' : 's'} in memory`;
$('rows').innerHTML = data.users.map((u) => `
<tr>
<td>${esc(u.email)}</td>
<td>${esc(u.name) || '<span class="muted">&mdash;</span>'}</td>
<td>${statusPill(u)}</td>
<td>${u.is_admin ? '<span class="pill">admin</span>' : '<span class="muted">member</span>'}</td>
<td>${when(u.last_login_at)}</td>
<td><div class="acts">${actions(u)}</div></td>
</tr>`).join('');
}
$('rows').addEventListener('click', async (ev) => {
const btn = ev.target.closest('button[data-act]');
if (!btn) return;
A.busy(btn, true, '…');
try {
const r = await A.postJSON(`/api/admin/users/${btn.dataset.id}/${btn.dataset.act}`, {});
A.msg($('note'), r.message, 'ok');
await load();
} catch (err) {
A.msg($('note'), String(err.message || err), 'err');
A.busy(btn, false);
}
});
$('signout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
location.replace('/login');
});
(async () => {
const res = await A.api('/api/auth/me');
me = (await res.json()).user;
await load();
})().catch((err) => A.msg($('note'), String(err.message || err), 'err'));

11
web/page-forgot.js Normal file
View File

@ -0,0 +1,11 @@
'use strict';
const $ = (id) => document.getElementById(id);
A.wire($('form'), $('submit'), $('note'), async () => {
const r = await A.postJSON('/api/auth/forgot', { email: $('email').value.trim() });
// The server answers the same way for an address it has never seen, and so
// does this page: nothing here reveals who has an account.
A.msg($('note'), r.message, 'ok');
$('form').hidden = true;
});

39
web/page-login.js Normal file
View File

@ -0,0 +1,39 @@
'use strict';
// In its own file, not inline: the Content-Security-Policy is script-src
// 'self', so an inline block would be blocked and the page would do nothing.
const $ = (id) => document.getElementById(id);
const next = A.safeNext(A.qs('next'));
A.wire($('form'), $('submit'), $('note'), async () => {
const email = $('email').value.trim();
try {
await A.postJSON('/api/auth/login', { email, password: $('password').value });
} catch (err) {
// The one specific answer login gives: right password, unconfirmed address.
if (err.code === 'email_not_verified') {
$('resend-wrap').hidden = false;
$('resend').dataset.email = email;
}
throw err;
}
location.replace(next);
});
$('resend').addEventListener('click', async () => {
A.busy($('resend'), true, 'Sending…');
try {
const r = await A.postJSON('/api/auth/resend-verification',
{ email: $('resend').dataset.email });
A.msg($('note'), r.message, 'ok');
$('resend-wrap').hidden = true;
} catch (err) {
A.msg($('note'), String(err.message || err), 'err');
} finally {
A.busy($('resend'), false);
}
});
if (A.qs('verified')) A.msg($('note'), 'Your email is confirmed. Sign in below.', 'ok');
if (A.qs('reset')) A.msg($('note'), 'Your password has been changed. Sign in with it now.', 'ok');

22
web/page-reset.js Normal file
View File

@ -0,0 +1,22 @@
'use strict';
const $ = (id) => document.getElementById(id);
// The token is read here rather than acted on by the server during the GET:
// mail scanners fetch every link in an inbound message, and a link that acted
// on GET would be spent before anyone clicked it.
const token = A.qs('token');
if (!token) {
$('form-panel').hidden = true;
$('bad-panel').hidden = false;
$('bad-text').textContent = 'That link is missing its token. Ask for a new one.';
}
A.wire($('form'), $('submit'), $('note'), async () => {
if ($('password').value !== $('confirm').value) {
throw new Error('Those two passwords are not the same.');
}
await A.postJSON('/api/auth/reset', { token, password: $('password').value });
location.replace('/login?reset=1');
});

19
web/page-signup.js Normal file
View File

@ -0,0 +1,19 @@
'use strict';
const $ = (id) => document.getElementById(id);
A.wire($('form'), $('submit'), $('note'), async () => {
if ($('password').value !== $('confirm').value) {
throw new Error('Those two passwords are not the same.');
}
const r = await A.postJSON('/api/auth/signup', {
email: $('email').value.trim(),
password: $('password').value,
name: $('name').value.trim(),
});
// Deliberately the same screen whether or not the address was already
// registered -- the server answers identically, so the page must too.
$('done-text').textContent = r.message;
$('form-panel').hidden = true;
$('done-panel').hidden = false;
});

40
web/page-verify.js Normal file
View File

@ -0,0 +1,40 @@
'use strict';
const $ = (id) => document.getElementById(id);
const token = A.qs('token');
function fail(text) {
$('busy-panel').hidden = true;
$('ok-panel').hidden = true;
$('bad-panel').hidden = false;
$('bad-text').textContent = text;
}
async function confirmEmail() {
try {
const r = await A.postJSON('/api/auth/verify', { token });
$('busy-panel').hidden = true;
$('ok-text').textContent = r.message;
$('ok-panel').hidden = false;
} catch (err) {
fail(String(err.message || err));
}
}
// Confirming happens here, on a POST from script, rather than on the GET that
// loaded this page. A mail scanner following the link does not run this, so the
// token survives until a person actually opens it.
if (!token) {
fail('That link is missing its token.');
} else {
$('manual').hidden = false;
$('manual').addEventListener('click', confirmEmail);
confirmEmail();
}
A.wire($('resend-form'), $('resend'), $('note'), async () => {
const r = await A.postJSON('/api/auth/resend-verification',
{ email: $('email').value.trim() });
A.msg($('note'), r.message, 'ok');
$('resend-form').hidden = true;
});

52
web/reset.html Normal file
View File

@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choose a new password &middot; Out-of-Budget Dashboard</title>
<link rel="stylesheet" href="/static/styles.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>📉</text></svg>">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="mark"></span>
<div>
<h1>Out-of-Budget Dashboard</h1>
<p>Amazon Ads change history &rarr; which campaigns keep going dark</p>
</div>
</div>
</header>
<main class="auth">
<div class="panel" id="form-panel">
<h2>Choose a new password</h2>
<p class="sub">Every browser currently signed in to this account will be signed out.</p>
<form id="form" novalidate>
<label>New password
<input type="password" id="password" autocomplete="new-password" required minlength="10" autofocus>
<small>At least 10 characters.</small>
</label>
<label>Confirm new password
<input type="password" id="confirm" autocomplete="new-password" required>
</label>
<button type="submit" class="primary" id="submit">Change my password</button>
</form>
<p id="note" class="msg" hidden></p>
<p class="alt"><a href="/login">Back to sign in</a></p>
</div>
<div class="panel" id="bad-panel" hidden>
<h2>That link no longer works</h2>
<p class="sub" id="bad-text">Reset links can only be used once, and they expire.</p>
<p class="alt"><a href="/forgot">Send me a new one</a></p>
</div>
</main>
<script src="/static/auth.js"></script>
<script src="/static/page-reset.js"></script>
</body>
</html>

59
web/signup.html Normal file
View File

@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create an account &middot; Out-of-Budget Dashboard</title>
<link rel="stylesheet" href="/static/styles.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>📉</text></svg>">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="mark"></span>
<div>
<h1>Out-of-Budget Dashboard</h1>
<p>Amazon Ads change history &rarr; which campaigns keep going dark</p>
</div>
</div>
</header>
<main class="auth">
<div class="panel" id="form-panel">
<h2>Create an account</h2>
<p class="sub">You will need to confirm your email address before you can sign in.</p>
<form id="form" novalidate>
<label>Name
<input type="text" id="name" autocomplete="name" maxlength="120" autofocus>
</label>
<label>Email
<input type="email" id="email" autocomplete="username" required>
</label>
<label>Password
<input type="password" id="password" autocomplete="new-password" required minlength="10">
<small>At least 10 characters. Length beats punctuation.</small>
</label>
<label>Confirm password
<input type="password" id="confirm" autocomplete="new-password" required>
</label>
<button type="submit" class="primary" id="submit">Create account</button>
</form>
<p id="note" class="msg" hidden></p>
<p class="alt">Already have an account? <a href="/login">Sign in</a></p>
</div>
<div class="panel centre" id="done-panel" hidden>
<h2>Check your inbox</h2>
<p class="sub" id="done-text"></p>
<p class="alt"><a href="/login">Back to sign in</a></p>
</div>
</main>
<script src="/static/auth.js"></script>
<script src="/static/page-signup.js"></script>
</body>
</html>

View File

@ -1,39 +1,86 @@
/* ============================================================================
Utopia Brands identity.
Brand swatches are held verbatim in the --u-* tokens and never altered;
everything else is derived from them. Two colours are NOT in the brand guide:
--red and --amber. The guide covers identity, not function, and it has no
warning colour -- but this dashboard exists to show campaigns going dark, so
"out of budget" has to read as a problem. They are tuned to sit against the
brand greens and are used only to encode state, never as decoration.
========================================================================== */
/* Belleza carries the headings, per the guide's typeface hierarchy. Drop
Belleza-Regular.woff2 into web/fonts/ and it takes over; until then the
fallbacks below are the nearest humanist faces already on most machines.
Fonts must be self-hosted -- the Content-Security-Policy blocks every
external host, so a Google Fonts <link> would silently fail. */
@font-face {
font-family: 'Belleza';
src: url('/static/fonts/Belleza-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Body copy is Neue Montreal in the guide. It is a commercial licence, so the
stack falls through to the closest grotesques until the files are added:
@font-face { font-family:'Neue Montreal';
src:url('/static/fonts/NeueMontreal-Regular.woff2') format('woff2');
font-weight:400; font-display:swap; }
Add that block plus the Medium/Bold cuts and the whole app picks it up. */
:root { :root {
--bg: #f6f7f9; /* ---- brand, exactly as published ---- */
--u-deep: #004d43; /* primary deep green */
--u-lime: #ceff71; /* primary lime */
--u-ink: #1a3134; /* primary near-black teal */
--u-mist: #eafff4; /* primary pale mint */
--u-mint: #25e9a5; /* secondary mint */
--u-peri: #8e92ff; /* secondary periwinkle */
--font-display: 'Belleza', Optima, Candara, 'Gill Sans MT', 'Trebuchet MS', sans-serif;
--font-body: 'Neue Montreal', -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
/* ---- light mode ---- */
--bg: var(--u-mist);
--panel: #ffffff; --panel: #ffffff;
--ink: #16202e; --ink: var(--u-ink);
--ink-2: #55637a; --ink-2: #4a6b64;
--ink-3: #8b97ab; --ink-3: #7c9a92;
--line: #e3e7ee; --line: #cfe8dc;
--line-2: #eef1f6; --line-2: #e4f5ec;
--navy: #1f3864; --brand: #004d43;
--accent: #2563eb; --accent: #5a5fd6; /* periwinkle, darkened to hold up as link text */
--green: #16a34a; --green: #0f7a5c;
--amber: #b45309; --amber: #a2600a;
--red: #dc2626; --red: #c9401f;
--red-soft: #fee2e2; --red-soft: #ffe4dc;
--amber-soft: #fef3c7; --amber-soft: #fdefd0;
--green-soft: #dcfce7; --green-soft: #d3f6e8;
--shadow: 0 1px 2px rgba(16,32,46,.06), 0 8px 24px rgba(16,32,46,.06); --shadow: 0 1px 2px rgba(0,77,67,.07), 0 8px 24px rgba(0,77,67,.07);
--radius: 12px; --radius: 12px;
--row-h: 34px; --row-h: 34px;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #0f1419; --bg: #14282a;
--panel: #171d26; --panel: var(--u-ink);
--ink: #e6ebf2; --ink: var(--u-mist);
--ink-2: #9aa7ba; --ink-2: #a4c6bc;
--ink-3: #6b7789; --ink-3: #789c93;
--line: #263040; --line: #2c4a4a;
--line-2: #1e2734; --line-2: #21393a;
--navy: #7aa2e8; /* Lime with dark text is the brand's own call-to-action treatment. */
--accent: #60a5fa; --brand: var(--u-lime);
--red-soft: #3b1d1d; --accent: var(--u-peri);
--amber-soft: #3a2e12; --green: var(--u-mint);
--green-soft: #12301f; --amber: #f0b429;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.3); --red: #ff7a5c;
--red-soft: #40201a;
--amber-soft: #3d2f10;
--green-soft: #0f3a2c;
--shadow: 0 1px 2px rgba(0,0,0,.45), 0 8px 24px rgba(0,0,0,.35);
} }
} }
@ -43,13 +90,17 @@ body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--ink); color: var(--ink);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; font: 14px/1.5 var(--font-body);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* Headings in Belleza, body and sub-headings in the body face -- the hierarchy
set out in the brand guide. Belleza runs small and light for its size, so it
is stepped up slightly and given back the letter-spacing a display face wants. */
h1, h2, h3 { margin: 0; font-weight: 650; letter-spacing: -.01em; } h1, h2, h3 { margin: 0; font-weight: 650; letter-spacing: -.01em; }
h1 { font-size: 17px; } h1, h2 { font-family: var(--font-display); font-weight: 400; letter-spacing: 0; }
h2 { font-size: 15px; } h1 { font-size: 19px; }
h2 { font-size: 17px; }
h3 { font-size: 13px; } h3 { font-size: 13px; }
p { margin: 0; } p { margin: 0; }
.sub { color: var(--ink-2); font-size: 12.5px; max-width: 78ch; } .sub { color: var(--ink-2); font-size: 12.5px; max-width: 78ch; }
@ -67,9 +118,15 @@ p { margin: 0; }
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.brand { display: flex; align-items: center; gap: 12px; } .brand { display: flex; align-items: center; gap: 12px; }
/* Placeholder for the Utopia emblem: the brand's two primaries, split on the
same diagonal the guide's livery uses. Swap for the real mark when the SVG
lands -- redrawing a registered emblem by hand is one of the guide's don'ts. */
.mark { .mark {
width: 30px; height: 30px; border-radius: 8px; flex: none; width: 30px; height: 30px; border-radius: 8px; flex: none;
background: linear-gradient(135deg, var(--green) 0%, var(--green) 32%, var(--red) 32%, var(--red) 100%); background: linear-gradient(135deg, var(--u-deep) 0 32%, var(--u-lime) 32% 100%);
}
@media (prefers-color-scheme: dark) {
.mark { background: linear-gradient(135deg, var(--u-mint) 0 32%, var(--u-lime) 32% 100%); }
} }
.brand p { font-size: 12px; color: var(--ink-2); } .brand p { font-size: 12px; color: var(--ink-2); }
.topbar-actions { display: flex; gap: 8px; } .topbar-actions { display: flex; gap: 8px; }
@ -84,8 +141,8 @@ button {
button:hover { border-color: var(--ink-3); } button:hover { border-color: var(--ink-3); }
button:active { transform: translateY(1px); } button:active { transform: translateY(1px); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.primary { background: var(--navy); border-color: var(--navy); color: #fff; font-weight: 600; } .primary { background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600; }
@media (prefers-color-scheme: dark) { .primary { color: #0f1419; } } @media (prefers-color-scheme: dark) { .primary { color: var(--u-ink); } }
.primary:hover { filter: brightness(1.08); } .primary:hover { filter: brightness(1.08); }
.ghost { background: transparent; } .ghost { background: transparent; }
.danger:hover { border-color: var(--red); color: var(--red); } .danger:hover { border-color: var(--red); color: var(--red); }
@ -133,7 +190,7 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.loading { text-align: center; padding: 90px 20px; color: var(--ink-2); } .loading { text-align: center; padding: 90px 20px; color: var(--ink-2); }
.spinner { .spinner {
width: 30px; height: 30px; margin: 0 auto 16px; border-radius: 50%; width: 30px; height: 30px; margin: 0 auto 16px; border-radius: 50%;
border: 3px solid var(--line); border-top-color: var(--navy); border: 3px solid var(--line); border-top-color: var(--brand);
animation: spin .8s linear infinite; animation: spin .8s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@ -163,6 +220,8 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
font-size: 12px; font-weight: 650; color: #fff; white-space: nowrap; overflow: hidden; font-size: 12px; font-weight: 650; color: #fff; white-space: nowrap; overflow: hidden;
text-shadow: 0 1px 2px rgba(0,0,0,.3); text-shadow: 0 1px 2px rgba(0,0,0,.3);
} }
/* Paused and not-yet-created are pale by design, so white would vanish. */
.daybar span.on-pale { color: var(--u-ink); text-shadow: none; }
.daykeys { display: flex; flex-wrap: wrap; gap: 20px; margin-top: 11px; font-size: 12.5px; } .daykeys { display: flex; flex-wrap: wrap; gap: 20px; margin-top: 11px; font-size: 12.5px; }
.daykeys div { display: flex; align-items: center; gap: 7px; color: var(--ink-2); } .daykeys div { display: flex; align-items: center; gap: 7px; color: var(--ink-2); }
.daykeys b { color: var(--ink); font-variant-numeric: tabular-nums; } .daykeys b { color: var(--ink); font-variant-numeric: tabular-nums; }
@ -186,7 +245,7 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
} }
.kpi .value { .kpi .value {
font-size: 27px; font-weight: 680; letter-spacing: -.02em; font-size: 27px; font-weight: 680; letter-spacing: -.02em;
margin: 5px 0 3px; font-variant-numeric: tabular-nums; color: var(--navy); margin: 5px 0 3px; font-variant-numeric: tabular-nums; color: var(--brand);
} }
.kpi .note { font-size: 11.5px; color: var(--ink-3); } .kpi .note { font-size: 11.5px; color: var(--ink-3); }
.kpi.alarm .value { color: var(--red); } .kpi.alarm .value { color: var(--red); }
@ -215,8 +274,8 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
background: transparent; color: var(--ink-2); background: transparent; color: var(--ink-2);
} }
.segmented button + button { border-left: 1px solid var(--line); } .segmented button + button { border-left: 1px solid var(--line); }
.segmented button[aria-pressed="true"] { background: var(--navy); color: #fff; font-weight: 600; } .segmented button[aria-pressed="true"] { background: var(--brand); color: #fff; font-weight: 600; }
@media (prefers-color-scheme: dark) { .segmented button[aria-pressed="true"] { color: #0f1419; } } @media (prefers-color-scheme: dark) { .segmented button[aria-pressed="true"] { color: var(--u-ink); } }
/* One cell per day: which days were bad, at a glance. */ /* One cell per day: which days were bad, at a glance. */
.dayheat { display: flex; gap: 2px; height: 15px; margin: 0 10px; } .dayheat { display: flex; gap: 2px; height: 15px; margin: 0 10px; }
@ -230,7 +289,10 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
align-items: center; height: 100%; gap: 5px; } align-items: center; height: 100%; gap: 5px; }
.curve .fill { .curve .fill {
width: 100%; border-radius: 4px 4px 0 0; min-height: 2px; width: 100%; border-radius: 4px 4px 0 0; min-height: 2px;
background: linear-gradient(180deg, var(--red), #ef4444); /* The same coral the timeline strips use for out-of-budget: this chart
measures the identical thing by hour, so it must not read as a new colour.
Two adjacent steps of the warm ramp, not a darkened tint. */
background: linear-gradient(180deg, #f2542d, #d8431f);
transition: filter .12s; transition: filter .12s;
} }
.curve .bar:hover .fill { filter: brightness(1.2); } .curve .bar:hover .fill { filter: brightness(1.2); }
@ -251,8 +313,8 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px;
font-size: 12px; background: var(--panel); color: var(--ink-2); font-size: 12px; background: var(--panel); color: var(--ink-2);
} }
.chip[aria-pressed="true"] { background: var(--navy); border-color: var(--navy); color: #fff; } .chip[aria-pressed="true"] { background: var(--brand); border-color: var(--brand); color: #fff; }
@media (prefers-color-scheme: dark) { .chip[aria-pressed="true"] { color: #0f1419; } } @media (prefers-color-scheme: dark) { .chip[aria-pressed="true"] { color: var(--u-ink); } }
.chip .n { opacity: .65; margin-left: 5px; font-variant-numeric: tabular-nums; } .chip .n { opacity: .65; margin-left: 5px; font-variant-numeric: tabular-nums; }
/* ------------------------------------------------------------------- table */ /* ------------------------------------------------------------------- table */
@ -331,16 +393,18 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
font-size: 10.5px; font-weight: 600; white-space: nowrap; font-size: 10.5px; font-weight: 600; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; max-width: 100%; overflow: hidden; text-overflow: ellipsis; max-width: 100%;
} }
/* Diagnoses run from worst to benign. The label text is always present, so the
tint reinforces severity rather than carrying it alone. */
.dx-under { background: var(--red-soft); color: var(--red); } .dx-under { background: var(--red-soft); color: var(--red); }
.dx-early { background: var(--amber-soft); color: var(--amber); } .dx-early { background: var(--amber-soft); color: var(--amber); }
.dx-thrash { background: #ede9fe; color: #6d28d9; } .dx-thrash { background: #e8e9ff; color: #4b50c8; } /* periwinkle */
.dx-evening { background: var(--amber-soft); color: var(--amber); } .dx-evening { background: var(--amber-soft); color: var(--amber); }
.dx-inter { background: #dbeafe; color: #1d4ed8; } .dx-inter { background: #f1ffd7; color: #5d7d1f; } /* lime */
.dx-healthy { background: var(--green-soft); color: var(--green); } .dx-healthy { background: var(--green-soft); color: var(--green); }
.dx-paused { background: var(--line-2); color: var(--ink-2); } .dx-paused { background: var(--line-2); color: var(--ink-2); }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.dx-thrash { background: #2e1f4d; color: #c4b5fd; } .dx-thrash { background: #272b55; color: #babdff; }
.dx-inter { background: #17294d; color: #93c5fd; } .dx-inter { background: #303f18; color: #ceff71; }
} }
.unpriced { color: var(--ink-3); font-style: italic; font-size: 11.5px; } .unpriced { color: var(--ink-3); font-style: italic; font-size: 11.5px; }
@ -421,19 +485,21 @@ table.grid th small { display: block; font-size: 9px; font-weight: 500; opacity:
table.act-log td { font-size: 11.5px; vertical-align: top; } table.act-log td { font-size: 11.5px; vertical-align: top; }
table.act-log td:last-child { color: var(--ink-2); word-break: break-word; } table.act-log td:last-child { color: var(--ink-2); word-break: break-word; }
.pill.act-budget { background: #dbeafe; color: #1d4ed8; } /* Eight action categories, more than the palette has distinct hues. Each pill
.pill.act-placement { background: #ede9fe; color: #6d28d9; } is labelled, so colour is reinforcement and the two quietest categories can
.pill.act-strategy { background: #fce7f3; color: #be185d; } share the neutral tint without ambiguity. */
.pill.act-budget { background: #d5f0e7; color: #045f50; } /* deep green */
.pill.act-placement { background: #e8e9ff; color: #4b50c8; } /* periwinkle */
.pill.act-strategy { background: var(--red-soft); color: var(--red); }
.pill.act-bid { background: var(--green-soft); color: var(--green); } .pill.act-bid { background: var(--green-soft); color: var(--green); }
.pill.act-targeting { background: var(--amber-soft); color: var(--amber); } .pill.act-targeting { background: var(--amber-soft); color: var(--amber); }
.pill.act-status { background: var(--line-2); color: var(--ink-2); } .pill.act-status { background: var(--line-2); color: var(--ink-2); }
.pill.act-structure { background: #e0f2fe; color: #0369a1; } .pill.act-structure { background: #f1ffd7; color: #5d7d1f; } /* lime */
.pill.act-portfolio { background: var(--line-2); color: var(--ink-2); } .pill.act-portfolio { background: var(--line-2); color: var(--ink-2); }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.pill.act-budget { background: #17294d; color: #93c5fd; } .pill.act-budget { background: #0d3f37; color: #6fe0bf; }
.pill.act-placement { background: #2e1f4d; color: #c4b5fd; } .pill.act-placement { background: #272b55; color: #babdff; }
.pill.act-strategy { background: #401027; color: #f9a8d4; } .pill.act-structure { background: #303f18; color: #ceff71; }
.pill.act-structure { background: #0c2b3d; color: #7dd3fc; }
} }
/* ---------------------------------------------------------------- settings */ /* ---------------------------------------------------------------- settings */
@ -443,14 +509,44 @@ dialog {
background: var(--panel); color: var(--ink); max-width: 440px; box-shadow: var(--shadow); background: var(--panel); color: var(--ink); max-width: 440px; box-shadow: var(--shadow);
} }
dialog::backdrop { background: rgba(16,32,46,.4); } dialog::backdrop { background: rgba(16,32,46,.4); }
dialog label { display: block; margin: 16px 0; font-size: 12.5px; font-weight: 600; } dialog label, .auth label { display: block; margin: 16px 0; font-size: 12.5px; font-weight: 600; }
dialog input { dialog input, .auth input {
display: block; width: 100%; margin-top: 5px; font: inherit; padding: 7px 10px; display: block; width: 100%; margin-top: 5px; font: inherit; padding: 7px 10px;
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink);
} }
dialog small { display: block; margin-top: 4px; font-weight: 400; color: var(--ink-3); } dialog small, .auth small { display: block; margin-top: 4px; font-weight: 400; color: var(--ink-3); }
dialog menu { display: flex; justify-content: flex-end; gap: 8px; padding: 0; margin: 20px 0 0; } dialog menu { display: flex; justify-content: flex-end; gap: 8px; padding: 0; margin: 20px 0 0; }
/* -------------------------------------------------------------- auth pages */
.auth { max-width: 400px; margin: 56px auto; padding: 0 20px; }
.auth .panel { padding: 24px; }
.auth h2 { font-size: 17px; margin-bottom: 6px; }
.auth .primary { width: 100%; margin-top: 6px; }
.auth .alt { margin-top: 18px; font-size: 12.5px; color: var(--ink-2); text-align: center; }
.auth .alt a { color: var(--accent); }
.auth .msg { border-radius: 9px; padding: 10px 12px; font-size: 12.5px; margin-top: 14px; }
.auth .msg.err { background: var(--red-soft); color: var(--red); }
.auth .msg.ok { background: var(--green-soft); color: var(--green); }
.auth .centre { text-align: center; padding: 8px 0 4px; }
.auth .centre .spinner { margin: 0 auto 14px; }
/* ------------------------------------------------------------------- admin */
.admin { max-width: 1080px; margin: 28px auto; padding: 0 24px; }
.utable { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.utable th {
text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em;
color: var(--ink-2); font-weight: 600; padding: 8px 10px; border-bottom: 1px solid var(--line);
}
.utable td { padding: 9px 10px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
.utable tr:last-child td { border-bottom: 0; }
.utable .acts { display: flex; gap: 6px; justify-content: flex-end; }
.utable button { padding: 4px 9px; font-size: 11.5px; }
.tag-on { background: var(--green-soft); color: var(--green); }
.tag-off { background: var(--red-soft); color: var(--red); }
.tag-wait { background: var(--amber-soft); color: var(--amber); }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.thead, .row { grid-template-columns: minmax(160px, 2fr) 66px 60px 56px 150px 160px; } .thead, .row { grid-template-columns: minmax(160px, 2fr) 66px 60px 56px 150px 160px; }
.thead > div:nth-child(n+7), .row > div:nth-child(n+7) { display: none; } .thead > div:nth-child(n+7), .row > div:nth-child(n+7) { display: none; }

53
web/verify.html Normal file
View File

@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Confirming your email &middot; Out-of-Budget Dashboard</title>
<link rel="stylesheet" href="/static/styles.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>📉</text></svg>">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="mark"></span>
<div>
<h1>Out-of-Budget Dashboard</h1>
<p>Amazon Ads change history &rarr; which campaigns keep going dark</p>
</div>
</div>
</header>
<main class="auth">
<div class="panel centre" id="busy-panel">
<div class="spinner" aria-hidden="true"></div>
<p class="sub">Confirming your email address&hellip;</p>
<!-- Shown if the automatic attempt does not run, e.g. scripting is limited. -->
<p class="alt"><button type="button" class="primary" id="manual" hidden>Confirm my email</button></p>
</div>
<div class="panel centre" id="ok-panel" hidden>
<h2>You are all set</h2>
<p class="sub" id="ok-text"></p>
<p class="alt"><a href="/login?verified=1">Sign in</a></p>
</div>
<div class="panel" id="bad-panel" hidden>
<h2>That link no longer works</h2>
<p class="sub" id="bad-text"></p>
<form id="resend-form" novalidate>
<label>Email
<input type="email" id="email" autocomplete="username" required>
</label>
<button type="submit" class="primary" id="resend">Send me a new link</button>
</form>
<p id="note" class="msg" hidden></p>
<p class="alt"><a href="/login">Back to sign in</a></p>
</div>
</main>
<script src="/static/auth.js"></script>
<script src="/static/page-verify.js"></script>
</body>
</html>