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
49 changes: 0 additions & 49 deletions .github/workflows/sync-addons.yml

This file was deleted.

9 changes: 9 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ COPY docker/default.conf.template /etc/nginx/templates/default.conf.template
COPY docker/entrypoint.sh /docker-entrypoint.d/40-substitute-env-vars.sh
RUN chmod +x /docker-entrypoint.d/40-substitute-env-vars.sh

# Bundle the addon-sync script + a Node binary so the running container can
# self-refresh PostGuard addon downloads (see /docker-entrypoint.d/50-…).
# We pull the binary from node:24-slim — same debian-bookworm base as nginx,
# so the runtime libs match without an apt install.
COPY --from=node:24-slim /usr/local/bin/node /usr/local/bin/node
COPY scripts/sync-addons.mjs /opt/sync-addons/sync-addons.mjs
COPY docker/sync-addons-loop.sh /docker-entrypoint.d/50-sync-addons.sh
RUN chmod +x /docker-entrypoint.d/50-sync-addons.sh

# Copy frontend built in Stage 1
COPY --from=frontend /app/build /usr/share/nginx/html/postguard

Expand Down
39 changes: 39 additions & 0 deletions docker/sync-addons-loop.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/bin/sh
# Periodically refresh /downloads/* with the latest published PostGuard addon
# release artifacts. Runs in the background of the nginx container so the
# image never has to be rebuilt for an addon-only release.
#
# The first iteration runs concurrently with nginx start — until it finishes,
# requests are served from whatever was baked into the image at build time.
# scripts/sync-addons.mjs writes via a temp-file + rename, so live downloads
# never observe a partially-written file.
#
# Tunables (env):
# SYNC_ADDONS_INTERVAL seconds between refreshes (default 21600 = 6h)
# SYNC_ADDONS_DISABLE set to any non-empty value to skip the loop entirely
set -eu

DOWNLOADS_DIR="/usr/share/nginx/html/postguard/downloads"
SCRIPT="/opt/sync-addons/sync-addons.mjs"
INTERVAL="${SYNC_ADDONS_INTERVAL:-21600}"

if [ -n "${SYNC_ADDONS_DISABLE:-}" ]; then
echo "[sync-addons] disabled via SYNC_ADDONS_DISABLE"
exit 0
fi

if [ ! -d "${DOWNLOADS_DIR}" ]; then
echo "[sync-addons] ${DOWNLOADS_DIR} does not exist; skipping" >&2
exit 0
fi

(
while true; do
if ! DOWNLOADS_DIR="${DOWNLOADS_DIR}" node "${SCRIPT}"; then
echo "[sync-addons] sync iteration failed; will retry after ${INTERVAL}s" >&2
fi
sleep "${INTERVAL}"
done
) &

echo "[sync-addons] background refresh loop started (interval ${INTERVAL}s, dir ${DOWNLOADS_DIR})"
97 changes: 68 additions & 29 deletions scripts/sync-addons.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@
// 5. Otherwise download, verify the sha256 matches, and write the asset + metadata.

import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const downloadsDir = resolve(projectRoot, 'static/downloads')
// DOWNLOADS_DIR lets the container point at the live nginx htdocs dir at
// runtime instead of the in-repo static/ tree.
const downloadsDir = process.env.DOWNLOADS_DIR
? resolve(process.env.DOWNLOADS_DIR)
: resolve(projectRoot, 'static/downloads')

const TARGETS = [
{
Expand All @@ -34,16 +38,17 @@ const TARGETS = [
},
]

// At the 6h container interval we make 2 unauthenticated calls / 6h — far under
// GitHub's 60/h unauthenticated cap. No token plumbing needed; downloads from
// browser_download_url aren't API-rate-limited at all.
async function fetchJson(url) {
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'postguard-website-sync',
}
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`
}
const res = await fetch(url, { headers })
const res = await fetch(url, {
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'postguard-website-sync',
},
})
if (!res.ok) {
throw new Error(`GET ${url} failed: ${res.status} ${res.statusText}`)
}
Expand Down Expand Up @@ -97,6 +102,34 @@ async function findReleaseWithAsset(target) {
)
}

// writeAtomic writes to <path>.tmp and then renames into place so concurrent
// readers (e.g. nginx serving live download requests) never observe a
// partially-written file. POSIX rename(2) is atomic within a single filesystem.
async function writeAtomic(path, data) {
const tmp = `${path}.tmp`
await writeFile(tmp, data)
await rename(tmp, path)
}

async function writeMeta(metaPath, { release, asset, sha256, size }) {
await writeAtomic(
metaPath,
JSON.stringify(
{
tag: release.tag_name,
assetName: asset.name,
sha256,
size,
publishedAt: release.published_at,
sourceUrl: asset.browser_download_url,
releaseUrl: release.html_url,
},
null,
2
) + '\n'
)
}

async function syncTarget(target) {
const outputPath = resolve(downloadsDir, target.outputFile)
const metaPath = resolve(downloadsDir, target.metaFile)
Expand All @@ -111,9 +144,25 @@ async function syncTarget(target) {

const cached = await readCached(metaPath)
if (cached?.sha256 === remoteSha) {
if (cached.tag === release.tag_name) {
console.log(
`[${target.name}] up-to-date: ${release.tag_name} (sha256 ${remoteSha})`
)
return
}
// Upstream re-tagged with byte-identical content (e.g. v0.1.3 → v0.1.5
// with the same manifest.xml). Refresh the metadata so tag/releaseUrl/
// publishedAt stay current without a redundant re-download.
console.log(
`[${target.name}] up-to-date: ${cached.tag} (sha256 ${remoteSha})`
`[${target.name}] re-tagged: ${cached.tag} -> ${release.tag_name} (content unchanged)`
)
await writeMeta(metaPath, {
release,
asset,
sha256: remoteSha,
size: asset.size ?? cached.size,
})
console.log(`[${target.name}] wrote ${metaPath}`)
return
}
console.log(
Expand All @@ -139,23 +188,13 @@ async function syncTarget(target) {
}

await mkdir(downloadsDir, { recursive: true })
await writeFile(outputPath, buf)
await writeFile(
metaPath,
JSON.stringify(
{
tag: release.tag_name,
assetName: asset.name,
sha256: localSha,
size: buf.length,
publishedAt: release.published_at,
sourceUrl: asset.browser_download_url,
releaseUrl: release.html_url,
},
null,
2
) + '\n'
)
await writeAtomic(outputPath, buf)
await writeMeta(metaPath, {
release,
asset,
sha256: localSha,
size: buf.length,
})
console.log(`[${target.name}] wrote ${outputPath} (${buf.length} bytes)`)
console.log(`[${target.name}] wrote ${metaPath}`)
}
Expand Down
8 changes: 4 additions & 4 deletions static/downloads/postguard-outlook-manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"tag": "v0.1.3",
"tag": "v0.1.5",
"assetName": "manifest.xml",
"sha256": "4646512fab112bd109c8ee56457c7c9676a361f2a36185b78288a4187daaf434",
"size": 10357,
"publishedAt": "2026-05-06T16:02:32Z",
"sourceUrl": "https://github.com/encryption4all/postguard-outlook-addon/releases/download/v0.1.3/manifest.xml",
"releaseUrl": "https://github.com/encryption4all/postguard-outlook-addon/releases/tag/v0.1.3"
"publishedAt": "2026-05-07T15:05:29Z",
"sourceUrl": "https://github.com/encryption4all/postguard-outlook-addon/releases/download/v0.1.5/manifest.xml",
"releaseUrl": "https://github.com/encryption4all/postguard-outlook-addon/releases/tag/v0.1.5"
}
Loading