Compare commits

..

2 Commits

Author SHA1 Message Date
0ca212dd4c Release-Skript: ATN-Signierung (unlisted) + Gitea-Release + Auto-Update
scripts/release.sh signiert die Extension via web-ext gegen
addons.thunderbird.net (Kanal unlisted, also signiert aber nicht öffentlich
gelistet), lädt das XPI als Gitea-Release hoch und trägt es in updates.json
ein. web-ext wird global genutzt oder per npx (kein globales Install nötig).
Secrets kommen aus scripts/.env (gitignored); .env.example als Vorlage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:14:15 +02:00
f1454540d5 Passwort nicht mehr speichern: OAuth-Token nur im RAM, 1x-Login pro Sitzung
Das DocuWare-Passwort wird nicht mehr in storage.local abgelegt. Das
Hintergrundskript fungiert als Auth-Broker und hält den OAuth-Token nur im
Speicher der TB-Sitzung. Der Ablage-Dialog fragt das Passwort einmalig per
Overlay ab, holt darüber den Token und verwirft das Passwort sofort.

- store.js: password aus DEFAULTS entfernt; Settings.set() löscht password defensiv
- auth.js: setToken/currentToken für Token-Transfer, Cookie-Modus als Sentinel
- background.js: Broker (auth:status/auth:logon/auth:logout)
- dialog: ensureAuth() + Passwort-Overlay statt direktem Auth.logon
- options: Passwort nur transient für Test/Diagnose, nicht gespeichert; Export ohne Passwort
- manifest: lib/store.js + lib/auth.js ins Hintergrundskript geladen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:14:05 +02:00
13 changed files with 384 additions and 22 deletions

5
.gitignore vendored
View File

@ -5,6 +5,11 @@ settings*.json
# Build-Artefakte (das signierte XPI wird als Gitea-Release verteilt, nicht im Repo)
*.xpi
web-ext-artifacts/
# Release-Secrets (ATN-/Gitea-Token) NIE committen!
scripts/.env
.env
# No docs files
docs/

View File

@ -49,9 +49,12 @@ Add-on-Einstellungen öffnen und ausfüllen:
- **Verbindung testen** → lädt die Aktenschränke (Bestätigung, dass Login & URL passen)
- optional **Standard-Aktenschrank** und Default-Optionen (eml/pdf/Anhänge)
> ⚠️ Das Passwort wird unverschlüsselt in `storage.local` gespeichert (v1). Für den
> Produktiveinsatz sollte auf den DocuWare Identity Service / OAuth umgestellt werden
> (`lib/auth.js` ist dafür gekapselt).
> 🔒 **Das Passwort wird nicht gespeichert.** Es wird beim ersten Ablegen pro
> Thunderbird-Sitzung einmal abgefragt, daraus ein OAuth-Token geholt und sofort
> verworfen. Nur der Token bleibt im Speicher des Hintergrundskripts (überlebt keinen
> Neustart). In `storage.local` liegen ausschließlich Server, Organisation, Benutzer
> und Standardwerte keine Zugangsdaten. Auch der Einstellungs-Export enthält **kein**
> Passwort.
## Benutzung
@ -78,7 +81,7 @@ Das Add-on wird **nicht** öffentlich auf addons.thunderbird.net gelistet, sonde
Gitea verteilt + aktualisiert.
> Im XPI und in `updates.json` stehen **keine** Zugangsdaten. Das DocuWare-Passwort
> liegt ausschließlich lokal in `storage.local` und wird nie mitverteilt.
> wird ohnehin nirgends gespeichert (nur Sitzungs-Token im Speicher) und nie mitverteilt.
**Einmalige Einrichtung:**
@ -102,7 +105,9 @@ neueste dort gelistete Version.
## Bekannte Grenzen / nächste Schritte
- **PDF** ist v1 ein einfaches Text-PDF (Kopf + Klartext-Body) ohne HTML-Layout/Bilder.
- **Auth** nur Cookie-Logon; OAuth/Identity Service noch nicht implementiert.
- **Auth**: primär DocuWare Identity Service (OAuth, ROPC mit öffentlichem Client),
Fallback Cookie-Logon für alte On-Prem-Server. Passwort wird nicht gespeichert
(Token nur im RAM, 1× Abfrage pro Sitzung).
- **Upload-Multipart-Format** ggf. je nach DocuWare-Version anpassen
(`docuware.js → uploadDocument`).
- Mehrfachauswahl von Mails: aktuell wird die erste markierte Mail abgelegt.

