feat: manual update via tarball + signature upload (#197)
This commit was merged in pull request #197.
This commit is contained in:
+1
-1
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user