feat: manual update via tarball + signature upload (#197)

This commit was merged in pull request #197.
This commit is contained in:
2026-04-17 21:37:53 +02:00
parent d960361ba0
commit 62f7053e5b
8 changed files with 367 additions and 41 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<name>Mitgliederverwaltung</name>
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
<version>0.2.9</version>
<version>0.2.10</version>
<licence>agpl</licence>
<author>shahondin1624</author>
<namespace>Mitgliederverwaltung</namespace>
+1
View File
@@ -171,6 +171,7 @@ return [
// ── Self-update (admin-only) ───────────────────────────────
['name' => 'backup#checkUpdate', 'url' => '/api/v1/update/check', 'verb' => 'GET'],
['name' => 'backup#installUpdate', 'url' => '/api/v1/update/install', 'verb' => 'POST'],
['name' => 'backup#installUpdateManual', 'url' => '/api/v1/update/install-manual', 'verb' => 'POST'],
// ── Calendar sync ──────────────────────────────────────────────
['name' => 'calendar#status', 'url' => '/api/v1/calendar/status', 'verb' => 'GET'],
+79
View File
@@ -215,4 +215,83 @@ class BackupController extends ApiController {
);
}
}
/**
* Install a manually-uploaded release archive.
*
* POST /api/v1/update/install-manual (multipart/form-data)
* - archive: <file> (the .tar.gz)
* - signature: <file> (the matching .tar.gz.sig, 64 bytes Ed25519)
*
* The signature is verified against the hardcoded public key before
* extraction. Uploads exceeding {@see MANUAL_UPLOAD_MAX_BYTES} are
* rejected outright.
*/
public function installUpdateManual(): JSONResponse {
try {
$archive = $this->request->getUploadedFile('archive');
$signature = $this->request->getUploadedFile('signature');
if ($archive === null || !is_array($archive) || ($archive['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return new JSONResponse(
['error' => 'Feld "archive" (.tar.gz) fehlt oder Upload fehlgeschlagen.'],
Http::STATUS_BAD_REQUEST
);
}
if ($signature === null || !is_array($signature) || ($signature['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return new JSONResponse(
['error' => 'Feld "signature" (.tar.gz.sig) fehlt oder Upload fehlgeschlagen.'],
Http::STATUS_BAD_REQUEST
);
}
if (($archive['size'] ?? 0) > self::MANUAL_UPLOAD_MAX_BYTES) {
return new JSONResponse(
['error' => 'Archiv zu gross (Maximum: ' . self::MANUAL_UPLOAD_MAX_BYTES . ' Bytes).'],
Http::STATUS_PAYLOAD_TOO_LARGE
);
}
if (($signature['size'] ?? 0) > 1024) {
return new JSONResponse(
['error' => 'Signaturdatei zu gross — erwartet ~64 Bytes.'],
Http::STATUS_BAD_REQUEST
);
}
$archivePath = $archive['tmp_name'] ?? '';
$signaturePath = $signature['tmp_name'] ?? '';
if (!is_string($archivePath) || !is_uploaded_file($archivePath)
|| !is_string($signaturePath) || !is_uploaded_file($signaturePath)
) {
return new JSONResponse(
['error' => 'Upload-Pfade ungueltig.'],
Http::STATUS_BAD_REQUEST
);
}
$result = $this->updateService->performManualUpdate($archivePath, $signaturePath);
return new JSONResponse($result);
} catch (\RuntimeException $e) {
$this->logger->warning('Manuelles Update abgelehnt', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\Exception $e) {
$this->logger->error('Manuelles Update fehlgeschlagen', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Manuelles Update fehlgeschlagen: ' . $e->getMessage()],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/** 25 MiB cap on manual archive uploads — plenty for this app. */
private const MANUAL_UPLOAD_MAX_BYTES = 25 * 1024 * 1024;
}
+143 -14
View File
@@ -155,28 +155,104 @@ class SelfUpdateService {
try {
$tmpSig = $this->downloadAsset($assets['sigUrl'], SODIUM_CRYPTO_SIGN_BYTES);
return $this->applyVerifiedUpdate($tmpTarball, $tmpSig, $currentVersion, $latestVersion);
} finally {
if (file_exists($tmpTarball)) {
unlink($tmpTarball);
}
if ($tmpSig !== null && file_exists($tmpSig)) {
unlink($tmpSig);
}
}
}
// Verify signature BEFORE extraction
$this->verifySignature($tmpTarball, $tmpSig);
/**
* Install a manually-uploaded release archive.
*
* Security model identical to {@see performUpdate}: the Ed25519
* signature must verify against the hardcoded public key before
* extraction. The only difference is that the bytes come from an
* authenticated admin's upload instead of a Gitea fetch — so the
* same trust boundary applies (possession of the private key).
*
* @param string $uploadedTarballPath Temp path of the uploaded .tar.gz
* @param string $uploadedSigPath Temp path of the uploaded .sig
* @return array{success: bool, oldVersion: string, newVersion: string, message: string}
* @throws \RuntimeException on invalid signature / malformed archive
*/
public function performManualUpdate(string $uploadedTarballPath, string $uploadedSigPath): array {
$currentVersion = $this->getCurrentVersion();
// Copy uploads into our own tempnam paths — keeps file-permissions
// predictable and makes the finally cleanup below unambiguous.
$tmpTarball = $this->moveUploadToTempWithExtension($uploadedTarballPath, '.tar.gz');
$tmpSig = null;
try {
$tmpSig = $this->moveUploadToTempWithExtension($uploadedSigPath, '.tar.gz.sig');
if (filesize($tmpSig) !== SODIUM_CRYPTO_SIGN_BYTES) {
throw new \RuntimeException(
'Ungueltige Signaturdatei (erwartet: ' . SODIUM_CRYPTO_SIGN_BYTES
. ' Bytes, erhalten: ' . filesize($tmpSig) . ').'
);
}
if (filesize($tmpTarball) < 1024) {
throw new \RuntimeException('Archiv zu klein — vermutlich kein gueltiges Update-Paket.');
}
if (!$this->looksLikeGzip($tmpTarball)) {
throw new \RuntimeException('Archiv ist keine gueltige .tar.gz Datei.');
}
// Peek at the bundled appinfo/info.xml to discover the target version.
// This is a best-effort read — the signature check below is authoritative.
$declaredVersion = $this->peekVersionFromArchive($tmpTarball) ?? 'unbekannt';
return $this->applyVerifiedUpdate($tmpTarball, $tmpSig, $currentVersion, $declaredVersion);
} finally {
if (file_exists($tmpTarball)) {
unlink($tmpTarball);
}
if ($tmpSig !== null && file_exists($tmpSig)) {
unlink($tmpSig);
}
}
}
/**
* Verify the signature against the embedded public key, then extract
* the archive over the installed app and run occ upgrade.
*
* This is the shared critical section for both the Gitea-fetch path
* and the manual-upload path. Verification MUST complete successfully
* before extraction starts.
*
* @return array{success: bool, oldVersion: string, newVersion: string, message: string}
*/
private function applyVerifiedUpdate(
string $tarballPath,
string $signaturePath,
string $currentVersion,
string $newVersion
): array {
$this->verifySignature($tarballPath, $signaturePath);
$this->logger->info('Release signature verified', [
'version' => $latestVersion,
'version' => $newVersion,
'app' => self::APP_ID,
]);
// Extract over the installed app
$this->extractUpdate($tmpTarball);
$this->extractUpdate($tarballPath);
$this->logger->info('App updated successfully', [
'from' => $currentVersion,
'to' => $latestVersion,
'to' => $newVersion,
'app' => self::APP_ID,
]);
// Run occ upgrade automatically
$occResult = $this->runOccUpgrade();
$message = "Update von v{$currentVersion} auf v{$latestVersion} erfolgreich (Signatur geprueft).";
$message = "Update von v{$currentVersion} auf v{$newVersion} erfolgreich (Signatur geprueft).";
if ($occResult['success']) {
$message .= ' occ upgrade wurde automatisch ausgefuehrt.';
} else {
@@ -186,19 +262,72 @@ class SelfUpdateService {
return [
'success' => true,
'oldVersion' => $currentVersion,
'newVersion' => $latestVersion,
'newVersion' => $newVersion,
'message' => $message,
'occUpgrade' => $occResult,
];
} finally {
if (file_exists($tmpTarball)) {
unlink($tmpTarball);
}
if ($tmpSig !== null && file_exists($tmpSig)) {
unlink($tmpSig);
/**
* Move an uploaded file into a uniquely-named temp file carrying the
* expected extension (PharData requires recognizable extensions).
*/
private function moveUploadToTempWithExtension(string $source, string $extension): string {
$tmp = tempnam(sys_get_temp_dir(), 'mv_update_');
if ($tmp === false) {
throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.');
}
$target = $tmp . $extension;
rename($tmp, $target);
if (!copy($source, $target)) {
@unlink($target);
throw new \RuntimeException('Hochgeladene Datei konnte nicht gelesen werden.');
}
chmod($target, 0600);
return $target;
}
/**
* Sanity-check the leading two bytes for the gzip magic (0x1f 0x8b).
*/
private function looksLikeGzip(string $path): bool {
$fh = fopen($path, 'rb');
if ($fh === false) {
return false;
}
$magic = fread($fh, 2);
fclose($fh);
return $magic === "\x1f\x8b";
}
/**
* Best-effort: open the archive read-only and pull the <version> out
* of appinfo/info.xml so the UI can show "Update auf vX.Y.Z" before
* committing. Does NOT touch the filesystem outside a temp read.
*/
private function peekVersionFromArchive(string $tarballPath): ?string {
try {
$phar = new \PharData($tarballPath, \FilesystemIterator::SKIP_DOTS);
foreach (new \RecursiveIteratorIterator($phar) as $file) {
$sub = $file->getSubPathname();
if ($sub === self::APP_ID . '/appinfo/info.xml'
|| $sub === 'appinfo/info.xml'
) {
$xml = file_get_contents($file->getPathname());
if ($xml !== false && preg_match('#<version>([^<]+)</version>#', $xml, $m)) {
return trim($m[1]);
}
}
}
} catch (\Throwable $e) {
// Don't block the update on a peek failure — verification is authoritative.
$this->logger->debug('peekVersionFromArchive failed', [
'exception' => $e->getMessage(),
'app' => self::APP_ID,
]);
}
return null;
}
/**
* Fetch the latest non-draft, non-prerelease from Gitea API.
+1 -1
View File
@@ -31,7 +31,7 @@ app.use(router)
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
// not via webpack DefinePlugin globals.
app.provide('appName', 'mitgliederverwaltung')
app.provide('appVersion', '0.2.9')
app.provide('appVersion', '0.2.10')
app.mount('#mitgliederverwaltung')
+28
View File
@@ -142,6 +142,34 @@ export const useBackupStore = defineStore('backup', {
}
},
async installManualUpdate(archiveFile, signatureFile) {
this.updating = true
this.error = null
this.success = null
try {
const form = new FormData()
form.append('archive', archiveFile)
form.append('signature', signatureFile)
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/update/install-manual')
const response = await axios.post(url, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
if (response.data.success) {
this.success = response.data.message
await this.checkForUpdate()
} else {
this.error = response.data.message || 'Manuelles Update fehlgeschlagen'
}
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Manuelles Update fehlgeschlagen'
throw err
} finally {
this.updating = false
}
},
clearError() {
this.error = null
},
+90 -1
View File
@@ -60,6 +60,50 @@
<NcLoadingIcon v-if="store.updating" :size="20" />
<template v-else>Update installieren</template>
</NcButton>
<NcButton :disabled="store.updating"
@click="showManualUpdate = !showManualUpdate">
{{ showManualUpdate ? 'Manuelles Update schließen' : 'Manuelles Update' }}
</NcButton>
</div>
<!-- Manual update panel (upload .tar.gz + .sig) -->
<div v-if="showManualUpdate" class="backup-page__manual-update">
<h4>Manuelles Update</h4>
<p class="backup-page__manual-update-hint">
Wähle das Release-Archiv (<code>.tar.gz</code>) und die dazugehörige
Signatur (<code>.tar.gz.sig</code>). Die Signatur wird <strong>vor</strong>
der Installation gegen den eingebetteten öffentlichen Schlüssel geprüft
nur gültig signierte Pakete werden installiert.
</p>
<div class="backup-page__manual-update-fields">
<label>
<span>Archiv (.tar.gz)</span>
<input ref="archiveInput"
type="file"
accept=".tar.gz,application/gzip,application/x-gzip"
:disabled="store.updating"
@change="onArchivePick">
</label>
<label>
<span>Signatur (.tar.gz.sig)</span>
<input ref="signatureInput"
type="file"
accept=".sig,application/octet-stream"
:disabled="store.updating"
@change="onSignaturePick">
</label>
</div>
<div class="backup-page__manual-update-actions">
<NcButton type="primary"
:disabled="store.updating || !manualArchive || !manualSignature"
@click="confirmManualUpdate">
<NcLoadingIcon v-if="store.updating" :size="20" />
<template v-else>Manuell installieren</template>
</NcButton>
<NcButton :disabled="store.updating" @click="resetManualUpdate">
Auswahl zurücksetzen
</NcButton>
</div>
</div>
</div>
</div>
@@ -210,6 +254,11 @@ const store = useBackupStore()
const createPassword = ref('')
const restoreTarget = ref(null)
const restorePassword = ref('')
const showManualUpdate = ref(false)
const manualArchive = ref(null)
const manualSignature = ref(null)
const archiveInput = ref(null)
const signatureInput = ref(null)
onMounted(() => {
store.fetchBackups()
@@ -242,6 +291,37 @@ async function confirmUpdate() {
}
}
function onArchivePick(event) {
manualArchive.value = event.target.files?.[0] || null
}
function onSignaturePick(event) {
manualSignature.value = event.target.files?.[0] || null
}
function resetManualUpdate() {
manualArchive.value = null
manualSignature.value = null
if (archiveInput.value) archiveInput.value.value = ''
if (signatureInput.value) signatureInput.value.value = ''
}
async function confirmManualUpdate() {
if (!manualArchive.value || !manualSignature.value) return
const msg = `Manuelles Update mit "${manualArchive.value.name}" installieren? Die Signatur wird vor der Installation geprüft. Die Seite wird danach neu geladen.`
if (!confirm(msg)) return
try {
const result = await store.installManualUpdate(manualArchive.value, manualSignature.value)
if (result?.success) {
resetManualUpdate()
showManualUpdate.value = false
window.location.reload()
}
} catch {
// error shown via store.error
}
}
function confirmDelete(backup) {
if (confirm(`Backup "${backup.filename}" wirklich loeschen?`)) {
store.deleteBackup(backup.filename)
@@ -310,7 +390,16 @@ function formatTrigger(trigger) {
.backup-page__update-error p { margin: 4px 0 8px; }
.backup-page__update-error summary { cursor: pointer; color: var(--color-text-lighter); font-size: 0.85em; }
.backup-page__update-error-detail { white-space: pre-wrap; word-break: break-word; font-size: 0.8em; margin: 4px 0; padding: 6px 8px; background: var(--color-background-dark); border-radius: var(--border-radius); }
.backup-page__update-actions { display: flex; gap: 8px; }
.backup-page__update-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.backup-page__manual-update { margin-top: 16px; padding: 14px 16px; border: 1px dashed var(--color-border); border-radius: var(--border-radius); background: var(--color-background-dark); }
.backup-page__manual-update h4 { margin: 0 0 6px; }
.backup-page__manual-update-hint { font-size: 0.85em; color: var(--color-text-lighter); margin: 0 0 12px; }
.backup-page__manual-update-hint code { font-family: monospace; background: var(--color-background-darker, var(--color-background-dark)); padding: 1px 4px; border-radius: 3px; }
.backup-page__manual-update-fields { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
.backup-page__manual-update-fields label { display: flex; flex-direction: column; font-size: 0.9em; gap: 4px; }
.backup-page__manual-update-fields label span { color: var(--color-text-lighter); }
.backup-page__manual-update-fields input[type="file"] { font-size: 0.85em; }
.backup-page__manual-update-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.backup-page__create { display: flex; gap: 16px; align-items: flex-end; }
.backup-page__loading { display: flex; justify-content: center; margin-top: 40px; }
+1 -1
View File
@@ -41,7 +41,7 @@ module.exports = {
new VueLoaderPlugin(),
new webpack.DefinePlugin({
appName: JSON.stringify('mitgliederverwaltung'),
appVersion: JSON.stringify('0.2.9'),
appVersion: JSON.stringify('0.2.10'),
}),
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,