Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sonarcloud.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ sonar.test.inclusions=frontend/src/**/*.test.ts,frontend/src/**/*.test.tsx,backe
sonar.exclusions=frontend/src/api/schema.d.ts,backend/migrations/**,**/node_modules/**,**/dist/**
sonar.python.version=3.12
sonar.sourceEncoding=UTF-8

# css:S8776 ("missing scoping root") misreads Tailwind's @custom-variant block in index.css.
sonar.issue.ignore.multicriteria=tailwind
sonar.issue.ignore.multicriteria.tailwind.ruleKey=css:S8776
sonar.issue.ignore.multicriteria.tailwind.resourceKey=frontend/src/styles/index.css
2 changes: 1 addition & 1 deletion backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

def create_app(settings: Settings | None = None) -> Flask:
settings = settings or get_settings()
app = Flask(__name__)
app = Flask(__name__) # NOSONAR: CSRF is enforced by app/security/csrf.py, not Flask-WTF
app.config["MAX_CONTENT_LENGTH"] = settings.max_request_bytes
app.json.sort_keys = False # type: ignore[attr-defined]

Expand Down
1 change: 0 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ class Settings(BaseSettings):
smtp_port: int = 587
smtp_username: str = ""
smtp_password: SecretStr = SecretStr("")
smtp_starttls: bool = True
mail_from: str = "OfflineShelf <noreply@ahmershah.dev>"

@field_validator("database_url")
Expand Down
15 changes: 12 additions & 3 deletions backend/app/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
log = logging.getLogger(__name__)

SMTP_TIMEOUT_SECONDS = 30
SMTPS_PORT = 465


def send_email(settings: Settings, to: str, subject: str, body: str) -> bool:
Expand All @@ -20,9 +21,17 @@ def send_email(settings: Settings, to: str, subject: str, body: str) -> bool:
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=SMTP_TIMEOUT_SECONDS) as smtp:
if settings.smtp_starttls:
smtp.starttls(context=ssl.create_default_context())
# Always encrypted, always with certificate checks: implicit TLS on 465, STARTTLS otherwise.
context = ssl.create_default_context()
host, port = settings.smtp_host, settings.smtp_port
smtp: smtplib.SMTP
if port == SMTPS_PORT:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
smtp = smtplib.SMTP_SSL(host, port, timeout=SMTP_TIMEOUT_SECONDS, context=context)
else:
smtp = smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT_SECONDS)
with smtp:
if port != SMTPS_PORT:
smtp.starttls(context=context)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password.get_secret_value())
smtp.send_message(msg)
Expand Down
18 changes: 13 additions & 5 deletions backend/tests/test_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,17 @@ def test_sends_with_starttls_and_login(smtp) -> None:
assert msg.get_content().strip() == "Body"


def test_plain_smtp_without_auth(smtp) -> None:
settings = get_settings().model_copy(
update={"smtp_host": "localhost", "smtp_port": 25, "smtp_starttls": False}
)
def test_port_465_uses_implicit_tls_without_auth(monkeypatch) -> None:
opened: list[dict[str, object]] = []

class FakeSMTPSSL(FakeSMTP):
def __init__(self, host: str, port: int, timeout: int, context: object) -> None:
super().__init__(host, port, timeout)
opened.append({"context": context})

monkeypatch.setattr(mail.smtplib, "SMTP_SSL", FakeSMTPSSL)
FakeSMTP.instances = []
settings = get_settings().model_copy(update={"smtp_host": "smtp.example.org", "smtp_port": 465})
assert mail.send_email(settings, "a@example.org", "Hi", "Body")
assert smtp.instances[0].calls == ["quit"]
assert FakeSMTP.instances[0].calls == ["quit"]
assert opened[0]["context"] is not None
9 changes: 9 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,12 @@ in CI, and seed data.
- [x] Seed data: three example lists (a category, a Wikidata query, a combination): `python -m app.seed <email>`
- [x] README setup steps and screenshots
- [ ] Deploy at `offlineshelf.ahmershah.dev`; tag `v1.0.0`

## Before launch: test at scale (planned, not started)

- [ ] End-to-end tests with Playwright: sign up, every builder, combinations, exports, delete
- [ ] Smoke tests against a fresh Docker Compose stack on every release tag
- [ ] Stress test: 15 to 20 simulated users in Docker building, combining and exporting at once,
watching queue depth, build times, memory and Wikimedia request rates
- [ ] 100% line and branch coverage for the whole backend and frontend (today: `app/core` 100%,
backend about 97%)
7 changes: 4 additions & 3 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Last updated: 2026-11-01. Update at the end of every work session.
README with setup steps and screenshots (`docs/screenshots`).
- **M6 ZIM:** `format: "zim"` exports write a ZIM file with libzim 3.9.0 (same pages as the HTML
bundle, full-text index, Kiwix metadata), offered in the Details panel. Checked live.
- **Quality gates:** SonarQube Cloud (automatic analysis) and CodeRabbit are installed and are
required checks on `main` alongside backend, frontend and CodeQL.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- **Changes:** each ready version stores the articles added and removed since the previous ready
one (`/versions/<v>/changes`), shown in the build history and the Details panel.
- **Robustness:** the scheduler runs `recover_stalled` hourly: builds and exports whose RQ job
Expand Down Expand Up @@ -62,9 +64,8 @@ proxies `/api`). `npm run dev:down` stops the containers. Needs Docker, uv and N

1. Read the About page copy and confirm it says what you want about yourself.
2. M6: deploy to offlineshelf.ahmershah.dev and tag v1.0.0.
3. Install SonarQube Cloud and CodeRabbit on the repository (needs the owner's GitHub
authorisation; `.sonarcloud.properties` and `.coderabbit.yaml` are ready), then add them as
required checks in `.github/rulesets/protect-main.json`.
3. Before launch, at the very end: Playwright end-to-end tests, smoke and stress tests with 15 to
20 simulated users in Docker, and 100% coverage everywhere (see the roadmap).
4. Release images: `.github/workflows/images.yml` publishes `offlineshelf-api` and
`offlineshelf-web` to GHCR on every `v*` tag.

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/DragShelf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function DragShelf() {
ref={track}
data-cursor="grab"
data-lenis-prevent-wheel=""
tabIndex={0}
tabIndex={0} // NOSONAR: a scrolling region needs focus so keyboard users can scroll it
role="region"
aria-label="Example lists, scroll sideways"
className="no-scrollbar flex cursor-grab items-end gap-4 overflow-x-auto px-[var(--gutter)] pb-4 select-none active:cursor-grabbing"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function Hero() {
<div className="sticky top-0 h-[100svh] overflow-hidden">
<canvas
ref={canvas}
aria-hidden="true"
aria-hidden="true" // NOSONAR: a canvas is not focusable; it is decoration"
className="absolute inset-0 size-full opacity-0 transition-opacity duration-[2000ms] data-[ready=true]:opacity-100"
/>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_30%,var(--color-ink)_85%)]" />
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/components/ListDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,7 @@ function Articles({ list, version }: { list: ShelfList; version: ListVersion })
<ol
className="max-h-96 overflow-y-auto font-mono text-sm"
aria-label="Articles"
// A scrolling region needs focus so keyboard users can scroll it.
tabIndex={0}
tabIndex={0} // NOSONAR: a scrolling region needs focus so keyboard users can scroll it
>
{items.map((a) => (
<li key={a.page_id} className="border-b hairline py-1.5">
Expand Down Expand Up @@ -146,7 +145,7 @@ function Changes({ list, version }: { list: ShelfList; version: ListVersion }) {
<ul
className="mt-2 max-h-60 overflow-y-auto font-mono text-sm"
aria-label={`Articles ${kind}`}
tabIndex={0}
tabIndex={0} // NOSONAR: a scrolling region needs focus so keyboard users can scroll it
>
{items.map((a) => (
<li key={a.page_id} className="border-b hairline py-1.5">
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/AppPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,9 @@ export function AppPage() {
>
<button
type="button"
autoFocus
ref={(el) => {
el?.focus()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}}
className="label link-draw py-2 text-signal"
onClick={() => {
setConfirming(null)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/SelfHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ docker compose up -d --build`}</Code>
<Facts
items={[
['OFFLINESHELF_SMTP_HOST', 'For example smtp.resend.com. Empty turns email off'],
['OFFLINESHELF_SMTP_PORT', 'Default 587 (STARTTLS)'],
['OFFLINESHELF_SMTP_PORT', '587 (STARTTLS, default) or 465 (TLS); always encrypted'],
['OFFLINESHELF_SMTP_USERNAME', 'For Resend: resend'],
['OFFLINESHELF_SMTP_PASSWORD', 'An API key or password; keep it in .env only'],
['OFFLINESHELF_MAIL_FROM', 'A sender on a domain verified with your provider'],
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/seo/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export const NOT_FOUND: RouteMeta = {
}

export function routeFor(pathname: string): RouteMeta {
const clean = pathname !== '/' ? pathname.replace(/\/+$/, '') : '/'
let clean = pathname
while (clean.length > 1 && clean.endsWith('/')) clean = clean.slice(0, -1)
return ROUTES.find((r) => r.path === clean) ?? NOT_FOUND
}
2 changes: 1 addition & 1 deletion frontend/src/webgl/shelfScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const ROW_HEIGHT = 1.12
function mulberry32(seed: number): () => number {
let a = seed
return () => {
a = (a + 0x6d2b79f5) | 0
a = (a + 0x6d2b79f5) | 0 // NOSONAR: 32-bit wraparound for the PRNG, not truncation
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
Expand Down