diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..8144f02 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing to Compendio + +Thanks for helping improve Compendio. This page covers how the repository is organised and what a +change has to clear before it can merge. + +## Branching model + +| Branch | Purpose | Direct pushes | +|---|---|---| +| `master` | Released code. Tagged releases are cut from here. | Protected — PR only | +| `develop` | Integration branch. **Open your pull requests against this one.** | Protected — PR only | +| `feature/*`, `fix/*` | Your work, branched from `develop`. | Free | + +Release flow: `feature/*` → `develop` → `master`. + +## Rules for merging + +Both `develop` and `master` are protected by a repository ruleset. To merge you need: + +- a **pull request** — direct pushes are blocked; +- at least **one approving review**; +- **all CI checks green** (`client`, `server`, `contract`, `licence`); +- every **review conversation resolved**; +- no force-pushes and no branch deletion. + +The **repository owner is on the bypass list** and can merge without satisfying these — that is for +hotfixes and administrative changes, and is meant to be used sparingly. + +## Reporting bugs + +Open an issue with the **Bug report** template. A good report includes what you expected, what +happened instead, the **version** (`GET /api/v1/about` or the footer of any page), how the instance +is running (Windows Service, systemd, Docker, or standalone), and any logs — **with secrets and +encrypted-folder contents removed**. + +Found a security problem? Report it privately through GitHub's **Report a vulnerability** button on +the Security tab rather than opening a public issue. + +## Making a change + +1. Branch from `develop`. +2. Build and test: + ```bash + dotnet build + dotnet test + ``` + For client work, also run the checks in `src/client`: `npm run check:i18n`, `npx tsc -b`, and + `npx vitest run`. +3. Keep every user-facing string in **both Spanish and English**. +4. Update documentation when behaviour or the API changes, and regenerate the API contract if you + touched an endpoint (see [`docs/development.md`](../docs/development.md)). +5. Open a pull request against `develop` and fill in the template. + +## Licence + +Compendio is **AGPL-3.0-or-later**. By contributing you agree your changes are licensed under it, +and every dependency must be licence-compatible — CI rejects one that is not (see +[`.github/scripts/check-licences.py`](scripts/check-licences.py)). diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..cbe4189 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,53 @@ +name: Bug report +description: Something in Compendio behaves incorrectly +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug. Please fill in as much as you can. + Do **not** paste secrets, passwords, or encrypted-folder contents. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do, what did you expect, and what happened instead? + placeholder: Steps to reproduce, expected vs. actual behaviour. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: From `GET /api/v1/about` or the footer of any page. + placeholder: e.g. 1.1.0 + validations: + required: true + - type: dropdown + id: platform + attributes: + label: How is it running? + options: + - Windows Service + - systemd (Linux) + - Docker + - Standalone executable / development + validations: + required: true + - type: dropdown + id: language + attributes: + label: Interface language + options: + - Español + - English + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Paste any relevant log output, with secrets removed. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..771408d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest an improvement, keeping Compendio's scope in mind +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Compendio deliberately refuses some things — no SMTP, no real-time co-editing, no plugin + marketplace, no native mobile apps, and no required external service. Please read the + README's **"What it refuses to be"** section before filing. + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: Describe the need, not just the feature. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: false + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..31c9e52 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## What and why + + + +## Checklist + +- [ ] Targets the `develop` branch (not `master`) +- [ ] `dotnet build` and `dotnet test` pass locally +- [ ] Client checks pass (`npm run check:i18n`, `npx tsc -b`, `npx vitest run`) if the client changed +- [ ] User-facing text is provided in **both Spanish and English** +- [ ] Docs updated if behaviour or the API changed (`docs/`, in-product Help, `docs/openapi/v1.json`) +- [ ] No secrets, credentials, or encrypted-folder contents committed diff --git a/.github/rulesets/README.md b/.github/rulesets/README.md new file mode 100644 index 0000000..31d628c --- /dev/null +++ b/.github/rulesets/README.md @@ -0,0 +1,33 @@ +# Branch protection ruleset + +[`protected-branches.json`](protected-branches.json) is the repository ruleset that protects +`master` and `develop`. It is stored here as code so the rules are reviewable and reproducible. + +## What it enforces + +On both `master` and `develop`: + +- **Pull request required** — no direct pushes, with **1 approving review** and all review + conversations resolved. +- **Status checks must pass** — `client`, `server (ubuntu-latest)`, `server (windows-latest)`, + `contract`, and `licence`. +- **No force-pushes** (`non_fast_forward`) and **no branch deletion**. + +The **repository owner** (admin role) is on the bypass list and can merge without satisfying these. + +## Applying it + +Repository rulesets require the repository to be **public** (or on a paid plan). Once the repo is +public, apply — or update — the ruleset with: + +```bash +# Create it the first time +gh api -X POST repos/shernandezp/Compendio/rulesets \ + -H "Accept: application/vnd.github+json" \ + --input .github/rulesets/protected-branches.json + +# Update it later (RULESET_ID from: gh api repos/shernandezp/Compendio/rulesets) +gh api -X PUT repos/shernandezp/Compendio/rulesets/RULESET_ID \ + -H "Accept: application/vnd.github+json" \ + --input .github/rulesets/protected-branches.json +``` diff --git a/.github/rulesets/protected-branches.json b/.github/rulesets/protected-branches.json new file mode 100644 index 0000000..ebddc83 --- /dev/null +++ b/.github/rulesets/protected-branches.json @@ -0,0 +1,42 @@ +{ + "name": "Protected branches", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" } + ], + "conditions": { + "ref_name": { + "include": ["refs/heads/master", "refs/heads/develop"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["merge", "squash", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "client" }, + { "context": "server (ubuntu-latest)" }, + { "context": "server (windows-latest)" }, + { "context": "contract" }, + { "context": "licence" } + ] + } + } + ] +} diff --git a/.github/scripts/check-licences.py b/.github/scripts/check-licences.py index 585d114..2fbc5a8 100644 --- a/.github/scripts/check-licences.py +++ b/.github/scripts/check-licences.py @@ -10,6 +10,7 @@ of known-bad ones. """ +import gzip import json import sys import urllib.error @@ -39,6 +40,14 @@ "Common.Mediator", ) +# Exact package ids that are known-good but carry no SPDX expression the check can read. +# SQLite — the native e_sqlite3 build (author Eric Sink, projectUrl sqlite.org) that the trusted +# SQLitePCLRaw.* family depends on. SQLite itself is public domain; the package only ships the +# deprecated aka.ms/deprecateLicenseUrl placeholder instead of a licence expression. +TRUSTED_IDS = { + "SQLite", +} + NUGET_REGISTRATION = "https://api.nuget.org/v3/registration5-gz-semver2/{id}/{version}.json" @@ -46,11 +55,32 @@ def licence_for(package_id: str, version: str) -> str | None: url = NUGET_REGISTRATION.format(id=package_id.lower(), version=version.lower()) try: with urllib.request.urlopen(url, timeout=20) as response: - data = json.load(response) - except (urllib.error.URLError, json.JSONDecodeError, TimeoutError): + body = response.read() + # The registration5-gz-semver2 resource serves gzip-encoded content that urllib does not + # transparently decode, so a raw json.load would choke on the 0x1f 0x8b magic bytes. + if body[:2] == b"\x1f\x8b": + body = gzip.decompress(body) + data = json.loads(body) + except (urllib.error.URLError, OSError, ValueError, TimeoutError): + return None + + catalog = data.get("catalogEntry") if isinstance(data, dict) else None + + # `catalogEntry` is normally an inlined object, but the registration hive is allowed to leave it + # as a bare URL string when the entry is not inlined. Resolve that one extra hop before giving up. + if isinstance(catalog, str): + try: + with urllib.request.urlopen(catalog, timeout=20) as response: + body = response.read() + if body[:2] == b"\x1f\x8b": + body = gzip.decompress(body) + catalog = json.loads(body) + except (urllib.error.URLError, OSError, ValueError, TimeoutError): + return None + + if not isinstance(catalog, dict): return None - catalog = data.get("catalogEntry", {}) return catalog.get("licenseExpression") or catalog.get("licenseUrl") @@ -69,7 +99,7 @@ def main(path: str) -> int: unknown: list[str] = [] for package_id, version in sorted(seen.items()): - if package_id.startswith(TRUSTED_PREFIXES): + if package_id.startswith(TRUSTED_PREFIXES) or package_id in TRUSTED_IDS: continue expression = licence_for(package_id, version) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 875fcee..2110d84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [master, develop] pull_request: workflow_dispatch: @@ -128,7 +128,7 @@ jobs: working-directory: src/client run: | npm ci --no-audit --no-fund - npx license-checker-rseidelsohn --production --summary \ + npx license-checker-rseidelsohn --production --summary --excludePrivatePackages \ --onlyAllow "MIT;ISC;Apache-2.0;BSD-2-Clause;BSD-3-Clause;0BSD;CC0-1.0;Unlicense;Python-2.0;BlueOak-1.0.0;CC-BY-4.0" docker: diff --git a/deploy/Dockerfile b/deploy/Dockerfile index d987d3c..3cf7adf 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -44,6 +44,10 @@ RUN dotnet publish Compendio/src/Server/Compendio.Server.csproj \ -p:SkipClientBuild=true \ --no-restore +# Pre-create the data tree here; the chiselled runtime has no shell to mkdir at runtime. Ownership +# is set on the way into the runtime stage below, where the app user's UID is known. +RUN mkdir -p /data/content /data/db /data/keys /data/logs /data/backups + # ---- Runtime ----------------------------------------------------------------------------------- FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime @@ -56,9 +60,17 @@ ENV DOTNET_RUNNING_IN_CONTAINER=true \ WORKDIR /app COPY --from=server /app . -# Three volumes, not one. `keys` is separate and called out in the docs because losing it loses -# every encrypted page — an operator who bind-mounts only `content` and `db` finds that out late. -VOLUME ["/data/content", "/data/db", "/data/keys"] +# The runtime user is the image's non-root app user ($APP_UID, 1654 on the .NET 10 chiselled base). +# The data tree is copied in already owned by it, so the first write to /data/logs or the creation +# of /data/keys/dataprotection is not denied. $APP_UID is used rather than a hard-coded number +# because Microsoft has changed it between releases (it was 64198 on .NET 8). +COPY --from=server --chown=$APP_UID:$APP_UID /data /data + +# No VOLUME declarations: under the containerd image store an anonymous volume is created root-owned +# rather than inheriting the ownership above, which stops the non-root process writing to /data/db +# or /data/keys. Persistence is always mounted explicitly instead — the three paths to mount +# (content, db, and keys separately) are documented in deploy/docker-compose.yml, because losing +# `keys` loses every encrypted page. EXPOSE 8080 @@ -66,5 +78,5 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD ["/app/compendio", "--version"] -# Non-root by default in the chiselled base (UID 64198). +# Non-root by default in the chiselled base ($APP_UID). ENTRYPOINT ["/app/compendio"] diff --git a/src/Server.Tests/Compendio.Server.Tests.csproj b/src/Server.Tests/Compendio.Server.Tests.csproj index 759d8a8..e7b5196 100644 --- a/src/Server.Tests/Compendio.Server.Tests.csproj +++ b/src/Server.Tests/Compendio.Server.Tests.csproj @@ -18,9 +18,7 @@ - - TargetFramework=net10.0 - + diff --git a/src/Server/Hosting/DataDirectory.cs b/src/Server/Hosting/DataDirectory.cs index d13fc07..afe429b 100644 --- a/src/Server/Hosting/DataDirectory.cs +++ b/src/Server/Hosting/DataDirectory.cs @@ -90,16 +90,32 @@ public void EnsureCreated() /// on Windows the installer sets the ACL, because inherited ACLs are the norm there and /// stripping them from a running service is more likely to lock the product out of its own key. /// + /// + /// The mode change is best-effort. When keys is a mounted volume the operator owns — the + /// default for the container, where the host bind-mount is created root-owned — a non-root + /// process can write inside it but cannot chmod it, and crashing there would take the + /// whole instance down over a hardening step the operator is already responsible for. The + /// tightening still applies on the common path where the process owns the directory. + /// private static void CreateProtectedDirectory(string path) { + Directory.CreateDirectory(path); + if (OperatingSystem.IsWindows()) { - Directory.CreateDirectory(path); return; } - Directory.CreateDirectory(path); - File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + try + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + Console.Error.WriteLine( + $"warning: could not restrict permissions on '{path}' ({ex.Message}). " + + "Ensure the directory is not readable by other users."); + } } ///