Merge dev into main (v0.22.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s

This commit is contained in:
sandrodach committed 2026-10-31 11:54:19 +01:00
commit da4fd5d826
14 files changed
+345 -31

No files matched your search

+11 -1
View File
@@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.22.0] - 2026-10-31
### Added
- Kanban am Handy und Tablet: Karten lassen sich mit dem Finger verschieben – kurz gedrückt halten, bis sie sich anhebt, dann auf eine andere Karte oder Spalte ziehen und loslassen. Am Rand scrollt die Seite mit (am Handy stehen die Spalten untereinander). Ein kurzer Tipp öffnet die Aufgabe wie bisher, Wischen scrollt.
- Admin-Einstellungen: Datenbank-Backups lassen sich neben „Download“ auch löschen (mit Rückfrage). Nur für Admins; Benutzer-Backups laufen weiter über die Aufbewahrungsdauer ab. Neu dafür ist `DELETE /api/v1/admin/backups/{name}`.
### Changed
- Meldung nach dem Teilen: größer und mit dem Daumen gut erreichbar – Text oben, darunter zwei große Knöpfe **Liste** und **Aufgabe** wie in der Triage, ein kleines ✕ in der Ecke; ein Tipp daneben schließt sie.
## [0.21.0] - 2026-10-31
### Added
@@ -450,7 +459,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...dev
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...dev
[0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0
[0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0
[0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0
[0.19.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.1...v0.19.0
+1
View File
@@ -221,6 +221,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| GET | `/admin/voice` | Aktive Spracherkennung (`mode`: `server`/`browser`/`off`), Einstellungen aus `STT_*` ohne Schlüssel, Grenzen und im Server-Modus ein frischer Zustand (`status.reachable`, `status.modelReady`) |
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
| DELETE | `/admin/backups/{name}` | Datenbank-Abzug löschen (gibt die verbleibende Liste zurück; nicht in der Demo) |
## Beispiele
+1 -1
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.21.0-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.22.0-16a34a?style=flat-square)](CHANGELOG.md)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.21.0",
"version": "0.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.21.0",
"version": "0.22.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.21.0",
"version": "0.22.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+39 -23
View File
@@ -49,52 +49,68 @@ export const SharedImportToast: React.FC<SharedImportToastProps> = ({ thoughtIds
else setChoosingList(true);
};
const chip =
'h-9 px-3 inline-flex items-center gap-1.5 rounded-btn border border-border bg-canvas text-main text-xs font-medium active:bg-panel-elevated disabled:opacity-50 touch-manipulation';
const big =
'min-h-[52px] px-3 py-2 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98 disabled:opacity-50';
return (
<>
{/* Tapping anywhere else closes it (transparent, the page stays visible) */}
<div className="fixed inset-0 z-[59]" onClick={onClose} aria-hidden />
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] w-[calc(100vw-1.5rem)] max-w-sm panel-surface shadow-panel overflow-hidden p-3 space-y-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] w-[calc(100vw-1.5rem)] max-w-md panel-surface shadow-panel overflow-hidden p-4 space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-200"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute top-2 right-2 h-8 w-8 flex items-center justify-center rounded-btn text-faint hover:text-main"
>
<div className="flex items-center gap-2 text-sm text-main">
<Inbox className="w-4 h-4 text-accent shrink-0" />
<span className="flex-1 font-medium">
{count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`}
</span>
<button type="button" onClick={onClose} aria-label="Schließen" className="h-7 w-7 -mr-1 flex items-center justify-center text-faint">
<X className="w-4 h-4" />
</button>
<div className="pr-8">
<p className="flex items-center gap-2 text-base font-semibold text-main">
<Inbox className="w-5 h-5 text-accent shrink-0" />
{count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`}
</p>
<p className="text-xs text-muted mt-1">
{choosingList ? 'Auf welche Liste?' : 'Oder gleich woanders hin:'}
</p>
</div>
{failed && <p className="text-xs text-status-err">Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.</p>}
{!choosingList ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Stattdessen</span>
<button type="button" className={chip} disabled={busy} onClick={() => convert('task')}>
<ArrowRight className="w-3.5 h-3.5 text-faint" />
<CheckSquare className="w-3.5 h-3.5 text-accent" />
Aufgabe
</button>
<div className={`grid gap-2 ${writableLists.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{writableLists.length > 0 && (
<button type="button" className={chip} disabled={busy} onClick={toList}>
<ArrowRight className="w-3.5 h-3.5 text-faint" />
<ShoppingCart className="w-3.5 h-3.5 text-accent" />
Liste
<button type="button" disabled={busy} onClick={toList} className={`${big} bg-canvas border border-border text-main`}>
<ShoppingCart className="w-4 h-4 text-accent" />
<span>Liste</span>
</button>
)}
<button type="button" disabled={busy} onClick={() => convert('task')} className={`${big} bg-accent text-on-accent`}>
<CheckSquare className="w-4 h-4" />
<span>Aufgabe</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<div className="grid grid-cols-2 gap-2">
{writableLists.map((l) => (
<button key={l.id} type="button" className={chip} disabled={busy} onClick={() => convert('list_item', l)}>
<button
key={l.id}
type="button"
disabled={busy}
onClick={() => convert('list_item', l)}
className={`${big} bg-canvas border border-border text-main`}
>
<span>{l.icon}</span>
{l.title}
<span className="truncate">{l.title}</span>
</button>
))}
</div>
)}
</div>
</>
);
};
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Archive, Download, Loader2 } from 'lucide-react';
import { Archive, Download, Loader2, Trash2 } from 'lucide-react';
import { BackupFile, SystemSettings } from '../../lib/types';
import { api } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
@@ -41,6 +41,16 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
}
};
const remove = async (name: string) => {
if (!window.confirm(`Backup ${name} endgültig löschen?`)) return;
setError(null);
try {
setFiles(await api.adminDeleteBackup(name));
} catch (err: any) {
setError(err.message || 'Löschen fehlgeschlagen');
}
};
return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<div className="flex items-center justify-between gap-4">
@@ -109,14 +119,25 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={() => download(f.name)}
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0"
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
>
<Download className="w-3.5 h-3.5" />
Download
</button>
<button
type="button"
onClick={() => remove(f.name)}
title="Backup löschen"
aria-label={`Backup ${f.name} löschen`}
className="btn-action h-11 w-11 sm:h-7 sm:w-7 inline-flex items-center justify-center text-faint hover:text-status-err"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
+36 -1
View File
@@ -31,6 +31,7 @@ import {
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
@@ -807,7 +808,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
await moveTaskNextTo(sourceId, targetTask, position, newKanbanCol);
};
// Shared by mouse drag & drop and the touch drag on the Kanban board
const moveTaskNextTo = async (sourceId: string | null, targetTask: Thought, position: 'before' | 'after', newKanbanCol?: string) => {
if (!sourceId || sourceId === targetTask.id) return;
triggerHaptic(15);
@@ -864,7 +869,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
await moveTaskToColumn(sourceId, colId);
};
const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
if (!sourceId) return;
triggerHaptic(15);
@@ -905,6 +913,28 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
};
// Kanban with a finger: hold a card, drag it, let go (mouse and pen use HTML5 drag & drop)
const clearDragState = () => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
};
const kanbanTouch = useKanbanTouchDrag({
enabled: !canDragTasks && viewMode === 'kanban',
onStart: (taskId) => setDraggingTaskId(taskId),
onHover: (colId, target) => {
setDragOverKanbanCol(colId);
setDragOverTaskTarget(target);
},
onDrop: (taskId, colId, target) => {
clearDragState();
const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined;
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, colId ?? undefined);
else if (colId) void moveTaskToColumn(taskId, colId);
},
onCancel: clearDragState,
});
const handleCreateTaskList = async (e: React.FormEvent) => {
e.preventDefault();
const title = newListTitle.trim();
@@ -2224,6 +2254,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
{viewMode === 'kanban' ? (
/* DYNAMIC KANBAN BOARD */
<div className="space-y-2">
{kanbanTouch.ghost}
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
@@ -2251,6 +2282,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
}}
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
data-kanban-col={col.id}
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
}`}
@@ -2295,11 +2327,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
onDragOver={(e) => handleTaskDragOver(e, task.id)}
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
onDrop={(e) => handleDropOnTask(e, task, col.id)}
data-kanban-task={task.id}
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
onClick={() => {
if (kanbanTouch.consumeClick()) return;
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative select-none ${
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
} ${
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
@@ -0,0 +1,169 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// Drag & drop on the Kanban board with a finger (browsers offer HTML5 drag & drop only for mouse and
// pen): hold a card for a moment, it lifts, drag it onto another card or column, let go. A short tap
// still opens the task and a swipe still scrolls, because the drag starts only after the hold.
// Cards carry data-kanban-task="<id>", columns data-kanban-col="<id>".
const HOLD_MS = 350;
const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling
const EDGE = 90; // px near the top/bottom edge that scroll the page while dragging
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
interface Options {
enabled: boolean;
onStart: (taskId: string) => void;
onHover: (colId: string | null, target: KanbanDropTarget) => void;
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
onCancel: () => void;
}
interface Drag {
taskId: string;
label: string;
x: number;
y: number;
offsetX: number;
offsetY: number;
width: number;
}
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) {
const [drag, setDrag] = useState<Drag | null>(null);
const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null);
const dragRef = useRef<Drag | null>(null);
const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null });
const suppressClickRef = useRef(false);
const callbacks = useRef({ onStart, onHover, onDrop, onCancel });
callbacks.current = { onStart, onHover, onDrop, onCancel };
const cancelHold = () => {
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
holdRef.current = null;
};
const hoverAt = (x: number, y: number) => {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
const card = el?.closest('[data-kanban-task]') as HTMLElement | null;
const col = (el?.closest('[data-kanban-col]') as HTMLElement | null)?.dataset.kanbanCol ?? null;
let target: KanbanDropTarget = null;
const cardId = card?.dataset.kanbanTask;
if (card && cardId && cardId !== dragRef.current?.taskId) {
const r = card.getBoundingClientRect();
target = { taskId: cardId, position: y - r.top < r.height / 2 ? 'before' : 'after' };
}
const prev = hoverRef.current;
if (prev.col !== col || prev.target?.taskId !== target?.taskId || prev.target?.position !== target?.position) {
hoverRef.current = { col, target };
callbacks.current.onHover(col, target);
}
};
// While dragging: the page must not scroll under the finger (non-passive listener), it scrolls
// only near the edges
useEffect(() => {
if (!drag) return;
let frame = 0;
const scroll = () => {
const d = dragRef.current;
if (d) {
if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
hoverAt(d.x, d.y);
}
frame = requestAnimationFrame(scroll);
};
frame = requestAnimationFrame(scroll);
const move = (e: TouchEvent) => {
e.preventDefault();
const t = e.touches[0];
if (!t || !dragRef.current) return;
dragRef.current = { ...dragRef.current, x: t.clientX, y: t.clientY };
setDrag(dragRef.current);
};
const end = (e: TouchEvent) => {
const d = dragRef.current;
dragRef.current = null;
setDrag(null);
suppressClickRef.current = true;
window.setTimeout(() => (suppressClickRef.current = false), 400);
if (!d) return;
if (e.type === 'touchcancel') callbacks.current.onCancel();
else callbacks.current.onDrop(d.taskId, hoverRef.current.col, hoverRef.current.target);
hoverRef.current = { col: null, target: null };
};
document.addEventListener('touchmove', move, { passive: false });
document.addEventListener('touchend', end);
document.addEventListener('touchcancel', end);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('touchmove', move);
document.removeEventListener('touchend', end);
document.removeEventListener('touchcancel', end);
};
// Listeners live as long as one drag; positions are read from refs
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Boolean(drag)]);
/** Props for a card; label is what the lifted card shows */
const cardProps = useCallback(
(taskId: string, label: string, draggable: boolean) => {
if (!enabled || !draggable) return {};
return {
onTouchStart: (e: React.TouchEvent<HTMLElement>) => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
const el = e.currentTarget;
cancelHold();
holdRef.current = {
x: t.clientX,
y: t.clientY,
timer: window.setTimeout(() => {
const hold = holdRef.current;
holdRef.current = null;
if (!hold) return;
const r = el.getBoundingClientRect();
navigator.vibrate?.(15);
const d: Drag = { taskId, label, x: hold.x, y: hold.y, offsetX: hold.x - r.left, offsetY: hold.y - r.top, width: r.width };
dragRef.current = d;
hoverRef.current = { col: null, target: null };
setDrag(d);
callbacks.current.onStart(taskId);
}, HOLD_MS),
};
},
onTouchMove: (e: React.TouchEvent<HTMLElement>) => {
const h = holdRef.current;
const t = e.touches[0];
if (h && t && Math.hypot(t.clientX - h.x, t.clientY - h.y) > MOVE_TOLERANCE) cancelHold(); // scrolling
},
onTouchEnd: cancelHold,
onTouchCancel: cancelHold,
// The long press must not open the browser's context menu or select text
onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
style: { WebkitTouchCallout: 'none' } as React.CSSProperties,
};
},
[enabled]
);
/** A tap right after a drag must not open the task */
const consumeClick = () => suppressClickRef.current;
const ghost = drag
? createPortal(
<div
className="fixed z-[80] pointer-events-none panel-surface shadow-panel px-3 py-2.5 text-xs font-medium text-main rotate-1 opacity-95 border-accent"
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}
>
{drag.label}
</div>,
document.body
)
: null;
return { cardProps, ghost, consumeClick, dragging: Boolean(drag) };
}
+4
View File
@@ -1305,6 +1305,10 @@ export const api = {
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
},
async adminDeleteBackup(name: string): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>(`/api/v1/admin/backups/${encodeURIComponent(name)}`, { method: 'DELETE' }));
},
async adminDownloadBackup(name: string): Promise<Blob> {
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
},
+10
View File
@@ -110,6 +110,16 @@ func (s *Server) handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
s.handleAdminListBackups(w, r)
}
// handleAdminDeleteBackup removes one database snapshot (admins only; users cannot delete their own
// backups, those expire with the retention period).
func (s *Server) handleAdminDeleteBackup(w http.ResponseWriter, r *http.Request) {
if err := storage.DeleteBackup(filepath.Join(s.backupRoot(), "system"), chi.URLParam(r, "name")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
s.handleAdminListBackups(w, r)
}
func (s *Server) handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
serveBackupFile(w, r, filepath.Join(s.backupRoot(), "system"), chi.URLParam(r, "name"), "application/gzip")
}
+38
View File
@@ -89,3 +89,41 @@ func mustTime(day string) (t time.Time) {
t, _ = time.Parse("2006-01-02", day)
return t
}
// Admins can delete database snapshots; members cannot, and only real backup names are accepted
func TestAdminDeletesBackups(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
call := func(method, path, tok string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+tok)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
var files []storage.BackupFile
_ = json.Unmarshal(call("POST", "/api/v1/admin/backups", token).Body.Bytes(), &files)
if len(files) != 1 {
t.Fatalf("expected one snapshot, got %+v", files)
}
name := files[0].Name
_, memberToken := newTestUser(t, store, "member@example.com")
if rec := call("DELETE", "/api/v1/admin/backups/"+name, memberToken); rec.Code != http.StatusForbidden {
t.Fatalf("members must not delete backups, got %d", rec.Code)
}
if rec := call("DELETE", "/api/v1/admin/backups/..%2F..%2Fmindsift.db", token); rec.Code != http.StatusNotFound {
t.Fatalf("path tricks must be refused, got %d", rec.Code)
}
rec := call("DELETE", "/api/v1/admin/backups/"+name, token)
if rec.Code != http.StatusOK {
t.Fatalf("delete: %d %s", rec.Code, rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &files)
if len(files) != 0 {
t.Fatalf("snapshot should be gone, got %+v", files)
}
if _, err := os.Stat(filepath.Join(srv.backupRoot(), "system", name)); !os.IsNotExist(err) {
t.Fatalf("file must be removed from disk")
}
}
+1
View File
@@ -303,6 +303,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/admin/backups", s.handleAdminListBackups)
r.Post("/api/v1/admin/backups", s.notInDemo(s.handleAdminRunBackup))
r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup)
r.Delete("/api/v1/admin/backups/{name}", s.notInDemo(s.handleAdminDeleteBackup))
r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings))
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
r.Get("/api/v1/admin/voice", s.handleAdminVoiceStatus)
+9
View File
@@ -54,6 +54,15 @@ func BackupPath(dir, name string) (string, error) {
return p, nil
}
// DeleteBackup removes one backup in dir; like BackupPath it only accepts our own file names.
func DeleteBackup(dir, name string) error {
p, err := BackupPath(dir, name)
if err != nil {
return err
}
return os.Remove(p)
}
// ListBackups lists the backups in dir, newest first.
func ListBackups(dir string) ([]BackupFile, error) {
entries, err := os.ReadDir(dir)