diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 955bd30..c5c34e6 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -20,7 +20,7 @@ jobs: id: release run: | python3 scripts/release/validate_release.py --github-output "$GITHUB_OUTPUT" - backend_version=$(python3 -c "import re; text=open('backend/app/core/config.py').read(); print(re.search(r\"version: str = os\\.getenv\\('LINKLOG_VERSION', '([^']+)'\\)\", text).group(1))") + backend_version=$(python3 -c "import json; print(json.load(open('frontend/version.json'))['version'])") if [ "${GITHUB_REF_NAME#v}" != "$backend_version" ]; then echo "tag ${GITHUB_REF_NAME} does not match backend version $backend_version" >&2 exit 1 diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 100b296..b9d1eb9 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -22,6 +22,7 @@ * Added a "Re-fetch Title" button to manually re-scrape the title after editing the URL * New Entry duplicate detection and submission strip known tracking parameters (utm_*, gclid, fbclid, etc.) from URLs, mirroring the browser extension * Browser extension popup now matches the New Entry page: it warns on duplicate title/URL, notes when the stored link has a different URL, and gained a "Re-fetch title" button; bumped extension version to 0.2.0 + * Generate Firefox update metadata from every signed XPI with verified SHA-256 archive hashes ### Fixed * Fixed element ID conflict on the Admin page so Available Themes load correctly diff --git a/Makefile b/Makefile index d978709..8890642 100644 --- a/Makefile +++ b/Makefile @@ -17,8 +17,9 @@ XPI_OUTPUT := $(XPI_UNSIGNED_DIR)/$(XPI_FILE) EXTENSION_FILES := manifest.json logo.svg icon-16.png icon-32.png icon-48.png icon-96.png options.css options.html options.js popup.css popup.html popup.js l10n.js _locales/en-US/messages.json _locales/es/messages.json _locales/de/messages.json _locales/fr/messages.json _locales/nl/messages.json EXTENSION_SOURCES := $(addprefix webextension/,$(EXTENSION_FILES)) XPI_VALIDATOR := scripts/release/validate_xpi.py +UPDATES_GENERATOR := scripts/release/generate_updates.py -.PHONY: all logos xpi check-tools clean-generated +.PHONY: all logos xpi update-updates check-tools clean-generated all: logos @@ -28,6 +29,9 @@ logos: check-tools $(GENERATED_LOGOS) xpi: $(XPI_OUTPUT) +update-updates: $(UPDATES_GENERATOR) webextension/manifest.json + @python3 $(UPDATES_GENERATOR) + $(XPI_OUTPUT): $(EXTENSION_SOURCES) $(GENERATED_LOGOS) $(XPI_VALIDATOR) @test -n "$(EXTENSION_VERSION)" || { echo "Error: extension version is missing from webextension/manifest.json" >&2; exit 1; } @mkdir -p $(XPI_UNSIGNED_DIR) $(XPI_SIGNED_DIR) diff --git a/README.md b/README.md index 0f77f89..d9e3be2 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ For local development: The backend currently uses FastAPI, uvicorn, SQLite, and Pydantic. `httpx2` is included for the Starlette-compatible test client. Jinja2 is included for server-rendered HTML templates. -The backend version is `0.1.1` and is exposed through the FastAPI/OpenAPI metadata. It can be overridden with `LINKLOG_VERSION`. +The backend version is `0.2.0` and is exposed through the FastAPI/OpenAPI metadata. It is read from `frontend/version.json`, the single source of truth shared by the backend and frontend. LinkLog is licensed under the GNU General Public License, version 3 or any later version. See [LICENSE](LICENSE). ## Local Installation @@ -110,11 +110,11 @@ This publishes `${APP_PORT:-8000}` and defaults the application URL to `http://l ## Releases -Releases run in Gitea Actions when a `v*` tag is pushed. The Docker release version comes from `LINKLOG_VERSION`'s default in `backend/app/core/config.py`; the tag must match that backend version. The Firefox plugin version is independent and comes from the most recent signed `XPI/signed/LinkLog-.xpi` checked into the repository. +Releases run in Gitea Actions when a `v*` tag is pushed. The Docker release version comes from `frontend/version.json`; the tag must match that version. The Firefox plugin version is independent and comes from the most recent signed `XPI/signed/LinkLog-.xpi` checked into the repository. -The signed XPI is produced manually and should be checked into `XPI/signed/LinkLog-.xpi`. The workflow validates the latest signed XPI's embedded manifest, publishes Docker images to `git.kolkman.org/olaf/link-log:` and `:latest`, and creates a release README that describes the project, the current backend/container version, and the raw signed XPI download URL with the plugin version. +The signed XPI is produced manually and should be checked into `XPI/signed/LinkLog-.xpi`. Run `make update-updates` after adding a signed XPI to regenerate `webextension/updates.json` from every valid signed release artifact. The workflow validates the latest signed XPI's embedded manifest, publishes Docker images to `git.kolkman.org/olaf/link-log:` and `:latest`, and creates a release README that describes the project, the current backend/container version, and the raw signed XPI download URL with the plugin version. -The extension's `update_url` points at the stable raw repository URL `https://git.kolkman.org/olaf/Link-Log/raw/branch/main/webextension/updates.json`. Update `webextension/updates.json` with each signed XPI version and commit it together with the XPI. Release READMEs point to the raw signed XPI at `https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-.xpi`. +The extension's `update_url` points at the stable raw repository URL `https://git.kolkman.org/olaf/Link-Log/raw/branch/main/webextension/updates.json`. Commit the regenerated `webextension/updates.json` together with each signed XPI. Release READMEs point to the raw signed XPI at `https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-.xpi`. The workflow requires Gitea Actions secrets named `REGISTRY_USERNAME`, `REGISTRY_TOKEN`, and `RELEASE_TOKEN`. `REGISTRY_TOKEN` is a Gitea access token with permission to push packages; `RELEASE_TOKEN` needs permission to create releases and upload release assets. diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 898b8e3..229a03a 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -1466,3 +1466,27 @@ Make sure the webplugin has the same functionality as just implemented in the ne ### Assistant outcome Brought `webextension/popup.js` to parity with `new-entry.js`: the duplicate check now distinguishes an exact title/URL match from a title match with a differing stored URL (using the same `/api/links/check` `url_matches`/`stored_url` fields) and shows an additional localized note in the latter case; added a "Re-fetch title" button that calls the backend `/api/scrape` endpoint (guarded by existing backend-permission checks) and auto-fills the title on URL blur when empty; reworked `setStatus` to render real `
` line breaks via DOM APIs instead of `textContent`. Added `duplicateLinkDifferentUrl`, `refetchTitle`, and `titleFetchFailed` strings to all five locale files, updated `popup.html` with the new button, and bumped `manifest.json` to version `0.2.0`. Full backend test suite (36 tests) passed. + +### User +Add a feature to the Make file that updates the updates.json file based on available signed releases. + +### Assistant outcome +Added `make update-updates`, which regenerates `webextension/updates.json` from valid versioned signed XPIs. The generator reads each packaged manifest, validates its version, add-on ID, and Firefox minimum version, and ignores non-release artifacts. + +### User +Add sha hashes to the updates.json + +### Assistant outcome +Added Firefox-compatible `sha256:` update hashes generated from each signed XPI's bytes. Release validation now rejects missing or mismatched hashes; the generated metadata and release validator passed for signed versions 0.2.0 and 0.1.0. + +### User +Update changelog and VIBE + +### Assistant outcome +Recorded the signed-XPI update metadata automation and SHA-256 integrity hashes in the v0.2.0 changelog and VIBE logs. + +### User +Make sure that when the plugin is signed the version in the link it the about page is updated too + +### Assistant outcome +Extended `make update-updates` to set the About page plugin download URL to the newest valid signed XPI. Release validation now also rejects an About page whose link does not match the latest signed artifact; verified with LinkLog 0.2.0. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index 0508614..0128c39 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -263,6 +263,10 @@ 241. In the check duplicate I have a `
` tag, but that shows as formatted text on the page, I want a real break to occur at that position. 242. Update VIBE and Changelog. 243. Make sure the webplugin has the same functionality as just implemented in the new-entry page. Increase its version number to 0.2.0. +244. Add a feature to the Make file that updates the updates.json file based on available signed releases. +245. Add sha hashes to the updates.json +246. Update changelog and VIBE +247. Make sure that when the plugin is signed the version in the link it the about page is updated too ## Future entries diff --git a/XPI/signed/LinkLog-0.2.0.xpi b/XPI/signed/LinkLog-0.2.0.xpi new file mode 100644 index 0000000..f4fcd8d Binary files /dev/null and b/XPI/signed/LinkLog-0.2.0.xpi differ diff --git a/XPI/signed/LinkLog-dummy.xpi b/XPI/signed/LinkLog-dummy.xpi deleted file mode 100644 index e3a1bd7..0000000 Binary files a/XPI/signed/LinkLog-dummy.xpi and /dev/null differ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index cf3c0e5..9cb165a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,7 +1,8 @@ ## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later -from dataclasses import dataclass +from dataclasses import dataclass, field +import json import os from pathlib import Path @@ -10,6 +11,7 @@ from cryptography.fernet import Fernet BASE_DIR = Path(__file__).resolve().parent.parent.parent DB_PATH = BASE_DIR / 'data' / 'linklog.db' +VERSION_FILE = BASE_DIR.parent / 'frontend' / 'version.json' def normalize_public_url(value: str) -> str: @@ -20,11 +22,19 @@ def normalize_public_url(value: str) -> str: return f'{scheme}://{value}' +def load_version() -> str: + # frontend/version.json is the single source of truth for the app version, shared by backend and frontend. + try: + return json.loads(VERSION_FILE.read_text())['version'] + except (OSError, KeyError, ValueError): + return '0.0.0' + + @dataclass class Settings: app_env: str = os.getenv('APP_ENV', 'development').lower() app_name: str = os.getenv('LINKLOG_APP_NAME', 'LinkLog') - version: str = os.getenv('LINKLOG_VERSION', '0.1.1') + version: str = field(default_factory=load_version) database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}') secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me') data_encryption_key: str = os.getenv('LINKLOG_DATA_ENCRYPTION_KEY', '') diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 751628c..b1664f2 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -2,6 +2,7 @@ ## SPDX-License-Identifier: GPL-3.0-or-later import json +from pathlib import Path import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs @@ -11,6 +12,7 @@ from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from backend.app.main import app +from backend.app.core.config import settings from backend.app.database import get_connection, hash_password from backend.app.services.email_service import get_smtp_settings from backend.app.services.login_throttle import clear_login_failures @@ -31,7 +33,7 @@ def login_headers(username='alice'): def test_login_returns_token(): - assert app.version == '0.1.1' + assert app.version == settings.version response = client.post('/api/auth/login', json={ 'email': 'alice@example.com', 'password': 'secret123', @@ -806,7 +808,9 @@ def test_public_and_admin_pages_render_html(): assert about_page.status_code == 200 assert 'Save the good stuff' in about_page.text assert '

Plugin

' in about_page.text - assert 'https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.1.0.xpi' in about_page.text + updates = json.loads((Path(__file__).resolve().parents[2] / 'webextension' / 'updates.json').read_text()) + latest_update = updates['addons']['linklog@kolkman.org']['updates'][0] + assert latest_update['update_link'] in about_page.text assert 'id="auth-about-link" href="/about"' in about_page.text assert client.get('/admin').status_code == 200 admin_page = client.get('/admin').text diff --git a/docker-compose-example.yml b/docker-compose-example.yml index 5fb52e6..951f25c 100644 --- a/docker-compose-example.yml +++ b/docker-compose-example.yml @@ -2,7 +2,7 @@ services: app: - image: git.kolkman.org/olaf/link-log:development # or :latest or a version-tag + image: git.kolkman.org/olaf/link-log:${LINKLOG_VERSION:-latest} # or :development or a :version-tag container_name: ${APP_CONTAINER_NAME:-linklog-app} volumes: - ./linklog_data:/app/backend/data diff --git a/frontend/templates/about.html b/frontend/templates/about.html index f0f8f2c..53769ca 100644 --- a/frontend/templates/about.html +++ b/frontend/templates/about.html @@ -60,7 +60,7 @@ diff --git a/frontend/version.json b/frontend/version.json index cf0d650..d787358 100644 --- a/frontend/version.json +++ b/frontend/version.json @@ -1,3 +1,3 @@ { - "version": "0.1.1" + "version": "0.2.0" } \ No newline at end of file diff --git a/scripts/release/generate_updates.py b/scripts/release/generate_updates.py new file mode 100644 index 0000000..33071f9 --- /dev/null +++ b/scripts/release/generate_updates.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Generate Firefox update metadata from LinkLog signed XPI artifacts.""" + +import hashlib +import json +import re +import sys +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SIGNED_DIR = ROOT / 'XPI' / 'signed' +MANIFEST_PATH = ROOT / 'webextension' / 'manifest.json' +UPDATES_PATH = ROOT / 'webextension' / 'updates.json' +ABOUT_TEMPLATE_PATH = ROOT / 'frontend' / 'templates' / 'about.html' +RAW_BASE_URL = 'https://git.kolkman.org/olaf/Link-Log/raw/branch/main' +XPI_NAME_RE = re.compile(r'LinkLog-(\d+\.\d+\.\d+)\.xpi') +ABOUT_XPI_URL_RE = re.compile(rf'{re.escape(RAW_BASE_URL)}/XPI/signed/LinkLog-\d+\.\d+\.\d+\.xpi') + + +def fail(message: str) -> None: + raise SystemExit(f'update metadata generation failed: {message}') + + +def version_key(version: str) -> tuple[int, int, int]: + return tuple(int(part) for part in version.split('.')) + + +def read_packaged_manifest(xpi_path: Path) -> dict: + try: + with zipfile.ZipFile(xpi_path) as archive: + if archive.testzip() is not None: + fail(f'{xpi_path.relative_to(ROOT)} contains a corrupt member') + return json.loads(archive.read('manifest.json')) + except (OSError, KeyError, json.JSONDecodeError, zipfile.BadZipFile) as error: + fail(f'could not read {xpi_path.relative_to(ROOT)}: {error}') + + +def sha256_digest(xpi_path: Path) -> str: + with xpi_path.open('rb') as xpi_file: + return hashlib.file_digest(xpi_file, 'sha256').hexdigest() + + +def update_about_plugin_link(xpi_path: Path) -> None: + about_template = ABOUT_TEMPLATE_PATH.read_text() + latest_xpi_url = f'{RAW_BASE_URL}/{xpi_path.relative_to(ROOT).as_posix()}' + updated_template, replacements = ABOUT_XPI_URL_RE.subn(latest_xpi_url, about_template) + if replacements != 1: + fail(f'expected one signed XPI link in {ABOUT_TEMPLATE_PATH.relative_to(ROOT)}; found {replacements}') + ABOUT_TEMPLATE_PATH.write_text(updated_template) + + +def main() -> None: + source_manifest = json.loads(MANIFEST_PATH.read_text()) + addon_id = source_manifest.get('browser_specific_settings', {}).get('gecko', {}).get('id') + if not addon_id: + fail('webextension/manifest.json is missing browser_specific_settings.gecko.id') + + releases = [] + for xpi_path in SIGNED_DIR.glob('LinkLog-*.xpi'): + match = XPI_NAME_RE.fullmatch(xpi_path.name) + if not match: + continue + version = match.group(1) + manifest = read_packaged_manifest(xpi_path) + if manifest.get('version') != version: + fail(f'{xpi_path.relative_to(ROOT)} manifest version does not match its filename') + gecko = manifest.get('browser_specific_settings', {}).get('gecko', {}) + if gecko.get('id') != addon_id: + fail(f'{xpi_path.relative_to(ROOT)} add-on id does not match webextension/manifest.json') + strict_min_version = gecko.get('strict_min_version') + if not strict_min_version: + fail(f'{xpi_path.relative_to(ROOT)} is missing browser_specific_settings.gecko.strict_min_version') + releases.append((version, xpi_path, strict_min_version, sha256_digest(xpi_path))) + + if not releases: + fail(f'no signed LinkLog release artifacts found in {SIGNED_DIR.relative_to(ROOT)}') + + releases.sort(key=lambda release: version_key(release[0]), reverse=True) + updates = [ + { + 'version': version, + 'update_link': f'{RAW_BASE_URL}/{xpi_path.relative_to(ROOT).as_posix()}', + 'update_hash': f'sha256:{digest}', + 'applications': { + 'gecko': { + 'strict_min_version': strict_min_version, + }, + }, + } + for version, xpi_path, strict_min_version, digest in releases + ] + UPDATES_PATH.write_text(json.dumps({'addons': {addon_id: {'updates': updates}}}, indent=2) + '\n') + update_about_plugin_link(releases[0][1]) + print(f'updated {UPDATES_PATH.relative_to(ROOT)} with {len(updates)} signed release(s)') + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/scripts/release/validate_release.py b/scripts/release/validate_release.py index f8c6b86..fcd4ef1 100644 --- a/scripts/release/validate_release.py +++ b/scripts/release/validate_release.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Validate the version and checked-in artifacts for a LinkLog release.""" +import hashlib import json import re import sys @@ -9,9 +10,12 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -SETTINGS_PATH = ROOT / 'backend' / 'app' / 'core' / 'config.py' +VERSION_FILE = ROOT / 'frontend' / 'version.json' SIGNED_DIR = ROOT / 'XPI' / 'signed' MANIFEST_PATH = ROOT / 'webextension' / 'manifest.json' +UPDATES_PATH = ROOT / 'webextension' / 'updates.json' +ABOUT_TEMPLATE_PATH = ROOT / 'frontend' / 'templates' / 'about.html' +RAW_BASE_URL = 'https://git.kolkman.org/olaf/Link-Log/raw/branch/main' VERSION_RE = re.compile(r'\d+\.\d+\.\d+') @@ -34,12 +38,62 @@ def find_latest_signed_xpi() -> tuple[str, Path]: return max(candidates, key=lambda candidate: version_key(candidate[0])) +def validate_self_update(source_manifest: dict, extension_version: str, xpi_path: Path) -> None: + gecko_settings = source_manifest.get('browser_specific_settings', {}).get('gecko', {}) + addon_id = gecko_settings.get('id') + strict_min_version = gecko_settings.get('strict_min_version') + if not addon_id: + fail('webextension/manifest.json is missing browser_specific_settings.gecko.id') + + updates_data = json.loads(UPDATES_PATH.read_text()) + addon_entry = updates_data.get('addons', {}).get(addon_id) + if not addon_entry: + fail(f'{UPDATES_PATH.relative_to(ROOT)} has no entry for add-on id {addon_id!r}') + + entry = next((u for u in addon_entry.get('updates', []) if u.get('version') == extension_version), None) + if entry is None: + fail( + f'{UPDATES_PATH.relative_to(ROOT)} has no update entry for version {extension_version!r}; ' + 'add one alongside the signed XPI so the self-update mechanism can find it' + ) + + expected_link = f'{RAW_BASE_URL}/{xpi_path.relative_to(ROOT).as_posix()}' + if entry.get('update_link') != expected_link: + fail( + f'{UPDATES_PATH.relative_to(ROOT)} update_link {entry.get("update_link")!r} does not match ' + f'the expected raw signed XPI URL {expected_link!r}' + ) + + with xpi_path.open('rb') as xpi_file: + expected_hash = f'sha256:{hashlib.file_digest(xpi_file, "sha256").hexdigest()}' + if entry.get('update_hash') != expected_hash: + fail( + f'{UPDATES_PATH.relative_to(ROOT)} update_hash {entry.get("update_hash")!r} does not match ' + f'the SHA-256 hash of {xpi_path.relative_to(ROOT)}' + ) + + entry_min_version = entry.get('applications', {}).get('gecko', {}).get('strict_min_version') + if entry_min_version != strict_min_version: + fail( + f'{UPDATES_PATH.relative_to(ROOT)} applications.gecko.strict_min_version {entry_min_version!r} ' + f'does not match webextension/manifest.json strict_min_version {strict_min_version!r}' + ) + + +def validate_about_plugin_link(xpi_path: Path) -> None: + expected_link = f'{RAW_BASE_URL}/{xpi_path.relative_to(ROOT).as_posix()}' + if expected_link not in ABOUT_TEMPLATE_PATH.read_text(): + fail( + f'{ABOUT_TEMPLATE_PATH.relative_to(ROOT)} does not link to the latest signed XPI ' + f'{xpi_path.relative_to(ROOT)}' + ) + + def main() -> None: - settings = SETTINGS_PATH.read_text() - match = re.search(r"version: str = os\.getenv\('LINKLOG_VERSION', '([^']+)'\)", settings) - if not match: - fail('backend version default could not be found') - backend_version = match.group(1) + version_data = json.loads(VERSION_FILE.read_text()) + backend_version = version_data.get('version') + if not backend_version: + fail(f'version could not be found in {VERSION_FILE.relative_to(ROOT)}') if not VERSION_RE.fullmatch(backend_version): fail(f'backend version {backend_version} is not a valid three-part version') @@ -64,6 +118,9 @@ def main() -> None: if archive.testzip() is not None: fail('signed XPI contains a corrupt member') + validate_self_update(source_manifest, extension_version, xpi_path) + validate_about_plugin_link(xpi_path) + signed_xpi = xpi_path.relative_to(ROOT) if len(sys.argv) == 3 and sys.argv[1] == '--github-output': with Path(sys.argv[2]).open('a') as output: diff --git a/webextension/updates.json b/webextension/updates.json index bb810d9..d37403c 100644 --- a/webextension/updates.json +++ b/webextension/updates.json @@ -2,11 +2,27 @@ "addons": { "linklog@kolkman.org": { "updates": [ + { + "version": "0.2.0", + "update_link": "https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.2.0.xpi", + "update_hash": "sha256:86619ec9aa35345c5c2bcad8fa5701762b8bd237ad9457b131404ff68bc2ce6b", + "applications": { + "gecko": { + "strict_min_version": "142.0" + } + } + }, { "version": "0.1.0", - "update_link": "https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.1.0.xpi" + "update_link": "https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.1.0.xpi", + "update_hash": "sha256:d95e23339facfa2a499bb35f9130d41739f622a3c0ac197ac3fd8cb76d6d6110", + "applications": { + "gecko": { + "strict_min_version": "142.0" + } + } } ] } } -} \ No newline at end of file +}