Skip to content
10 changes: 9 additions & 1 deletion apps/desktop/src/main/handlers/shareHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ export function registerShareHandlers(deps: ShareHandlerDependencies): void {
'share:create',
async (
_event,
input: { noteId: string; title: string; content: string }
input: {
noteId: string;
title: string;
content: string;
tags?: string[];
backlinks?: Array<{ noteId: string; title: string }>;
wordCount?: number;
notebookName?: string;
}
): Promise<{ success: boolean; url?: string; slug?: string; error?: string }> => {
try {
const result = await apiClient.shareNote(input);
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/services/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,10 @@ export class ApiClient {
noteId: string;
title: string;
content: string;
tags?: string[];
backlinks?: Array<{ noteId: string; title: string }>;
wordCount?: number;
notebookName?: string;
}): Promise<{ slug: string; url: string }> {
return this.request<{ slug: string; url: string }>('/share', {
method: 'POST',
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,10 @@ export interface ReadiedAPI {
noteId: string;
title: string;
content: string;
tags?: string[];
backlinks?: Array<{ noteId: string; title: string }>;
wordCount?: number;
notebookName?: string;
}) => Promise<{ success: boolean; url?: string; slug?: string; error?: string }>;
/** Remove a shared note */
delete: (slug: string) => Promise<{ success: boolean; error?: string }>;
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/renderer/components/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useScrollSync } from '../hooks/useScrollSync';
import { useManualTags } from '../hooks/useManualTags';
import { useEmbedResolver } from '../hooks/useEmbedResolver';
import { useBacklinks } from '../hooks/useLinks';
import { useNotebook } from '../hooks/useNotebooks';
import type { MarkdownEditorHandle } from './MarkdownEditor';
import type { MarkdownPreviewHandle, ToolbarVisibility } from './editor';
import { ImageLightbox } from './ImageLightbox';
Expand Down Expand Up @@ -107,6 +108,7 @@ export function NoteEditor({
const [revisionHistoryOpen, setRevisionHistoryOpen] = useState(false);
const { data: backlinks } = useBacklinks(note?.id ?? null);
const backlinksCount = backlinks?.length ?? 0;
const { data: notebook } = useNotebook(note?.notebookId ?? null);

// Lightbox state for embedded images
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
Expand Down Expand Up @@ -168,13 +170,17 @@ export function NoteEditor({
noteId: note.id,
title: note.title,
content: note.content,
tags: note.tags,
wordCount: note.wordCount,
notebookName: notebook?.name ?? '',
backlinks: (backlinks ?? []).map(bl => ({ noteId: bl.noteId, title: bl.noteTitle })),
});
if (result.success) {
showToast('Link copied to clipboard');
} else {
showToast(result.error || 'Failed to share note', 'error');
}
}, [note, showToast]);
}, [note, notebook, backlinks, showToast]);

// Handle title change
const handleTitleChange = useCallback(
Expand Down
9 changes: 8 additions & 1 deletion packages/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ export const sharedNotes = sqliteTable(
slug: text('slug').notNull().unique(),
title: text('title').notNull().default(''),
content: text('content').notNull().default(''),
tags: text('tags').notNull().default('[]'),
backlinks: text('backlinks').notNull().default('[]'),
wordCount: integer('word_count').notNull().default(0),
notebookName: text('notebook_name').notNull().default(''),
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(true),
createdAt: text('created_at')
.notNull()
Expand All @@ -253,7 +257,10 @@ export const sharedNotes = sqliteTable(
.notNull()
.$defaultFn(() => new Date().toISOString()),
},
table => [uniqueIndex('shared_notes_user_note_unique').on(table.userId, table.noteId)]
table => [
uniqueIndex('shared_notes_user_note_unique').on(table.userId, table.noteId),
index('idx_shared_notes_user_public').on(table.userId, table.isPublic),
]
);

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ const app = new Hono<{ Bindings: Env }>();
app.use('*', logger());
app.use('*', prettyJSON());
app.use('*', secureHeaders());
// Public share endpoints: permissive CORS (any origin, no credentials)
app.use(
'/share/public/*',
cors({
origin: '*',
allowMethods: ['GET', 'OPTIONS'],
maxAge: 86400,
})
);
// All other endpoints: restricted CORS with credentials
app.use(
'*',
cors({
Expand Down
69 changes: 63 additions & 6 deletions packages/api/src/routes/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@
* Share Routes
*
* Public sharing of notes via unique slugs.
* - POST / — Create or update a shared note (authenticated)
* - GET /:slug — Get a shared note (public)
* - DELETE /:slug — Remove a shared note (authenticated, owner only)
* - POST / — Create or update a shared note (authenticated)
* - GET /public/:userId — List public notes for a user (public, paginated)
* - GET /:slug — Get a shared note (public)
* - DELETE /:slug — Remove a shared note (authenticated, owner only)
*/

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq, and } from 'drizzle-orm';
import { eq, and, desc } from 'drizzle-orm';
import { createDb, type Env } from '../db/client.js';
import { sharedNotes } from '../db/schema.js';
import { authMiddleware, type AuthUser } from '../middleware/auth.js';
Expand All @@ -26,13 +27,22 @@ const createShareSchema = z.object({
noteId: z.string().min(1),
title: z.string().default(''),
content: z.string().default(''),
tags: z.array(z.string()).default([]),
backlinks: z.array(z.object({ noteId: z.string(), title: z.string() })).default([]),
wordCount: z.number().int().min(0).default(0),
notebookName: z.string().default(''),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

const listQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});

// ─── POST / — Create or update shared note (upsert) ─────────────────────────

share.post('/', authMiddleware, zValidator('json', createShareSchema), async c => {
const { userId } = c.get('user');
const { noteId, title, content } = c.req.valid('json');
const { noteId, title, content, tags, backlinks, wordCount, notebookName } = c.req.valid('json');
const db = createDb(c.env);

// Generate slug: first 8 hex chars of a UUID
Expand All @@ -48,6 +58,10 @@ share.post('/', authMiddleware, zValidator('json', createShareSchema), async c =
slug,
title,
content,
tags: JSON.stringify(tags),
backlinks: JSON.stringify(backlinks),
wordCount,
notebookName,
createdAt: now,
updatedAt: now,
})
Expand All @@ -56,6 +70,10 @@ share.post('/', authMiddleware, zValidator('json', createShareSchema), async c =
set: {
title,
content,
tags: JSON.stringify(tags),
backlinks: JSON.stringify(backlinks),
wordCount,
notebookName,
updatedAt: now,
},
})
Expand All @@ -67,6 +85,37 @@ share.post('/', authMiddleware, zValidator('json', createShareSchema), async c =
return c.json({ slug: result.slug, url });
});

// ─── GET /public/:userId — List public notes (no auth) ──────────────────────

share.get('/public/:userId', zValidator('query', listQuerySchema), async c => {
const userId = c.req.param('userId');
const { limit, offset } = c.req.valid('query');
const db = createDb(c.env);

const notes = await db
.select({
slug: sharedNotes.slug,
title: sharedNotes.title,
tags: sharedNotes.tags,
wordCount: sharedNotes.wordCount,
notebookName: sharedNotes.notebookName,
createdAt: sharedNotes.createdAt,
updatedAt: sharedNotes.updatedAt,
})
.from(sharedNotes)
.where(and(eq(sharedNotes.userId, userId), eq(sharedNotes.isPublic, true)))
.orderBy(desc(sharedNotes.updatedAt))
.limit(limit)
.offset(offset);

const parsed = notes.map(n => ({
...n,
tags: JSON.parse(n.tags ?? '[]'),
}));

return c.json({ notes: parsed, limit, offset });
});

// ─── GET /:slug — Get shared note (public, no auth) ─────────────────────────

share.get('/:slug', async c => {
Expand All @@ -77,6 +126,10 @@ share.get('/:slug', async c => {
.select({
title: sharedNotes.title,
content: sharedNotes.content,
tags: sharedNotes.tags,
backlinks: sharedNotes.backlinks,
wordCount: sharedNotes.wordCount,
notebookName: sharedNotes.notebookName,
createdAt: sharedNotes.createdAt,
updatedAt: sharedNotes.updatedAt,
})
Expand All @@ -88,7 +141,11 @@ share.get('/:slug', async c => {
return c.json({ error: 'Not found' }, 404);
}

return c.json(note);
return c.json({
...note,
tags: JSON.parse(note.tags ?? '[]'),
backlinks: JSON.parse(note.backlinks ?? '[]'),
});
});

// ─── DELETE /:slug — Remove shared note (owner only) ─────────────────────────
Expand Down
Loading