Compare commits
3 Commits
972ccce62a
...
911d9ed683
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911d9ed683 | ||
|
|
94d12d55c6 | ||
|
|
a185c91407 |
@@ -35,12 +35,15 @@ The app expects a MariaDB instance configured through environment variables.
|
|||||||
### Session and Cookie Env Vars
|
### Session and Cookie Env Vars
|
||||||
|
|
||||||
- `SESSION_TTL_SECONDS` (default: `86400`)
|
- `SESSION_TTL_SECONDS` (default: `86400`)
|
||||||
|
- `SESSION_ROTATE_SECONDS` (default: `3600`, rotate active session token when exceeded)
|
||||||
- `SESSION_COOKIE_SECURE` (default: `false`, set `true` in production HTTPS)
|
- `SESSION_COOKIE_SECURE` (default: `false`, set `true` in production HTTPS)
|
||||||
- `REQUIRE_CSRF` (default: `false`, checks same-origin/same-referer for write routes when enabled)
|
- `REQUIRE_CSRF` (default: `false`, checks same-origin/same-referer for write routes when enabled)
|
||||||
- `LOGIN_MAX_ATTEMPTS` (default: `5`)
|
- `LOGIN_MAX_ATTEMPTS` (default: `5`)
|
||||||
- `LOGIN_WINDOW_SECONDS` (default: `300`)
|
- `LOGIN_WINDOW_SECONDS` (default: `300`)
|
||||||
- `LOGIN_LOCKOUT_SECONDS` (default: `900`)
|
- `LOGIN_LOCKOUT_SECONDS` (default: `900`)
|
||||||
- `MAX_ICON_BYTES` (default: `2097152`)
|
- `MAX_ICON_BYTES` (default: `2097152`)
|
||||||
|
- `USERNAME_MAX_LEN` (default: `64`)
|
||||||
|
- `PASSWORD_MIN_LEN` (default: `12`)
|
||||||
|
|
||||||
## Gitea CI/CD
|
## Gitea CI/CD
|
||||||
|
|
||||||
|
|||||||
111
TODO.md
111
TODO.md
@@ -1,63 +1,68 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Concrete follow-up work for Jellomator.
|
Concrete follow-up work for Jellomator, prioritized by implementation risk and user impact.
|
||||||
|
|
||||||
## P0
|
## P0 - Security and Reliability
|
||||||
|
|
||||||
- Add a backup and restore flow for the database in the admin UI.
|
- [ ] Add session expiry and rotation.
|
||||||
- Let an admin download the current database.
|
- [x] Add `expires_at` and `last_seen_at` to `sessions`.
|
||||||
- Let an admin upload a replacement database after confirmation.
|
- [x] Reject expired tokens in `current_user`.
|
||||||
- Validate the uploaded file before swapping it in.
|
- [ ] Rotate session token on login and periodically on use.
|
||||||
- Add a basic health endpoint for Docker and orchestration.
|
- [x] Harden auth endpoints.
|
||||||
- Return `200` when the app can read and write the database.
|
- [x] Add login rate limiting by IP + username pair.
|
||||||
- Return `503` if startup initialization or DB access fails.
|
- [x] Add brute-force lockout window with clear error message.
|
||||||
- Add login rate limiting.
|
- [x] Add optional CSRF protection for cookie-authenticated write routes.
|
||||||
- Track failed attempts per session or IP.
|
- [x] Fix cookie/security defaults for deployment.
|
||||||
- Temporarily block repeated failures.
|
- [x] Set cookie `secure` from environment (true in production).
|
||||||
- Add session expiry controls.
|
- [x] Make cookie max-age configurable.
|
||||||
- Expire idle admin sessions after a configurable period.
|
- [x] Keep `httponly` and `samesite=lax`.
|
||||||
- Renew active sessions on successful requests.
|
- [x] Add input and payload validation.
|
||||||
|
- [x] Validate URL scheme for links (`http`/`https` only).
|
||||||
|
- [x] Enforce max lengths for `name`, `category`, `description`, and `icon_url`.
|
||||||
|
- [x] Validate uploaded icon type and max file size before reading blob.
|
||||||
|
- [x] Add health/readiness endpoints.
|
||||||
|
- [x] `/healthz` returns `200` when process is up.
|
||||||
|
- [x] `/readyz` checks DB query + optional write test and returns `503` on failure.
|
||||||
|
|
||||||
## P1
|
## P1 - Data Model and Backend Quality
|
||||||
|
|
||||||
- Add drag-and-drop ordering for service cards.
|
- Replace string timestamps with DB-native datetime.
|
||||||
- Persist display order in the database.
|
- Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`.
|
||||||
- Support moving a card up, down, or to the top in admin.
|
- Use UTC consistently for writes and reads.
|
||||||
- Add a featured/pinned flag for important links.
|
- Add display ordering support.
|
||||||
- Keep pinned links above the normal list.
|
- Add `sort_order` column and stable ordering fallback by `name`.
|
||||||
- Let admins toggle pinned status from the edit form.
|
- Update read query to order by `enabled desc`, `sort_order`, `name`.
|
||||||
- Add multi-category support.
|
- Remove duplicate connection pattern in create flow.
|
||||||
- Store categories as a normalized table or join table.
|
- Use one DB transaction/connection per request path where possible.
|
||||||
- Allow filtering by more than one category in the dashboard.
|
- Add backup and restore flow in admin API/UI.
|
||||||
- Add duplicate/cloning for existing links.
|
- Download full export.
|
||||||
- Pre-fill a new form from an existing service.
|
- Upload validated import with explicit confirmation.
|
||||||
- Keep the original service unchanged.
|
- Add dry-run validation mode before apply.
|
||||||
- Add a password autofill helper for first-run setup.
|
- Add structured logging.
|
||||||
- Offer a generated strong password suggestion on the setup screen.
|
- Log auth attempts, CRUD actions, and restore events with request IDs.
|
||||||
- Let the admin copy it or autofill the password fields.
|
|
||||||
- Add a public read-only mode.
|
|
||||||
- Hide admin-only links from the dashboard.
|
|
||||||
- Keep the same UI but remove edit affordances.
|
|
||||||
|
|
||||||
## P2
|
## P2 - UX and Product Improvements
|
||||||
|
|
||||||
- Add JSON import/export for services.
|
- Replace browser `alert()` with inline form errors/toasts.
|
||||||
- Include metadata and icon blobs in the export format.
|
- Show server errors near submit controls.
|
||||||
- Support importing a whole dashboard from a single file.
|
- Add success toasts for create/update/delete.
|
||||||
- Add better icon handling.
|
- Remove forced reload in auth forms.
|
||||||
- Show initials when no icon exists.
|
- Replace `location.reload()` with state refresh only.
|
||||||
- Allow cropping or centering uploaded icons.
|
- Keep SPA navigation predictable on setup/login/logout.
|
||||||
- Add audit history for admin changes.
|
- Add drag-and-drop ordering in admin.
|
||||||
- Record create, update, delete, and preset actions.
|
- Persist `sort_order` updates.
|
||||||
- Show a simple timeline in the admin area.
|
- Provide keyboard-accessible move controls as fallback.
|
||||||
- Add a compact dashboard mode.
|
- Add duplicate/cloning for links.
|
||||||
- Reduce card padding and text size.
|
- Pre-fill form from an existing link.
|
||||||
- Make it easier to scan large lists of links.
|
- Save as new record with unique name validation.
|
||||||
|
- Add public read-only mode toggle.
|
||||||
|
- Hide admin entry points and editing affordances for non-admin view.
|
||||||
|
|
||||||
## P3
|
## P3 - Nice-to-Have
|
||||||
|
|
||||||
- Add keyboard shortcuts for search and quick launch.
|
- Add multi-category support with normalization.
|
||||||
- Add a toast system for save, delete, and upload actions.
|
- Add audit history timeline in admin.
|
||||||
- Add Open Graph metadata for better link previews.
|
- Add JSON import/export for services with icons.
|
||||||
- Add structured JSON logging for auth and CRUD events.
|
- Add keyboard shortcuts for search/quick launch.
|
||||||
- Add a CI verification step that builds the container image after publish.
|
- Add Open Graph metadata and richer SEO tags.
|
||||||
|
- Add CI verification that builds container image for pull requests.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ PUBLIC_DIR = Path("public")
|
|||||||
SESSION_COOKIE = "jellomator_session"
|
SESSION_COOKIE = "jellomator_session"
|
||||||
SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "86400"))
|
SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "86400"))
|
||||||
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "false").lower() in ("1", "true", "yes", "on")
|
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "false").lower() in ("1", "true", "yes", "on")
|
||||||
|
SESSION_ROTATE_SECONDS = int(os.getenv("SESSION_ROTATE_SECONDS", "3600"))
|
||||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("LOGIN_MAX_ATTEMPTS", "5"))
|
LOGIN_MAX_ATTEMPTS = int(os.getenv("LOGIN_MAX_ATTEMPTS", "5"))
|
||||||
LOGIN_WINDOW_SECONDS = int(os.getenv("LOGIN_WINDOW_SECONDS", "300"))
|
LOGIN_WINDOW_SECONDS = int(os.getenv("LOGIN_WINDOW_SECONDS", "300"))
|
||||||
LOGIN_LOCKOUT_SECONDS = int(os.getenv("LOGIN_LOCKOUT_SECONDS", "900"))
|
LOGIN_LOCKOUT_SECONDS = int(os.getenv("LOGIN_LOCKOUT_SECONDS", "900"))
|
||||||
@@ -32,6 +33,8 @@ MAX_DESCRIPTION_LEN = int(os.getenv("MAX_DESCRIPTION_LEN", "2000"))
|
|||||||
MAX_ICON_URL_LEN = int(os.getenv("MAX_ICON_URL_LEN", "2048"))
|
MAX_ICON_URL_LEN = int(os.getenv("MAX_ICON_URL_LEN", "2048"))
|
||||||
MAX_ICON_BYTES = int(os.getenv("MAX_ICON_BYTES", str(2 * 1024 * 1024)))
|
MAX_ICON_BYTES = int(os.getenv("MAX_ICON_BYTES", str(2 * 1024 * 1024)))
|
||||||
ALLOWED_ICON_MIME = {"image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml", "image/x-icon"}
|
ALLOWED_ICON_MIME = {"image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml", "image/x-icon"}
|
||||||
|
USERNAME_MAX_LEN = int(os.getenv("USERNAME_MAX_LEN", "64"))
|
||||||
|
PASSWORD_MIN_LEN = int(os.getenv("PASSWORD_MIN_LEN", "12"))
|
||||||
DB_HOST = os.getenv("DB_HOST", "mariadb")
|
DB_HOST = os.getenv("DB_HOST", "mariadb")
|
||||||
DB_PORT = int(os.getenv("DB_PORT", "3306"))
|
DB_PORT = int(os.getenv("DB_PORT", "3306"))
|
||||||
DB_USER = os.getenv("DB_USER", "jellomator")
|
DB_USER = os.getenv("DB_USER", "jellomator")
|
||||||
@@ -50,12 +53,16 @@ def healthz():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/readyz")
|
@app.get("/readyz")
|
||||||
def readyz():
|
def readyz(write_test: bool = False):
|
||||||
try:
|
try:
|
||||||
with db() as c:
|
with db() as c:
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
cur.execute("select 1 as ok")
|
cur.execute("select 1 as ok")
|
||||||
cur.fetchone()
|
cur.fetchone()
|
||||||
|
if write_test:
|
||||||
|
cur.execute("create temporary table if not exists readyz_probe(id int)")
|
||||||
|
cur.execute("insert into readyz_probe(id) values (1)")
|
||||||
|
cur.execute("truncate table readyz_probe")
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(503, "Database not ready")
|
raise HTTPException(503, "Database not ready")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -154,14 +161,14 @@ def expires_at_iso() -> str:
|
|||||||
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
|
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def current_user(request: Request):
|
def current_user(request: Request, response: Response | None = None):
|
||||||
token = request.cookies.get(SESSION_COOKIE)
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
with db() as c:
|
with db() as c:
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"select s.expires_at,u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=%s",
|
"select s.created_at,s.last_seen_at,s.expires_at,u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=%s",
|
||||||
(token,),
|
(token,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@@ -177,10 +184,37 @@ def current_user(request: Request):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
cur.execute("delete from sessions where token=%s", (token,))
|
cur.execute("delete from sessions where token=%s", (token,))
|
||||||
return None
|
return None
|
||||||
cur.execute(
|
now = datetime.utcnow()
|
||||||
"update sessions set last_seen_at=%s where token=%s",
|
now_iso = now.isoformat()
|
||||||
(utc_now_iso(), token),
|
new_expires_at = expires_at_iso()
|
||||||
)
|
last_seen_at = row.get("last_seen_at") or row.get("created_at")
|
||||||
|
should_rotate = False
|
||||||
|
if last_seen_at:
|
||||||
|
try:
|
||||||
|
should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS
|
||||||
|
except ValueError:
|
||||||
|
should_rotate = True
|
||||||
|
if should_rotate:
|
||||||
|
new_token = secrets.token_urlsafe(32)
|
||||||
|
cur.execute(
|
||||||
|
"update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s",
|
||||||
|
(new_token, now_iso, new_expires_at, token),
|
||||||
|
)
|
||||||
|
if response is not None:
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
new_token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=SESSION_COOKIE_SECURE,
|
||||||
|
max_age=SESSION_TTL_SECONDS,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"update sessions set last_seen_at=%s,expires_at=%s where token=%s",
|
||||||
|
(now_iso, new_expires_at, token),
|
||||||
|
)
|
||||||
return {"username": row["username"], "role": row["role"]}
|
return {"username": row["username"], "role": row["role"]}
|
||||||
|
|
||||||
|
|
||||||
@@ -244,6 +278,15 @@ def validate_http_url(value: str, field_name: str = "url") -> None:
|
|||||||
raise HTTPException(422, f"{field_name} must be a valid http(s) URL")
|
raise HTTPException(422, f"{field_name} must be a valid http(s) URL")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_credentials(username: str, password: str) -> None:
|
||||||
|
if not username or len(username.strip()) == 0:
|
||||||
|
raise HTTPException(422, "username is required")
|
||||||
|
if len(username) > USERNAME_MAX_LEN:
|
||||||
|
raise HTTPException(422, f"username exceeds max length of {USERNAME_MAX_LEN}")
|
||||||
|
if len(password or "") < PASSWORD_MIN_LEN:
|
||||||
|
raise HTTPException(422, f"password must be at least {PASSWORD_MIN_LEN} characters")
|
||||||
|
|
||||||
|
|
||||||
def validate_length(value: str | None, limit: int, field_name: str) -> None:
|
def validate_length(value: str | None, limit: int, field_name: str) -> None:
|
||||||
if value is not None and len(value) > limit:
|
if value is not None and len(value) > limit:
|
||||||
raise HTTPException(422, f"{field_name} exceeds max length of {limit}")
|
raise HTTPException(422, f"{field_name} exceeds max length of {limit}")
|
||||||
@@ -271,16 +314,18 @@ def read_icon_blob(icon: UploadFile | None) -> tuple[bytes | None, str | None]:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/me")
|
@app.get("/api/me")
|
||||||
def me(request: Request):
|
def me(request: Request, response: Response):
|
||||||
|
current = current_user(request, response)
|
||||||
with db() as c:
|
with db() as c:
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
cur.execute("select count(*) as count from users")
|
cur.execute("select count(*) as count from users")
|
||||||
needs_setup = cur.fetchone()["count"] == 0
|
needs_setup = cur.fetchone()["count"] == 0
|
||||||
return {"needs_setup": needs_setup, "current_user": current_user(request)}
|
return {"needs_setup": needs_setup, "current_user": current}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/setup")
|
@app.post("/api/setup")
|
||||||
def setup(inp: SetupIn):
|
def setup(inp: SetupIn):
|
||||||
|
validate_credentials(inp.username, inp.password)
|
||||||
with db() as c:
|
with db() as c:
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
cur.execute("select count(*) as count from users")
|
cur.execute("select count(*) as count from users")
|
||||||
@@ -293,6 +338,7 @@ def setup(inp: SetupIn):
|
|||||||
|
|
||||||
@app.post("/api/login")
|
@app.post("/api/login")
|
||||||
def login(request: Request, inp: LoginIn):
|
def login(request: Request, inp: LoginIn):
|
||||||
|
validate_credentials(inp.username, inp.password)
|
||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
prune_login_tracking(now_ts)
|
prune_login_tracking(now_ts)
|
||||||
key = login_key(request, inp.username)
|
key = login_key(request, inp.username)
|
||||||
|
|||||||
Reference in New Issue
Block a user