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.");
+ }
}
///