Manage the password in the app; close remaining audit findings
Password management moves out of the CLI entirely. The credential is now a salted scrypt hash in the database (so it survives rebuilds, living in the /data volume) rather than an environment variable; PARTS_PASSWORD is demoted to a bootstrap value that stops working the moment a password is set in the UI. Every token carries a session epoch, so changing the password — or "sign out other devices" — invalidates outstanding cookies while keeping the browser that made the change signed in. A banner nags until the handed-over password is replaced. app/admin.py remains for the one case the UI cannot cover, a forgotten password. Audit findings: - Taxonomy update and delete scanned affected parts before taking the write lock, so a concurrent rename could leave the search index matching a name the UI no longer showed. All four routes now lock first; removing the lock again makes the new test fail exactly that way. - History of a missing part returned 200 with an empty list; now 404. - Infinity and NaN passed ge=0 and failed at the database. They are rejected as 422 now, and the validation error handler no longer chokes trying to echo a non-finite value back. Checks go from 134 to 181. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,3 +7,4 @@ __pycache__/
|
|||||||
data/
|
data/
|
||||||
.venv/
|
.venv/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env.bak-*
|
||||||
|
|||||||
@@ -45,26 +45,34 @@ Then open http://127.0.0.1:8123.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
.venv/bin/python -m tests.test_api # 122 checks, in-process
|
.venv/bin/python -m tests.test_api # 166 checks, in-process
|
||||||
.venv/bin/python -m tests.test_concurrency # 12 checks, against a real uvicorn
|
.venv/bin/python -m tests.test_concurrency # 15 checks, against a real uvicorn
|
||||||
```
|
```
|
||||||
|
|
||||||
`test_api` exercises the API end to end against a throwaway database — the auth
|
`test_api` exercises the API end to end against a throwaway database — the auth
|
||||||
gate and session-token signing, nested categories and locations, search across
|
gate and session-token signing, nested categories and locations, search across
|
||||||
every indexed field, filter rollups, stock adjustment and history, patch
|
every indexed field, filter rollups, stock adjustment and history, patch
|
||||||
semantics including explicit nulls, taxonomy cycle rejection, security headers
|
semantics including explicit nulls, taxonomy cycle rejection, security headers
|
||||||
and asset versioning, and login throttling.
|
and asset versioning, login throttling, and the full password-management flow
|
||||||
|
including scrypt hashing, session invalidation and the recovery CLI.
|
||||||
|
|
||||||
`test_concurrency` needs a real server process, because a lost update only shows
|
`test_concurrency` needs a real server process, because a lost update only shows
|
||||||
up when two requests genuinely overlap inside SQLite. It fires overlapping
|
up when two requests genuinely overlap inside SQLite. It fires overlapping
|
||||||
adjustments, patches and creates at one part and asserts the stock log always
|
adjustments, patches and creates at one part and asserts the stock log always
|
||||||
sums to the stored quantity.
|
sums to the stored quantity, and races taxonomy renames against reads to check
|
||||||
|
the search index never describes a name the tree no longer has.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
The password is **managed from the app**, not from a config file. `PARTS_PASSWORD`
|
||||||
|
is only the bootstrap credential: it works until a password is set through the
|
||||||
|
UI, and is ignored from then on (otherwise "changing" the password would leave
|
||||||
|
the old one working). The stored password is a salted scrypt hash in the
|
||||||
|
`settings` table, which lives in the `/data` volume and survives rebuilds.
|
||||||
|
|
||||||
| Variable | Meaning |
|
| Variable | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `PARTS_PASSWORD` | The single shared password. Required when auth is on. |
|
| `PARTS_PASSWORD` | Bootstrap password, used only until one is set in the app. |
|
||||||
| `PARTS_SECRET` | Signing key for the session cookie. `openssl rand -hex 32`. |
|
| `PARTS_SECRET` | Signing key for the session cookie. `openssl rand -hex 32`. |
|
||||||
| `PARTS_SESSION_DAYS` | Session lifetime, default 30. |
|
| `PARTS_SESSION_DAYS` | Session lifetime, default 30. |
|
||||||
| `PARTS_AUTH` | `off` disables the login gate (LAN-only use). |
|
| `PARTS_AUTH` | `off` disables the login gate (LAN-only use). |
|
||||||
@@ -73,9 +81,11 @@ sums to the stored quantity.
|
|||||||
| `PARTS_LOGIN_WINDOW` | Throttle window in seconds, default 300. |
|
| `PARTS_LOGIN_WINDOW` | Throttle window in seconds, default 300. |
|
||||||
| `PARTS_SECURE_COOKIE` | `auto` (default) trusts `X-Forwarded-Proto`; `on`/`off` force it. |
|
| `PARTS_SECURE_COOKIE` | `auto` (default) trusts `X-Forwarded-Proto`; `on`/`off` force it. |
|
||||||
|
|
||||||
Rotating `PARTS_PASSWORD` alone does **not** invalidate existing sessions when
|
Every token carries a session epoch. Changing the password bumps it, which
|
||||||
`PARTS_SECRET` is set independently — the cookie is signed with the secret.
|
invalidates every outstanding cookie at once while re-issuing one for the
|
||||||
Rotate both to revoke every outstanding cookie.
|
browser that made the change — so a password change really does sign out other
|
||||||
|
devices, with no need to touch `PARTS_SECRET` on the host. **Sign out other
|
||||||
|
devices** in Settings bumps the epoch on its own.
|
||||||
|
|
||||||
Failed logins are throttled on a **global** window rather than per source
|
Failed logins are throttled on a **global** window rather than per source
|
||||||
address. Caddy appends to `X-Forwarded-For` instead of replacing it, so the
|
address. Caddy appends to `X-Forwarded-For` instead of replacing it, so the
|
||||||
@@ -90,11 +100,16 @@ Lives at `/home/jay/srv/parts/` on `.8` and follows the same conventions as the
|
|||||||
other services there — `build: .`, external `caddy_web` network, named volume
|
other services there — `build: .`, external `caddy_web` network, named volume
|
||||||
for `/data`.
|
for `/data`.
|
||||||
|
|
||||||
|
`~/srv/parts` is a checkout of this repository, so deploying is a pull:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ssh jay@192.168.50.8
|
ssh jay@192.168.50.8
|
||||||
cd ~/srv/parts && sudo docker compose up -d --build
|
cd ~/srv/parts && git pull && docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`.env` is untracked and gitignored, so it survives the pull. `~/srv` (the infra
|
||||||
|
repo) ignores `parts/`, since this directory is its own repository.
|
||||||
|
|
||||||
The database is in the `parts_parts_data` docker volume, which survives
|
The database is in the `parts_parts_data` docker volume, which survives
|
||||||
rebuilds.
|
rebuilds.
|
||||||
|
|
||||||
@@ -135,6 +150,22 @@ The "what could I build with what I'm holding?" idea is meant to arrive as
|
|||||||
another consumer of these endpoints — the arbiter on `.8` already has the lane
|
another consumer of these endpoints — the arbiter on `.8` already has the lane
|
||||||
routing and typed-action machinery for it — rather than as a fork of them.
|
routing and typed-action machinery for it — rather than as a fork of them.
|
||||||
|
|
||||||
|
## Passwords
|
||||||
|
|
||||||
|
Open **Settings** (the gear in the header) and use the Password section. It asks
|
||||||
|
for the current password, takes a new one twice, and signs out every other
|
||||||
|
device. Until a password has been set in the app, a banner says so.
|
||||||
|
|
||||||
|
The only reason to touch a terminal is a forgotten password:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -it parts python -m app.admin set-password # prompts, twice
|
||||||
|
docker exec parts python -m app.admin show-status # where the password comes from
|
||||||
|
docker exec parts python -m app.admin clear-password # fall back to PARTS_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
Each of those signs out every session too.
|
||||||
|
|
||||||
## Hardening notes
|
## Hardening notes
|
||||||
|
|
||||||
Responses carry `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`
|
Responses carry `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Recovery CLI.
|
||||||
|
|
||||||
|
Everyday password changes happen in the UI. This exists for the one case the UI
|
||||||
|
cannot help with — a forgotten password — and for the initial handover.
|
||||||
|
|
||||||
|
docker exec -it parts python -m app.admin set-password
|
||||||
|
docker exec parts python -m app.admin show-status
|
||||||
|
"""
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import auth, db
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(argv):
|
||||||
|
password = argv[0] if argv else None
|
||||||
|
if password is None:
|
||||||
|
password = getpass.getpass("New password: ")
|
||||||
|
if password != getpass.getpass("Repeat: "):
|
||||||
|
print("Passwords did not match.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
problem = auth.password_problem(password)
|
||||||
|
if problem:
|
||||||
|
print(problem, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
with db.session() as conn:
|
||||||
|
auth.set_password(conn, password)
|
||||||
|
print("Password set. All existing sessions have been signed out.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def clear_password(_argv):
|
||||||
|
"""Fall back to the PARTS_PASSWORD environment variable again."""
|
||||||
|
with db.session() as conn:
|
||||||
|
conn.execute("DELETE FROM settings WHERE key = ?", (auth.PASSWORD_KEY,))
|
||||||
|
auth.bump_epoch(conn)
|
||||||
|
print("Stored password cleared; PARTS_PASSWORD from the environment is live again.")
|
||||||
|
print("All existing sessions have been signed out.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def show_status(_argv):
|
||||||
|
with db.session() as conn:
|
||||||
|
stored = auth.stored_hash(conn)
|
||||||
|
print("password source :", "database (set in the app)" if stored else "PARTS_PASSWORD env var")
|
||||||
|
print("session epoch :", auth.current_epoch(conn))
|
||||||
|
print("auth enabled :", auth.auth_enabled())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
COMMANDS = {"set-password": set_password, "clear-password": clear_password, "show-status": show_status}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if not argv or argv[0] not in COMMANDS:
|
||||||
|
print("usage: python -m app.admin {" + "|".join(COMMANDS) + "}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
db.init()
|
||||||
|
return COMMANDS[argv[0]](argv[1:])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+112
-9
@@ -9,10 +9,13 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import Cookie, HTTPException, Request
|
from fastapi import Cookie, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
|
||||||
COOKIE_NAME = "parts_session"
|
COOKIE_NAME = "parts_session"
|
||||||
DIGEST_SIZE = hashlib.sha256().digest_size
|
DIGEST_SIZE = hashlib.sha256().digest_size
|
||||||
@@ -46,13 +49,103 @@ def session_days() -> int:
|
|||||||
return _env_int("PARTS_SESSION_DAYS", 30)
|
return _env_int("PARTS_SESSION_DAYS", 30)
|
||||||
|
|
||||||
|
|
||||||
def check_password(candidate: str) -> bool:
|
# --- stored password --------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The password lives in the database, not the environment, so it can be changed
|
||||||
|
# from the UI and survives a container rebuild (it is in the /data volume).
|
||||||
|
# PARTS_PASSWORD is only the bootstrap credential: it works until a password has
|
||||||
|
# been set through the app, and is ignored from then on — otherwise "changing"
|
||||||
|
# the password would leave the old one working.
|
||||||
|
|
||||||
|
PASSWORD_KEY = "password_hash"
|
||||||
|
EPOCH_KEY = "session_epoch"
|
||||||
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
|
_SCRYPT_N = 1 << 14
|
||||||
|
_SCRYPT_R = 8
|
||||||
|
_SCRYPT_P = 1
|
||||||
|
_SCRYPT_MAXMEM = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
digest = hashlib.scrypt(
|
||||||
|
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
|
||||||
|
dklen=32, maxmem=_SCRYPT_MAXMEM,
|
||||||
|
)
|
||||||
|
return "scrypt${}${}${}${}${}".format(
|
||||||
|
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P,
|
||||||
|
base64.b64encode(salt).decode(), base64.b64encode(digest).decode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hash(password: str, stored: str) -> bool:
|
||||||
|
try:
|
||||||
|
scheme, n, r, pp, salt_b64, digest_b64 = stored.split("$")
|
||||||
|
if scheme != "scrypt":
|
||||||
|
return False
|
||||||
|
digest = hashlib.scrypt(
|
||||||
|
password.encode(), salt=base64.b64decode(salt_b64),
|
||||||
|
n=int(n), r=int(r), p=int(pp), dklen=len(base64.b64decode(digest_b64)),
|
||||||
|
maxmem=_SCRYPT_MAXMEM,
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(digest, base64.b64decode(digest_b64))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stored_hash(conn) -> str | None:
|
||||||
|
return db.get_setting(conn, PASSWORD_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def using_bootstrap_password(conn) -> bool:
|
||||||
|
"""True while the env-provided password is still the live one."""
|
||||||
|
return stored_hash(conn) is None
|
||||||
|
|
||||||
|
|
||||||
|
def check_password(conn, candidate: str) -> bool:
|
||||||
|
stored = stored_hash(conn)
|
||||||
|
if stored:
|
||||||
|
return verify_hash(candidate, stored)
|
||||||
expected = _env("PARTS_PASSWORD")
|
expected = _env("PARTS_PASSWORD")
|
||||||
if not expected:
|
if not expected:
|
||||||
return False
|
return False
|
||||||
return hmac.compare_digest(candidate.encode(), expected.encode())
|
return hmac.compare_digest(candidate.encode(), expected.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(conn, new_password: str):
|
||||||
|
"""Store a new password and invalidate every outstanding session."""
|
||||||
|
db.set_setting(conn, PASSWORD_KEY, hash_password(new_password))
|
||||||
|
bump_epoch(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def password_problem(new_password: str) -> str | None:
|
||||||
|
if len(new_password) < MIN_PASSWORD_LENGTH:
|
||||||
|
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters"
|
||||||
|
if not new_password.strip():
|
||||||
|
return "Password must not be blank"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- session epoch ----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Baked into every token. Bumping it invalidates all outstanding cookies at
|
||||||
|
# once, which is what makes "change my password" and "sign out everywhere"
|
||||||
|
# work without having to rotate PARTS_SECRET by hand on the host.
|
||||||
|
|
||||||
|
def current_epoch(conn) -> int:
|
||||||
|
try:
|
||||||
|
return int(db.get_setting(conn, EPOCH_KEY, "0") or "0")
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def bump_epoch(conn) -> int:
|
||||||
|
epoch = current_epoch(conn) + 1
|
||||||
|
db.set_setting(conn, EPOCH_KEY, epoch)
|
||||||
|
return epoch
|
||||||
|
|
||||||
|
|
||||||
# --- session tokens ---------------------------------------------------------
|
# --- session tokens ---------------------------------------------------------
|
||||||
#
|
#
|
||||||
# The signature is raw HMAC bytes, which can contain any byte value including
|
# The signature is raw HMAC bytes, which can contain any byte value including
|
||||||
@@ -61,14 +154,14 @@ def check_password(candidate: str) -> bool:
|
|||||||
# split in the wrong place and failed their own validator. The digest is a fixed
|
# split in the wrong place and failed their own validator. The digest is a fixed
|
||||||
# 32 bytes, so slice by length instead of looking for a delimiter.
|
# 32 bytes, so slice by length instead of looking for a delimiter.
|
||||||
|
|
||||||
def issue_token() -> str:
|
def issue_token(conn) -> str:
|
||||||
expires = int(time.time()) + session_days() * 86400
|
expires = int(time.time()) + session_days() * 86400
|
||||||
payload = str(expires).encode()
|
payload = f"{expires}:{current_epoch(conn)}".encode()
|
||||||
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
||||||
return base64.urlsafe_b64encode(payload + sig).decode()
|
return base64.urlsafe_b64encode(payload + sig).decode()
|
||||||
|
|
||||||
|
|
||||||
def token_valid(token: str) -> bool:
|
def token_valid(token: str, conn=None) -> bool:
|
||||||
try:
|
try:
|
||||||
raw = base64.urlsafe_b64decode(token.encode())
|
raw = base64.urlsafe_b64decode(token.encode())
|
||||||
if len(raw) <= DIGEST_SIZE:
|
if len(raw) <= DIGEST_SIZE:
|
||||||
@@ -77,7 +170,12 @@ def token_valid(token: str) -> bool:
|
|||||||
expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
||||||
if not hmac.compare_digest(sig, expected):
|
if not hmac.compare_digest(sig, expected):
|
||||||
return False
|
return False
|
||||||
return int(payload) > time.time()
|
expires_s, _, epoch_s = payload.decode().partition(":")
|
||||||
|
if int(expires_s) <= time.time():
|
||||||
|
return False
|
||||||
|
if conn is not None and int(epoch_s or 0) != current_epoch(conn):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -149,10 +247,15 @@ def is_https(request: Request) -> bool:
|
|||||||
return request.url.scheme == "https"
|
return request.url.scheme == "https"
|
||||||
|
|
||||||
|
|
||||||
def require_auth(parts_session: str | None = Cookie(default=None)):
|
def require_auth(parts_session: str | None = Cookie(default=None), conn=Depends(db.get_db)):
|
||||||
"""FastAPI dependency guarding every data route."""
|
"""FastAPI dependency guarding every data route.
|
||||||
|
|
||||||
|
Takes the same request-scoped connection the route uses (FastAPI caches
|
||||||
|
dependency results per request), so checking the session epoch costs one
|
||||||
|
indexed lookup rather than a second connection.
|
||||||
|
"""
|
||||||
if not auth_enabled():
|
if not auth_enabled():
|
||||||
return True
|
return True
|
||||||
if parts_session and token_valid(parts_session):
|
if parts_session and token_valid(parts_session, conn):
|
||||||
return True
|
return True
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ CREATE TABLE IF NOT EXISTS stock_log (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS stock_log_part ON stock_log(part_id, id DESC);
|
CREATE INDEX IF NOT EXISTS stock_log_part ON stock_log(part_id, id DESC);
|
||||||
|
|
||||||
|
-- Small key/value store for things that must outlive a container rebuild and
|
||||||
|
-- be changeable without editing a file on the host: the password hash and the
|
||||||
|
-- session epoch live here, in the /data volume.
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5(
|
||||||
name, description, manufacturer, mpn, specs, tags, category, location,
|
name, description, manufacturer, mpn, specs, tags, category, location,
|
||||||
tokenize='unicode61 remove_diacritics 2'
|
tokenize='unicode61 remove_diacritics 2'
|
||||||
@@ -131,6 +139,19 @@ def init():
|
|||||||
_initialised = True
|
_initialised = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(conn, key: str, default: str | None = None) -> str | None:
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(conn, key: str, value: str):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings(key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
(key, str(value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def session():
|
def session():
|
||||||
conn = connect()
|
conn = connect()
|
||||||
|
|||||||
+125
-22
@@ -9,12 +9,14 @@ consumer of the same endpoints rather than a fork of them.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import AfterValidator, BaseModel, Field
|
from pydantic import AfterValidator, BaseModel, Field
|
||||||
@@ -69,10 +71,10 @@ class PartIn(BaseModel):
|
|||||||
location_id: int | None = None
|
location_id: int | None = None
|
||||||
manufacturer: Text = Field(default="", max_length=200)
|
manufacturer: Text = Field(default="", max_length=200)
|
||||||
mpn: Text = Field(default="", max_length=120)
|
mpn: Text = Field(default="", max_length=120)
|
||||||
quantity: float = Field(default=0, ge=0)
|
quantity: float = Field(default=0, ge=0, allow_inf_nan=False)
|
||||||
unit: RequiredText = Field(default="pcs", max_length=20)
|
unit: RequiredText = Field(default="pcs", max_length=20)
|
||||||
min_quantity: float | None = Field(default=None, ge=0)
|
min_quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||||
cost_each: float | None = Field(default=None, ge=0)
|
cost_each: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||||
datasheet_url: Text = Field(default="", max_length=1000)
|
datasheet_url: Text = Field(default="", max_length=1000)
|
||||||
product_url: Text = Field(default="", max_length=1000)
|
product_url: Text = Field(default="", max_length=1000)
|
||||||
notes: Text = Field(default="", max_length=4000)
|
notes: Text = Field(default="", max_length=4000)
|
||||||
@@ -87,10 +89,10 @@ class PartPatch(BaseModel):
|
|||||||
location_id: int | None = None
|
location_id: int | None = None
|
||||||
manufacturer: Text | None = Field(default=None, max_length=200)
|
manufacturer: Text | None = Field(default=None, max_length=200)
|
||||||
mpn: Text | None = Field(default=None, max_length=120)
|
mpn: Text | None = Field(default=None, max_length=120)
|
||||||
quantity: float | None = Field(default=None, ge=0)
|
quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||||
unit: RequiredText | None = Field(default=None, max_length=20)
|
unit: RequiredText | None = Field(default=None, max_length=20)
|
||||||
min_quantity: float | None = Field(default=None, ge=0)
|
min_quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||||
cost_each: float | None = Field(default=None, ge=0)
|
cost_each: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||||
datasheet_url: Text | None = Field(default=None, max_length=1000)
|
datasheet_url: Text | None = Field(default=None, max_length=1000)
|
||||||
product_url: Text | None = Field(default=None, max_length=1000)
|
product_url: Text | None = Field(default=None, max_length=1000)
|
||||||
notes: Text | None = Field(default=None, max_length=4000)
|
notes: Text | None = Field(default=None, max_length=4000)
|
||||||
@@ -109,7 +111,7 @@ PART_COLUMNS = [
|
|||||||
|
|
||||||
|
|
||||||
class AdjustIn(BaseModel):
|
class AdjustIn(BaseModel):
|
||||||
delta: float
|
delta: float = Field(allow_inf_nan=False)
|
||||||
reason: Text = Field(default="", max_length=300)
|
reason: Text = Field(default="", max_length=300)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +128,11 @@ class LoginIn(BaseModel):
|
|||||||
password: str = Field(max_length=500)
|
password: str = Field(max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeIn(BaseModel):
|
||||||
|
current_password: str = Field(max_length=500)
|
||||||
|
new_password: str = Field(max_length=500)
|
||||||
|
|
||||||
|
|
||||||
# --- helpers ----------------------------------------------------------------
|
# --- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
def _specs_for(conn, part_ids: list[int]) -> dict[int, list[dict]]:
|
def _specs_for(conn, part_ids: list[int]) -> dict[int, list[dict]]:
|
||||||
@@ -240,6 +247,32 @@ def _check_parent(conn, table: str, node_id: int, parent_id: int | None, label:
|
|||||||
raise HTTPException(400, f"A {label} cannot be moved beneath itself")
|
raise HTTPException(400, f"A {label} cannot be moved beneath itself")
|
||||||
|
|
||||||
|
|
||||||
|
# --- error handling ---------------------------------------------------------
|
||||||
|
|
||||||
|
def _json_safe(value):
|
||||||
|
"""Make a validation-error payload encodable.
|
||||||
|
|
||||||
|
FastAPI echoes the rejected value back in the error detail. When that value
|
||||||
|
is Infinity or NaN — the very thing being rejected — the JSON encoder
|
||||||
|
refuses it and a clean 422 turns into a serialisation failure. Stringify
|
||||||
|
anything the encoder can't represent.
|
||||||
|
"""
|
||||||
|
if isinstance(value, float):
|
||||||
|
return value if math.isfinite(value) else str(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(k): _json_safe(v) for k, v in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [_json_safe(v) for v in value]
|
||||||
|
if isinstance(value, (str, int, bool)) or value is None:
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
def validation_error(request: Request, exc: RequestValidationError):
|
||||||
|
return JSONResponse({"detail": _json_safe(exc.errors())}, status_code=422)
|
||||||
|
|
||||||
|
|
||||||
# --- middleware -------------------------------------------------------------
|
# --- middleware -------------------------------------------------------------
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -268,19 +301,35 @@ async def security_headers(request: Request, call_next):
|
|||||||
|
|
||||||
# --- auth routes ------------------------------------------------------------
|
# --- auth routes ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _set_session_cookie(response: Response, request: Request, conn):
|
||||||
|
response.set_cookie(
|
||||||
|
auth.COOKIE_NAME,
|
||||||
|
auth.issue_token(conn),
|
||||||
|
max_age=auth.session_days() * 86400,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=auth.is_https(request),
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/me")
|
@app.get("/api/me")
|
||||||
def me(request: Request):
|
def me(request: Request, conn=Depends(db.get_db)):
|
||||||
if not auth.auth_enabled():
|
if not auth.auth_enabled():
|
||||||
return {"authenticated": True, "auth_required": False}
|
return {"authenticated": True, "auth_required": False, "using_bootstrap_password": False}
|
||||||
token = request.cookies.get(auth.COOKIE_NAME)
|
token = request.cookies.get(auth.COOKIE_NAME)
|
||||||
|
authenticated = bool(token and auth.token_valid(token, conn))
|
||||||
return {
|
return {
|
||||||
"authenticated": bool(token and auth.token_valid(token)),
|
"authenticated": authenticated,
|
||||||
"auth_required": True,
|
"auth_required": True,
|
||||||
|
# Surfaced so the UI can nag until the handed-over password is replaced.
|
||||||
|
"using_bootstrap_password": authenticated and auth.using_bootstrap_password(conn),
|
||||||
|
"min_password_length": auth.MIN_PASSWORD_LENGTH,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/login")
|
@app.post("/api/login")
|
||||||
async def login(body: LoginIn, response: Response, request: Request):
|
async def login(body: LoginIn, response: Response, request: Request, conn=Depends(db.get_db)):
|
||||||
if not auth.auth_enabled():
|
if not auth.auth_enabled():
|
||||||
return {"authenticated": True, "auth_required": False}
|
return {"authenticated": True, "auth_required": False}
|
||||||
|
|
||||||
@@ -290,7 +339,7 @@ async def login(body: LoginIn, response: Response, request: Request):
|
|||||||
429, "Too many failed attempts", headers={"Retry-After": str(retry_after)}
|
429, "Too many failed attempts", headers={"Retry-After": str(retry_after)}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not auth.check_password(body.password):
|
if not auth.check_password(conn, body.password):
|
||||||
auth.record_failure()
|
auth.record_failure()
|
||||||
# Async sleep, so a burst of guesses can't tie up the worker threadpool
|
# Async sleep, so a burst of guesses can't tie up the worker threadpool
|
||||||
# that real requests need.
|
# that real requests need.
|
||||||
@@ -298,16 +347,52 @@ async def login(body: LoginIn, response: Response, request: Request):
|
|||||||
raise HTTPException(401, "Incorrect password")
|
raise HTTPException(401, "Incorrect password")
|
||||||
|
|
||||||
auth.clear_failures()
|
auth.clear_failures()
|
||||||
response.set_cookie(
|
_set_session_cookie(response, request, conn)
|
||||||
auth.COOKIE_NAME,
|
return {
|
||||||
auth.issue_token(),
|
"authenticated": True,
|
||||||
max_age=auth.session_days() * 86400,
|
"auth_required": True,
|
||||||
httponly=True,
|
"using_bootstrap_password": auth.using_bootstrap_password(conn),
|
||||||
samesite="lax",
|
}
|
||||||
secure=auth.is_https(request),
|
|
||||||
path="/",
|
|
||||||
)
|
@app.post("/api/password", dependencies=[Depends(auth.require_auth)])
|
||||||
return {"authenticated": True, "auth_required": True}
|
async def change_password(
|
||||||
|
body: PasswordChangeIn, response: Response, request: Request, conn=Depends(db.get_db)
|
||||||
|
):
|
||||||
|
"""Change the password from inside the app.
|
||||||
|
|
||||||
|
Requires the current password even though the caller already holds a valid
|
||||||
|
session: a borrowed browser tab should not be enough to lock the owner out.
|
||||||
|
"""
|
||||||
|
if not auth.auth_enabled():
|
||||||
|
raise HTTPException(400, "Authentication is disabled, so there is no password to change")
|
||||||
|
|
||||||
|
if not auth.check_password(conn, body.current_password):
|
||||||
|
auth.record_failure()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
raise HTTPException(403, "Current password is incorrect")
|
||||||
|
|
||||||
|
problem = auth.password_problem(body.new_password)
|
||||||
|
if problem:
|
||||||
|
raise HTTPException(422, problem)
|
||||||
|
if body.new_password == body.current_password:
|
||||||
|
raise HTTPException(422, "New password must be different from the current one")
|
||||||
|
|
||||||
|
auth.clear_failures()
|
||||||
|
# Bumps the session epoch, so every cookie issued before now stops working.
|
||||||
|
auth.set_password(conn, body.new_password)
|
||||||
|
# Re-issue for the caller, so changing the password doesn't log you out of
|
||||||
|
# the tab you changed it in.
|
||||||
|
_set_session_cookie(response, request, conn)
|
||||||
|
return {"changed": True, "other_sessions_signed_out": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/sessions/revoke", dependencies=[Depends(auth.require_auth)])
|
||||||
|
def revoke_sessions(response: Response, request: Request, conn=Depends(db.get_db)):
|
||||||
|
"""Sign out every other device, keeping this one signed in."""
|
||||||
|
auth.bump_epoch(conn)
|
||||||
|
_set_session_cookie(response, request, conn)
|
||||||
|
return {"revoked": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/logout")
|
@app.post("/api/logout")
|
||||||
@@ -503,6 +588,8 @@ def adjust_part(part_id: int, body: AdjustIn, conn=Depends(db.get_db)):
|
|||||||
|
|
||||||
@app.get("/api/parts/{part_id}/history", dependencies=[Depends(auth.require_auth)])
|
@app.get("/api/parts/{part_id}/history", dependencies=[Depends(auth.require_auth)])
|
||||||
def part_history(part_id: int, conn=Depends(db.get_db), limit: int = Query(default=50, ge=1, le=500)):
|
def part_history(part_id: int, conn=Depends(db.get_db), limit: int = Query(default=50, ge=1, le=500)):
|
||||||
|
if conn.execute("SELECT 1 FROM parts WHERE id = ?", (part_id,)).fetchone() is None:
|
||||||
|
raise HTTPException(404, "Part not found")
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM stock_log WHERE part_id = ? ORDER BY id DESC LIMIT ?", (part_id, limit)
|
"SELECT * FROM stock_log WHERE part_id = ? ORDER BY id DESC LIMIT ?", (part_id, limit)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -578,6 +665,10 @@ def create_category(body: NodeIn, conn=Depends(db.get_db)):
|
|||||||
|
|
||||||
@app.patch("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
|
@app.patch("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
|
||||||
def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
||||||
|
# Before the first read: the affected-parts scan and the reindex that
|
||||||
|
# follows it have to see one consistent tree, or a concurrent write can slip
|
||||||
|
# between them and leave the search index describing the old taxonomy.
|
||||||
|
db.begin_immediate(conn)
|
||||||
_require_node(conn, "categories", cat_id, "Category")
|
_require_node(conn, "categories", cat_id, "Category")
|
||||||
_check_parent(conn, "categories", cat_id, body.parent_id, "category")
|
_check_parent(conn, "categories", cat_id, body.parent_id, "category")
|
||||||
affected = db.parts_under(conn, "categories", cat_id)
|
affected = db.parts_under(conn, "categories", cat_id)
|
||||||
@@ -596,6 +687,10 @@ def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
|||||||
|
|
||||||
@app.delete("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
|
@app.delete("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
|
||||||
def delete_category(cat_id: int, conn=Depends(db.get_db)):
|
def delete_category(cat_id: int, conn=Depends(db.get_db)):
|
||||||
|
# Before the first read: the affected-parts scan and the reindex that
|
||||||
|
# follows it have to see one consistent tree, or a concurrent write can slip
|
||||||
|
# between them and leave the search index describing the old taxonomy.
|
||||||
|
db.begin_immediate(conn)
|
||||||
_require_node(conn, "categories", cat_id, "Category")
|
_require_node(conn, "categories", cat_id, "Category")
|
||||||
# Capture the parts first: ON DELETE SET NULL means they survive as
|
# Capture the parts first: ON DELETE SET NULL means they survive as
|
||||||
# uncategorised rather than vanish, but their indexed path must be rebuilt.
|
# uncategorised rather than vanish, but their indexed path must be rebuilt.
|
||||||
@@ -626,6 +721,10 @@ def create_location(body: NodeIn, conn=Depends(db.get_db)):
|
|||||||
|
|
||||||
@app.patch("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
|
@app.patch("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
|
||||||
def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
||||||
|
# Before the first read: the affected-parts scan and the reindex that
|
||||||
|
# follows it have to see one consistent tree, or a concurrent write can slip
|
||||||
|
# between them and leave the search index describing the old taxonomy.
|
||||||
|
db.begin_immediate(conn)
|
||||||
_require_node(conn, "locations", loc_id, "Location")
|
_require_node(conn, "locations", loc_id, "Location")
|
||||||
_check_parent(conn, "locations", loc_id, body.parent_id, "location")
|
_check_parent(conn, "locations", loc_id, body.parent_id, "location")
|
||||||
affected = db.parts_under(conn, "locations", loc_id)
|
affected = db.parts_under(conn, "locations", loc_id)
|
||||||
@@ -642,6 +741,10 @@ def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
|||||||
|
|
||||||
@app.delete("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
|
@app.delete("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
|
||||||
def delete_location(loc_id: int, conn=Depends(db.get_db)):
|
def delete_location(loc_id: int, conn=Depends(db.get_db)):
|
||||||
|
# Before the first read: the affected-parts scan and the reindex that
|
||||||
|
# follows it have to see one consistent tree, or a concurrent write can slip
|
||||||
|
# between them and leave the search index describing the old taxonomy.
|
||||||
|
db.begin_immediate(conn)
|
||||||
_require_node(conn, "locations", loc_id, "Location")
|
_require_node(conn, "locations", loc_id, "Location")
|
||||||
affected = db.parts_under(conn, "locations", loc_id)
|
affected = db.parts_under(conn, "locations", loc_id)
|
||||||
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
|
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
|
||||||
|
|||||||
@@ -89,6 +89,21 @@ header .brand { font-weight: 700; letter-spacing: -.01em; white-space: nowrap; }
|
|||||||
header .search { flex: 1 1 240px; min-width: 160px; }
|
header .search { flex: 1 1 240px; min-width: 160px; }
|
||||||
header .stat { color: var(--dim); font-size: 13px; white-space: nowrap; }
|
header .stat { color: var(--dim); font-size: 13px; white-space: nowrap; }
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 12px 16px -4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--warn);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(240, 168, 72, .10);
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.layout { display: flex; align-items: flex-start; gap: 18px; padding: 16px; max-width: 1400px; margin: 0 auto; }
|
.layout { display: flex; align-items: flex-start; gap: 18px; padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||||
|
|
||||||
aside {
|
aside {
|
||||||
|
|||||||
+78
-4
@@ -23,6 +23,8 @@ const state = {
|
|||||||
locations: [],
|
locations: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
authRequired: true,
|
authRequired: true,
|
||||||
|
usingBootstrap: false,
|
||||||
|
minPasswordLength: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- plumbing ---------------------------------------------------------------
|
// --- plumbing ---------------------------------------------------------------
|
||||||
@@ -70,6 +72,7 @@ function showApp() {
|
|||||||
$("#login").classList.add("hidden");
|
$("#login").classList.add("hidden");
|
||||||
$("#app").classList.remove("hidden");
|
$("#app").classList.remove("hidden");
|
||||||
$("#logout-btn").classList.toggle("hidden", !state.authRequired);
|
$("#logout-btn").classList.toggle("hidden", !state.authRequired);
|
||||||
|
$("#bootstrap-banner").classList.toggle("hidden", !state.usingBootstrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#login-form").addEventListener("submit", async (e) => {
|
$("#login-form").addEventListener("submit", async (e) => {
|
||||||
@@ -77,8 +80,9 @@ $("#login-form").addEventListener("submit", async (e) => {
|
|||||||
const err = $("#login-error");
|
const err = $("#login-error");
|
||||||
err.classList.add("hidden");
|
err.classList.add("hidden");
|
||||||
try {
|
try {
|
||||||
await api("/api/login", { method: "POST", body: { password: $("#password").value } });
|
const session = await api("/api/login", { method: "POST", body: { password: $("#password").value } });
|
||||||
$("#password").value = "";
|
$("#password").value = "";
|
||||||
|
state.usingBootstrap = !!session.using_bootstrap_password;
|
||||||
showApp();
|
showApp();
|
||||||
await boot();
|
await boot();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@@ -493,13 +497,36 @@ function openPartModal(part) {
|
|||||||
|
|
||||||
// --- manage categories / locations ------------------------------------------
|
// --- manage categories / locations ------------------------------------------
|
||||||
|
|
||||||
function openManageModal() {
|
function openManageModal(focusPassword = false) {
|
||||||
const overlay = openModal(`
|
const overlay = openModal(`
|
||||||
<header>
|
<header>
|
||||||
<strong>Categories & locations</strong>
|
<strong>Settings</strong>
|
||||||
<button class="ghost small" data-close>✕</button>
|
<button class="ghost small" data-close>✕</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
${state.authRequired ? `
|
||||||
|
<section id="password-section" style="margin-bottom:24px">
|
||||||
|
<label>Password</label>
|
||||||
|
${state.usingBootstrap
|
||||||
|
? '<p style="color:var(--warn);font-size:13px;margin:0 0 10px">You\'re still using the password you were handed. Setting your own here also signs out every other device.</p>'
|
||||||
|
: '<p style="color:var(--dim);font-size:13px;margin:0 0 10px">Changing your password signs out every other device.</p>'}
|
||||||
|
<div class="grid">
|
||||||
|
<div class="full">
|
||||||
|
<input id="pw-current" type="password" autocomplete="current-password" placeholder="Current password">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input id="pw-new" type="password" autocomplete="new-password" placeholder="New password">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input id="pw-confirm" type="password" autocomplete="new-password" placeholder="Repeat new password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap">
|
||||||
|
<button class="small primary" id="pw-save">Change password</button>
|
||||||
|
<button class="small ghost" id="pw-revoke" title="Keeps this device signed in">Sign out other devices</button>
|
||||||
|
<span id="pw-msg" style="font-size:13px"></span>
|
||||||
|
</div>
|
||||||
|
</section>` : ""}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div>
|
<div>
|
||||||
<label>New category</label>
|
<label>New category</label>
|
||||||
@@ -530,6 +557,50 @@ function openManageModal() {
|
|||||||
|
|
||||||
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
|
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
|
||||||
|
|
||||||
|
const pwSave = overlay.querySelector("#pw-save");
|
||||||
|
if (pwSave) {
|
||||||
|
const msg = overlay.querySelector("#pw-msg");
|
||||||
|
const say = (text, bad) => {
|
||||||
|
msg.textContent = text;
|
||||||
|
msg.style.color = bad ? "var(--bad)" : "var(--good)";
|
||||||
|
};
|
||||||
|
pwSave.onclick = async () => {
|
||||||
|
const current = overlay.querySelector("#pw-current").value;
|
||||||
|
const next = overlay.querySelector("#pw-new").value;
|
||||||
|
const confirmed = overlay.querySelector("#pw-confirm").value;
|
||||||
|
if (!current) return say("Enter your current password", true);
|
||||||
|
if (next !== confirmed) return say("New passwords don't match", true);
|
||||||
|
if (next.length < state.minPasswordLength)
|
||||||
|
return say(`At least ${state.minPasswordLength} characters`, true);
|
||||||
|
pwSave.disabled = true;
|
||||||
|
try {
|
||||||
|
await api("/api/password", {
|
||||||
|
method: "POST",
|
||||||
|
body: { current_password: current, new_password: next },
|
||||||
|
});
|
||||||
|
overlay.querySelectorAll("#pw-current, #pw-new, #pw-confirm").forEach((i) => (i.value = ""));
|
||||||
|
state.usingBootstrap = false;
|
||||||
|
$("#bootstrap-banner").classList.add("hidden");
|
||||||
|
say("Password changed. Other devices signed out.", false);
|
||||||
|
toast("Password changed");
|
||||||
|
} catch (ex) {
|
||||||
|
say(ex.message, true);
|
||||||
|
} finally {
|
||||||
|
pwSave.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
overlay.querySelector("#pw-revoke").onclick = async () => {
|
||||||
|
if (!confirm("Sign out every other device? This one stays signed in.")) return;
|
||||||
|
try {
|
||||||
|
await api("/api/sessions/revoke", { method: "POST" });
|
||||||
|
say("Other devices signed out.", false);
|
||||||
|
} catch (ex) {
|
||||||
|
say(ex.message, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (focusPassword) setTimeout(() => overlay.querySelector("#pw-current").focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
const renderRows = () => {
|
const renderRows = () => {
|
||||||
const draw = (items, box, kind) => {
|
const draw = (items, box, kind) => {
|
||||||
box.innerHTML = "";
|
box.innerHTML = "";
|
||||||
@@ -645,7 +716,8 @@ $("#sort").addEventListener("change", (e) => {
|
|||||||
|
|
||||||
$("#filters-btn").onclick = () => setSidebar(!$("#sidebar").classList.contains("open"));
|
$("#filters-btn").onclick = () => setSidebar(!$("#sidebar").classList.contains("open"));
|
||||||
$("#add-btn").onclick = () => openPartModal(null);
|
$("#add-btn").onclick = () => openPartModal(null);
|
||||||
$("#manage-btn").onclick = openManageModal;
|
$("#manage-btn").onclick = () => openManageModal(false);
|
||||||
|
$("#banner-change").onclick = () => openManageModal(true);
|
||||||
$("#more-btn").onclick = () => search(true);
|
$("#more-btn").onclick = () => search(true);
|
||||||
|
|
||||||
$("#filter-all").onclick = () => {
|
$("#filter-all").onclick = () => {
|
||||||
@@ -669,6 +741,8 @@ async function boot() {
|
|||||||
try {
|
try {
|
||||||
const me = await api("/api/me");
|
const me = await api("/api/me");
|
||||||
state.authRequired = me.auth_required;
|
state.authRequired = me.auth_required;
|
||||||
|
state.usingBootstrap = !!me.using_bootstrap_password;
|
||||||
|
state.minPasswordLength = me.min_password_length || state.minPasswordLength;
|
||||||
if (!me.authenticated) return showLogin();
|
if (!me.authenticated) return showLogin();
|
||||||
showApp();
|
showApp();
|
||||||
await boot();
|
await boot();
|
||||||
|
|||||||
+6
-1
@@ -27,10 +27,15 @@
|
|||||||
<span class="stat" id="stats"></span>
|
<span class="stat" id="stats"></span>
|
||||||
<button class="ghost small mobile-only" id="filters-btn">Filters</button>
|
<button class="ghost small mobile-only" id="filters-btn">Filters</button>
|
||||||
<button class="primary" id="add-btn">+ Add</button>
|
<button class="primary" id="add-btn">+ Add</button>
|
||||||
<button class="ghost small" id="manage-btn" title="Categories & locations">⚙</button>
|
<button class="ghost small" id="manage-btn" title="Settings">⚙</button>
|
||||||
<button class="ghost small hidden" id="logout-btn" title="Log out">⏻</button>
|
<button class="ghost small hidden" id="logout-btn" title="Log out">⏻</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="bootstrap-banner" class="banner hidden">
|
||||||
|
<span>You're still using the password that was handed to you. Set your own.</span>
|
||||||
|
<button class="small primary" id="banner-change">Change password</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside id="sidebar">
|
<aside id="sidebar">
|
||||||
<h3>Filters</h3>
|
<h3>Filters</h3>
|
||||||
|
|||||||
+120
-4
@@ -27,8 +27,8 @@ with TestClient(app) as client:
|
|||||||
import app.auth as _auth
|
import app.auth as _auth
|
||||||
import base64 as _b64, hashlib as _hl, hmac as _hm
|
import base64 as _b64, hashlib as _hl, hmac as _hm
|
||||||
|
|
||||||
def _issue_at(exp):
|
def _issue_at(exp, epoch=0):
|
||||||
payload = str(exp).encode()
|
payload = f"{exp}:{epoch}".encode()
|
||||||
return _b64.urlsafe_b64encode(
|
return _b64.urlsafe_b64encode(
|
||||||
payload + _hm.new(_auth._secret(), payload, _hl.sha256).digest()).decode()
|
payload + _hm.new(_auth._secret(), payload, _hl.sha256).digest()).decode()
|
||||||
|
|
||||||
@@ -198,13 +198,52 @@ with TestClient(app) as client:
|
|||||||
check("deleted part is gone", client.get(f"/api/parts/{screw['id']}").status_code == 404)
|
check("deleted part is gone", client.get(f"/api/parts/{screw['id']}").status_code == 404)
|
||||||
check("deleted part leaves the index", "M3x8 socket cap screw" not in ids("M3x8"))
|
check("deleted part leaves the index", "M3x8 socket cap screw" not in ids("M3x8"))
|
||||||
check("missing part 404s", client.get("/api/parts/999999").status_code == 404)
|
check("missing part 404s", client.get("/api/parts/999999").status_code == 404)
|
||||||
|
check("history of a missing part 404s",
|
||||||
|
client.get("/api/parts/999999/history").status_code == 404)
|
||||||
|
# httpx refuses to serialise inf/nan, so these go as raw bodies — which is
|
||||||
|
# exactly how a real client would smuggle them in: Python's json.loads
|
||||||
|
# accepts the bare `Infinity` and `NaN` tokens.
|
||||||
|
JSONH = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
def raw_post(path, body):
|
||||||
|
return client.post(path, content=body, headers=JSONH)
|
||||||
|
|
||||||
|
def raw_patch(path, body):
|
||||||
|
return client.patch(path, content=body, headers=JSONH)
|
||||||
|
|
||||||
|
check("Infinity quantity rejected",
|
||||||
|
raw_post("/api/parts", '{"name":"inf","quantity":Infinity}').status_code == 422)
|
||||||
|
check("-Infinity quantity rejected",
|
||||||
|
raw_post("/api/parts", '{"name":"ninf","quantity":-Infinity}').status_code == 422)
|
||||||
|
check("NaN quantity rejected",
|
||||||
|
raw_post("/api/parts", '{"name":"nan","quantity":NaN}').status_code == 422)
|
||||||
|
check("Infinity cost rejected",
|
||||||
|
raw_post("/api/parts", '{"name":"inf2","cost_each":Infinity}').status_code == 422)
|
||||||
|
check("Infinity min_quantity rejected",
|
||||||
|
raw_post("/api/parts", '{"name":"inf3","min_quantity":Infinity}').status_code == 422)
|
||||||
|
_infp = client.post("/api/parts", json={"name": "inf target", "quantity": 5}).json()["id"]
|
||||||
|
check("Infinity adjustment delta rejected",
|
||||||
|
raw_post(f"/api/parts/{_infp}/adjust", '{"delta":Infinity}').status_code == 422)
|
||||||
|
check("NaN adjustment delta rejected",
|
||||||
|
raw_post(f"/api/parts/{_infp}/adjust", '{"delta":NaN}').status_code == 422)
|
||||||
|
check("Infinity PATCH quantity rejected",
|
||||||
|
raw_patch(f"/api/parts/{_infp}", '{"quantity":Infinity}').status_code == 422)
|
||||||
|
check("the part is untouched after a rejected Infinity",
|
||||||
|
client.get(f"/api/parts/{_infp}").json()["quantity"] == 5)
|
||||||
check("adjusting a missing part 404s",
|
check("adjusting a missing part 404s",
|
||||||
client.post("/api/parts/999999/adjust", json={"delta": 1}).status_code == 404)
|
client.post("/api/parts/999999/adjust", json={"delta": 1}).status_code == 404)
|
||||||
|
|
||||||
# --- stats & validation ---
|
# --- stats & validation ---
|
||||||
|
# Derived from the listing rather than a magic number, so adding a fixture
|
||||||
|
# part earlier in the file can't silently invalidate it.
|
||||||
s = client.get("/api/stats").json()
|
s = client.get("/api/stats").json()
|
||||||
check("stats counts remaining parts", s["parts"] == 2, str(s))
|
listed = client.get("/api/parts", params={"limit": 1000}).json()
|
||||||
check("stats counts low stock", s["low_stock"] == 2, str(s))
|
check("stats part count matches the listing", s["parts"] == listed["total"], str(s))
|
||||||
|
check("stats low-stock count matches the listing",
|
||||||
|
s["low_stock"] == sum(1 for p in listed["items"] if p["low_stock"]), str(s))
|
||||||
|
check("stats value matches the listing",
|
||||||
|
s["estimated_value"] == round(sum(p["quantity"] * (p["cost_each"] or 0)
|
||||||
|
for p in listed["items"]), 2), str(s))
|
||||||
check("whitespace-only name rejected",
|
check("whitespace-only name rejected",
|
||||||
client.post("/api/parts", json={"name": " "}).status_code == 422)
|
client.post("/api/parts", json={"name": " "}).status_code == 422)
|
||||||
check("negative quantity rejected",
|
check("negative quantity rejected",
|
||||||
@@ -351,6 +390,83 @@ with TestClient(app) as client:
|
|||||||
client.post("/api/login", json={"password": "hunter2"}).status_code == 200)
|
client.post("/api/login", json={"password": "hunter2"}).status_code == 200)
|
||||||
check("a successful login resets the failure budget", auth_mod.login_retry_after() == 0)
|
check("a successful login resets the failure budget", auth_mod.login_retry_after() == 0)
|
||||||
|
|
||||||
|
# --- password management through the API ---
|
||||||
|
check("bootstrap password is flagged", client.get("/api/me").json()["using_bootstrap_password"] is True)
|
||||||
|
check("min length is advertised", client.get("/api/me").json()["min_password_length"] == 8)
|
||||||
|
|
||||||
|
check("wrong current password is refused",
|
||||||
|
client.post("/api/password",
|
||||||
|
json={"current_password": "nope", "new_password": "brand-new-secret"}).status_code == 403)
|
||||||
|
auth_mod.clear_failures()
|
||||||
|
check("short new password rejected",
|
||||||
|
client.post("/api/password",
|
||||||
|
json={"current_password": "hunter2", "new_password": "short"}).status_code == 422)
|
||||||
|
check("reusing the current password rejected",
|
||||||
|
client.post("/api/password",
|
||||||
|
json={"current_password": "hunter2", "new_password": "hunter2"}).status_code == 422)
|
||||||
|
check("still logged in after failed attempts", client.get("/api/parts").status_code == 200)
|
||||||
|
|
||||||
|
# A second, independent session that should be cut off by the change.
|
||||||
|
other = TestClient(app)
|
||||||
|
other.post("/api/login", json={"password": "hunter2"})
|
||||||
|
check("second session works before the change", other.get("/api/parts").status_code == 200)
|
||||||
|
|
||||||
|
r = client.post("/api/password",
|
||||||
|
json={"current_password": "hunter2", "new_password": "correct-horse-battery"})
|
||||||
|
check("password change succeeds", r.status_code == 200, r.text)
|
||||||
|
check("change reports other sessions signed out", r.json()["other_sessions_signed_out"] is True)
|
||||||
|
check("the changing session stays signed in", client.get("/api/parts").status_code == 200)
|
||||||
|
check("other sessions are signed out", other.get("/api/parts").status_code == 401)
|
||||||
|
check("bootstrap flag clears", client.get("/api/me").json()["using_bootstrap_password"] is False)
|
||||||
|
|
||||||
|
fresh = TestClient(app)
|
||||||
|
check("the old password no longer works",
|
||||||
|
fresh.post("/api/login", json={"password": "hunter2"}).status_code == 401)
|
||||||
|
auth_mod.clear_failures()
|
||||||
|
check("the bootstrap env password is ignored once one is set",
|
||||||
|
fresh.post("/api/login", json={"password": os.environ["PARTS_PASSWORD"]}).status_code == 401)
|
||||||
|
auth_mod.clear_failures()
|
||||||
|
check("the new password works",
|
||||||
|
fresh.post("/api/login", json={"password": "correct-horse-battery"}).status_code == 200)
|
||||||
|
check("the new session can read data", fresh.get("/api/parts").status_code == 200)
|
||||||
|
|
||||||
|
# --- sign out other devices, without signing out this one ---
|
||||||
|
third = TestClient(app)
|
||||||
|
third.post("/api/login", json={"password": "correct-horse-battery"})
|
||||||
|
check("third session works", third.get("/api/parts").status_code == 200)
|
||||||
|
check("revoke succeeds", client.post("/api/sessions/revoke").status_code == 200)
|
||||||
|
check("revoke keeps the calling session", client.get("/api/parts").status_code == 200)
|
||||||
|
check("revoke cuts off the other session", third.get("/api/parts").status_code == 401)
|
||||||
|
|
||||||
|
# --- the hash itself ---
|
||||||
|
import app.db as _db
|
||||||
|
with _db.session() as _conn:
|
||||||
|
stored = auth_mod.stored_hash(_conn)
|
||||||
|
check("password is stored hashed, not in the clear",
|
||||||
|
stored is not None and stored.startswith("scrypt$") and "correct-horse-battery" not in stored)
|
||||||
|
check("the stored hash verifies", auth_mod.verify_hash("correct-horse-battery", stored))
|
||||||
|
check("the stored hash rejects a wrong password", not auth_mod.verify_hash("wrong", stored))
|
||||||
|
check("two hashes of the same password differ (salted)",
|
||||||
|
auth_mod.hash_password("same") != auth_mod.hash_password("same"))
|
||||||
|
|
||||||
|
# --- recovery CLI ---
|
||||||
|
import app.admin as _admin
|
||||||
|
check("admin set-password works", _admin.main(["set-password", "cli-set-password"]) == 0)
|
||||||
|
cli = TestClient(app)
|
||||||
|
check("CLI-set password logs in",
|
||||||
|
cli.post("/api/login", json={"password": "cli-set-password"}).status_code == 200)
|
||||||
|
auth_mod.clear_failures()
|
||||||
|
check("CLI change signed out the API session", client.get("/api/parts").status_code == 401)
|
||||||
|
check("admin rejects a short password", _admin.main(["set-password", "abc"]) == 1)
|
||||||
|
check("admin show-status works", _admin.main(["show-status"]) == 0)
|
||||||
|
check("admin rejects an unknown command", _admin.main(["nonsense"]) == 2)
|
||||||
|
check("admin clear-password works", _admin.main(["clear-password"]) == 0)
|
||||||
|
back = TestClient(app)
|
||||||
|
check("clearing restores the env password",
|
||||||
|
back.post("/api/login", json={"password": os.environ["PARTS_PASSWORD"]}).status_code == 200)
|
||||||
|
check("bootstrap flag returns after clearing",
|
||||||
|
back.get("/api/me").json()["using_bootstrap_password"] is True)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if failures:
|
if failures:
|
||||||
print(f"{len(failures)} FAILED: " + "; ".join(failures))
|
print(f"{len(failures)} FAILED: " + "; ".join(failures))
|
||||||
|
|||||||
@@ -107,6 +107,49 @@ def main():
|
|||||||
check("PATCH log's final quantity_after matches stored",
|
check("PATCH log's final quantity_after matches stored",
|
||||||
h3[0]["quantity_after"] == stored, f"log={h3[0]['quantity_after']} stored={stored}")
|
h3[0]["quantity_after"] == stored, f"log={h3[0]['quantity_after']} stored={stored}")
|
||||||
|
|
||||||
|
# --- taxonomy renames must leave the index agreeing with the tree ---
|
||||||
|
# The scan for affected parts and the reindex that follows have to see
|
||||||
|
# one consistent tree. Without the write lock a concurrent rename slips
|
||||||
|
# between them, and search keeps matching a name the UI no longer shows.
|
||||||
|
# Names are chosen so no token is a prefix of another: search uses prefix
|
||||||
|
# matching, so "Taxo 1" would legitimately match "Taxo 19" and the test
|
||||||
|
# would report a race that isn't there.
|
||||||
|
WORDS = ["alfa", "bravo", "charlie", "delta", "echo", "foxtrot", "golf",
|
||||||
|
"hotel", "india", "juliett", "kilo", "lima", "mike", "november",
|
||||||
|
"oscar", "papa", "quebec", "romeo", "sierra", "tango"]
|
||||||
|
cat = req("/api/categories", "POST", {"name": "Taxo zulu"})["id"]
|
||||||
|
req("/api/parts", "POST", {"name": "taxo widget", "category_id": cat, "quantity": 1})
|
||||||
|
names = [f"Taxo {w}" for w in WORDS]
|
||||||
|
with ThreadPoolExecutor(max_workers=20) as ex:
|
||||||
|
list(ex.map(lambda n: req(f"/api/categories/{cat}", "PATCH", {"name": n}), names))
|
||||||
|
final = [c["name"] for c in req("/api/categories")["items"] if c["id"] == cat][0]
|
||||||
|
hits_final = req(f"/api/parts?q={final.replace(' ', '+')}")["total"]
|
||||||
|
check("search matches the category's final name", hits_final == 1, f"{final} -> {hits_final}")
|
||||||
|
stale = [n for n in ["Taxo zulu"] + names if n != final
|
||||||
|
and req(f"/api/parts?q={n.replace(' ', '+')}")["total"] > 0]
|
||||||
|
check("no superseded category name still matches", not stale, f"stale: {stale}")
|
||||||
|
|
||||||
|
# Same race with a location, and with parts being created concurrently.
|
||||||
|
loc = req("/api/locations", "POST", {"name": "Loc zulu"})["id"]
|
||||||
|
loc_names = [f"Loc {w}" for w in WORDS[:15]]
|
||||||
|
|
||||||
|
def rename_or_add(i):
|
||||||
|
if i % 3 == 0:
|
||||||
|
req("/api/parts", "POST", {"name": f"loc widget {i}", "location_id": loc, "quantity": 1})
|
||||||
|
else:
|
||||||
|
req(f"/api/locations/{loc}", "PATCH", {"name": loc_names[i % len(loc_names)]})
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=15) as ex:
|
||||||
|
list(ex.map(rename_or_add, range(15)))
|
||||||
|
final_loc = [l["name"] for l in req("/api/locations")["items"] if l["id"] == loc][0]
|
||||||
|
in_loc = req(f"/api/parts?location_id={loc}&limit=100")["total"]
|
||||||
|
by_name = req(f"/api/parts?q={final_loc.replace(' ', '+')}&limit=100")["total"]
|
||||||
|
check("every part in the location is indexed under its final name",
|
||||||
|
by_name == in_loc, f"filter={in_loc} search={by_name}")
|
||||||
|
stale_loc = [n for n in ["Loc zulu"] + loc_names if n != final_loc
|
||||||
|
and req(f"/api/parts?q={n.replace(' ', '+')}&limit=100")["total"] > 0]
|
||||||
|
check("no superseded location name still matches", not stale_loc, f"stale: {stale_loc}")
|
||||||
|
|
||||||
# --- concurrent creates don't collide ---
|
# --- concurrent creates don't collide ---
|
||||||
with ThreadPoolExecutor(max_workers=25) as ex:
|
with ThreadPoolExecutor(max_workers=25) as ex:
|
||||||
ids = list(ex.map(
|
ids = list(ex.map(
|
||||||
|
|||||||
Reference in New Issue
Block a user