This commit is contained in:
Andras Schmelczer 2026-05-31 09:39:34 +01:00
parent ad7968dadd
commit 5bf8e752e7
22 changed files with 81 additions and 112 deletions

View file

@ -2,11 +2,10 @@
from __future__ import annotations
import uuid
from fastapi import HTTPException, Request
from .db import db_connection
from .models import _canonical_uuidv4
# Single generic detail used for ALL 401 responses. Per spec, the response
# must not distinguish between missing / malformed / unknown tokens — that
@ -21,26 +20,28 @@ def _unauthorized() -> HTTPException:
)
def get_current_user(request: Request) -> str:
"""Dependency that extracts and validates a Bearer token, returns user_id."""
def extract_bearer_token(request: Request) -> str | None:
"""Return the raw Bearer token from the Authorization header, or None."""
auth_header = request.headers.get("Authorization") or request.headers.get(
"authorization"
)
if not auth_header:
raise _unauthorized()
return None
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise _unauthorized()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]
return None
token = parts[1]
def get_current_user(request: Request) -> str:
"""Dependency that extracts and validates a Bearer token, returns user_id."""
token = extract_bearer_token(request)
if token is None:
raise _unauthorized()
try:
u = uuid.UUID(token)
if u.version != 4:
raise ValueError("Not v4")
token = str(u)
except (ValueError, AttributeError):
token = _canonical_uuidv4(token)
except ValueError:
raise _unauthorized()
with db_connection() as conn:

View file

@ -8,6 +8,8 @@ from fastapi import Request, Response
from slowapi import Limiter
from slowapi.util import get_remote_address
from .auth import extract_bearer_token
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
_TOO_LARGE_BODY = json.dumps(
@ -20,12 +22,7 @@ _TOO_LARGE_BODY = json.dumps(
def _get_token_or_ip(request: Request) -> str:
"""Key function for rate limiting: use Bearer token if present, else IP."""
auth = request.headers.get("Authorization") or request.headers.get("authorization")
if auth:
parts = auth.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]
return get_remote_address(request)
return extract_bearer_token(request) or get_remote_address(request)
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])

View file

@ -9,6 +9,8 @@ from hashlib import sha256
import structlog
from fastapi import Request, Response
from .auth import extract_bearer_token
def configure_logging() -> None:
"""Configure structlog for JSON output."""
@ -38,12 +40,8 @@ async def request_logging_middleware(request: Request, call_next) -> Response:
structlog.contextvars.bind_contextvars(request_id=request_id)
# Extract user_id from Authorization header for logging (no DB call here)
auth = request.headers.get("Authorization") or request.headers.get("authorization")
user_id: str | None = None
if auth:
parts = auth.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
user_id = token_log_id(parts[1])
token = extract_bearer_token(request)
user_id = token_log_id(token) if token else None
start = time.monotonic()
response = await call_next(request)

View file

