Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions .github/rulesets/protect-main.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@
{
"context": "codeql (javascript-typescript)",
"integration_id": 15368
},
{
"context": "SonarCloud Code Analysis",
"integration_id": 12526
},
{
"context": "CodeRabbit",
"integration_id": 347564
}
]
}
Expand Down
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
3 changes: 2 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ class Settings(BaseSettings):
smtp_port: int = 587
smtp_username: str = ""
smtp_password: SecretStr = SecretStr("")
smtp_starttls: bool = True
# Removed (mail is always encrypted); read only so installs that still set it get a warning.
smtp_starttls: bool | None = None
mail_from: str = "OfflineShelf <noreply@ahmershah.dev>"

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

SMTP_TIMEOUT_SECONDS = 30
# Ports where the provider expects TLS from the first byte (Resend and Amazon SES use both).
IMPLICIT_TLS_PORTS = frozenset({465, 2465})


def send_email(settings: Settings, to: str, subject: str, body: str) -> bool:
"""Send a plain-text email. Returns False when SMTP is not configured."""
if settings.smtp_starttls is False:
log.warning(
"OFFLINESHELF_SMTP_STARTTLS=false is no longer supported: mail is always sent over "
"TLS. Use a relay that offers STARTTLS (port 587) or TLS (465 or 2465)."
)
if not settings.smtp_host:
log.info("SMTP is not configured; not sending %r", subject)
return False
Expand All @@ -20,9 +27,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 in IMPLICIT_TLS_PORTS:
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 not in IMPLICIT_TLS_PORTS:
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
27 changes: 23 additions & 4 deletions backend/tests/test_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,28 @@ def test_sends_with_starttls_and_login(smtp) -> None:
assert msg.get_content().strip() == "Body"


def test_plain_smtp_without_auth(smtp) -> None:
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 = []
for port in (465, 2465):
settings = get_settings().model_copy(
update={"smtp_host": "smtp.example.org", "smtp_port": port}
)
assert mail.send_email(settings, "a@example.org", "Hi", "Body")
assert FakeSMTP.instances[-1].calls == ["quit"]
assert all(o["context"] is not None for o in opened)


def test_the_removed_plaintext_setting_is_warned_about(smtp, caplog) -> None:
settings = get_settings().model_copy(
update={"smtp_host": "localhost", "smtp_port": 25, "smtp_starttls": False}
update={"smtp_starttls": False, "smtp_host": "smtp.example.org", "smtp_port": 587}
)
assert mail.send_email(settings, "a@example.org", "Hi", "Body")
assert smtp.instances[0].calls == ["quit"]
mail.send_email(settings, "a@example.org", "Hi", "Body")
assert "no longer supported" in caplog.text
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%)
10 changes: 7 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 All @@ -77,6 +78,9 @@ proxies `/api`). `npm run dev:down` stops the containers. Needs Docker, uv and N
bumps that would leave those lines (Python 3.12, MariaDB 11.4 LTS, Node LTS, nginx stable,
TypeScript 5); those upgrades are made on purpose.
- Failure emails have no opt-out yet; add one with the account settings page.
- Upgrade note: `OFFLINESHELF_SMTP_STARTTLS=false` is no longer supported; mail is always sent
over TLS (STARTTLS on 587, implicit TLS on 465 and 2465). Installs that still set it log a
warning.
- Account deletion is by email for now; an endpoint comes with the account settings page.

## Open questions
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
8 changes: 6 additions & 2 deletions frontend/src/pages/AppPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState, type SyntheticEvent } from 'react'
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react'

import { ApiError, api, type ShelfList, type User } from '../api/client'
import { ListDetail } from '../components/ListDetail'
Expand Down Expand Up @@ -321,6 +321,10 @@ export function AppPage() {
const [open, setOpen] = useState<number | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [confirming, setConfirming] = useState<number | null>(null)
// Stable, so re-renders from polling don't pull focus back while the confirmation is open.
const focusOnMount = useCallback((el: HTMLButtonElement | null) => {
el?.focus()
}, [])
const building = lists.some((l) => isActive(l.latest_status))

function setStatus(id: number, status: ShelfList['latest_status']): void {
Expand Down Expand Up @@ -474,7 +478,7 @@ export function AppPage() {
>
<button
type="button"
autoFocus
ref={focusOnMount}
className="label link-draw py-2 text-signal"
onClick={() => {
setConfirming(null)
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/pages/SelfHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ 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), 465 or 2465 (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