merge(06.2): integrate code review fixes from gsd-reviewfix/06.2-2490

This commit is contained in:
curo1305
2026-06-01 14:37:21 +02:00
9 changed files with 175 additions and 55 deletions
@@ -36,7 +36,8 @@ findings:
warning: 8
info: 5
total: 20
status: issues_found
status: fixed
fixed_at: 2026-06-01T00:00:00Z
---
# Phase 06.2: Code Review Report
@@ -68,6 +69,7 @@ Seven blocker-level issues were found:
### CR-01: Audit log event-type filter always returns zero results — feature non-functional
**Status:** fixed — commit a3ad36c
**File:** `frontend/src/components/admin/AuditLogTab.vue:37-41`
**Issue:** The filter `<select>` emits bare category strings (`"auth"`, `"document"`, `"folder"`, `"share"`, `"admin"`). The backend applies exact equality (`AuditLog.event_type == event_type`) at `backend/api/audit.py:120, 159, 284`. All actual event types use dot-namespaced format: `"auth.login"`, `"document.uploaded"`, `"share.granted"`, `"admin.user_created"`, etc. An exact match of `"auth"` against `"auth.login"` never fires. Selecting any category silently returns an empty list — the entire filter feature is non-functional.
@@ -91,6 +93,7 @@ if event_type is not None:
### CR-02: `download_daily_export` accesses `backend._client` without MinIOBackend guard — AttributeError on non-MinIO deployments
**Status:** fixed — commit 50859bb
**File:** `backend/api/audit.py:219-228`
**Issue:** `list_daily_exports` (line 182) correctly guards with `isinstance(backend, MinIOBackend)` and returns empty for non-MinIO storage. `download_daily_export` at line 219 calls `get_storage_backend()` and directly accesses `backend._client` with no type guard. On Google Drive, OneDrive, Nextcloud, or WebDAV storage backends, `_client` does not exist — an `AttributeError` is raised and swallowed by the broad `except Exception` at line 232, returning a misleading 404 "Export not found" to the admin.
@@ -105,6 +108,7 @@ key = f"audit-logs/{date}.csv"
### CR-03: CSV export serializes `metadata_` as Python repr — not valid JSON
**Status:** fixed — commit 792d463
**File:** `backend/api/audit.py:372`
**Issue:** `csv.DictWriter.writerow()` calls `str()` on values it cannot natively serialize. `entry.metadata_` is a Python `dict` (SQLAlchemy deserializes JSONB to native Python), producing `{'size_bytes': 100}` — Python repr with single quotes — rather than valid JSON `{"size_bytes": 100}`. Any downstream consumer that parses the `metadata_` column as JSON will fail. The test `test_audit_log_export_csv` does not assert on the cell content so this bug passes the test suite.
@@ -122,6 +126,7 @@ for row in rows:
### CR-04: Three audit export functions bypass 401-refresh-retry — exports silently break on token expiry
**Status:** fixed — commit 3fa7e8b
**File:** `frontend/src/api/client.js:398-483`
**Issue:** `adminExportAuditLogCsv`, `adminListDailyExports`, and `adminDownloadDailyExport` all use raw `fetch()` with no 401-refresh-then-retry logic. When the 15-minute access token expires mid-session, all three functions throw immediately (`Error("Export failed: 401")`) with no session recovery. The auth store is not cleared, so the user cannot distinguish a token expiry from a network error. The `request()` helper (lines 27-30) and `fetchDocumentContent()` (lines 520-529) both implement this retry correctly.
@@ -148,6 +153,7 @@ if (res.status === 401 && !options?._retry) {
### CR-05: UUID format mismatch in quota SQL — quota enforcement unreliable in `confirm_upload`
**Status:** fixed — commit 653cb3a
**File:** `backend/api/documents.py:348-356`
**Issue:** The atomic quota `UPDATE` in `confirm_upload` strips dashes from the user UUID:
@@ -167,6 +173,7 @@ PostgreSQL stores UUIDs in native `uuid` type (dashed format). Binding a 32-hex-
### CR-06: `Content-Disposition` filename not RFC 5987-encoded — header injection via special characters
**Status:** fixed — commit 1a34209
**File:** `backend/api/documents.py:791`
**Issue:**
@@ -188,6 +195,7 @@ headers = {
### CR-07: `PATCH /api/shares/{share_id}` writes no audit log — permission escalations unrecorded
**Status:** fixed — commit 1f2cec9
**File:** `backend/api/shares.py:246-270`
**Issue:** `update_share_permission` changes the effective access level on a document share (e.g. `"view"``"edit"`) but writes no audit log entry. Every other share mutation — `grant_share` (logs `share.granted`) and `revoke_share` (logs `share.revoked`) — writes to the audit log. A permission escalation on a high-value document is therefore invisible in the ADMIN-06 audit trail. The endpoint also has no `Request` parameter, so IP address cannot be captured.
@@ -223,6 +231,7 @@ async def update_share_permission(
### WR-01: `generateRandomPassword` discards 4 random chars and appends a fixed suffix
**Status:** fixed — commit 1cba903
**File:** `frontend/src/components/admin/AdminUsersTab.vue:299-302`
**Issue:**
@@ -235,6 +244,7 @@ The 16-char random password is truncated to 12, then `'A1!'` is always appended.
### WR-02: `format` query parameter on `/audit-log/export` is accepted but ignored — dead parameter
**Status:** fixed — commit 683670a
**File:** `backend/api/audit.py:313`
**Issue:** `format: str = Query(default="csv")` is declared but the variable `format` is never read in the handler. Any caller passing `?format=json` receives a CSV response with HTTP 200 and no error. This is misleading API design — the parameter should either be used or removed.
@@ -246,6 +256,7 @@ format: Literal["csv"] = Query(default="csv"), # noqa: A002
### WR-03: Pagination "Next" button uses wrong heuristic — breaks when total is exact multiple of page size
**Status:** fixed — commit 2542c81
**File:** `frontend/src/components/admin/AuditLogTab.vue:137,266`
**Issue:** The "Next" button is disabled when `entries.value.length < perPage`. If the last page has exactly `perPage` entries, the button remains enabled. Clicking it fetches an empty page and leaves the user on a blank audit log view with the page counter incremented. The `total` ref is populated from `data.total` but is never used for pagination control.
@@ -266,6 +277,7 @@ function nextPage() {
### WR-04: `loadDailyExports` swallows errors silently — admin sees "no exports" instead of error message
**Status:** fixed — commit 2542c81
**File:** `frontend/src/components/admin/AuditLogTab.vue:289-299`
**Issue:** The catch block sets `dailyExports.value = []` but never sets `exportsError.value`. An admin whose MinIO bucket is unreachable sees "No daily exports available" — identical to a legitimately empty bucket — with no error indication.
@@ -280,6 +292,7 @@ function nextPage() {
### WR-05: `URL.revokeObjectURL` called synchronously before browser download begins — potential silent cancellation
**Status:** fixed — commit 3fa7e8b (combined with CR-04)
**File:** `frontend/src/api/client.js:425-426,481-482`
**Issue:** Both CSV download functions call `a.click()` then immediately `URL.revokeObjectURL(url)`. The `click()` is asynchronous relative to the OS download manager handoff; revoking before the handoff is complete can silently cancel the download on some browser/OS combinations. The `<a>` element is also never appended to the DOM, which causes silent failure in Firefox.
@@ -294,6 +307,7 @@ setTimeout(() => URL.revokeObjectURL(url), 1000)
### WR-06: `listShares` uses raw template string for query params — inconsistent with all other API functions
**Status:** fixed — commit 9e8f8d5
**File:** `frontend/src/api/client.js:353`
**Issue:**
@@ -312,6 +326,7 @@ export function listShares(docId) {
### WR-07: `X-Forwarded-For` used as trusted client IP without validation — IP spoofing in audit logs
**Status:** fixed — commit 50b6e7f
**File:** `backend/api/admin.py:249,301,411,456,517`, `backend/api/documents.py:379,635`, `backend/api/shares.py:67`
**Issue:** All audit log IP captures use:
@@ -324,6 +339,7 @@ request.headers.get("X-Forwarded-For") or (request.client.host if request.client
### WR-08: `storage.delete_document` commits inside the service, then `delete_document` API handler commits again — split-transaction audit log risk
**Status:** fixed — commit 2072c3d
**File:** `backend/api/documents.py:654-668` and `backend/services/storage.py:182`
**Issue:** `storage.delete_document` calls `await session.commit()` at line 182, which ends the transaction. The API handler then calls `write_audit_log` and `await session.commit()` at lines 659-668, which commits in a *separate* transaction. If any statement between the two commits raises an exception, the document row is gone but the audit log entry is never written — a silent gap in the audit trail. This is a transaction atomicity violation.
+29 -11
View File
@@ -52,6 +52,24 @@ _PASSWORD_DETAIL = (
)
# ── IP extraction helper ──────────────────────────────────────────────────────
def _ip(request: Request) -> Optional[str]:
"""Extract best-effort client IP from request for audit logging.
TRUST BOUNDARY: X-Forwarded-For is a client-controlled header and can be
forged by any caller. This value is used for forensic audit logging only —
not for authentication or access control decisions. In production, deploy
behind a trusted reverse proxy (e.g. nginx with
`proxy_set_header X-Forwarded-For $remote_addr;`) which overwrites this
header with the real remote IP before it reaches FastAPI, or use a
trusted-proxy middleware that validates the source CIDR.
"""
return request.headers.get("X-Forwarded-For") or (
request.client.host if request.client else None
)
# ── Safe response helper ──────────────────────────────────────────────────────
def _user_to_dict(user: User) -> dict:
@@ -246,14 +264,14 @@ async def create_user(
session.add(quota)
await session.flush() # persist User + Quota before audit_log FK references them
# D-13: admin user created event
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
_ip_addr = _ip(request)
await write_audit_log(
session,
event_type="admin.user_created",
user_id=new_user.id,
actor_id=_admin.id,
resource_id=new_user.id,
ip_address=_ip,
ip_address=_ip_addr,
)
await session.commit()
@@ -298,7 +316,7 @@ async def update_user_status(
detail="Cannot deactivate the only admin",
)
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
_ip_addr = _ip(request)
user.is_active = body.is_active
if not body.is_active:
@@ -315,7 +333,7 @@ async def update_user_status(
user_id=user.id,
actor_id=_admin.id,
resource_id=user.id,
ip_address=_ip,
ip_address=_ip_addr,
)
await session.commit()
@@ -408,7 +426,7 @@ async def update_user_quota(
else None
)
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
_ip_addr = _ip(request)
old_limit = quota.limit_bytes
quota.limit_bytes = body.limit_bytes
session.add(quota)
@@ -420,7 +438,7 @@ async def update_user_quota(
user_id=user_id,
actor_id=_admin.id,
resource_id=None,
ip_address=_ip,
ip_address=_ip_addr,
metadata_={"old_bytes": old_limit, "new_bytes": body.limit_bytes},
)
await session.commit()
@@ -453,7 +471,7 @@ async def update_ai_config(
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
_ip_addr = _ip(request)
user.ai_provider = body.ai_provider
user.ai_model = body.ai_model
session.add(user)
@@ -465,7 +483,7 @@ async def update_ai_config(
user_id=user_id,
actor_id=_admin.id,
resource_id=None,
ip_address=_ip,
ip_address=_ip_addr,
metadata_={"provider": body.ai_provider, "model": body.ai_model},
)
await session.commit()
@@ -514,7 +532,7 @@ async def delete_user(
detail="Cannot delete admin accounts",
)
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
_ip_addr = _ip(request)
# SEC-09 (cloud): purge cloud-stored documents and credentials BEFORE DB delete.
# Must run before MinIO cleanup so that credentials are still available to build
@@ -548,7 +566,7 @@ async def delete_user(
user_id=user_id,
actor_id=_admin.id,
resource_id=user_id,
ip_address=_ip,
ip_address=_ip_addr,
metadata_={"providers": [c.provider for c in cloud_conns]},
)
@@ -572,7 +590,7 @@ async def delete_user(
user_id=user_id,
actor_id=_admin.id,
resource_id=user_id,
ip_address=_ip,
ip_address=_ip_addr,
)
await session.flush()
+11 -6
View File
@@ -23,10 +23,11 @@ from __future__ import annotations
import asyncio
import csv
import io
import json
import re
import uuid
from datetime import datetime
from typing import Optional
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
@@ -117,7 +118,7 @@ def _build_filtered_query(
if user_id is not None:
q = q.where(AuditLog.user_id == user_id)
if event_type is not None:
q = q.where(AuditLog.event_type == event_type)
q = q.where(AuditLog.event_type.like(f"{event_type}%"))
return q
@@ -156,7 +157,7 @@ def _build_filtered_query_with_handles(
if user_uuid is not None:
q = q.where(AuditLog.user_id == user_uuid)
if event_type is not None:
q = q.where(AuditLog.event_type == event_type)
q = q.where(AuditLog.event_type.like(f"{event_type}%"))
return q
@@ -217,6 +218,8 @@ async def download_daily_export(
raise HTTPException(status_code=404, detail="Invalid date format")
backend = get_storage_backend()
if not isinstance(backend, MinIOBackend):
raise HTTPException(status_code=404, detail="Export not found")
key = f"audit-logs/{date}.csv"
def _get() -> bytes:
@@ -281,7 +284,7 @@ async def list_audit_log(
if user_uuid is not None:
count_q = count_q.where(AuditLog.user_id == user_uuid)
if event_type is not None:
count_q = count_q.where(AuditLog.event_type == event_type)
count_q = count_q.where(AuditLog.event_type.like(f"{event_type}%"))
count_result = await session.execute(count_q)
total = count_result.scalar_one()
@@ -310,7 +313,7 @@ async def export_audit_log(
end: Optional[datetime] = Query(default=None),
user_handle: Optional[str] = Query(default=None),
event_type: Optional[str] = Query(default=None),
format: str = Query(default="csv"), # noqa: A002
format: Literal["csv"] = Query(default="csv"), # noqa: A002
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> StreamingResponse:
@@ -369,7 +372,9 @@ async def export_audit_log(
writer.writeheader()
for row in rows:
entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]
writer.writerow(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val))
record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val)
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
writer.writerow(record)
return StreamingResponse(
iter([output.getvalue()]),
+15 -5
View File
@@ -21,6 +21,7 @@ to all handlers. The doc.user_id=None guard in /confirm is a Wave 2 placeholder.
"""
from __future__ import annotations
import urllib.parse
import uuid
from pathlib import Path
from typing import Optional
@@ -345,7 +346,7 @@ async def confirm_upload(
" AND (used_bytes + :delta) <= limit_bytes "
"RETURNING used_bytes, limit_bytes"
),
{"delta": size, "uid": str(doc.user_id).replace("-", "")},
{"delta": size, "uid": str(doc.user_id)},
)
row = result.fetchone()
@@ -353,7 +354,7 @@ async def confirm_upload(
# Quota exceeded — fetch current quota state for the 413 body
quota_result = await session.execute(
text("SELECT used_bytes, limit_bytes FROM quotas WHERE user_id = :uid"),
{"uid": str(doc.user_id).replace("-", "")},
{"uid": str(doc.user_id)},
)
q = quota_result.fetchone()
# Delete the pending Document row and best-effort remove the MinIO object
@@ -376,6 +377,9 @@ async def confirm_upload(
doc.status = "uploaded"
# D-13: document uploaded event — size_bytes + storage_backend only, NO filename, NO extracted_text (T-04-07-02)
# TRUST BOUNDARY: X-Forwarded-For is client-controlled — for audit logging only,
# not for auth/access control. Use a trusted reverse proxy in production to
# overwrite this header with the real remote IP before it reaches FastAPI.
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
await write_audit_log(
session,
@@ -632,6 +636,9 @@ async def delete_document(
is_cloud = doc.storage_backend != "minio"
_doc_size = doc.size_bytes
_doc_id = doc.id
# TRUST BOUNDARY: X-Forwarded-For is client-controlled — for audit logging only,
# not for auth/access control. Use a trusted reverse proxy in production to
# overwrite this header with the real remote IP before it reaches FastAPI.
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
# Cloud routing: attempt provider delete unless remove_only is set
@@ -651,11 +658,13 @@ async def delete_document(
},
)
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud)
# auto_commit=False defers the commit so the audit log write below happens
# in the same transaction — avoids the split-transaction gap (WR-08).
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud, auto_commit=False)
if not ok:
raise HTTPException(404, "Document not found")
# D-13: document deleted event — written AFTER successful delete, size_bytes only (T-04-07-02)
# D-13: document deleted event — written in the same transaction as the delete (WR-08).
await write_audit_log(
session,
event_type="document.deleted",
@@ -786,9 +795,10 @@ async def stream_document_content(
) from exc
file_size = len(file_bytes)
safe_name = urllib.parse.quote(doc.filename, safe='')
headers = {
"content-type": doc.content_type,
"content-disposition": f'inline; filename="{doc.filename}"',
"content-disposition": f"inline; filename*=UTF-8''{safe_name}",
"accept-ranges": "bytes",
"content-length": str(file_size),
}
+21 -1
View File
@@ -63,7 +63,16 @@ class SharePermissionPatch(BaseModel):
def _ip(request: Request) -> Optional[str]:
"""Extract best-effort client IP from request (behind proxy or direct)."""
"""Extract best-effort client IP from request (behind proxy or direct).
TRUST BOUNDARY: X-Forwarded-For is a client-controlled header and can be
forged by any caller. This value is used for forensic audit logging only —
not for authentication or access control decisions. In production, deploy
behind a trusted reverse proxy (e.g. nginx with
`proxy_set_header X-Forwarded-For $remote_addr;`) which overwrites this
header with the real remote IP before it reaches FastAPI, or use a
trusted-proxy middleware that validates the source CIDR.
"""
return request.headers.get("X-Forwarded-For") or (
request.client.host if request.client else None
)
@@ -247,6 +256,7 @@ async def list_shared_with_me(
async def update_share_permission(
share_id: str,
body: SharePermissionPatch,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
@@ -265,6 +275,16 @@ async def update_share_permission(
raise HTTPException(status_code=404, detail="Share not found")
share.permission = body.permission
await write_audit_log(
session=session,
event_type="share.permission_changed",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=share.document_id,
ip_address=_ip(request),
metadata_={"share_id": str(share.id), "new_permission": body.permission},
)
await session.commit()
return {"id": str(share.id), "permission": share.permission}
+13 -2
View File
@@ -140,7 +140,12 @@ async def list_metadata(
return rows
async def delete_document(session: AsyncSession, doc_id: str, skip_quota: bool = False) -> bool:
async def delete_document(
session: AsyncSession,
doc_id: str,
skip_quota: bool = False,
auto_commit: bool = True,
) -> bool:
"""Delete a document's MinIO object and its PostgreSQL row.
Returns False if the document is not found; True on success.
@@ -149,6 +154,11 @@ async def delete_document(session: AsyncSession, doc_id: str, skip_quota: bool =
skip_quota=True skips the quota decrement — used for cloud-stored documents
that were never charged against the user's MinIO quota (T-06.2-03-01).
auto_commit=False defers the session.commit() to the caller, allowing the
caller to write an audit log entry in the same transaction before committing
(avoids the split-transaction gap where a failed audit write loses the record
while the document row is already gone).
"""
try:
uid = uuid.UUID(doc_id)
@@ -179,7 +189,8 @@ async def delete_document(session: AsyncSession, doc_id: str, skip_quota: bool =
)
await session.delete(doc)
await session.commit()
if auto_commit:
await session.commit()
return True
+37 -20
View File
@@ -350,7 +350,8 @@ export function updateSharePermission(shareId, permission) {
}
export function listShares(docId) {
return request(`/api/shares?document_id=${docId}`)
const params = new URLSearchParams({ document_id: docId })
return request(`/api/shares?${params}`)
}
export function deleteShare(shareId) {
@@ -395,7 +396,7 @@ export function adminListAuditLog({ start, end, user_handle, event_type, page =
* the endpoint can authenticate the request (D-13, T-06.2-04-03).
* Must NOT call res.json() — CSV is text/csv (Pitfall 5).
*/
export async function adminExportAuditLogCsv(params = {}) {
export async function adminExportAuditLogCsv(params = {}, _retry = false) {
const { useAuthStore } = await import('../stores/auth.js')
const authStore = useAuthStore()
@@ -414,6 +415,18 @@ export async function adminExportAuditLogCsv(params = {}) {
headers,
credentials: 'include',
})
if (res.status === 401 && !_retry) {
try {
await authStore.refresh()
return adminExportAuditLogCsv(params, true)
} catch {
authStore.accessToken = null
authStore.user = null
throw new Error('Session expired')
}
}
if (!res.ok) throw new Error(`Export failed: ${res.status}`)
const text = await res.text()
@@ -422,8 +435,10 @@ export async function adminExportAuditLogCsv(params = {}) {
const a = document.createElement('a')
a.href = url
a.download = 'audit-export.csv'
document.body.appendChild(a)
a.click()
URL.revokeObjectURL(url)
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
/**
@@ -431,22 +446,10 @@ export async function adminExportAuditLogCsv(params = {}) {
*
* Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] }
* Items are sorted descending by date.
* Routes through request() which has built-in 401-refresh-retry logic.
*/
export async function adminListDailyExports() {
const { useAuthStore } = await import('../stores/auth.js')
const authStore = useAuthStore()
const headers = {}
if (authStore.accessToken) {
headers['Authorization'] = `Bearer ${authStore.accessToken}`
}
const res = await fetch('/api/admin/audit-log/daily-exports', {
headers,
credentials: 'include',
})
if (!res.ok) throw new Error(`Failed to list daily exports: ${res.status}`)
return res.json()
export function adminListDailyExports() {
return request('/api/admin/audit-log/daily-exports')
}
/**
@@ -457,7 +460,7 @@ export async function adminListDailyExports() {
*
* @param {string} date — YYYY-MM-DD format date string
*/
export async function adminDownloadDailyExport(date) {
export async function adminDownloadDailyExport(date, _retry = false) {
const { useAuthStore } = await import('../stores/auth.js')
const authStore = useAuthStore()
@@ -470,6 +473,18 @@ export async function adminDownloadDailyExport(date) {
headers,
credentials: 'include',
})
if (res.status === 401 && !_retry) {
try {
await authStore.refresh()
return adminDownloadDailyExport(date, true)
} catch {
authStore.accessToken = null
authStore.user = null
throw new Error('Session expired')
}
}
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
const text = await res.text()
@@ -478,8 +493,10 @@ export async function adminDownloadDailyExport(date) {
const a = document.createElement('a')
a.href = url
a.download = `audit-${date}.csv`
document.body.appendChild(a)
a.click()
URL.revokeObjectURL(url)
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
// ── Document content proxy URL ────────────────────────────────────────────────
@@ -290,16 +290,38 @@ const newUser = reactive({
})
function generateRandomPassword() {
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
const lower = 'abcdefghijkmnpqrstuvwxyz'
const digits = '23456789'
const special = '!@#$%^&*'
const charset = upper + lower + digits + special // 64 chars — 256 % 64 === 0, no modulo bias
// Generate 16 random positions
const arr = new Uint8Array(16)
crypto.getRandomValues(arr)
let pw = ''
for (const byte of arr) {
pw += charset[byte % charset.length]
const chars = Array.from(arr, byte => charset[byte % charset.length])
// Inject one guaranteed character from each required class at random positions
// using four additional random bytes to pick the injection positions.
const posArr = new Uint8Array(8)
crypto.getRandomValues(posArr)
const required = [
upper[posArr[0] % upper.length],
lower[posArr[1] % lower.length],
digits[posArr[2] % digits.length],
special[posArr[3] % special.length],
]
// Place each required char at a distinct position (0..3) in the array
for (let i = 0; i < 4; i++) {
chars[i] = required[i]
}
// Ensure all character classes are represented
pw = pw.slice(0, 12) + 'A1!'
return pw
// Shuffle using Fisher-Yates with the last 4 random bytes as seeds
for (let i = chars.length - 1; i > 0; i--) {
const j = posArr[4 + (i % 4)] % (i + 1)
;[chars[i], chars[j]] = [chars[j], chars[i]]
}
return chars.join('')
}
function generatePassword() {
@@ -134,7 +134,7 @@
<span class="text-sm text-gray-500">Page {{ page }}</span>
<button
@click="nextPage"
:disabled="entries.length < perPage"
:disabled="page * perPage >= total"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Next
@@ -263,7 +263,7 @@ function prevPage() {
}
function nextPage() {
if (entries.value.length >= perPage) {
if (page.value * perPage < total.value) {
page.value++
fetchLog()
}
@@ -294,6 +294,7 @@ async function loadDailyExports() {
dailyExports.value = data.items ?? []
} catch (e) {
dailyExports.value = []
exportsError.value = 'Failed to load daily exports. Please try again.'
} finally {
loadingExports.value = false
}