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,
|
||||
how often it happened, and what it plausibly cost.
|
||||
|
||||
Two ways to use it. Both run the same analysis code, so they can never
|
||||
disagree — the dashboard just makes it explorable and the report makes it
|
||||
shareable.
|
||||
Three ways to use it. All of them run the same analysis code — `ppcbudget/`
|
||||
via `app/services/analysis.py` — so they can never disagree. The dashboard
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
python3 run_report.py
|
||||
```
|
||||
|
||||
Put your exports in `data/` first. The report lands in `reports/`. Nothing to
|
||||
install — it uses `openpyxl`, which you already have.
|
||||
Put your exports in `data/` first. The report lands in `reports/`. Same
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
```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,
|
||||
overlapping-export handling, action classification, and edge cases. The
|
||||
important ones are `test_chain_breaks_canary` and
|
||||
`test_amazon_pacing_rows_are_not_actions`.
|
||||
`test_golden.py` is 34 checks: frozen totals from the reference export,
|
||||
structural invariants, overlapping-export handling, action classification, and
|
||||
edge cases. The important ones are `test_chain_breaks_canary` and
|
||||
`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
|
||||
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
|
||||
|
||||
NAVY = "1F3864"
|
||||
SLATE = "44546A"
|
||||
# Utopia Brands. The report is the artefact that leaves the building, so it
|
||||
# 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"
|
||||
GREEN = "16A34A"
|
||||
AMBER = "B45309"
|
||||
RED = "C0392B"
|
||||
GREY = "9E9E9E"
|
||||
PANEL = "F4F6FA"
|
||||
GREEN = "0F7A5C"
|
||||
AMBER = "A2600A"
|
||||
RED = "C9401F"
|
||||
GREY = "7C9A92"
|
||||
PANEL = MIST
|
||||
|
||||
HDR_FILL = PatternFill("solid", fgColor=NAVY)
|
||||
HDR_FILL = PatternFill("solid", fgColor=DEEP)
|
||||
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)
|
||||
SECTION = Font(color=NAVY, bold=True, size=12)
|
||||
SECTION = Font(color=DEEP, bold=True, size=12)
|
||||
TILE_FILL = PatternFill("solid", fgColor=PANEL)
|
||||
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_NOTE = Font(color=GREY, size=8, italic=True)
|
||||
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)
|
||||
# Per-date sub-columns run narrow, so they get their own smaller type.
|
||||
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)
|
||||
|
||||
EDGE = Side(style="thin", color="C9D2E3")
|
||||
EDGE = Side(style="thin", color="CFE8DC")
|
||||
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
|
||||
("E8F5E9", "FFF9C4", "FFECB3", "FFE0B2", "FFCCBC",
|
||||
"FFAB91", "FF8A65", "EF5350", "C62828")]
|
||||
PAUSED_FILL = PatternFill("solid", fgColor="E0E0E0")
|
||||
NA_FILL = PatternFill("solid", fgColor="F5F5F5")
|
||||
HEAT_FONT = Font(size=7, color="616161")
|
||||
("D3F6E8", "FDECE7", "FBD7CD", "F9BFAE", "F7A58C",
|
||||
"F4886A", "F2542D", "D8431F", "B53617")]
|
||||
PAUSED_FILL = PatternFill("solid", fgColor="DDE5E3")
|
||||
NA_FILL = PatternFill("solid", fgColor="F2F8F5")
|
||||
HEAT_FONT = Font(size=7, color=SLATE)
|
||||
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 = {
|
||||
"Structurally underfunded": PatternFill("solid", fgColor="FFCDD2"),
|
||||
"Exhausts early": PatternFill("solid", fgColor="FFE0B2"),
|
||||
"Pacing thrash": PatternFill("solid", fgColor="E1BEE7"),
|
||||
"Evening cap": PatternFill("solid", fgColor="FFF9C4"),
|
||||
"Intermittent": PatternFill("solid", fgColor="E3F2FD"),
|
||||
"Healthy": PatternFill("solid", fgColor="C8E6C9"),
|
||||
"Mostly paused": PatternFill("solid", fgColor="ECEFF1"),
|
||||
"Structurally underfunded": PatternFill("solid", fgColor="FFE4DC"),
|
||||
"Exhausts early": PatternFill("solid", fgColor="FDEFD0"),
|
||||
"Pacing thrash": PatternFill("solid", fgColor="E8E9FF"),
|
||||
"Evening cap": PatternFill("solid", fgColor="FEF7E4"),
|
||||
"Intermittent": PatternFill("solid", fgColor="F1FFD7"),
|
||||
"Healthy": PatternFill("solid", fgColor="D3F6E8"),
|
||||
"Mostly paused": PatternFill("solid", fgColor="E4F5EC"),
|
||||
}
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
STALE_FILL = PatternFill("solid", fgColor="FFCDD2")
|
||||
WARM_FILL = PatternFill("solid", fgColor="FFF3CD")
|
||||
STALE_FONT = Font(color="7F1D1D", size=10, bold=True)
|
||||
STALE_FILL = PatternFill("solid", fgColor="FFE4DC")
|
||||
WARM_FILL = PatternFill("solid", fgColor="FDEFD0")
|
||||
STALE_FONT = Font(color="8F2810", size=10, bold=True)
|
||||
|
||||
|
||||
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)):
|
||||
letter = get_column_letter(base + 2 + j * 3)
|
||||
ws.conditional_formatting.add(f"{letter}2:{letter}{last_row}", ColorScaleRule(
|
||||
start_type="num", start_value=0, start_color="E8F5E9",
|
||||
mid_type="num", mid_value=0.5, mid_color="FFCC80",
|
||||
end_type="num", end_value=1, end_color="C62828"))
|
||||
start_type="num", start_value=0, start_color="D3F6E8",
|
||||
mid_type="num", mid_value=0.5, mid_color="F7A58C",
|
||||
end_type="num", end_value=1, end_color="8F2810"))
|
||||
_as_table(ws, "Campaigns", last_row, last_col)
|
||||
ws.freeze_panes = "B2"
|
||||
|
||||
|
|
@ -676,7 +686,7 @@ def _sheet_method(wb: Workbook, settings: ModelSettings, metas: list[WorkbookMet
|
|||
]
|
||||
row = 4
|
||||
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")
|
||||
c = ws.cell(row=row, column=2, value=text)
|
||||
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
|
||||
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,
|
||||
value=f"{datetime.now():%Y-%m-%d %H:%M} from "
|
||||
+ ", ".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 .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 = {
|
||||
IN: "#16a34a",
|
||||
OOB: "#dc2626",
|
||||
PAUSED: "#9ca3af",
|
||||
NA: "#e5e7eb",
|
||||
IN: "#0f9a74", # brand deep green lifted to read on a dark surface
|
||||
OOB: "#f2542d", # functional warning; the brand guide has no such colour
|
||||
PAUSED: "#9aa6ad", # nothing is happening, so it recedes
|
||||
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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
disagree.
|
||||
This is the single-user local mode: no accounts, no database, no dependencies
|
||||
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
|
||||
|
|
@ -20,16 +21,13 @@ import tempfile
|
|||
import threading
|
||||
import traceback
|
||||
import webbrowser
|
||||
from datetime import date
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from ppcbudget import actions as actions_mod
|
||||
from ppcbudget import aggregate, excelout, metrics, payload, perfjoin
|
||||
from ppcbudget.ingest import dedupe_events, load_history
|
||||
from ppcbudget.scoring import check_invariants, score_all
|
||||
from app.services import exporters
|
||||
from app.services.analysis import run_analysis
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
WEB = HERE / "web"
|
||||
|
|
@ -76,66 +74,9 @@ SESSION = Session()
|
|||
|
||||
def analyze(settings_in: dict) -> dict:
|
||||
"""Run the pipeline over everything uploaded so far."""
|
||||
if not SESSION.history:
|
||||
raise ValueError("No change-history files uploaded yet.")
|
||||
|
||||
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
|
||||
result = run_analysis(SESSION.history, SESSION.perf, settings_in)
|
||||
SESSION.last = result.artifacts
|
||||
return result.payload
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
|
|
@ -236,71 +177,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||
if not last:
|
||||
self._error("Analyse some files first.")
|
||||
return
|
||||
stamp = f"{last['date_keys'][-1]}_{date.today():%Y%m%d}"
|
||||
stamp = exporters.export_stamp(last)
|
||||
|
||||
if fmt == "csv":
|
||||
body = _csv(last["days"], last.get("actions") or {}).encode("utf-8-sig")
|
||||
self._send(HTTPStatus.OK, body, "text/csv; charset=utf-8",
|
||||
self._send(HTTPStatus.OK, exporters.csv_bytes(last), exporters.CSV_MIME,
|
||||
{"Content-Disposition":
|
||||
f'attachment; filename="ppc-budget_{stamp}.csv"'})
|
||||
return
|
||||
|
||||
out = Path(tempfile.mkdtemp()) / f"ppc-budget-report_{stamp}.xlsx"
|
||||
excelout.write_report(out, last["days"], last["totals"], last["rollups"],
|
||||
last["qas"], last["metas"], last["settings"],
|
||||
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 "")
|
||||
self._send(HTTPStatus.OK, exporters.xlsx_bytes(last), exporters.XLSX_MIME,
|
||||
{"Content-Disposition":
|
||||
f'attachment; filename="ppc-budget-report_{stamp}.xlsx"'})
|
||||
|
||||
|
||||
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) {
|
||||
const body = await file.arrayBuffer();
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Filename': encodeURIComponent(file.name).replace(/%20/g, ' '), 'X-Kind': kind },
|
||||
body,
|
||||
});
|
||||
// FormData rather than a raw body: the filename travels in the part header,
|
||||
// so no X-Filename escaping, and the server can stream it to disk instead of
|
||||
// holding the whole thing in memory.
|
||||
const form = new FormData();
|
||||
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');
|
||||
state.files.push({ name: file.name, kind });
|
||||
renderFileList();
|
||||
|
|
@ -92,7 +93,7 @@ async function analyze() {
|
|||
let i = 0;
|
||||
const tick = setInterval(() => { $('loading-text').textContent = steps[++i % steps.length]; }, 900);
|
||||
try {
|
||||
const res = await fetch('/api/analyze', {
|
||||
const res = await A.api('/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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.` +
|
||||
(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 = [
|
||||
['running', a.running, '#16a34a', 'Running'],
|
||||
['out', a.out, '#dc2626', 'Out of budget'],
|
||||
['paused', a.paused, '#9ca3af', 'Paused'],
|
||||
['na', a.na, '#e5e7eb', 'Not yet created'],
|
||||
['running', a.running, '#0f9a74', 'Running'],
|
||||
['out', a.out, '#f2542d', 'Out of budget'],
|
||||
['paused', a.paused, '#9aa6ad', 'Paused'],
|
||||
['na', a.na, '#cbdbd4', 'Not yet created'],
|
||||
].filter(([, v]) => v > 0.01);
|
||||
|
||||
$('daybar').innerHTML = segs.map(([, v, color, label]) =>
|
||||
`<span style="width:${(v / 24) * 100}%;background:${color}"
|
||||
// The two recessive states are pale enough that the default white label
|
||||
// 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('');
|
||||
$('daykeys').innerHTML = segs.map(([, v, color, label]) =>
|
||||
`<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);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const f = eligibleHours > 0 ? Math.min(1, lostHours / eligibleHours) : 0;
|
||||
if (f <= 0.005) return '#16a34a';
|
||||
const ramp = ['#fff9c4', '#ffecb3', '#ffe0b2', '#ffccbc', '#ffab91',
|
||||
'#ff8a65', '#ef5350', '#dc2626', '#b71c1c'];
|
||||
if (f <= 0.005) return '#0f9a74';
|
||||
const ramp = ['#fdece7', '#fbd7cd', '#f9bfae', '#f7a58c', '#f4886a',
|
||||
'#f2542d', '#d8431f', '#b53617', '#8f2810'];
|
||||
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-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 () => {
|
||||
await fetch('/api/clear', { method: 'POST' });
|
||||
await A.api('/api/clear', { method: 'POST' });
|
||||
state.data = null; state.files = []; state.diagnoses.clear();
|
||||
state.search = ''; $('search').value = ''; state.pricedOnly = false; $('only-priced').checked = false;
|
||||
state.staleOnly = false; $('only-stale').checked = false;
|
||||
|
|
@ -701,8 +715,28 @@ $('btn-reset').addEventListener('click', async () => {
|
|||
stage('upload');
|
||||
});
|
||||
|
||||
$('btn-csv').addEventListener('click', () => { location.href = '/api/export?format=csv'; });
|
||||
$('btn-xlsx').addEventListener('click', () => { location.href = '/api/export?format=xlsx'; });
|
||||
// Fetched rather than navigated to, so a refusal renders in the UI instead of
|
||||
// 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(); });
|
||||
$('only-priced').addEventListener('change', (ev) => { state.pricedOnly = ev.target.checked; applyFilters(); });
|
||||
|
|
@ -775,8 +809,15 @@ $('settings').addEventListener('close', (ev) => {
|
|||
analyze();
|
||||
});
|
||||
|
||||
// Pick up anything preloaded from data/ on startup.
|
||||
fetch('/api/state').then((r) => r.json()).then((s) => {
|
||||
// Pick up whatever this account already has loaded. Routed through A.api so an
|
||||
// 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' }));
|
||||
if (s.perf) state.files.push({ name: s.perf, kind: 'perf' });
|
||||
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 name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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>">
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -22,6 +22,10 @@
|
|||
<button id="btn-csv" class="ghost" hidden>CSV</button>
|
||||
<button id="btn-xlsx" class="ghost" hidden>Excel</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>
|
||||
</header>
|
||||
|
||||
|
|
@ -40,7 +44,8 @@
|
|||
<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>
|
||||
— 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>
|
||||
</div>
|
||||
|
||||
|
|
@ -136,10 +141,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<p class="legend">
|
||||
<span><i class="sw" style="background:#16a34a"></i>In budget</span>
|
||||
<span><i class="sw" style="background:#dc2626"></i>Out of budget</span>
|
||||
<span><i class="sw" style="background:#9ca3af"></i>Paused</span>
|
||||
<span><i class="sw" style="background:#e5e7eb"></i>Not yet created</span>
|
||||
<span><i class="sw" style="background:#0f9a74"></i>In budget</span>
|
||||
<span><i class="sw" style="background:#f2542d"></i>Out of budget</span>
|
||||
<span><i class="sw" style="background:#9aa6ad"></i>Paused</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>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -198,6 +203,7 @@
|
|||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
<script src="/static/auth.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</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 {
|
||||
--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;
|
||||
--ink: #16202e;
|
||||
--ink-2: #55637a;
|
||||
--ink-3: #8b97ab;
|
||||
--line: #e3e7ee;
|
||||
--line-2: #eef1f6;
|
||||
--navy: #1f3864;
|
||||
--accent: #2563eb;
|
||||
--green: #16a34a;
|
||||
--amber: #b45309;
|
||||
--red: #dc2626;
|
||||
--red-soft: #fee2e2;
|
||||
--amber-soft: #fef3c7;
|
||||
--green-soft: #dcfce7;
|
||||
--shadow: 0 1px 2px rgba(16,32,46,.06), 0 8px 24px rgba(16,32,46,.06);
|
||||
--ink: var(--u-ink);
|
||||
--ink-2: #4a6b64;
|
||||
--ink-3: #7c9a92;
|
||||
--line: #cfe8dc;
|
||||
--line-2: #e4f5ec;
|
||||
--brand: #004d43;
|
||||
--accent: #5a5fd6; /* periwinkle, darkened to hold up as link text */
|
||||
--green: #0f7a5c;
|
||||
--amber: #a2600a;
|
||||
--red: #c9401f;
|
||||
--red-soft: #ffe4dc;
|
||||
--amber-soft: #fdefd0;
|
||||
--green-soft: #d3f6e8;
|
||||
--shadow: 0 1px 2px rgba(0,77,67,.07), 0 8px 24px rgba(0,77,67,.07);
|
||||
--radius: 12px;
|
||||
--row-h: 34px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--panel: #171d26;
|
||||
--ink: #e6ebf2;
|
||||
--ink-2: #9aa7ba;
|
||||
--ink-3: #6b7789;
|
||||
--line: #263040;
|
||||
--line-2: #1e2734;
|
||||
--navy: #7aa2e8;
|
||||
--accent: #60a5fa;
|
||||
--red-soft: #3b1d1d;
|
||||
--amber-soft: #3a2e12;
|
||||
--green-soft: #12301f;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.3);
|
||||
--bg: #14282a;
|
||||
--panel: var(--u-ink);
|
||||
--ink: var(--u-mist);
|
||||
--ink-2: #a4c6bc;
|
||||
--ink-3: #789c93;
|
||||
--line: #2c4a4a;
|
||||
--line-2: #21393a;
|
||||
/* Lime with dark text is the brand's own call-to-action treatment. */
|
||||
--brand: var(--u-lime);
|
||||
--accent: var(--u-peri);
|
||||
--green: var(--u-mint);
|
||||
--amber: #f0b429;
|
||||
--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;
|
||||
background: var(--bg);
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 { font-size: 17px; }
|
||||
h2 { font-size: 15px; }
|
||||
h1, h2 { font-family: var(--font-display); font-weight: 400; letter-spacing: 0; }
|
||||
h1 { font-size: 19px; }
|
||||
h2 { font-size: 17px; }
|
||||
h3 { font-size: 13px; }
|
||||
p { margin: 0; }
|
||||
.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);
|
||||
}
|
||||
.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 {
|
||||
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); }
|
||||
.topbar-actions { display: flex; gap: 8px; }
|
||||
|
|
@ -84,8 +141,8 @@ button {
|
|||
button:hover { border-color: var(--ink-3); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.primary { background: var(--navy); border-color: var(--navy); color: #fff; font-weight: 600; }
|
||||
@media (prefers-color-scheme: dark) { .primary { color: #0f1419; } }
|
||||
.primary { background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600; }
|
||||
@media (prefers-color-scheme: dark) { .primary { color: var(--u-ink); } }
|
||||
.primary:hover { filter: brightness(1.08); }
|
||||
.ghost { background: transparent; }
|
||||
.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); }
|
||||
.spinner {
|
||||
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;
|
||||
}
|
||||
@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;
|
||||
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 div { display: flex; align-items: center; gap: 7px; color: var(--ink-2); }
|
||||
.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 {
|
||||
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.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);
|
||||
}
|
||||
.segmented button + button { border-left: 1px solid var(--line); }
|
||||
.segmented button[aria-pressed="true"] { background: var(--navy); color: #fff; font-weight: 600; }
|
||||
@media (prefers-color-scheme: dark) { .segmented button[aria-pressed="true"] { color: #0f1419; } }
|
||||
.segmented button[aria-pressed="true"] { background: var(--brand); color: #fff; font-weight: 600; }
|
||||
@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. */
|
||||
.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; }
|
||||
.curve .fill {
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
font-size: 12px; background: var(--panel); color: var(--ink-2);
|
||||
}
|
||||
.chip[aria-pressed="true"] { background: var(--navy); border-color: var(--navy); color: #fff; }
|
||||
@media (prefers-color-scheme: dark) { .chip[aria-pressed="true"] { color: #0f1419; } }
|
||||
.chip[aria-pressed="true"] { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
@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; }
|
||||
|
||||
/* ------------------------------------------------------------------- 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;
|
||||
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-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-inter { background: #dbeafe; color: #1d4ed8; }
|
||||
.dx-inter { background: #f1ffd7; color: #5d7d1f; } /* lime */
|
||||
.dx-healthy { background: var(--green-soft); color: var(--green); }
|
||||
.dx-paused { background: var(--line-2); color: var(--ink-2); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dx-thrash { background: #2e1f4d; color: #c4b5fd; }
|
||||
.dx-inter { background: #17294d; color: #93c5fd; }
|
||||
.dx-thrash { background: #272b55; color: #babdff; }
|
||||
.dx-inter { background: #303f18; color: #ceff71; }
|
||||
}
|
||||
.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:last-child { color: var(--ink-2); word-break: break-word; }
|
||||
|
||||
.pill.act-budget { background: #dbeafe; color: #1d4ed8; }
|
||||
.pill.act-placement { background: #ede9fe; color: #6d28d9; }
|
||||
.pill.act-strategy { background: #fce7f3; color: #be185d; }
|
||||
/* Eight action categories, more than the palette has distinct hues. Each pill
|
||||
is labelled, so colour is reinforcement and the two quietest categories can
|
||||
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-targeting { background: var(--amber-soft); color: var(--amber); }
|
||||
.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); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pill.act-budget { background: #17294d; color: #93c5fd; }
|
||||
.pill.act-placement { background: #2e1f4d; color: #c4b5fd; }
|
||||
.pill.act-strategy { background: #401027; color: #f9a8d4; }
|
||||
.pill.act-structure { background: #0c2b3d; color: #7dd3fc; }
|
||||
.pill.act-budget { background: #0d3f37; color: #6fe0bf; }
|
||||
.pill.act-placement { background: #272b55; color: #babdff; }
|
||||
.pill.act-structure { background: #303f18; color: #ceff71; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- settings */
|
||||
|
|
@ -443,14 +509,44 @@ dialog {
|
|||
background: var(--panel); color: var(--ink); max-width: 440px; box-shadow: var(--shadow);
|
||||
}
|
||||
dialog::backdrop { background: rgba(16,32,46,.4); }
|
||||
dialog label { display: block; margin: 16px 0; font-size: 12.5px; font-weight: 600; }
|
||||
dialog input {
|
||||
dialog label, .auth label { display: block; margin: 16px 0; font-size: 12.5px; font-weight: 600; }
|
||||
dialog input, .auth input {
|
||||
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);
|
||||
}
|
||||
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; }
|
||||
|
||||
/* -------------------------------------------------------------- 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) {
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -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