Compare commits
2 Commits
17b4793a73
...
791126cdd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
791126cdd0 | ||
|
|
643785ad1e |
20
TODO.md
20
TODO.md
@@ -4,10 +4,10 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
|
|||||||
|
|
||||||
## P0 - Security and Reliability
|
## P0 - Security and Reliability
|
||||||
|
|
||||||
- [ ] Add session expiry and rotation.
|
- [x] Add session expiry and rotation.
|
||||||
- [x] Add `expires_at` and `last_seen_at` to `sessions`.
|
- [x] Add `expires_at` and `last_seen_at` to `sessions`.
|
||||||
- [x] Reject expired tokens in `current_user`.
|
- [x] Reject expired tokens in `current_user`.
|
||||||
- [ ] Rotate session token on login and periodically on use.
|
- [x] Rotate session token on login and periodically on use.
|
||||||
- [x] Harden auth endpoints.
|
- [x] Harden auth endpoints.
|
||||||
- [x] Add login rate limiting by IP + username pair.
|
- [x] Add login rate limiting by IP + username pair.
|
||||||
- [x] Add brute-force lockout window with clear error message.
|
- [x] Add brute-force lockout window with clear error message.
|
||||||
@@ -26,14 +26,14 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
|
|||||||
|
|
||||||
## P1 - Data Model and Backend Quality
|
## P1 - Data Model and Backend Quality
|
||||||
|
|
||||||
- Replace string timestamps with DB-native datetime.
|
- [x] Replace string timestamps with DB-native datetime.
|
||||||
- Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`.
|
- [x] Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`.
|
||||||
- Use UTC consistently for writes and reads.
|
- [x] Use UTC consistently for writes and reads.
|
||||||
- Add display ordering support.
|
- [x] Add display ordering support.
|
||||||
- Add `sort_order` column and stable ordering fallback by `name`.
|
- [x] Add `sort_order` column and stable ordering fallback by `name`.
|
||||||
- Update read query to order by `enabled desc`, `sort_order`, `name`.
|
- [x] Update read query to order by `enabled desc`, `sort_order`, `name`.
|
||||||
- Remove duplicate connection pattern in create flow.
|
- [x] Remove duplicate connection pattern in create flow.
|
||||||
- Use one DB transaction/connection per request path where possible.
|
- [x] Use one DB transaction/connection per request path where possible.
|
||||||
- Add backup and restore flow in admin API/UI.
|
- Add backup and restore flow in admin API/UI.
|
||||||
- Download full export.
|
- Download full export.
|
||||||
- Upload validated import with explicit confirmation.
|
- Upload validated import with explicit confirmation.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import secrets
|
import secrets
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -122,14 +122,48 @@ def init_db():
|
|||||||
url text not null,
|
url text not null,
|
||||||
description text,
|
description text,
|
||||||
category varchar(255),
|
category varchar(255),
|
||||||
|
sort_order int not null default 0,
|
||||||
icon_blob longblob,
|
icon_blob longblob,
|
||||||
icon_mime varchar(255),
|
icon_mime varchar(255),
|
||||||
icon_url text,
|
icon_url text,
|
||||||
enabled tinyint(1) not null default 1,
|
enabled tinyint(1) not null default 1,
|
||||||
created_at varchar(64) not null,
|
created_at datetime not null,
|
||||||
updated_at varchar(64) not null
|
updated_at datetime not null
|
||||||
) engine=InnoDB default charset=utf8mb4
|
) engine=InnoDB default charset=utf8mb4
|
||||||
""")
|
""")
|
||||||
|
cur.execute("show columns from links like 'sort_order'")
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute("alter table links add column sort_order int not null default 0 after category")
|
||||||
|
cur.execute("show columns from links like 'created_at'")
|
||||||
|
created_col = cur.fetchone()
|
||||||
|
cur.execute("show columns from links like 'updated_at'")
|
||||||
|
updated_col = cur.fetchone()
|
||||||
|
created_is_varchar = bool(created_col and str(created_col.get("Type", "")).startswith("varchar"))
|
||||||
|
updated_is_varchar = bool(updated_col and str(updated_col.get("Type", "")).startswith("varchar"))
|
||||||
|
if created_is_varchar or updated_is_varchar:
|
||||||
|
cur.execute("alter table links add column created_at_dt datetime null, add column updated_at_dt datetime null")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
update links
|
||||||
|
set created_at_dt=coalesce(
|
||||||
|
str_to_date(created_at, '%Y-%m-%dT%H:%i:%s.%f'),
|
||||||
|
str_to_date(created_at, '%Y-%m-%dT%H:%i:%s'),
|
||||||
|
str_to_date(created_at, '%Y-%m-%d %H:%i:%s'),
|
||||||
|
utc_timestamp()
|
||||||
|
),
|
||||||
|
updated_at_dt=coalesce(
|
||||||
|
str_to_date(updated_at, '%Y-%m-%dT%H:%i:%s.%f'),
|
||||||
|
str_to_date(updated_at, '%Y-%m-%dT%H:%i:%s'),
|
||||||
|
str_to_date(updated_at, '%Y-%m-%d %H:%i:%s'),
|
||||||
|
utc_timestamp()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute("alter table links drop column created_at, drop column updated_at")
|
||||||
|
cur.execute(
|
||||||
|
"alter table links change column created_at_dt created_at datetime not null, "
|
||||||
|
"change column updated_at_dt updated_at datetime not null"
|
||||||
|
)
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +190,10 @@ def utc_now_iso() -> str:
|
|||||||
return datetime.utcnow().isoformat()
|
return datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_db() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
def expires_at_iso() -> str:
|
def expires_at_iso() -> str:
|
||||||
now = datetime.utcnow().timestamp()
|
now = datetime.utcnow().timestamp()
|
||||||
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
|
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
|
||||||
@@ -194,22 +232,21 @@ def current_user(request: Request, response: Response | None = None):
|
|||||||
should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS
|
should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS
|
||||||
except ValueError:
|
except ValueError:
|
||||||
should_rotate = True
|
should_rotate = True
|
||||||
if should_rotate:
|
if should_rotate and response is not None:
|
||||||
new_token = secrets.token_urlsafe(32)
|
new_token = secrets.token_urlsafe(32)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s",
|
"update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s",
|
||||||
(new_token, now_iso, new_expires_at, token),
|
(new_token, now_iso, new_expires_at, token),
|
||||||
)
|
)
|
||||||
if response is not None:
|
response.set_cookie(
|
||||||
response.set_cookie(
|
SESSION_COOKIE,
|
||||||
SESSION_COOKIE,
|
new_token,
|
||||||
new_token,
|
httponly=True,
|
||||||
httponly=True,
|
samesite="lax",
|
||||||
samesite="lax",
|
secure=SESSION_COOKIE_SECURE,
|
||||||
secure=SESSION_COOKIE_SECURE,
|
max_age=SESSION_TTL_SECONDS,
|
||||||
max_age=SESSION_TTL_SECONDS,
|
path="/",
|
||||||
path="/",
|
)
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"update sessions set last_seen_at=%s,expires_at=%s where token=%s",
|
"update sessions set last_seen_at=%s,expires_at=%s where token=%s",
|
||||||
@@ -358,6 +395,10 @@ def login(request: Request, inp: LoginIn):
|
|||||||
raise HTTPException(401, "Invalid credentials")
|
raise HTTPException(401, "Invalid credentials")
|
||||||
login_attempts.pop(key, None)
|
login_attempts.pop(key, None)
|
||||||
login_lockouts.pop(key, None)
|
login_lockouts.pop(key, None)
|
||||||
|
old_token = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if old_token:
|
||||||
|
with c.cursor() as cur:
|
||||||
|
cur.execute("delete from sessions where token=%s", (old_token,))
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
now = utc_now_iso()
|
now = utc_now_iso()
|
||||||
@@ -395,7 +436,7 @@ def logout(request: Request):
|
|||||||
def links():
|
def links():
|
||||||
with db() as c:
|
with db() as c:
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
cur.execute("select * from links order by enabled desc, category, name")
|
cur.execute("select * from links order by enabled desc, sort_order asc, name asc")
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -434,17 +475,16 @@ def create_link(
|
|||||||
require_admin(request)
|
require_admin(request)
|
||||||
require_csrf(request)
|
require_csrf(request)
|
||||||
validate_link_payload(name, url, description, category, icon_url)
|
validate_link_payload(name, url, description, category, icon_url)
|
||||||
|
icon_blob, icon_mime = read_icon_blob(icon)
|
||||||
|
now = utc_now_db()
|
||||||
with db() as c:
|
with db() as c:
|
||||||
if link_name_exists(c, name):
|
if link_name_exists(c, name):
|
||||||
raise HTTPException(409, "Link name already exists")
|
raise HTTPException(409, "Link name already exists")
|
||||||
icon_blob, icon_mime = read_icon_blob(icon)
|
|
||||||
now = datetime.utcnow().isoformat()
|
|
||||||
with db() as c:
|
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
|
"""insert into links(name,url,description,category,sort_order,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
|
||||||
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||||
(name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now),
|
(name, url, description, category, 0, icon_blob, icon_mime, icon_url, int(enabled), now, now),
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -486,7 +526,7 @@ def update_link(
|
|||||||
require_csrf(request)
|
require_csrf(request)
|
||||||
validate_link_payload(name, url, description, category, icon_url)
|
validate_link_payload(name, url, description, category, icon_url)
|
||||||
icon_blob, icon_mime = read_icon_blob(icon)
|
icon_blob, icon_mime = read_icon_blob(icon)
|
||||||
now = datetime.utcnow().isoformat()
|
now = utc_now_db()
|
||||||
with db() as c:
|
with db() as c:
|
||||||
if link_name_exists(c, name, exclude_id=link_id):
|
if link_name_exists(c, name, exclude_id=link_id):
|
||||||
raise HTTPException(409, "Link name already exists")
|
raise HTTPException(409, "Link name already exists")
|
||||||
|
|||||||
Reference in New Issue
Block a user