Merge branch 'feat/footer-share-ci' into dev
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s

CI keeps the 'test once per code state' plan but records commit and
git tree as image labels instead of extra :sha-/:tree- tags, so the
registry only holds dev, latest, vX.Y.Z and stable; the plan test now
asserts the expected decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 21:29:56 +01:00
commit 141f87f3a0
22 files changed
+735 -61

No files matched your search

+3
View File
@@ -93,6 +93,9 @@ LEGAL_PRIVACY_CONTENT=
# Pfad im Container, z. B. /legal/impressum.md
LEGAL_IMPRINT_CONTENT_FILE=
LEGAL_PRIVACY_CONTENT_FILE=
# Eigener Link im Footer (nur https://)
LEGAL_CUSTOM_LINK_LABEL=
LEGAL_CUSTOM_LINK_URL=
# ─── Bilder und Audio in Notizen ───────────────────────────────────────────────────────────────────
# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Decides per CI run: build with tests, build without tests, or only retag an existing image.
# Tests run once per code state: the images carry the git tree and commit as labels
# (io.mindsift.tree, org.opencontainers.image.revision), so no extra image tags are needed.
# dev -> build and test, tag :dev
# main -> build :latest; tests are skipped when :dev was built from the same tree
# v* -> retag :latest when it was built from this commit, else build (tests skipped when
# :dev or :latest has the same tree); :stable only for releases without "-"
# Inputs (environment): REF, COMMIT (default HEAD), IMAGE, PKG_VERSION (default from package.json),
# LABEL_CMD (prints a label of a remote image: LABEL_CMD <image> <label>; for tests)
set -euo pipefail
REF="${REF:-}"
COMMIT="${COMMIT:-HEAD}"
IMAGE="${IMAGE:-}"
PKG_VERSION="${PKG_VERSION:-}"
if [ -z "${PKG_VERSION}" ] && [ -f frontend/package.json ]; then
PKG_VERSION="$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)"
fi
SHA="$(git rev-parse "${COMMIT}^{commit}")"
TREE="$(git rev-parse "${COMMIT}^{tree}")"
# Label of an image in the registry ("" when the image or label does not exist)
image_label() {
if [ -n "${LABEL_CMD:-}" ]; then
${LABEL_CMD} "$1" "$2" 2>/dev/null || true
return
fi
docker pull -q "$1" >/dev/null 2>&1 || return 0
docker inspect -f "{{ index .Config.Labels \"$2\" }}" "$1" 2>/dev/null | sed 's/^<no value>$//' || true
}
ACTION="build"
SKIP_TESTS="0"
APP_VERSION=""
TAGS=""
SOURCE_IMAGE=""
REASON=""
case "${REF}" in
refs/heads/dev)
TAGS="dev"
REASON="dev: build with all tests"
;;
refs/heads/main)
TAGS="latest"
APP_VERSION="v${PKG_VERSION}"
if [ -n "${IMAGE}" ] && [ "$(image_label "${IMAGE}:dev" io.mindsift.tree)" = "${TREE}" ]; then
SKIP_TESTS="1"
REASON="main: same code as the tested :dev image, tests skipped"
else
REASON="main: code not tested as :dev yet, build with all tests"
fi
;;
refs/tags/v*)
TAG_NAME="${REF#refs/tags/}"
TAGS="${TAG_NAME}"
case "${TAG_NAME}" in *-*) ;; *) TAGS="${TAG_NAME} stable" ;; esac
if [ -n "${IMAGE}" ] && [ "$(image_label "${IMAGE}:latest" org.opencontainers.image.revision)" = "${SHA}" ]; then
ACTION="retag"
SOURCE_IMAGE="${IMAGE}:latest"
SKIP_TESTS="1"
REASON="tag ${TAG_NAME}: :latest was built from this commit, retag without building"
else
APP_VERSION="${TAG_NAME}"
if [ -n "${IMAGE}" ] && { [ "$(image_label "${IMAGE}:dev" io.mindsift.tree)" = "${TREE}" ] ||
[ "$(image_label "${IMAGE}:latest" io.mindsift.tree)" = "${TREE}" ]; }; then
SKIP_TESTS="1"
REASON="tag ${TAG_NAME}: code already tested, build without tests"
else
REASON="tag ${TAG_NAME}: build with all tests"
fi
fi
;;
*)
TAGS="$(echo "${REF#refs/heads/}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')"
REASON="branch ${REF}: build with all tests"
;;
esac
{
echo "action=${ACTION}"
echo "skip_tests=${SKIP_TESTS}"
echo "app_version=${APP_VERSION}"
echo "tags=${TAGS}"
echo "source_image=${SOURCE_IMAGE}"
echo "sha=${SHA}"
echo "tree=${TREE}"
echo "reason=${REASON}"
} | tee -a "${GITHUB_OUTPUT:-/dev/null}"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Checks build-plan.sh against a fake registry (labels given per case). Usage: .gitea/scripts/test-build-plan.sh
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
PLAN=.gitea/scripts/build-plan.sh
IMAGE=registry.example/mindsift
SHA="$(git rev-parse HEAD)"
TREE="$(git rev-parse HEAD^{tree})"
MOCK="$(mktemp)"
trap 'rm -f "$MOCK"' EXIT
# The fake registry answers from $DEV_TREE, $LATEST_TREE and $LATEST_SHA
cat >"$MOCK" <<'EOF'
#!/usr/bin/env bash
case "$1 $2" in
*:dev\ io.mindsift.tree) echo "${DEV_TREE:-}" ;;
*:latest\ io.mindsift.tree) echo "${LATEST_TREE:-}" ;;
*:latest\ org.opencontainers.image.revision) echo "${LATEST_SHA:-}" ;;
esac
EOF
chmod +x "$MOCK"
fails=0
expect() { # name, REF, expected "action skip_tests tags", env assignments…
local name="$1" ref="$2" want="$3"
shift 3
local out
out="$(env "$@" REF="$ref" IMAGE="$IMAGE" PKG_VERSION=0.10.0 LABEL_CMD="$MOCK" GITHUB_OUTPUT= "$PLAN")"
local got
got="$(echo "$out" | sed -n 's/^action=//p') $(echo "$out" | sed -n 's/^skip_tests=//p') $(echo "$out" | sed -n 's/^tags=//p')"
if [ "$got" = "$want" ]; then
echo "OK $name: $got"
else
echo "FEHL $name: erwartet '$want', bekommen '$got'"
fails=$((fails + 1))
fi
}
expect "dev baut mit Tests" refs/heads/dev "build 0 dev"
expect "main nach getestetem dev" refs/heads/main "build 1 latest" DEV_TREE="$TREE"
expect "main mit anderem Stand" refs/heads/main "build 0 latest" DEV_TREE=0000
expect "Tag auf latest-Commit" refs/tags/v0.10.0 "retag 1 v0.10.0 stable" LATEST_SHA="$SHA" LATEST_TREE="$TREE"
expect "Tag, latest fehlt, dev getestet" refs/tags/v0.10.0 "build 1 v0.10.0 stable" DEV_TREE="$TREE"
expect "Tag auf unbekanntem Stand" refs/tags/v0.10.0 "build 0 v0.10.0 stable"
expect "Vorabversion ohne stable" refs/tags/v0.11.0-rc.1 "retag 1 v0.11.0-rc.1" LATEST_SHA="$SHA"
expect "Feature-Branch" refs/heads/feat/x "build 0 feat-x"
if [ "$fails" -gt 0 ]; then
echo "$fails FEHLGESCHLAGEN"
exit 1
fi
echo "ALLE FÄLLE BESTANDEN"
+32 -40
View File
@@ -5,6 +5,9 @@ name: MindSift CI & Docker Release
# push/merge main -> :latest
# git tag v1.2.0 -> :v1.2.0 and :stable
# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays
# Tests run once per code state (.gitea/scripts/build-plan.sh): main skips them when :dev was built
# from the same code, a tag on the commit of :latest only retags it. The images carry commit and git
# tree as labels for this, so no extra tags end up in the registry.
on:
push:
branches:
@@ -21,45 +24,16 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
- name: Determine Registry & Docker Tag
- name: Determine Registry & Image
id: prep
run: |
SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}"
REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##')
if [ -z "${REGISTRY_HOST}" ]; then
REGISTRY_HOST="git.dsit.me"
fi
[ -z "${REGISTRY_HOST}" ] && REGISTRY_HOST="git.dsit.me"
REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}"
IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]')
REF_NAME="${GITHUB_REF_NAME:-latest}"
ACTOR_NAME="${GITHUB_ACTOR:-sandrodach}"
# Ersetze evtl. Slashes in Branch-Namen (z.B. rc/1.1 -> rc-1.1) für valide Docker-Tags
DOCKER_TAG=$(echo "${REF_NAME}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')
# All tags of this run (space separated)
case "${GITHUB_REF:-}" in
refs/tags/*-*) TAGS="${DOCKER_TAG}" ;;
refs/tags/*) TAGS="${DOCKER_TAG} stable" ;;
refs/heads/main) TAGS="latest" ;;
*) TAGS="${DOCKER_TAG}" ;;
esac
echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT"
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
# Version in the app header: tags show the tag, main the package version, everything else
# (dev, feature branches) keeps the "-dev" suffix from vite.config.ts
APP_VERSION=""
case "${GITHUB_REF:-}" in
refs/tags/*) APP_VERSION="${REF_NAME}" ;;
refs/heads/main) APP_VERSION="v$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)" ;;
esac
echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT"
echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
echo "image=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
echo "actor=${GITHUB_ACTOR:-sandrodach}" >> "$GITHUB_OUTPUT"
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
@@ -68,12 +42,30 @@ jobs:
username: ${{ steps.prep.outputs.actor }}
password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }}
- name: Build & Run All Checks in Container, then Push
- name: Build Plan
id: plan
run: REF="${GITHUB_REF:-}" COMMIT="${GITHUB_SHA:-HEAD}" IMAGE="${{ steps.prep.outputs.image }}" .gitea/scripts/build-plan.sh
- name: Build (with or without tests) or Retag, then Push
run: |
IMAGE="${{ steps.prep.outputs.image }}"
TAG_ARGS=""
for t in ${{ steps.prep.outputs.tags }}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done
echo "🚀 Building container & executing Frontend/Backend checks inside image: ${IMAGE} (tags: ${{ steps.prep.outputs.tags }})"
DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" ${TAG_ARGS} .
echo "✅ All checks & tests passed inside container! Pushing..."
for t in ${{ steps.prep.outputs.tags }}; do docker push "${IMAGE}:${t}"; done
TAGS="${{ steps.plan.outputs.tags }}"
echo "ℹ️ ${{ steps.plan.outputs.reason }}"
if [ "${{ steps.plan.outputs.action }}" = "retag" ]; then
SOURCE="${{ steps.plan.outputs.source_image }}"
docker pull "${SOURCE}"
for t in ${TAGS}; do
docker tag "${SOURCE}" "${IMAGE}:${t}"
docker push "${IMAGE}:${t}"
done
else
TAG_ARGS=""
for t in ${TAGS}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done
DOCKER_BUILDKIT=1 docker build --progress=plain \
--build-arg APP_VERSION="${{ steps.plan.outputs.app_version }}" \
--build-arg SKIP_TESTS="${{ steps.plan.outputs.skip_tests }}" \
--build-arg GIT_SHA="${{ steps.plan.outputs.sha }}" \
--build-arg GIT_TREE="${{ steps.plan.outputs.tree }}" \
${TAG_ARGS} .
for t in ${TAGS}; do docker push "${IMAGE}:${t}"; done
fi
+4
View File
@@ -9,12 +9,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
### Added
- **Push-Benachrichtigungen** (Web Push) auf Handy und Desktop, auch wenn MindSift geschlossen ist: unter Kontoeinstellungen → Alarme pro Gerät einschalten und testen; „Push“ ist eine eigene Spalte für jedes Ereignis (auch fällige Aufgaben) und die Tageszusammenfassung. iPhone und iPad ab iOS 16.4, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. Schlüssel per `VAPID_*` oder automatisch erzeugt. API: `/push/key`, `/push/devices`, `/push/subscribe`, `/push/unsubscribe`, `/push/test`.
- Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar.
- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`).
- „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts).
- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server).
### Changed
- Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste.
- Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile.
- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz).
- Notizen: Beim Einfügen und beim Markdown-Import werden Absätze und Listen unter einer Überschrift eingerückt (H2 unter H1 eine Ebene tiefer), so lassen sich Abschnitte einklappen und fokussieren. Enter am Ende einer Überschrift legt den neuen Punkt als Unterpunkt an; `- `, `* ` oder `+ ` in einem leeren Punkt machen ihn zum normalen Aufzählungspunkt.
- CI: Tests laufen pro Code-Stand nur einmal. `main` baut ohne erneute Tests, wenn derselbe Stand schon als `dev` getestet wurde; ein Release-Tag auf denselben Commit wie `latest` baut nicht neu, sondern taggt `latest` um. Die Zuordnung steht als Label im Image (keine zusätzlichen Image-Tags in der Registry). Dockerfile: `SKIP_TESTS`, BuildKit-Caches für Go und npm.
## [0.10.0] - 2026-10-30
+23 -6
View File
@@ -4,11 +4,17 @@
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install --no-audit --no-fund
RUN --mount=type=cache,target=/root/.npm 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 lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
ARG SKIP_TESTS=0
RUN if [ "$SKIP_TESTS" != "1" ]; then \
npx tsc --noEmit && npm run lint && npm run test:run; \
else \
echo "Skipping frontend tests (SKIP_TESTS=1)"; \
fi && \
APP_VERSION="${APP_VERSION}" npm run build
# ==============================================================================
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
@@ -19,7 +25,8 @@ RUN apk add --no-cache git ca-certificates
# Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer
COPY go.mod ./
RUN go get github.com/go-chi/chi/v5@v5.2.0 \
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go get github.com/go-chi/chi/v5@v5.2.0 \
github.com/go-chi/cors@v1.2.1 \
github.com/golang-jwt/jwt/v5@v5.2.1 \
github.com/gorilla/websocket@v1.5.3 \
@@ -38,16 +45,26 @@ COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
ARG SKIP_TESTS=0
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
RUN go mod tidy && \
CGO_ENABLED=0 go vet ./... && \
CGO_ENABLED=0 go test -v ./... && \
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go mod tidy && \
if [ "$SKIP_TESTS" != "1" ]; then \
CGO_ENABLED=0 go vet ./... && \
CGO_ENABLED=0 go test -v ./...; \
else \
echo "Skipping backend tests (SKIP_TESTS=1)"; \
fi && \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server
# ==============================================================================
# Stage 3: Minimal Production Runtime (< 35 MB)
# ==============================================================================
FROM alpine:3.21
# The code state the image was built from (CI: decides whether a later build may skip the tests)
ARG GIT_SHA=""
ARG GIT_TREE=""
LABEL org.opencontainers.image.revision="${GIT_SHA}" io.mindsift.tree="${GIT_TREE}"
RUN apk add --no-cache ca-certificates tzdata && \
mkdir -p /data /tmp && \
chmod 1777 /tmp
+2 -1
View File
@@ -7,8 +7,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/health` | Lebenszeichen |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`) |
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
| POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv |
| 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; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
+1
View File
@@ -84,6 +84,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster |
| `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. |
| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Eigener Link im globalen Desktop-Footer (nur `https://`, sonst ignoriert) |
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
+3
View File
@@ -42,6 +42,9 @@ services:
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
# Eigener Link im Footer (nur https://)
# - LEGAL_CUSTOM_LINK_LABEL=${LEGAL_CUSTOM_LINK_LABEL:-}
# - LEGAL_CUSTOM_LINK_URL=${LEGAL_CUSTOM_LINK_URL:-}
# Bilder und Audio in Notizen
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
+16
View File
@@ -47,6 +47,22 @@
"purpose": "maskable"
}
],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "media",
"accept": ["image/*"]
}
]
}
},
"shortcuts": [
{
"name": "Gedanke sofort erfassen",
+73 -1
View File
@@ -5,10 +5,12 @@
// - 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 VERSION = 'v3';
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
const MAX_ASSETS = 80;
const SHARE_DB_NAME = 'mindsift-share-target';
const SHARE_STORE_NAME = 'shared_items';
const SHELL_URLS = [
'/',
'/index.html',
@@ -80,8 +82,78 @@ async function staleWhileRevalidate(request) {
return hit || refresh;
}
function openShareDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(SHARE_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function savePendingShare(item) {
const db = await openShareDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
tx.objectStore(SHARE_STORE_NAME).add(item);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
tx.onabort = () => {
db.close();
reject(tx.error);
};
});
}
async function handleShareTarget(event) {
try {
const formData = await event.request.formData();
const title = formData.get('title');
const text = formData.get('text');
const url = formData.get('url');
const media = formData.get('media');
let fileName = '';
let hasMedia = false;
if (media && typeof media === 'object') {
fileName = media.name || 'image';
hasMedia = true;
}
await savePendingShare({
title: typeof title === 'string' ? title.trim() : '',
text: typeof text === 'string' ? text.trim() : '',
url: typeof url === 'string' ? url.trim() : '',
fileName,
hasMedia,
createdAt: Date.now(),
});
} catch (err) {
console.error('[SW] Share-Target Fehler:', err);
}
return Response.redirect('/?shared=pending', 303);
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method === 'POST') {
const url = new URL(request.url);
if (url.origin === self.location.origin && url.pathname === '/share-target') {
event.respondWith(handleShareTarget(event));
}
return;
}
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
+71 -2
View File
@@ -65,6 +65,9 @@ import { useMobileActions } from './context/MobileActionContext';
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
import { UserAvatar } from './components/UserAvatar';
import { CommandPalette } from './components/CommandPalette';
import { AppFooter } from './components/AppFooter';
import { AboutModal } from './components/AboutModal';
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
import { APP_VERSION } from './version';
type ActiveTab =
@@ -170,6 +173,7 @@ export const App: React.FC = () => {
// Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
// Mobile Navigation & Quick-Action State
@@ -552,6 +556,46 @@ export const App: React.FC = () => {
);
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
// Web Share Target: import shared thoughts from external apps
useEffect(() => {
if (!user) return;
const params = new URLSearchParams(window.location.search);
const hasPendingShare = params.get('shared') === 'pending';
const processShares = async () => {
try {
const shares = await consumePendingShares();
if (shares.length === 0) return;
let importedCount = 0;
for (const share of shares) {
const content = formatSharedThought(share);
if (content) {
await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
importedCount++;
}
}
if (importedCount > 0) {
triggerHaptic(15);
setDemoNotice('Erfolgreich im Quick Dump abgelegt');
setTimeout(() => setDemoNotice(null), 4000);
refreshAll();
}
} catch (err) {
console.error('Fehler beim Import geteilter Daten:', err);
} finally {
if (hasPendingShare) {
const url = new URL(window.location.href);
url.searchParams.delete('shared');
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
}
}
};
processShares();
}, [user, refreshAll]);
// 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(() => {
@@ -1119,8 +1163,8 @@ export const App: React.FC = () => {
{/* Main Container */}
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
<main
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6 ${
activeTab === 'workspace' ? 'min-h-0 flex flex-col' : ''
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
activeTab === 'workspace' ? 'min-h-0 flex flex-col sm:pb-0' : 'sm:pb-6'
}`}
>
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
@@ -1202,6 +1246,11 @@ export const App: React.FC = () => {
</ViewErrorBoundary>
</main>
<AppFooter
legal={authStatus.legal}
onOpenAbout={() => setShowAbout(true)}
/>
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
{/* Col 1: Triage */}
@@ -1594,6 +1643,19 @@ export const App: React.FC = () => {
<span>Hilfe & Anleitung</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setShowAbout(true);
}}
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer text-main"
>
<Brain className="w-4 h-4 text-accent" />
<span>Über MindSift</span>
</button>
{isServerAdmin && (
<button
type="button"
@@ -1626,6 +1688,11 @@ export const App: React.FC = () => {
<span>Abmelden</span>
</button>
</div>
{/* Version & About Hint on Mobile */}
<div className="pt-2 text-center text-[10px] text-faint font-mono">
MindSift {APP_VERSION}
</div>
</div>
</div>
)}
@@ -1725,6 +1792,8 @@ export const App: React.FC = () => {
onOpenTask={handleOpenTask}
onOpenList={handleOpenList}
/>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
</div>
);
};
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { AuthStatus } from '../lib/types';
import { APP_VERSION } from '../version';
import { LegalLinks } from './LegalLinks';
interface AppFooterProps {
legal?: AuthStatus['legal'];
onOpenAbout: () => void;
}
export const AppFooter: React.FC<AppFooterProps> = ({ legal, onOpenAbout }) => {
return (
<footer className="hidden sm:flex shrink-0 w-full max-w-6xl mx-auto px-4 py-2 items-center justify-between text-[11px] text-faint border-t border-border/40 select-none">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onOpenAbout}
className="font-semibold text-muted hover:text-main transition-colors cursor-pointer"
>
MindSift
</button>
<button
type="button"
onClick={onOpenAbout}
className="font-mono hover:text-main transition-colors cursor-pointer"
title="Über MindSift & Versionsdetails"
>
{APP_VERSION}
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={onOpenAbout}
className="hover:text-main transition-colors cursor-pointer underline-offset-2 hover:underline"
>
Über MindSift
</button>
</div>
<div className="flex items-center gap-3">
<LegalLinks legal={legal} className="!text-faint gap-3" />
</div>
</footer>
);
};
+13 -1
View File
@@ -28,7 +28,9 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
}))
.filter((e) => e.url || e.hasContent);
if (entries.length === 0 && !children) return null;
const customHref = legal?.customLinkUrl && safeHref(legal.customLinkUrl);
if (entries.length === 0 && !children && !customHref) return null;
const openText = async (type: LegalType) => {
setError(null);
@@ -52,6 +54,16 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
</button>
)
)}
{customHref && (
<a
href={customHref as string}
target="_blank"
rel="noopener noreferrer"
className="hover:text-main underline-offset-2 hover:underline"
>
{legal?.customLinkLabel || legal?.customLinkUrl}
</a>
)}
{children}
{error && <span className="text-status-err">{error}</span>}
{open && (
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { formatSharedThought } from '../shareTarget';
describe('formatSharedThought', () => {
it('formats pure text correctly', () => {
expect(formatSharedThought({ text: 'Spontaner Einfall' })).toBe('Spontaner Einfall');
});
it('formats pure url correctly', () => {
expect(formatSharedThought({ url: 'https://example.com/article' })).toBe('https://example.com/article');
});
it('combines title and url when text is empty', () => {
expect(formatSharedThought({ title: 'Toller Artikel', url: 'https://example.com/article' }))
.toBe('Toller Artikel\nhttps://example.com/article');
});
it('does not duplicate URL when text already contains it', () => {
const input = {
title: 'Artikel',
text: 'Lies das hier: https://example.com/article unbedingt!',
url: 'https://example.com/article',
};
expect(formatSharedThought(input))
.toBe('Artikel\nLies das hier: https://example.com/article unbedingt!');
});
it('does not duplicate URL when text is exactly the URL', () => {
const input = {
title: 'News',
text: 'https://news.example.com',
url: 'https://news.example.com',
};
expect(formatSharedThought(input))
.toBe('News\nhttps://news.example.com');
});
it('does not duplicate title if text starts with title', () => {
const input = {
title: 'MindSift Update',
text: 'MindSift Update ist jetzt verfügbar.',
};
expect(formatSharedThought(input))
.toBe('MindSift Update ist jetzt verfügbar.');
});
it('combines title, text and distinct URL', () => {
const input = {
title: 'Empfehlung',
text: 'Ein großartiges Tool für schnelles Erfassen.',
url: 'https://mindsift.app',
};
expect(formatSharedThought(input))
.toBe('Empfehlung\nEin großartiges Tool für schnelles Erfassen.\nhttps://mindsift.app');
});
it('handles image with text', () => {
const input = {
title: 'Foto vom Ausflug',
fileName: 'berge.jpg',
hasMedia: true,
};
expect(formatSharedThought(input))
.toBe('Foto vom Ausflug\n(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
});
it('handles image only with filename', () => {
const input = {
fileName: 'screenshot.png',
hasMedia: true,
};
expect(formatSharedThought(input))
.toBe('[screenshot.png] (Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
});
it('handles image only without filename', () => {
const input = {
hasMedia: true,
};
expect(formatSharedThought(input))
.toBe('(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
});
it('returns empty string if nothing is provided', () => {
expect(formatSharedThought({})).toBe('');
});
});
+117
View File
@@ -0,0 +1,117 @@
export interface SharedData {
id?: number;
title?: string;
text?: string;
url?: string;
fileName?: string;
hasMedia?: boolean;
createdAt?: number;
}
const SHARE_DB_NAME = 'mindsift-share-target';
const SHARE_STORE_NAME = 'shared_items';
/**
* Combines title, text, url and media note into a single thought string.
* Ensures URLs are not duplicated if already present in the shared text or title.
*/
export function formatSharedThought(share: SharedData): string {
const title = (share.title || '').trim();
const text = (share.text || '').trim();
const url = (share.url || '').trim();
const mediaNote = '(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)';
let content = '';
// Check if url is already contained in text or title
const urlInText = url !== '' && text.includes(url);
const urlInTitle = url !== '' && title.includes(url);
// Combine title and text
if (title && text) {
if (text === title) {
content = text;
} else if (text.startsWith(title)) {
content = text;
} else {
content = `${title}\n${text}`;
}
} else if (title) {
content = title;
} else if (text) {
content = text;
}
// Append URL if not already included in text or title
if (url && !urlInText && !urlInTitle) {
if (content) {
content = `${content}\n${url}`;
} else {
content = url;
}
}
// Media note handling
if (share.hasMedia || share.fileName) {
if (content) {
content = `${content}\n${mediaNote}`;
} else if (share.fileName) {
content = `[${share.fileName}] ${mediaNote}`;
} else {
content = mediaNote;
}
}
return content.trim();
}
/**
* Retrieves and removes all pending shared items stored in IndexedDB by the Service Worker.
*/
export async function consumePendingShares(): Promise<SharedData[]> {
if (typeof indexedDB === 'undefined') return [];
return new Promise((resolve) => {
try {
const req = indexedDB.open(SHARE_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
}
};
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
db.close();
return resolve([]);
}
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
const store = tx.objectStore(SHARE_STORE_NAME);
const getAllReq = store.getAll();
getAllReq.onsuccess = () => {
const items: SharedData[] = getAllReq.result || [];
store.clear();
tx.oncomplete = () => {
db.close();
resolve(items);
};
tx.onerror = () => {
db.close();
resolve(items);
};
};
getAllReq.onerror = () => {
db.close();
resolve([]);
};
};
req.onerror = () => resolve([]);
} catch {
resolve([]);
}
});
}
+2
View File
@@ -20,6 +20,8 @@ export interface AuthStatus {
hasImprintContent?: boolean;
privacyUrl?: string;
hasPrivacyContent?: boolean;
customLinkLabel?: string;
customLinkUrl?: string;
};
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
voice?: { mode: VoiceMode; maxSeconds: number };
+2
View File
@@ -52,6 +52,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"hasImprintContent": s.cfg.Legal.ImprintContent != "",
"privacyUrl": s.cfg.Legal.PrivacyURL,
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
"customLinkLabel": s.cfg.Legal.CustomLinkLabel,
"customLinkUrl": s.cfg.Legal.CustomLinkURL,
},
}
// Countdown to the next demo reset; seconds as well, so client clocks do not matter
+5
View File
@@ -316,6 +316,11 @@ func (s *Server) Router() http.Handler {
})
})
// Web Share Target fallback when service worker is not active yet: redirect to /
r.Post("/share-target", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
})
// Embedded Frontend SPA Handler
if s.staticFS != nil {
fileServer := http.FileServer(http.FS(s.staticFS))
+27 -2
View File
@@ -1388,7 +1388,12 @@ func TestLegalTextsArePublic(t *testing.T) {
}
defer store.Close()
cfg := &config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key",
Legal: config.LegalTexts{ImprintContent: "# Impressum\nMax Muster", PrivacyURL: "https://example.org/datenschutz"}}
Legal: config.LegalTexts{
ImprintContent: "# Impressum\nMax Muster",
PrivacyURL: "https://example.org/datenschutz",
CustomLinkLabel: "Support",
CustomLinkURL: "https://support.example.org",
}}
router := NewServer(cfg, store, ws.NewHub(), nil).Router()
rec := httptest.NewRecorder()
@@ -1399,10 +1404,12 @@ func TestLegalTextsArePublic(t *testing.T) {
HasImprintContent bool `json:"hasImprintContent"`
PrivacyURL string `json:"privacyUrl"`
HasPrivacyContent bool `json:"hasPrivacyContent"`
CustomLinkLabel string `json:"customLinkLabel"`
CustomLinkURL string `json:"customLinkUrl"`
} `json:"legal"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &st)
if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" {
if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" || st.Legal.CustomLinkLabel != "Support" || st.Legal.CustomLinkURL != "https://support.example.org" {
t.Fatalf("unexpected legal status: %+v", st.Legal)
}
@@ -1526,6 +1533,24 @@ func TestPasswordResetFlow(t *testing.T) {
}
}
func TestShareTargetFallbackRedirects(t *testing.T) {
store, err := storage.NewStore(":memory:")
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
router := NewServer(&config.Config{Port: "8080", DatabasePath: ":memory:", JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil).Router()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/share-target", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected 303 See Other, got %d", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/" {
t.Fatalf("expected Location '/', got %q", loc)
}
}
// testSession starts a server-side session, as a login would, for tokens made directly in tests.
func testSession(t *testing.T, store *storage.Store, userID string) string {
t.Helper()
+28 -8
View File
@@ -99,8 +99,9 @@ func (s SpeechConfig) Mode(demo bool) string {
}
type LegalTexts struct {
ImprintURL, ImprintContent string
PrivacyURL, PrivacyContent string
ImprintURL, ImprintContent string
PrivacyURL, PrivacyContent string
CustomLinkLabel, CustomLinkURL string
}
// Publicly known JWT secrets (old code default and the placeholders from .env.example, compose and
@@ -176,6 +177,20 @@ func legalURL(name string) string {
return v
}
// legalCustomLink accepts only https links for the custom footer link.
func legalCustomLink(labelVar, urlVar string) (string, string) {
u := strings.TrimSpace(os.Getenv(urlVar))
if u == "" {
return "", ""
}
if !strings.HasPrefix(u, "https://") {
slog.Warn("Custom-Link-URL ignoriert: nur https erlaubt", "variable", urlVar, "url", u)
return "", ""
}
label := strings.TrimSpace(os.Getenv(labelVar))
return label, u
}
func Load() *Config {
port := getEnv("PORT", "8080")
dataDir := getEnv("DATA_DIR", "./data")
@@ -255,12 +270,17 @@ func Load() *Config {
QuotaMB: envInt("MEDIA_QUOTA_MB", 0, 0, 10000000),
DemoMaxMB: envInt("MEDIA_DEMO_MAX_MB", 3, 1, 100),
},
Legal: LegalTexts{
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
PrivacyURL: legalURL("LEGAL_PRIVACY_URL"),
PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"),
},
Legal: func() LegalTexts {
customLabel, customURL := legalCustomLink("LEGAL_CUSTOM_LINK_LABEL", "LEGAL_CUSTOM_LINK_URL")
return LegalTexts{
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
PrivacyURL: legalURL("LEGAL_PRIVACY_URL"),
PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"),
CustomLinkLabel: customLabel,
CustomLinkURL: customURL,
}
}(),
}
}
+39
View File
@@ -81,3 +81,42 @@ func TestJWTSecretNeverUsesAKnownDefault(t *testing.T) {
t.Fatalf("a real JWT_SECRET must win, got %q", got)
}
}
func TestCustomLinkValidation(t *testing.T) {
t.Setenv("DATA_DIR", t.TempDir())
// 1. Valid https URL and label
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Unternehmensseite")
t.Setenv("LEGAL_CUSTOM_LINK_URL", "https://example.com/company")
cfg := Load()
if cfg.Legal.CustomLinkLabel != "Unternehmensseite" {
t.Errorf("expected label 'Unternehmensseite', got %q", cfg.Legal.CustomLinkLabel)
}
if cfg.Legal.CustomLinkURL != "https://example.com/company" {
t.Errorf("expected url 'https://example.com/company', got %q", cfg.Legal.CustomLinkURL)
}
// 2. Insecure http URL -> ignored
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Insecure Site")
t.Setenv("LEGAL_CUSTOM_LINK_URL", "http://insecure.example.com")
cfg = Load()
if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" {
t.Errorf("expected empty custom link for http, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
}
// 3. Invalid scheme (javascript:) -> ignored
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Exploit")
t.Setenv("LEGAL_CUSTOM_LINK_URL", "javascript:alert(1)")
cfg = Load()
if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" {
t.Errorf("expected empty custom link for javascript:, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
}
// 4. Empty URL -> empty
t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Only Label")
t.Setenv("LEGAL_CUSTOM_LINK_URL", "")
cfg = Load()
if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" {
t.Errorf("expected empty custom link when url is empty, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
}
}