diff --git a/.github/rulesets/protect-main.json b/.github/rulesets/protect-main.json index eabd256..e238f1f 100644 --- a/.github/rulesets/protect-main.json +++ b/.github/rulesets/protect-main.json @@ -54,6 +54,14 @@ { "context": "codeql (javascript-typescript)", "integration_id": 15368 + }, + { + "context": "SonarCloud Code Analysis", + "integration_id": 12526 + }, + { + "context": "CodeRabbit", + "integration_id": 347564 } ] } diff --git a/.sonarcloud.properties b/.sonarcloud.properties index aed6aa9..e1e972e 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 0d72b4c..92591f3 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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] diff --git a/backend/app/config.py b/backend/app/config.py index af1895d..a95bd9a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 " @field_validator("database_url") diff --git a/backend/app/mail.py b/backend/app/mail.py index 1d19a3c..2e41290 100644 --- a/backend/app/mail.py +++ b/backend/app/mail.py @@ -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 @@ -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) if settings.smtp_username: smtp.login(settings.smtp_username, settings.smtp_password.get_secret_value()) smtp.send_message(msg) diff --git a/backend/tests/test_mail.py b/backend/tests/test_mail.py index 119bb0a..3ee5b65 100644 --- a/backend/tests/test_mail.py +++ b/backend/tests/test_mail.py @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 17d44cf..d6f6d01 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 ` - [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%) diff --git a/docs/status.md b/docs/status.md index a660a64..ec746ea 100644 --- a/docs/status.md +++ b/docs/status.md @@ -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. - **Changes:** each ready version stores the articles added and removed since the previous ready one (`/versions//changes`), shown in the build history and the Details panel. - **Robustness:** the scheduler runs `recover_stalled` hourly: builds and exports whose RQ job @@ -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. @@ -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 diff --git a/frontend/src/components/DragShelf.tsx b/frontend/src/components/DragShelf.tsx index 025e918..6dab16f 100644 --- a/frontend/src/components/DragShelf.tsx +++ b/frontend/src/components/DragShelf.tsx @@ -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" diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 2e2dfc4..e568a36 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -71,7 +71,7 @@ export function Hero() {