Compare commits

...
7 Commits
Author SHA1 Message Date
github-actions[bot] a0f663d9d6 chore: pin docker-compose to v0.35.2 [skip ci] 2026-10-11 07:56:13 +00:00
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
github-actions[bot] 6f4938180a chore: pin docker-compose to v0.35.0 [skip ci] 2026-09-18 06:22:43 +00:00
9 changed files with 73 additions and 7 deletions
+16
View File
@@ -125,5 +125,21 @@ jobs:
push: false push: false
provenance: false provenance: false
tags: crumbs:ci tags: crumbs:ci
load: true
cache-from: type=gha,scope=ci-docker cache-from: type=gha,scope=ci-docker
cache-to: type=gha,scope=ci-docker,mode=max 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 # Create data directory
RUN mkdir -p /data && chown -R node:node /data RUN mkdir -p /data && chown -R node:node /data
# Use non-root user # Runs as root only long enough to hand the data folder (often a host bind
USER node # 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 # Configuration
ENV NODE_ENV=production 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: services:
crumbs: crumbs:
image: ghcr.io/bretzel-app/crumbs:0.34.2 image: ghcr.io/bretzel-app/crumbs:0.35.2
container_name: crumbs container_name: crumbs
ports: ports:
- "3000:3000" - "3000:3000"
+4
View File
@@ -26,6 +26,10 @@ services:
restart: unless-stopped 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) ### Reverse Proxy (nginx)
```nginx ```nginx
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "crumbs", "name": "crumbs",
"private": true, "private": true,
"version": "0.35.0", "version": "0.35.2",
"type": "module", "type": "module",
"packageManager": "pnpm@9.15.9", "packageManager": "pnpm@9.15.9",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -60,7 +60,7 @@
let showColorPicker = $state(false); let showColorPicker = $state(false);
let showImageUpload = $state(false); let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally // 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 textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state(); let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0); 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'; import type { Page } from '@playwright/test';
const DEFAULT_PREFS = { const DEFAULT_PREFS = {
@@ -81,6 +81,30 @@ test.describe('Settings — Preferences', () => {
await expect(page.getByTestId('note-content-input')).toBeVisible(); 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 ({ test('Scenario: Footer toggle hides and shows footer', async ({
authenticatedPage: page 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/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> <li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul> </ul>
<span class="footer-version">v0.35.0</span> <span class="footer-version">v0.35.2</span>
</div> </div>
</footer> </footer>