Compare commits

...
5 Commits
Author SHA1 Message Date
github-actions[bot] 9cf0c0285b chore: bump version to 0.35.2 2026-10-11 07:49:40 +00:00
Claude faf926c081 fix(editor): open existing notes in the user's default note mode
Existing notes initialised raw markdown mode from note.checklistMode
instead of the defaultNoteMode preference, so users with Markdown as
default always got the rich text editor when reopening a note.

Fixes #96

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017neWQAAxi6nZYmwRkDThsy
2026-10-11 09:46:43 +02:00
github-actions[bot] 31732e8144 chore: pin docker-compose to v0.35.1 [skip ci] 2026-10-06 07:11:38 +00:00
github-actions[bot] d71dbe0f4a chore: bump version to 0.35.1 2026-10-06 07:05:30 +00:00
Claude effe7e3e72 fix(docker): make bind-mounted data folders writable on startup
The image switched to the node user (uid 1000) at build time, so a host
folder mounted at /data kept its host ownership and SQLite failed with
SQLITE_CANTOPEN in a restart loop. Named and anonymous volumes worked only
because Docker copies the image's /data ownership into them.

The container now starts as root, hands DATA_DIR and the database folder
to node (only entries not already owned by it), and execs the app via
setpriv as node. Containers started with an explicit `user:` skip this.

Adds a CI step that runs the image with a host-owned bind mount.

Fixes #93

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MHECwBhBpM248EFSBi4L5
2026-10-06 08:43:59 +02:00
9 changed files with 73 additions and 7 deletions
+16
View File
@@ -125,5 +125,21 @@ jobs:
push: false
provenance: false
tags: crumbs:ci
load: true
cache-from: type=gha,scope=ci-docker
cache-to: type=gha,scope=ci-docker,mode=max
# Regression for #93: a host folder bind-mounted at /data is owned by the
# host user (the runner, uid 1001), not the container's node user (1000)
- name: Start with a host-owned bind mount
run: |
mkdir -p "$RUNNER_TEMP/crumbs-data"
docker run -d --name crumbs -p 3000:3000 -v "$RUNNER_TEMP/crumbs-data:/data" crumbs:ci
for i in $(seq 1 30); do
if curl -fsS -o /dev/null http://localhost:3000/login; then
test -f "$RUNNER_TEMP/crumbs-data/crumbs.db" && exit 0
fi
sleep 1
done
docker logs crumbs
exit 1
+4 -2
View File
@@ -37,8 +37,10 @@ COPY --from=build /app/package.json ./
# Create data directory
RUN mkdir -p /data && chown -R node:node /data
# Use non-root user
USER node
# Runs as root only long enough to hand the data folder (often a host bind
# mount) to the node user, then drops privileges — see crumbs-entrypoint.sh
COPY crumbs-entrypoint.sh /usr/local/bin/crumbs-entrypoint.sh
ENTRYPOINT ["crumbs-entrypoint.sh"]
# Configuration
ENV NODE_ENV=production
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Starts as root so a bind-mounted data folder owned by the host user can be
# handed to the unprivileged `node` user, then drops privileges before running
# the app. Without this, a host folder mounted at /data is not writable by
# uid 1000 and SQLite fails with SQLITE_CANTOPEN.
set -e
if [ "$(id -u)" = "0" ]; then
for dir in "${DATA_DIR:-/data}" "$(dirname "${DATABASE_URL:-/data/crumbs.db}")"; do
mkdir -p "$dir"
# Only touch entries not already owned by node, so restarts on a large
# attachment folder stay fast.
find "$dir" \! -user node -exec chown node:node {} + ||
echo "warning: could not change ownership of $dir; it must be writable by uid 1000" >&2
done
exec setpriv --reuid=node --regid=node --init-groups "$@"
fi
# Started with an explicit non-root user (e.g. `user:` in compose): run as-is.
exec "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.35.0
image: ghcr.io/bretzel-app/crumbs:0.35.1
container_name: crumbs
ports:
- "3000:3000"
+4
View File
@@ -26,6 +26,10 @@ services:
restart: unless-stopped
```
The app runs as the image's `node` user (uid 1000). On startup the container hands ownership of a
bind-mounted data folder to uid 1000, so a folder created by any host user works as-is. If you set
`user:` in compose, that step is skipped and the folder must already be writable by that user.
### Reverse Proxy (nginx)
```nginx
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.35.0",
"version": "0.35.2",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
+1 -1
View File
@@ -60,7 +60,7 @@
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let rawMarkdownMode = $state(prefs.defaultNoteMode === 'markdown');
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
+25 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
const DEFAULT_PREFS = {
@@ -81,6 +81,30 @@ test.describe('Settings — Preferences', () => {
await expect(page.getByTestId('note-content-input')).toBeVisible();
});
test('Scenario: Default note mode applies when reopening an existing note', async ({
authenticatedPage: page
}) => {
await resetPreferences(page);
// Given a note titled "Existing Markdown Note" exists
const title = `Existing Markdown Note ${Date.now()}`;
await page.request.post('/api/notes', { data: { title, content: 'Some **bold** text' } });
// And the user's default note mode is Markdown
await gotoWithStablePrefs(page, '/settings/preferences');
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
await page.getByTestId('pref-mode-markdown').click();
await putResponse;
// When the user opens the note
await gotoWithStablePrefs(page, '/');
await noteCard(page, title).click();
// Then the note editor opens in markdown mode showing the raw source
await expect(page.getByTestId('note-content-input')).toBeVisible();
await expect(page.getByTestId('note-content-input')).toHaveValue('Some **bold** text');
});
test('Scenario: Footer toggle hides and shows footer', async ({
authenticatedPage: page
}) => {
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.35.0</span>
<span class="footer-version">v0.35.2</span>
</div>
</footer>