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
parent
54d41b12a4
commit
b7cc1bac71
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
131
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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."
|
||||||
|
|
@ -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)}
|
||||||
|
|
@ -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"),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""HTTP routes."""
|
||||||
|
|
@ -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.")
|
||||||
|
|
@ -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}"'})
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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.
|
||||||
|
"""
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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 "")
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
139
serve.py
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 · 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…</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>
|
||||||
85
web/app.js
85
web/app.js
|
|
@ -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 — and <b class="out">${hrs(a.out)}</b> shut off because it hit its daily budget.` +
|
`able to run — 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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 · 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 → 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>
|
||||||
|
|
@ -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>
|
||||||
— or drop a folder.</p>
|
— 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>
|
||||||
|
|
|
||||||
|
|
@ -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 · 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 → 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> ·
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
let me = null;
|
||||||
|
|
||||||
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">—</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'));
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -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 · 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 → 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>
|
||||||
|
|
@ -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 · 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 → 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>
|
||||||
208
web/styles.css
208
web/styles.css
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 · 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 → 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…</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>
|
||||||
Loading…
Reference in New Issue