@ -98,7 +98,7 @@ def create_app() -> FastAPI:
if field
)
if fields:
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
detail_str = "Validation failed for: " + ", ".join(fields)
else:
detail_str = "Validation failed"
return JSONResponse(
@ -116,7 +116,7 @@ def create_app() -> FastAPI:
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
detail = {"error": code, "detail": str(exc.detail)}
headers = getattr(exc, "headers", None) or {}
headers = exc.headers or {}
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
# Generic 500 handler

View file

@ -1,10 +1,8 @@
-- Life Towers v4 initial schema.
-- SQLite with WAL mode and foreign keys enabled at connection time.
-- WAL mode, foreign keys, and busy_timeout are applied per-connection in
-- db._apply_pragmas(), so they are not (and need not be) set here.
-- All timestamps are unix epoch seconds (INTEGER).
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,

View file

@ -51,17 +51,6 @@ async def client(tmp_path: Path) -> AsyncGenerator[AsyncClient, None]:
db_module._DB_PATH = None
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_health(client: AsyncClient) -> None:
resp = await client.get("/api/v1/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
@pytest.mark.asyncio
async def test_spa_index_injects_absolute_open_graph_urls(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch

View file

@ -8,7 +8,6 @@
],
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",

View file

@ -4,7 +4,6 @@ import {
input,
output,
signal,
computed,
effect,
viewChild,
ElementRef,
@ -614,10 +613,12 @@ export class BlockEditComponent implements AfterViewInit {
const t = this.tags();
untracked(() => {
const cur = this.newValue();
if (!cur.tag && t.length > 0) {
this.newValue.set({ ...cur, tag: t[0], is_done: this.defaultDone() });
} else if (!cur.tag) {
this.newValue.set({ ...cur, is_done: this.defaultDone() });
if (!cur.tag) {
this.newValue.set({
...cur,
tag: t.length > 0 ? t[0] : '',
is_done: this.defaultDone(),
});
}
});
});
@ -648,19 +649,21 @@ export class BlockEditComponent implements AfterViewInit {
);
}
private colorOfTag(tag: string): string {
return tag ? getColorOfTag(tag, this.baseColor()) : 'transparent';
}
colorOfTagForBlock(id: string): string {
const v = this.editedFor(id);
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
return this.colorOfTag(this.editedFor(id).tag);
}
tagPlaceholder(fallback: string): string {
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
}
colorOfNewTag = computed(() => {
const t = this.newValue().tag;
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
});
colorOfNewTag(): string {
return this.colorOfTag(this.newValue().tag);
}
formatDate(ts: number, compact = false): string {
const d = new Date(ts * 1000);

View file

@ -16,9 +16,6 @@ export interface UpdatePagePayload {
keep_tasks_open: boolean;
}
const UUIDV4_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Component({
selector: 'lt-settings',
standalone: true,
@ -255,10 +252,15 @@ export class SettingsComponent {
readonly hideCreateTowerButton = signal(false);
readonly keepTasksOpen = signal(false);
private static readonly UUIDV4_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Token-switch state
readonly tokenInput = signal('');
readonly tokenInputTouched = signal(false);
readonly isValidToken = computed(() => UUIDV4_RE.test(this.tokenInput()));
readonly isValidToken = computed(() =>
SettingsComponent.UUIDV4_RE.test(this.tokenInput()),
);
constructor() {
effect(() => {

View file

@ -138,12 +138,14 @@ export class TowerSettingsComponent implements OnInit {
onSubmit(): void {
// Only the create flow reaches here via its Submit button; edit mode
// auto-saves (and Enter on the single field is a harmless redundant save).
if (this.form.invalid) return;
this.emitSave();
this.tryEmitSave();
}
/** Emit a save only when the form is valid (skips e.g. an empty name). */
private autoSave(): void {
this.tryEmitSave();
}
private tryEmitSave(): void {
if (this.form.invalid) return;
this.emitSave();
}

View file

@ -214,13 +214,16 @@ export class PageComponent {
onTrashEnter(): void {
this.nearTrashcan = true;
const preview = document.querySelector('.cdk-drag-preview');
if (preview) preview.classList.add('trash-highlight');
this.dragPreview()?.classList.add('trash-highlight');
}
onTrashLeave(): void {
this.nearTrashcan = false;
const preview = document.querySelector('.cdk-drag-preview');
if (preview) preview.classList.remove('trash-highlight');
this.dragPreview()?.classList.remove('trash-highlight');
}
/** The CDK drag preview currently in flight, if any. Matches legacy DOM-driven trash highlight. */
private dragPreview(): Element | null {
return document.querySelector('.cdk-drag-preview');
}
}

View file

@ -71,12 +71,6 @@
}
}
p {
strong {
font-weight: bold;
}
}
.confirm-buttons {
display: flex;
justify-content: center;

View file

@ -38,9 +38,10 @@ export class PagesComponent implements OnDestroy {
constructor() {
effect(() => {
if (!this.store.loading() && this.store.pages().length === 0) {
const pages = this.store.pages();
if (!this.store.loading() && pages.length === 0) {
this.showWelcome.set(true);
} else if (this.store.pages().length > 0) {
} else if (pages.length > 0) {
this.showWelcome.set(false);
}
});
@ -89,9 +90,10 @@ export class PagesComponent implements OnDestroy {
});
readonly selectedPageIndex = computed(() => {
const pages = this.store.pages();
const page = this.selectedPage();
if (!page) return -1;
return this.store.pages().findIndex((p) => p.id === page.id);
return pages.findIndex((p) => p.id === page.id);
});
onSelectPage(index: number): void {

View file

@ -185,9 +185,8 @@ export class ColorPickerComponent {
return `hsl(${h}, 70%, 55%)`;
}
toCss(c: HslColor): string {
return toCss(c);
}
/** Re-exported so the template can call the utility directly. */
readonly toCss = toCss;
pickHue(h: number): void {
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });

View file

@ -18,7 +18,7 @@ export interface DoubleSliderRange<T> {
* Two-thumb range slider legacy "double-slider".
* Hands an indexed range over an arbitrary values array; emits the
* underlying values on each change. Labels magnetically lift as a thumb
* approaches them (rotated -45°), per the legacy.
* approaches them (rotated -30°), per the legacy.
*/
@Component({
selector: 'lt-double-slider',
@ -228,7 +228,7 @@ export class DoubleSliderComponent {
/**
* Magnetic label position: returns a CSS `transform` that lifts the label
* upward and rotates -45° as a thumb approaches.
* upward and rotates -30° as a thumb approaches.
*/
getOffset(index: number): string {
const labelIndex = index / Math.max(1, this.drawnLabels().length - 1);

View file

@ -695,6 +695,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
this._visibleBlocks.set([]);
this.hiddenBlockCount.set(0);
this.prevDoneIds = allDone.map((b) => b.id);
this.isFirstRun = false;
return;
}
@ -708,9 +710,6 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
newIds.length === 1 &&
prev.every((id) => ids.includes(id)); // no IDs disappeared
const inRange = (b: Block) =>
!range || (b.created_at >= range.from && b.created_at <= range.to);
const styled: StyledBlock[] = [];
for (const b of allDone) {
if (range && b.created_at < range.from) {
@ -755,7 +754,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
this.hiddenBlockCount.set(hiddenCount);
if (this.isFirstRun && animateInitialStack) {
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1' && inRange(b));
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1');
if (initialBlocks.length > 0) {
this.startDescendAnimation(visibleStyled, initialBlocks);
this.prevDoneIds = ids;
@ -767,7 +766,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
if (grewByOne) {
const newId = newIds[0];
const newBlock = visibleStyled.find((b) => b.id === newId);
if (newBlock && inRange(newBlock)) {
if (newBlock) {
// Snap newly-added in-range block to start position, then on the next
// paint flip it back to rest — that's what makes it visibly fall.
this.startDescendAnimation(visibleStyled, [newBlock]);

View file

@ -333,20 +333,20 @@ import { A11yModule } from '@angular/cdk/a11y';
min-width: 0;
}
.basic__label {
.basic__label,
.basic__text {
font-family: inherit;
font-weight: inherit;
color: $text-color;
font-size: inherit;
line-height: inherit;
}
.basic__label {
color: $text-color;
}
.basic__text {
font-family: inherit;
font-weight: inherit;
color: rgba($text-color, 0.82);
font-size: inherit;
line-height: inherit;
}
.actions {

View file

@ -21,23 +21,6 @@ describe('ApiService', () => {
http.verify();
});
it('gets health', async () => {
const promise = service.health();
const req = http.expectOne('/api/v1/health');
expect(req.request.method).toBe('GET');
req.flush({ status: 'ok' });
await expect(promise).resolves.toEqual({ status: 'ok' });
});
it('registers a token', async () => {
const promise = service.register('token-1');
const req = http.expectOne('/api/v1/register');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ token: 'token-1' });
req.flush({ user_id: 'token-1' });
await expect(promise).resolves.toEqual({ user_id: 'token-1' });
});
it('gets data with a bearer token', async () => {
const tree: TreeDto = { pages: [] };
const promise = service.getData('token-1');

View file

@ -16,5 +16,5 @@ export function hash(s: string): number {
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
}
// Map the signed int32 to [0, 1) — same formula as legacy
return h / (Math.pow(2, 32) - 2) + 0.5;
return h / (2 ** 32 - 2) + 0.5;
}

View file

@ -10,6 +10,6 @@
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
"src/**/*.vitest.ts"
]
}

View file

@ -10,6 +10,6 @@
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
"src/**/*.vitest.ts"
]
}

View file

@ -12,7 +12,7 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.vitest.ts', 'src/**/*.spec.vitest.ts'],
include: ['src/**/*.vitest.ts'],
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',