View File

@ -1,7 +1,43 @@
// Hintergrund-Skript: Button + Kontextmenü, öffnet den Ablage-Dialog.
// Zusätzlich Auth-Broker: hält den OAuth-Token nur im Speicher dieser TB-Sitzung
// (siehe auth.js). Das Passwort wird einmalig zum Login durchgereicht, daraus ein
// Token geholt und sofort verworfen es wird NIE gespeichert.
const DIALOG_URL = "dialog/dialog.html";
// Auth-Broker: Dialog-/Optionsfenster fragen hier den Sitzungs-Token an bzw.
// melden sich mit dem Passwort an. So muss das Passwort nur 1x pro Sitzung getippt
// werden und liegt nirgends auf der Platte.
browser.runtime.onMessage.addListener((msg) => {
if (!msg || !msg.type) return undefined;
if (msg.type === "auth:status") {
return Promise.resolve({ hasToken: Auth.hasValidToken(), token: Auth.currentToken() });
}
if (msg.type === "auth:logon") {
return (async () => {
try {
const settings = await Settings.get();
if (!settings.serverUrl) throw new Error("Keine DocuWare-Server-URL konfiguriert.");
Auth.reset();
const r = await Auth.logon({ ...settings, password: msg.password || "" });
// Passwort wird hier verworfen nur der Token bleibt im Speicher.
return { ok: true, mode: r.mode, token: Auth.currentToken() };
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
}
})();
}
if (msg.type === "auth:logout") {
Auth.reset();
return Promise.resolve({ ok: true });
}
return undefined;
});
function openDialog(messageIds) {
const ids = Array.isArray(messageIds) ? messageIds : [messageIds];
// Pro Nachricht ein eigenes Ablagefenster (leicht versetzt gestapelt).

View File

@ -62,3 +62,23 @@ footer { border-top: 1px solid #e5e7eb; padding: 10px 18px; background: #fafafa;
.muted { color: #6b7280; font-size: 12px; }
.err { color: #b91c1c; } .ok { color: #15803d; }
.field.invalid input, .field.invalid textarea, .field.invalid select { border-color: #b91c1c; }
/* Passwort-Abfrage (Sitzungs-Login, nicht gespeichert) */
.overlay {
position: fixed; inset: 0; background: rgba(0, 0, 0, .35);
display: flex; align-items: center; justify-content: center; z-index: 50;
}
.overlay[hidden] { display: none; }
.overlay-box {
background: #fff; border-radius: 6px; padding: 18px 20px; width: 340px;
box-shadow: 0 8px 30px rgba(0, 0, 0, .25);
}
.overlay-box h2 { font-size: 15px; font-weight: 600; margin: 0 0 8px; }
.overlay-box input[type="password"] {
width: 100%; padding: 7px 9px; border: 1px solid #b9c0c8; border-radius: 3px;
font: inherit; margin-top: 8px;
}
.overlay-box input[type="password"]:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px #2563eb33; }
.overlay-box .err { min-height: 1em; margin-top: 6px; }
.overlay-actions { display: flex; justify-content: flex-end; align-items: center; gap: 12px; margin-top: 10px; }
.overlay-box .pw-note { margin-top: 10px; }

View File

@ -33,6 +33,21 @@
<div id="fields" class="fields"><div class="muted">Ziel wählen …</div></div>
</main>
<!-- Passwort-Abfrage: nur für diese Sitzung, wird nicht gespeichert. -->
<div id="pwOverlay" class="overlay" hidden>
<div class="overlay-box">
<h2>DocuWare-Anmeldung</h2>
<div id="pwWho" class="muted"></div>
<input type="password" id="pwInput" autocomplete="current-password" placeholder="Passwort" />
<div id="pwErr" class="err"></div>
<div class="overlay-actions">
<button id="pwCancel" class="link">Abbrechen</button>
<button id="pwOk" class="primary">Anmelden</button>
</div>
<div class="muted pw-note">Wird nur für diese Sitzung verwendet und nicht gespeichert.</div>
</div>
</div>
<footer>
<section class="options">
<label class="radio"><input type="radio" name="scope" value="both" checked /> E-Mail und Anhänge</label>

View File

@ -87,7 +87,7 @@ async function init() {
try {
status("Verbinde mit DocuWare …");
await Auth.logon(STATE.settings);
await ensureAuth();
await loadCabinets();
status("");
} catch (e) {
@ -95,6 +95,61 @@ async function init() {
}
}
// Holt den Sitzungs-Token vom Hintergrundskript. Ist keiner vorhanden, wird das
// Passwort einmalig abgefragt (nur im RAM, nicht gespeichert) und ein Token geholt.
async function ensureAuth() {
const st = await browser.runtime.sendMessage({ type: "auth:status" });
if (st && st.hasToken && st.token) {
Auth.setToken(st.token);
return;
}
let err = "";
while (true) {
const pw = await askPassword(err);
if (pw === null) throw new Error("Anmeldung abgebrochen.");
status("Anmeldung …");
const res = await browser.runtime.sendMessage({ type: "auth:logon", password: pw });
if (res && res.ok) {
Auth.setToken(res.token);
return;
}
err = (res && res.error) || "Anmeldung fehlgeschlagen.";
}
}
// Zeigt das Passwort-Overlay und löst mit dem eingegebenen Passwort (oder null
// bei Abbruch) auf. Das Passwort verlässt diese Funktion nur Richtung Broker.
function askPassword(errMsg) {
return new Promise((resolve) => {
const ov = $("pwOverlay");
const input = $("pwInput");
$("pwWho").textContent = [STATE.settings.username, STATE.settings.organization]
.filter(Boolean)
.join(" @ ");
$("pwErr").textContent = errMsg || "";
input.value = "";
ov.hidden = false;
input.focus();
const cleanup = (val) => {
ov.hidden = true;
$("pwOk").removeEventListener("click", onOk);
$("pwCancel").removeEventListener("click", onCancel);
input.removeEventListener("keydown", onKey);
resolve(val);
};
const onOk = () => { if (input.value) cleanup(input.value); };
const onCancel = () => cleanup(null);
const onKey = (e) => {
if (e.key === "Enter") { e.preventDefault(); onOk(); }
else if (e.key === "Escape") { e.preventDefault(); onCancel(); }
};
$("pwOk").addEventListener("click", onOk);
$("pwCancel").addEventListener("click", onCancel);
input.addEventListener("keydown", onKey);
});
}
function applyDefaults() {
$("optPdf").checked = STATE.settings.storePdf;
const hasAtt = STATE.attachments.length > 0;

View File

@ -16,12 +16,16 @@
// Fallback: Findet sich kein Identity Service (alte On-Prem-Server), wird das
// klassische Cookie-Logon versucht.
//
// Token wird im Speicher gehalten (TOKEN) und von authHeaders() ausgegeben.
// Token wird NUR im Speicher gehalten (TOKEN), nie persistiert. Im Hintergrund-
// skript überlebt es die TB-Sitzung und wird per Messaging an die Fenster gereicht
// (siehe background.js). Das Passwort wird ausschließlich zum Holen des Tokens
// verwendet und danach verworfen.
const DW_PUBLIC_CLIENT_ID = "docuware.platform.net.client";
const DW_SCOPE = "docuware.platform";
const COOKIE_TTL_MS = 8 * 60 * 60 * 1000; // Cookie-Session pragmatisch ~8h gültig
let TOKEN = null; // { accessToken, refreshToken, expiresAt, tokenEndpoint }
let TOKEN = null; // { accessToken, expiresAt, mode, tokenEndpoint }
const Auth = {
/**
@ -47,9 +51,35 @@ const Auth = {
return {};
},
/** Aktuell ein Token vorhanden und (noch) gültig? */
/** Aktuell ein Token vorhanden und (noch) gültig? (auch Cookie-Session) */
hasValidToken() {
return !!(TOKEN && TOKEN.accessToken && TOKEN.expiresAt > Date.now() + 5000);
return !!(
TOKEN &&
(TOKEN.accessToken || TOKEN.mode === "cookie") &&
TOKEN.expiresAt > Date.now() + 5000
);
},
/** Serialisierbare Token-Sicht für den Transfer per Messaging. */
currentToken() {
if (!TOKEN) return null;
return { accessToken: TOKEN.accessToken || null, expiresAt: TOKEN.expiresAt, mode: TOKEN.mode };
},
/**
* Übernimmt ein vom Broker (Hintergrundskript) geliefertes Token in diesen
* Kontext, damit authHeaders()/credentials greifen. Kein Passwort nötig.
*/
setToken(t) {
if (!t || (!t.accessToken && t.mode !== "cookie")) {
TOKEN = null;
return;
}
TOKEN = {
accessToken: t.accessToken || null,
expiresAt: t.expiresAt || Date.now() + COOKIE_TTL_MS,
mode: t.mode || (t.accessToken ? "identity" : "cookie"),
};
},
/** Token verwerfen (z.B. beim Wechsel der Zugangsdaten). */
@ -126,8 +156,8 @@ const Auth = {
TOKEN = {
accessToken: data.access_token,
refreshToken: data.refresh_token || null,
expiresAt: Date.now() + (Number(data.expires_in) || 3600) * 1000,
mode: "identity",
tokenEndpoint,
};
},
@ -156,6 +186,9 @@ const Auth = {
const text = await this._safeText(res);
throw new Error(`Logon fehlgeschlagen (HTTP ${res.status}). ${text}`.trim());
}
// Cookie-Session: kein Bearer-Token, Folgeaufrufe per credentials:"include".
// Wir merken nur, dass eine Session besteht (für hasValidToken/Sitzungs-Cache).
TOKEN = { accessToken: null, expiresAt: Date.now() + COOKIE_TTL_MS, mode: "cookie" };
},
async _safeText(res) {

View File

@ -1,11 +1,12 @@
// Einstellungen lesen/schreiben (browser.storage.local).
// Hinweis: Credentials werden v1 im Klartext gespeichert (siehe README / Risiken).
// Wichtig: Das Passwort wird NICHT gespeichert. Es wird beim Login einmalig
// abgefragt, daraus ein OAuth-Token geholt und sofort verworfen (siehe auth.js
// + background.js). In storage.local liegen nur unkritische Daten.
const DEFAULTS = {
serverUrl: "", // z.B. https://docuware.example.com (ohne /DocuWare/Platform)
organization: "",
username: "",
password: "",
defaultCabinetId: "",
dialogByCabinet: {}, // gemerkter Ablagedialog je Schrank: { [cabinetId]: dialogId }
storeEml: true,
@ -23,6 +24,8 @@ const Settings = {
async set(partial) {
const current = await this.get();
const next = { ...current, ...partial };
// Sicherheitsnetz: Passwort darf NIE persistiert werden (z.B. aus Alt-Importen).
delete next.password;
await browser.storage.local.set({ settings: next });
return next;
},

View File

@ -27,7 +27,11 @@
"<all_urls>"
],
"background": {
"scripts": ["background/background.js"]
"scripts": [
"lib/store.js",
"lib/auth.js",
"background/background.js"
]
},
"message_display_action": {
"default_title": "In DocuWare ablegen",

View File

@ -35,8 +35,9 @@
<input type="text" id="username" autocomplete="username" />
<label for="password">Passwort</label>
<input type="password" id="password" autocomplete="current-password" />
<div class="hint">⚠️ Wird unverschlüsselt lokal gespeichert.</div>
<input type="password" id="password" autocomplete="off" />
<div class="hint">Wird <strong>nicht gespeichert</strong> nur zum Anmelden/Testen für
diese Sitzung. Im Ablage-Fenster wird es 1× pro Sitzung abgefragt.</div>
</fieldset>
<fieldset>
@ -61,8 +62,9 @@
<fieldset>
<legend>Einstellungen sichern</legend>
<div class="hint">Exportiert/importiert alle Einstellungen (inkl. Login) als Datei
so musst du nach einer Neuinstallation nichts neu eintippen.</div>
<div class="hint">Exportiert/importiert die Einstellungen (Server, Organisation,
Benutzer, Standardwerte) als Datei. <strong>Das Passwort ist nicht enthalten</strong>
und muss nach einer Neuinstallation einmal neu eingegeben werden.</div>
<div class="row">
<button id="exportBtn" class="secondary">Exportieren</button>
<button id="importBtn" class="secondary">Importieren</button>

View File

@ -1,7 +1,9 @@
// Logik der Einstellungsseite.
const $ = (id) => document.getElementById(id);
const FIELDS = ["serverUrl", "organization", "username", "password"];
// Passwort ist bewusst NICHT dabei es wird nie gespeichert, nur zum Testen/Login
// transient verwendet (siehe "Verbindung testen").
const FIELDS = ["serverUrl", "organization", "username"];
const CHECKS = ["storeEml", "storePdf", "storeAttachments", "tagOnSuccess"];
function setStatus(msg, kind) {
@ -53,10 +55,17 @@ $("save").addEventListener("click", async () => {
});
$("test").addEventListener("click", async () => {
const pw = $("password").value;
if (!pw) { setStatus("Bitte Passwort eingeben.", "err"); return; }
setStatus("Verbinde …");
try {
const s = await Settings.set(collect()); // erst speichern, dann testen
await Auth.logon(s);
const s = await Settings.set(collect()); // erst speichern (ohne Passwort), dann testen
// Anmeldung über den Hintergrund-Broker: primt zugleich die Sitzung, sodass
// das Ablege-Fenster danach nicht erneut nach dem Passwort fragt.
const res = await browser.runtime.sendMessage({ type: "auth:logon", password: pw });
if (!res || !res.ok) throw new Error(res && res.error ? res.error : "Anmeldung fehlgeschlagen.");
Auth.setToken(res.token);
$("password").value = ""; // Passwort nicht behalten
const cabinets = await DocuWare.listCabinets(s);
await browser.storage.local.set({ cabinets });
fillCabinets(cabinets, s.defaultCabinetId);
@ -111,7 +120,18 @@ $("diagBtn").addEventListener("click", async () => {
out.value = "Analysiere …";
try {
const s = await Settings.set(collect());
await Auth.logon(s);
// Sitzungs-Token nutzen; falls noch keiner da ist, Passwortfeld verwenden.
const st = await browser.runtime.sendMessage({ type: "auth:status" });
if (st && st.hasToken && st.token) {
Auth.setToken(st.token);
} else {
const pw = $("password").value;
if (!pw) { out.value = "Bitte Passwort eingeben (oder zuerst „Verbindung testen“)."; return; }
const res = await browser.runtime.sendMessage({ type: "auth:logon", password: pw });
if (!res || !res.ok) throw new Error(res && res.error ? res.error : "Anmeldung fehlgeschlagen.");
Auth.setToken(res.token);
$("password").value = "";
}
const dump = await DocuWare.diagnoseStoreDialog(s, cabinetId);
out.value = JSON.stringify(dump, null, 2);
} catch (e) {

15
scripts/.env.example Normal file
View File

@ -0,0 +1,15 @@
# Vorlage für scripts/.env Kopieren nach scripts/.env und ausfüllen.
# scripts/.env ist gitignored und darf NIE committet werden.
# addons.thunderbird.net → Entwicklerbereich → "Manage API Keys"
ATN_API_KEY="user:000000:000"
ATN_API_SECRET="dein-atn-secret"
# Gitea → Settings → Applications → Generate New Token (Scope: write:repository)
GITEA_TOKEN="dein-gitea-token"
# Optional sonst aus dem git-Remote "origin" abgeleitet:
# GITEA_BASE="https://sylyx.xyz"
# GITEA_OWNER="sylyx"
# GITEA_REPO="thunderbird2docuware"
# REPO_DIR="/pfad/zum/repo" # nur nötig, wenn release.sh außerhalb des Repos liegt

149
scripts/release.sh Executable file
View File

@ -0,0 +1,149 @@
#!/usr/bin/env bash
#
# release.sh signiert die Extension bei addons.thunderbird.net (Kanal "unlisted",
# also signiert ABER nicht öffentlich gelistet), lädt das signierte XPI als
# Gitea-Release hoch und trägt es in updates.json fürs Auto-Update ein.
#
# Benötigt: bash, node, npx, curl, git. web-ext wird genutzt, wenn global
# installiert; sonst automatisch via "npx web-ext" (kein globales Install nötig).
#
# Secrets kommen aus der Umgebung bzw. aus scripts/.env (gitignored, NIE committen):
# ATN_API_KEY API-Schlüssel von addons.thunderbird.net (Entwicklerbereich)
# ATN_API_SECRET zugehöriges Secret
# GITEA_TOKEN Gitea Personal Access Token (Scope: write:repository)
# Optionale Overrides (sonst aus dem git-Remote "origin" abgeleitet):
# GITEA_BASE, GITEA_OWNER, GITEA_REPO, REPO_DIR
#
# Aufruf:
# scripts/release.sh # nutzt die Version aus manifest.json
# scripts/release.sh 0.9.0 # setzt zuerst diese Version in manifest.json
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# .env (falls vorhanden) laden liefert die Secrets, ohne sie ins Repo zu schreiben.
if [ -f "$SCRIPT_DIR/.env" ]; then
set -a; . "$SCRIPT_DIR/.env"; set +a
fi
# Projektwurzel: Standard = ein Ordner über diesem Skript. Bei abweichendem Ort
# REPO_DIR setzen. Es muss eine manifest.json enthalten.
REPO_DIR="${REPO_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}"
MANIFEST="$REPO_DIR/manifest.json"
UPDATES="$REPO_DIR/updates.json"
ARTIFACTS="$REPO_DIR/web-ext-artifacts"
die() { echo "FEHLER: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "'$1' nicht gefunden. Bitte installieren."; }
# --- Vorbedingungen -----------------------------------------------------------
need node; need curl; need git
# web-ext: global installiert bevorzugen, sonst per npx (kein globales Install nötig).
if command -v web-ext >/dev/null 2>&1; then
WEBEXT="web-ext"
else
need npx
WEBEXT="npx --yes web-ext"
fi
[ -f "$MANIFEST" ] || die "manifest.json nicht gefunden ($MANIFEST). Ggf. REPO_DIR setzen."
[ -f "$UPDATES" ] || die "updates.json nicht gefunden ($UPDATES)."
: "${ATN_API_KEY:?ATN_API_KEY fehlt (scripts/.env oder Umgebung)}"
: "${ATN_API_SECRET:?ATN_API_SECRET fehlt}"
: "${GITEA_TOKEN:?GITEA_TOKEN fehlt}"
# --- Version setzen / lesen ---------------------------------------------------
if [ "${1:-}" ]; then
node -e 'const fs=require("fs"),p=process.argv[1],v=process.argv[2];
const m=JSON.parse(fs.readFileSync(p,"utf8"));m.version=v;
fs.writeFileSync(p,JSON.stringify(m,null,2)+"\n");' "$MANIFEST" "$1"
echo "manifest.json → Version $1"
fi
VERSION="$(node -p "require('$MANIFEST').version")"
ADDON_ID="$(node -p "require('$MANIFEST').applications.gecko.id")"
[ -n "$VERSION" ] && [ -n "$ADDON_ID" ] || die "Version/Add-on-ID konnten nicht gelesen werden."
TAG="v${VERSION}"
XPI_NAME="docuware-ablage-${VERSION}.xpi"
# --- Gitea-Koordinaten aus dem Remote ableiten (oder per Env überschreiben) ---
REMOTE_URL="$(git -C "$REPO_DIR" remote get-url origin)"
case "$REMOTE_URL" in
git@*) host="${REMOTE_URL#git@}"; host="${host%%:*}"; path="${REMOTE_URL#*:}";;
ssh://*) rest="${REMOTE_URL#ssh://}"; rest="${rest#*@}"; host="${rest%%/*}"; path="${rest#*/}";;
http://*|https://*) rest="${REMOTE_URL#*://}"; host="${rest%%/*}"; path="${rest#*/}";;
*) die "Remote-URL-Form nicht erkannt: $REMOTE_URL";;
esac
path="${path%.git}"
GITEA_BASE="${GITEA_BASE:-https://$host}"
GITEA_OWNER="${GITEA_OWNER:-${path%%/*}}"
GITEA_REPO="${GITEA_REPO:-${path#*/}}"
API="$GITEA_BASE/api/v1/repos/$GITEA_OWNER/$GITEA_REPO"
BRANCH="$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD)"
UPDATE_LINK="$GITEA_BASE/$GITEA_OWNER/$GITEA_REPO/releases/download/$TAG/$XPI_NAME"
echo "Release $VERSION (Add-on $ADDON_ID)"
echo " Gitea : $GITEA_BASE/$GITEA_OWNER/$GITEA_REPO (Branch $BRANCH)"
echo " Asset : $UPDATE_LINK"
# --- 1) Signieren (zuerst schlägt hier fehl, bevor irgendwas gepusht wird) ---
echo "→ Signiere bei addons.thunderbird.net (unlisted) …"
rm -f "$ARTIFACTS"/*.xpi 2>/dev/null || true
$WEBEXT sign \
--channel=unlisted \
--api-url-prefix="https://addons.thunderbird.net/api/v4" \
--api-key="$ATN_API_KEY" \
--api-secret="$ATN_API_SECRET" \
--source-dir="$REPO_DIR" \
--artifacts-dir="$ARTIFACTS" \
--ignore-files "web-ext-artifacts/**" "scripts/**" "docs/**" "*.md" "*.xpi" \
".git/**" ".gitignore" "*.env" ".env"
SIGNED="$(ls -t "$ARTIFACTS"/*.xpi 2>/dev/null | head -1)"
[ -n "${SIGNED:-}" ] && [ -f "$SIGNED" ] || die "Kein signiertes XPI in $ARTIFACTS gefunden."
cp -f "$SIGNED" "$ARTIFACTS/$XPI_NAME"
echo "✓ Signiert: $ARTIFACTS/$XPI_NAME"
# --- 2) updates.json patchen --------------------------------------------------
node -e 'const fs=require("fs");
const [p,id,ver,link]=process.argv.slice(1);
const j=JSON.parse(fs.readFileSync(p,"utf8"));
j.addons=j.addons||{}; j.addons[id]=j.addons[id]||{updates:[]};
const u=j.addons[id].updates=j.addons[id].updates||[];
const e={version:ver,update_link:link}, i=u.findIndex(x=>x.version===ver);
if(i>=0)u[i]=e; else u.push(e);
fs.writeFileSync(p, JSON.stringify(j,null,2)+"\n");' \
"$UPDATES" "$ADDON_ID" "$VERSION" "$UPDATE_LINK"
echo "✓ updates.json aktualisiert"
# --- 3) Version + updates.json committen und pushen ---------------------------
git -C "$REPO_DIR" add manifest.json updates.json
if ! git -C "$REPO_DIR" diff --cached --quiet; then
git -C "$REPO_DIR" commit -m "Release $VERSION"
fi
git -C "$REPO_DIR" push origin "$BRANCH"
echo "✓ committed & gepusht"
# --- 4) Gitea-Release anlegen (oder vorhandenes nehmen) + XPI hochladen -------
echo "→ Lege Gitea-Release $TAG an …"
gitea() { curl -fsS -H "Authorization: token $GITEA_TOKEN" "$@"; }
REL_JSON="$(gitea -X POST "$API/releases" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"target_commitish\":\"$BRANCH\",\"name\":\"$TAG\",\"body\":\"DocuWare Ablage $VERSION\"}" \
2>/dev/null || true)"
if [ -z "$REL_JSON" ]; then
echo " (Release/Tag existiert evtl. schon hole vorhandenes)"
REL_JSON="$(gitea "$API/releases/tags/$TAG")"
fi
RELEASE_ID="$(printf '%s' "$REL_JSON" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id")"
[ -n "$RELEASE_ID" ] || die "Konnte Release-ID nicht ermitteln."
echo "→ Lade $XPI_NAME hoch …"
gitea -X POST "$API/releases/$RELEASE_ID/assets?name=$XPI_NAME" \
-F "attachment=@$ARTIFACTS/$XPI_NAME;type=application/x-xpinstall" >/dev/null \
|| die "Asset-Upload fehlgeschlagen (existiert der Asset-Name schon? Dann altes Asset löschen)."
echo
echo "✓ Release $VERSION fertig."
echo " Download : $UPDATE_LINK"
echo " Auto-Update zieht beim nächsten Thunderbird-Check."