@hiai-gg/hiai-docs 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.all-contributorsrc +18 -0
- package/.claude/settings.local.json +61 -0
- package/.dockerignore +113 -0
- package/.env.example +68 -0
- package/.github/FUNDING.yml +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
- package/.github/dependabot.yml +136 -0
- package/.github/pull_request_template.md +96 -0
- package/.github/workflows/ci.yml +283 -0
- package/AGENTS.md +237 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +77 -0
- package/Caddyfile +50 -0
- package/Dockerfile.backend +60 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/RELEASE_CHECKLIST.md +34 -0
- package/SECURITY.md +60 -0
- package/backend/package.json +43 -0
- package/backend/src/__tests__/auth-helpers.test.ts +51 -0
- package/backend/src/__tests__/chunker.test.ts +65 -0
- package/backend/src/__tests__/config.test.ts +91 -0
- package/backend/src/__tests__/csrf.test.ts +91 -0
- package/backend/src/__tests__/embedding.test.ts +48 -0
- package/backend/src/__tests__/rate-limit.test.ts +46 -0
- package/backend/src/__tests__/routes.test.ts +38 -0
- package/backend/src/__tests__/schema.test.ts +31 -0
- package/backend/src/__tests__/validation.test.ts +556 -0
- package/backend/src/api/middleware/auth.ts +56 -0
- package/backend/src/api/middleware/csrf.ts +91 -0
- package/backend/src/api/middleware/rate-limit.ts +77 -0
- package/backend/src/api/middleware/webhook-verify.ts +22 -0
- package/backend/src/api/routes/attachments.ts +280 -0
- package/backend/src/api/routes/auth.ts +52 -0
- package/backend/src/api/routes/collaboration.ts +121 -0
- package/backend/src/api/routes/documents.ts +664 -0
- package/backend/src/api/routes/folders.ts +226 -0
- package/backend/src/api/routes/search.ts +354 -0
- package/backend/src/api/routes/share.ts +512 -0
- package/backend/src/api/routes/tags.ts +247 -0
- package/backend/src/api/routes/versions.ts +99 -0
- package/backend/src/api/routes/webhooks.ts +43 -0
- package/backend/src/embedding/chunker.ts +74 -0
- package/backend/src/embedding/index.ts +117 -0
- package/backend/src/embedding/providers/ollama.ts +63 -0
- package/backend/src/embedding/providers/openrouter.ts +71 -0
- package/backend/src/embedding/utils.ts +13 -0
- package/backend/src/embedding/worker.ts +89 -0
- package/backend/src/index.ts +147 -0
- package/backend/src/lib/auth-helpers.ts +27 -0
- package/backend/src/lib/auth.ts +35 -0
- package/backend/src/lib/config.ts +73 -0
- package/backend/src/lib/db.ts +7 -0
- package/backend/src/lib/embedding-queue.ts +12 -0
- package/backend/src/lib/logger.ts +18 -0
- package/backend/src/lib/markdown-to-doc.ts +45 -0
- package/backend/src/lib/minio.ts +46 -0
- package/backend/src/lib/redis.ts +19 -0
- package/backend/src/lib/yjs-provider.ts +182 -0
- package/backend/tests/integration/_harness.ts +754 -0
- package/backend/tests/integration/auth.test.ts +296 -0
- package/backend/tests/integration/routes.documents.test.ts +459 -0
- package/backend/tests/integration/routes.folders.test.ts +337 -0
- package/backend/tests/integration/routes.search.test.ts +322 -0
- package/backend/tests/integration/routes.share.test.ts +773 -0
- package/backend/tests/integration/routes.tags.test.ts +425 -0
- package/backend/tests/integration/routes.versions.test.ts +233 -0
- package/backend/tsconfig.json +18 -0
- package/docker-compose.yml +218 -0
- package/docs/API.md +328 -0
- package/docs/ARCHITECTURE.md +75 -0
- package/docs/DEPLOYMENT.md +113 -0
- package/docs/PRODUCTION_STATUS.md +61 -0
- package/docs/openapi.json +385 -0
- package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
- package/frontend/.svelte-kit.old/env.d.ts +1 -0
- package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
- package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
- package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
- package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
- package/frontend/.svelte-kit.old/tsconfig.json +59 -0
- package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
- package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
- package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
- package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
- package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
- package/frontend/Dockerfile +44 -0
- package/frontend/biome.json +40 -0
- package/frontend/components.json +18 -0
- package/frontend/messages/en.json +434 -0
- package/frontend/package.json +70 -0
- package/frontend/project.inlang/settings.json +12 -0
- package/frontend/src/app.css +6 -0
- package/frontend/src/app.d.ts +13 -0
- package/frontend/src/app.html +30 -0
- package/frontend/src/hooks.server.ts +10 -0
- package/frontend/src/hooks.ts +10 -0
- package/frontend/src/lib/api/attachments.ts +45 -0
- package/frontend/src/lib/api/client.test.ts +15 -0
- package/frontend/src/lib/api/client.ts +57 -0
- package/frontend/src/lib/api/documents.ts +83 -0
- package/frontend/src/lib/api/folders.ts +180 -0
- package/frontend/src/lib/api/search.test.ts +52 -0
- package/frontend/src/lib/api/search.ts +128 -0
- package/frontend/src/lib/api/settings.ts +95 -0
- package/frontend/src/lib/api/share.ts +71 -0
- package/frontend/src/lib/api/tags.test.ts +91 -0
- package/frontend/src/lib/api/tags.ts +87 -0
- package/frontend/src/lib/auth-client.ts +10 -0
- package/frontend/src/lib/collaboration.ts +63 -0
- package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
- package/frontend/src/lib/components/DatePicker.svelte +322 -0
- package/frontend/src/lib/components/DocumentCard.svelte +166 -0
- package/frontend/src/lib/components/EmptyState.svelte +49 -0
- package/frontend/src/lib/components/FolderCard.svelte +93 -0
- package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
- package/frontend/src/lib/components/SearchBar.svelte +47 -0
- package/frontend/src/lib/components/SearchResult.svelte +115 -0
- package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
- package/frontend/src/lib/components/ShareDialog.svelte +158 -0
- package/frontend/src/lib/components/ShareLink.svelte +98 -0
- package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
- package/frontend/src/lib/components/VersionDiff.svelte +55 -0
- package/frontend/src/lib/components/VersionHistory.svelte +96 -0
- package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
- package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
- package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
- package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
- package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
- package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
- package/frontend/src/lib/components/editor/markdown.ts +38 -0
- package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
- package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
- package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
- package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
- package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
- package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
- package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
- package/frontend/src/lib/stores/theme.svelte.ts +97 -0
- package/frontend/src/lib/svelte.d.ts +6 -0
- package/frontend/src/lib/types.ts +44 -0
- package/frontend/src/lib/utils/clipboard.ts +17 -0
- package/frontend/src/lib/utils/strip-markdown.ts +59 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/routes/(app)/+layout.svelte +17 -0
- package/frontend/src/routes/(app)/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/+page.svelte +303 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
- package/frontend/src/routes/(app)/search/+page.svelte +593 -0
- package/frontend/src/routes/(app)/search/+page.ts +25 -0
- package/frontend/src/routes/+error.svelte +12 -0
- package/frontend/src/routes/+layout.svelte +18 -0
- package/frontend/src/routes/+layout.ts +2 -0
- package/frontend/src/routes/api/[...path]/+server.ts +111 -0
- package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
- package/frontend/src/routes/folders/[id]/+page.ts +14 -0
- package/frontend/src/routes/login/+page.svelte +90 -0
- package/frontend/src/routes/register/+page.svelte +97 -0
- package/frontend/src/routes/s/[token]/+page.svelte +496 -0
- package/frontend/src/routes/s/[token]/+page.ts +5 -0
- package/frontend/src/routes/settings/+page.svelte +175 -0
- package/frontend/static/favicon.png +0 -0
- package/frontend/static/logo.png +0 -0
- package/frontend/svelte.config.js +15 -0
- package/frontend/tsconfig.json +15 -0
- package/frontend/vite.config.ts +25 -0
- package/init.sql +9 -0
- package/logo.png +0 -0
- package/package.json +39 -0
- package/package.public.json +39 -0
- package/packages/db/drizzle.config.ts +10 -0
- package/packages/db/package.json +30 -0
- package/packages/db/src/client.ts +9 -0
- package/packages/db/src/index.ts +2 -0
- package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
- package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
- package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
- package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
- package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/_journal.json +27 -0
- package/packages/db/src/schema.ts +378 -0
- package/packages/db/tsconfig.json +17 -0
- package/scripts/export-openapi.ts +37 -0
- package/scripts/health-check.sh +75 -0
- package/scripts/migrate.sh +135 -0
- package/scripts/prework_backup.sh +25 -0
- package/scripts/release.sh +83 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import {
|
|
2
|
+
documents,
|
|
3
|
+
documentTags,
|
|
4
|
+
folders,
|
|
5
|
+
tags,
|
|
6
|
+
versions,
|
|
7
|
+
} from "@hiai-docs/db/schema";
|
|
8
|
+
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
|
|
9
|
+
import { Elysia } from "elysia";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { getSessionUserId } from "../../lib/auth-helpers";
|
|
12
|
+
import { db } from "../../lib/db";
|
|
13
|
+
import { enqueueEmbedding } from "../../lib/embedding-queue";
|
|
14
|
+
import { logger } from "../../lib/logger";
|
|
15
|
+
import { markdownToDocJson } from "../../lib/markdown-to-doc";
|
|
16
|
+
import {
|
|
17
|
+
documentRateLimiter,
|
|
18
|
+
rateLimitHeaders,
|
|
19
|
+
writeRateLimiter,
|
|
20
|
+
} from "../middleware/rate-limit";
|
|
21
|
+
|
|
22
|
+
const createDocumentSchema = z.object({
|
|
23
|
+
title: z.string().min(1).max(500).default("Untitled"),
|
|
24
|
+
content: z.string().optional(),
|
|
25
|
+
folderId: z.string().uuid().optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const updateDocumentSchema = z.object({
|
|
29
|
+
title: z.string().min(1).max(500).optional(),
|
|
30
|
+
content: z.string().optional(),
|
|
31
|
+
contentJson: z.unknown().optional(),
|
|
32
|
+
metadata: z.unknown().optional(),
|
|
33
|
+
folderId: z.string().uuid().nullable().optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const listQuerySchema = z.object({
|
|
37
|
+
folderId: z.string().uuid().optional(),
|
|
38
|
+
tag: z.string().uuid().optional(),
|
|
39
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
40
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const ALLOWED_IMPORT_EXTENSIONS = [".md", ".txt", ".markdown", ".json"];
|
|
44
|
+
const MAX_IMPORT_SIZE = 10 * 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
const importJsonSchema = z.object({
|
|
47
|
+
title: z.string().min(1).max(500).optional(),
|
|
48
|
+
content: z.string().min(1).max(5_000_000),
|
|
49
|
+
folderId: z.string().uuid().optional(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Attach a `tags` array (`{ id, name, color }`) to each document row in a
|
|
54
|
+
* list response. Runs a single grouped query for all rows so the list
|
|
55
|
+
* endpoint can show tags without an N+1 round trip.
|
|
56
|
+
*/
|
|
57
|
+
async function withTags<T extends { id: string }>(
|
|
58
|
+
rows: T[],
|
|
59
|
+
): Promise<
|
|
60
|
+
Array<T & { tags: Array<{ id: string; name: string; color: string | null }> }>
|
|
61
|
+
> {
|
|
62
|
+
if (rows.length === 0) return [];
|
|
63
|
+
const ids = rows.map((r) => r.id);
|
|
64
|
+
const tagRows = await db
|
|
65
|
+
.select({
|
|
66
|
+
documentId: documentTags.documentId,
|
|
67
|
+
id: tags.id,
|
|
68
|
+
name: tags.name,
|
|
69
|
+
color: tags.color,
|
|
70
|
+
})
|
|
71
|
+
.from(documentTags)
|
|
72
|
+
.innerJoin(tags, eq(tags.id, documentTags.tagId))
|
|
73
|
+
.where(inArray(documentTags.documentId, ids));
|
|
74
|
+
|
|
75
|
+
const byDoc = new Map<
|
|
76
|
+
string,
|
|
77
|
+
Array<{ id: string; name: string; color: string | null }>
|
|
78
|
+
>();
|
|
79
|
+
for (const t of tagRows) {
|
|
80
|
+
const list = byDoc.get(t.documentId) ?? [];
|
|
81
|
+
list.push({ id: t.id, name: t.name, color: t.color });
|
|
82
|
+
byDoc.set(t.documentId, list);
|
|
83
|
+
}
|
|
84
|
+
return rows.map((r) => ({ ...r, tags: byDoc.get(r.id) ?? [] }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const documentRoutes = new Elysia({ prefix: "/api" })
|
|
88
|
+
// GET /api/documents — List documents with pagination
|
|
89
|
+
.get("/documents", async ({ query, set, request }) => {
|
|
90
|
+
const ip =
|
|
91
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
92
|
+
request.headers.get("x-real-ip") ??
|
|
93
|
+
"unknown";
|
|
94
|
+
const rl = await documentRateLimiter(ip);
|
|
95
|
+
if (!rl.allowed) {
|
|
96
|
+
set.status = 429;
|
|
97
|
+
set.headers = rateLimitHeaders(0, rl.retryAfter);
|
|
98
|
+
return { error: "Too many requests" };
|
|
99
|
+
}
|
|
100
|
+
set.headers = rateLimitHeaders(rl.remaining);
|
|
101
|
+
|
|
102
|
+
const userId = await getSessionUserId(request.headers);
|
|
103
|
+
if (!userId) {
|
|
104
|
+
set.status = 401;
|
|
105
|
+
return { error: "Unauthorized" };
|
|
106
|
+
}
|
|
107
|
+
const parsed = listQuerySchema.safeParse(query);
|
|
108
|
+
if (!parsed.success) {
|
|
109
|
+
set.status = 400;
|
|
110
|
+
return { error: "Invalid query", details: parsed.error.flatten() };
|
|
111
|
+
}
|
|
112
|
+
const { folderId, tag, page, limit } = parsed.data;
|
|
113
|
+
const offset = (page - 1) * limit;
|
|
114
|
+
try {
|
|
115
|
+
const conditions = [eq(documents.ownerId, userId)];
|
|
116
|
+
if (folderId) conditions.push(eq(documents.folderId, folderId));
|
|
117
|
+
|
|
118
|
+
if (tag) {
|
|
119
|
+
const [countResult, rows] = await Promise.all([
|
|
120
|
+
db
|
|
121
|
+
.select({ total: count() })
|
|
122
|
+
.from(documents)
|
|
123
|
+
.innerJoin(documentTags, eq(documents.id, documentTags.documentId))
|
|
124
|
+
.where(and(eq(documentTags.tagId, tag), ...conditions)),
|
|
125
|
+
db
|
|
126
|
+
.select({
|
|
127
|
+
id: documents.id,
|
|
128
|
+
title: documents.title,
|
|
129
|
+
content: sql<string>`LEFT(${documents.content}, 200)`.as(
|
|
130
|
+
"content",
|
|
131
|
+
),
|
|
132
|
+
folderId: documents.folderId,
|
|
133
|
+
createdAt: documents.createdAt,
|
|
134
|
+
updatedAt: documents.updatedAt,
|
|
135
|
+
})
|
|
136
|
+
.from(documents)
|
|
137
|
+
.innerJoin(documentTags, eq(documents.id, documentTags.documentId))
|
|
138
|
+
.where(and(eq(documentTags.tagId, tag), ...conditions))
|
|
139
|
+
.orderBy(desc(documents.updatedAt))
|
|
140
|
+
.limit(limit)
|
|
141
|
+
.offset(offset),
|
|
142
|
+
]);
|
|
143
|
+
return {
|
|
144
|
+
items: await withTags(rows),
|
|
145
|
+
total: countResult[0]?.total ?? 0,
|
|
146
|
+
page,
|
|
147
|
+
limit,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const [countResult, rows] = await Promise.all([
|
|
152
|
+
db
|
|
153
|
+
.select({ total: count() })
|
|
154
|
+
.from(documents)
|
|
155
|
+
.where(and(...conditions)),
|
|
156
|
+
db
|
|
157
|
+
.select({
|
|
158
|
+
id: documents.id,
|
|
159
|
+
title: documents.title,
|
|
160
|
+
content: sql<string>`LEFT(${documents.content}, 200)`.as("content"),
|
|
161
|
+
folderId: documents.folderId,
|
|
162
|
+
createdAt: documents.createdAt,
|
|
163
|
+
updatedAt: documents.updatedAt,
|
|
164
|
+
})
|
|
165
|
+
.from(documents)
|
|
166
|
+
.where(and(...conditions))
|
|
167
|
+
.orderBy(desc(documents.updatedAt))
|
|
168
|
+
.limit(limit)
|
|
169
|
+
.offset(offset),
|
|
170
|
+
]);
|
|
171
|
+
return {
|
|
172
|
+
items: await withTags(rows),
|
|
173
|
+
total: countResult[0]?.total ?? 0,
|
|
174
|
+
page,
|
|
175
|
+
limit,
|
|
176
|
+
};
|
|
177
|
+
} catch (err) {
|
|
178
|
+
logger.error({ err }, "Failed to list documents");
|
|
179
|
+
set.status = 500;
|
|
180
|
+
return { error: "Failed to list documents" };
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// POST /api/documents — Create document + initial version
|
|
185
|
+
.post("/documents", async ({ request, set }) => {
|
|
186
|
+
const ip =
|
|
187
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
188
|
+
request.headers.get("x-real-ip") ??
|
|
189
|
+
"unknown";
|
|
190
|
+
const rl = await writeRateLimiter(ip);
|
|
191
|
+
if (!rl.allowed) {
|
|
192
|
+
set.status = 429;
|
|
193
|
+
set.headers = rateLimitHeaders(0, rl.retryAfter);
|
|
194
|
+
return { error: "Too many requests" };
|
|
195
|
+
}
|
|
196
|
+
set.headers = rateLimitHeaders(rl.remaining);
|
|
197
|
+
|
|
198
|
+
const userId = await getSessionUserId(request.headers);
|
|
199
|
+
if (!userId) {
|
|
200
|
+
set.status = 401;
|
|
201
|
+
return { error: "Unauthorized" };
|
|
202
|
+
}
|
|
203
|
+
const body = createDocumentSchema.safeParse(await request.json());
|
|
204
|
+
if (!body.success) {
|
|
205
|
+
set.status = 400;
|
|
206
|
+
return { error: "Invalid input", details: body.error.flatten() };
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
// If the client posts raw markdown without the TipTap JSON view
|
|
210
|
+
// (e.g. an import, a script, or any path that bypasses the
|
|
211
|
+
// editor), generate the JSON server-side so the editor opens
|
|
212
|
+
// with formatted content rather than the raw markdown source
|
|
213
|
+
// the next time the document is opened. `markdownToDocJson`
|
|
214
|
+
// returns `null` for empty input or on parse failure, in which
|
|
215
|
+
// case we simply persist the markdown without a JSON view — the
|
|
216
|
+
// frontend's `markdownToJson` helper will recover on load.
|
|
217
|
+
const initialContent = body.data.content ?? "";
|
|
218
|
+
const initialDocJson = initialContent
|
|
219
|
+
? await markdownToDocJson(initialContent)
|
|
220
|
+
: null;
|
|
221
|
+
const [created] = await db
|
|
222
|
+
.insert(documents)
|
|
223
|
+
.values({
|
|
224
|
+
ownerId: userId,
|
|
225
|
+
title: body.data.title,
|
|
226
|
+
content: initialContent,
|
|
227
|
+
contentJson: initialDocJson,
|
|
228
|
+
folderId: body.data.folderId ?? null,
|
|
229
|
+
})
|
|
230
|
+
.returning();
|
|
231
|
+
if (!created) {
|
|
232
|
+
set.status = 500;
|
|
233
|
+
return { error: "Failed to create document" };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await db.insert(versions).values({
|
|
237
|
+
documentId: created.id,
|
|
238
|
+
content: initialContent,
|
|
239
|
+
contentJson: initialDocJson,
|
|
240
|
+
createdBy: userId,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
enqueueEmbedding(created.id);
|
|
244
|
+
set.status = 201;
|
|
245
|
+
return created;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
logger.error({ err }, "Failed to create document");
|
|
248
|
+
set.status = 500;
|
|
249
|
+
return { error: "Failed to create document" };
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// GET /api/documents/:id — Get document with tags
|
|
254
|
+
.get("/documents/:id", async ({ params, set, request }) => {
|
|
255
|
+
const ip =
|
|
256
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
257
|
+
request.headers.get("x-real-ip") ??
|
|
258
|
+
"unknown";
|
|
259
|
+
const rl = await documentRateLimiter(ip);
|
|
260
|
+
if (!rl.allowed) {
|
|
261
|
+
set.status = 429;
|
|
262
|
+
set.headers = rateLimitHeaders(0, rl.retryAfter);
|
|
263
|
+
return { error: "Too many requests" };
|
|
264
|
+
}
|
|
265
|
+
set.headers = rateLimitHeaders(rl.remaining);
|
|
266
|
+
|
|
267
|
+
const userId = await getSessionUserId(request.headers);
|
|
268
|
+
if (!userId) {
|
|
269
|
+
set.status = 401;
|
|
270
|
+
return { error: "Unauthorized" };
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const rows = await db
|
|
274
|
+
.select({
|
|
275
|
+
id: documents.id,
|
|
276
|
+
ownerId: documents.ownerId,
|
|
277
|
+
folderId: documents.folderId,
|
|
278
|
+
folderName: folders.name,
|
|
279
|
+
title: documents.title,
|
|
280
|
+
content: documents.content,
|
|
281
|
+
contentJson: documents.contentJson,
|
|
282
|
+
metadata: documents.metadata,
|
|
283
|
+
createdAt: documents.createdAt,
|
|
284
|
+
updatedAt: documents.updatedAt,
|
|
285
|
+
})
|
|
286
|
+
.from(documents)
|
|
287
|
+
.leftJoin(folders, eq(folders.id, documents.folderId))
|
|
288
|
+
.where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
|
|
289
|
+
.limit(1);
|
|
290
|
+
|
|
291
|
+
const doc = rows[0];
|
|
292
|
+
if (!doc) {
|
|
293
|
+
set.status = 404;
|
|
294
|
+
return { error: "Document not found" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const docTags = await db
|
|
298
|
+
.select({ id: tags.id, name: tags.name, color: tags.color })
|
|
299
|
+
.from(tags)
|
|
300
|
+
.innerJoin(documentTags, eq(tags.id, documentTags.tagId))
|
|
301
|
+
.where(eq(documentTags.documentId, doc.id));
|
|
302
|
+
|
|
303
|
+
return { ...doc, tags: docTags };
|
|
304
|
+
} catch (err) {
|
|
305
|
+
logger.error({ err }, "Failed to get document");
|
|
306
|
+
set.status = 500;
|
|
307
|
+
return { error: "Failed to get document" };
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// PATCH /api/documents/:id — Update document, save version before
|
|
312
|
+
.patch("/documents/:id", async ({ params, request, set }) => {
|
|
313
|
+
const ip =
|
|
314
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
315
|
+
request.headers.get("x-real-ip") ??
|
|
316
|
+
"unknown";
|
|
317
|
+
const rl = await writeRateLimiter(ip);
|
|
318
|
+
if (!rl.allowed) {
|
|
319
|
+
set.status = 429;
|
|
320
|
+
set.headers = rateLimitHeaders(0, rl.retryAfter);
|
|
321
|
+
return { error: "Too many requests" };
|
|
322
|
+
}
|
|
323
|
+
set.headers = rateLimitHeaders(rl.remaining);
|
|
324
|
+
|
|
325
|
+
const userId = await getSessionUserId(request.headers);
|
|
326
|
+
if (!userId) {
|
|
327
|
+
set.status = 401;
|
|
328
|
+
return { error: "Unauthorized" };
|
|
329
|
+
}
|
|
330
|
+
const body = updateDocumentSchema.safeParse(await request.json());
|
|
331
|
+
if (!body.success) {
|
|
332
|
+
set.status = 400;
|
|
333
|
+
return { error: "Invalid input", details: body.error.flatten() };
|
|
334
|
+
}
|
|
335
|
+
if (
|
|
336
|
+
!body.data.title &&
|
|
337
|
+
body.data.content === undefined &&
|
|
338
|
+
body.data.contentJson === undefined &&
|
|
339
|
+
body.data.metadata === undefined &&
|
|
340
|
+
body.data.folderId === undefined
|
|
341
|
+
) {
|
|
342
|
+
set.status = 400;
|
|
343
|
+
return { error: "At least one field is required" };
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const existing = await db
|
|
347
|
+
.select({
|
|
348
|
+
id: documents.id,
|
|
349
|
+
content: documents.content,
|
|
350
|
+
contentJson: documents.contentJson,
|
|
351
|
+
})
|
|
352
|
+
.from(documents)
|
|
353
|
+
.where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
|
|
354
|
+
.limit(1);
|
|
355
|
+
if (existing.length === 0) {
|
|
356
|
+
set.status = 404;
|
|
357
|
+
return { error: "Document not found" };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await db.insert(versions).values({
|
|
361
|
+
documentId: params.id,
|
|
362
|
+
content: existing[0]?.content ?? "",
|
|
363
|
+
contentJson: existing[0]?.contentJson,
|
|
364
|
+
createdBy: userId,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// When the client sends new `content` but no `contentJson`,
|
|
368
|
+
// generate the JSON view server-side so the editor can render
|
|
369
|
+
// formatted content on the next open. When the client sends
|
|
370
|
+
// both fields (the editor's normal save path), prefer the
|
|
371
|
+
// client-supplied JSON — it reflects the user's live edits.
|
|
372
|
+
let resolvedDocJson: unknown | undefined = body.data.contentJson;
|
|
373
|
+
if (resolvedDocJson === undefined && body.data.content !== undefined) {
|
|
374
|
+
resolvedDocJson = body.data.content
|
|
375
|
+
? await markdownToDocJson(body.data.content)
|
|
376
|
+
: null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const [updated] = await db
|
|
380
|
+
.update(documents)
|
|
381
|
+
.set({
|
|
382
|
+
...(body.data.title !== undefined && { title: body.data.title }),
|
|
383
|
+
...(body.data.content !== undefined && {
|
|
384
|
+
content: body.data.content,
|
|
385
|
+
}),
|
|
386
|
+
...(resolvedDocJson !== undefined && {
|
|
387
|
+
contentJson: resolvedDocJson,
|
|
388
|
+
}),
|
|
389
|
+
...(body.data.metadata !== undefined && {
|
|
390
|
+
metadata: body.data.metadata,
|
|
391
|
+
}),
|
|
392
|
+
...(body.data.folderId !== undefined && {
|
|
393
|
+
folderId: body.data.folderId,
|
|
394
|
+
}),
|
|
395
|
+
updatedAt: new Date(),
|
|
396
|
+
})
|
|
397
|
+
.where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
|
|
398
|
+
.returning();
|
|
399
|
+
|
|
400
|
+
if (body.data.content !== undefined || body.data.title !== undefined) {
|
|
401
|
+
enqueueEmbedding(params.id);
|
|
402
|
+
}
|
|
403
|
+
return updated;
|
|
404
|
+
} catch (err) {
|
|
405
|
+
logger.error({ err }, "Failed to update document");
|
|
406
|
+
set.status = 500;
|
|
407
|
+
return { error: "Failed to update document" };
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
// POST /api/documents/:id/duplicate — Duplicate document with "(Copy)" suffix
|
|
412
|
+
.post("/documents/:id/duplicate", async ({ params, request, set }) => {
|
|
413
|
+
const ip =
|
|
414
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
415
|
+
request.headers.get("x-real-ip") ??
|
|
416
|
+
"unknown";
|
|
417
|
+
const rl = await writeRateLimiter(ip);
|
|
418
|
+
if (!rl.allowed) {
|
|
419
|
+
set.status = 429;
|
|
420
|
+
set.headers = rateLimitHeaders(0, rl.retryAfter);
|
|
421
|
+
return { error: "Too many requests" };
|
|
422
|
+
}
|
|
423
|
+
set.headers = rateLimitHeaders(rl.remaining);
|
|
424
|
+
|
|
425
|
+
const userId = await getSessionUserId(request.headers);
|
|
426
|
+
if (!userId) {
|
|
427
|
+
set.status = 401;
|
|
428
|
+
return { error: "Unauthorized" };
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const [source] = await db
|
|
432
|
+
.select()
|
|
433
|
+
.from(documents)
|
|
434
|
+
.where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
|
|
435
|
+
.limit(1);
|
|
436
|
+
if (!source) {
|
|
437
|
+
set.status = 404;
|
|
438
|
+
return { error: "Document not found" };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const [copy] = await db
|
|
442
|
+
.insert(documents)
|
|
443
|
+
.values({
|
|
444
|
+
ownerId: userId,
|
|
445
|
+
folderId: source.folderId,
|
|
446
|
+
title: `${source.title} (Copy)`,
|
|
447
|
+
content: source.content ?? "",
|
|
448
|
+
contentJson: source.contentJson,
|
|
449
|
+
metadata: source.metadata,
|
|
450
|
+
})
|
|
451
|
+
.returning();
|
|
452
|
+
if (!copy) {
|
|
453
|
+
set.status = 500;
|
|
454
|
+
return { error: "Failed to duplicate document" };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
await db.insert(versions).values({
|
|
458
|
+
documentId: copy.id,
|
|
459
|
+
content: copy.content ?? "",
|
|
460
|
+
contentJson: copy.contentJson,
|
|
461
|
+
createdBy: userId,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
enqueueEmbedding(copy.id);
|
|
465
|
+
set.status = 201;
|
|
466
|
+
return copy;
|
|
467
|
+
} catch (err) {
|
|
468
|
+
logger.error({ err }, "Failed to duplicate document");
|
|
469
|
+
set.status = 500;
|
|
470
|
+
return { error: "Failed to duplicate document" };
|
|
471
|
+
}
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
// DELETE /api/documents/:id — Delete document (cascade via FK)
|
|
475
|
+
.delete("/documents/:id", async ({ params, set, request }) => {
|
|
476
|
+
const ip =
|
|
477
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
478
|
+
request.headers.get("x-real-ip") ??
|
|
479
|
+
"unknown";
|
|
480
|
+
const rl = await writeRateLimiter(ip);
|
|
481
|
+
if (!rl.allowed) {
|
|
482
|
+
set.status = 429;
|
|
483
|
+
set.headers = rateLimitHeaders(0, rl.retryAfter);
|
|
484
|
+
return { error: "Too many requests" };
|
|
485
|
+
}
|
|
486
|
+
set.headers = rateLimitHeaders(rl.remaining);
|
|
487
|
+
|
|
488
|
+
const userId = await getSessionUserId(request.headers);
|
|
489
|
+
if (!userId) {
|
|
490
|
+
set.status = 401;
|
|
491
|
+
return { error: "Unauthorized" };
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const existing = await db
|
|
495
|
+
.select({ id: documents.id })
|
|
496
|
+
.from(documents)
|
|
497
|
+
.where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
|
|
498
|
+
.limit(1);
|
|
499
|
+
if (existing.length === 0) {
|
|
500
|
+
set.status = 404;
|
|
501
|
+
return { error: "Document not found" };
|
|
502
|
+
}
|
|
503
|
+
await db
|
|
504
|
+
.delete(documents)
|
|
505
|
+
.where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)));
|
|
506
|
+
return { success: true };
|
|
507
|
+
} catch (err) {
|
|
508
|
+
logger.error({ err }, "Failed to delete document");
|
|
509
|
+
set.status = 500;
|
|
510
|
+
return { error: "Failed to delete document" };
|
|
511
|
+
}
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
.get("/documents/:id/export", async ({ params, set, request }) => {
|
|
515
|
+
const userId = await getSessionUserId(request.headers);
|
|
516
|
+
if (!userId) {
|
|
517
|
+
set.status = 401;
|
|
518
|
+
return { error: "Unauthorized" };
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
const rows = await db
|
|
522
|
+
.select({
|
|
523
|
+
id: documents.id,
|
|
524
|
+
title: documents.title,
|
|
525
|
+
content: documents.content,
|
|
526
|
+
})
|
|
527
|
+
.from(documents)
|
|
528
|
+
.where(and(eq(documents.id, params.id), eq(documents.ownerId, userId)))
|
|
529
|
+
.limit(1);
|
|
530
|
+
const doc = rows[0];
|
|
531
|
+
if (!doc) {
|
|
532
|
+
set.status = 404;
|
|
533
|
+
return { error: "Document not found" };
|
|
534
|
+
}
|
|
535
|
+
const filename = `${doc.title.replace(/[^a-zA-Z0-9-_]/g, "_")}.md`;
|
|
536
|
+
set.headers = {
|
|
537
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
538
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
539
|
+
};
|
|
540
|
+
return doc.content ?? "";
|
|
541
|
+
} catch (err) {
|
|
542
|
+
logger.error({ err }, "Failed to export document");
|
|
543
|
+
set.status = 500;
|
|
544
|
+
return { error: "Failed to export document" };
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
.post("/documents/import", async ({ request, set }) => {
|
|
549
|
+
const ip =
|
|
550
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
551
|
+
request.headers.get("x-real-ip") ??
|
|
552
|
+
"unknown";
|
|
553
|
+
const rl = await writeRateLimiter(ip);
|
|
554
|
+
if (!rl.allowed) {
|
|
555
|
+
set.status = 429;
|
|
556
|
+
set.headers = rateLimitHeaders(0, rl.retryAfter);
|
|
557
|
+
return { error: "Too many requests" };
|
|
558
|
+
}
|
|
559
|
+
set.headers = rateLimitHeaders(rl.remaining);
|
|
560
|
+
|
|
561
|
+
const userId = await getSessionUserId(request.headers);
|
|
562
|
+
if (!userId) {
|
|
563
|
+
set.status = 401;
|
|
564
|
+
return { error: "Unauthorized" };
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
568
|
+
let title: string;
|
|
569
|
+
let content: string;
|
|
570
|
+
let folderId: string | null = null;
|
|
571
|
+
|
|
572
|
+
if (contentType.includes("application/json")) {
|
|
573
|
+
const body = await request.json();
|
|
574
|
+
const parsed = importJsonSchema.safeParse(body);
|
|
575
|
+
if (!parsed.success) {
|
|
576
|
+
set.status = 400;
|
|
577
|
+
return {
|
|
578
|
+
error: "Invalid import data",
|
|
579
|
+
details: parsed.error.flatten(),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
title = parsed.data.title ?? "Imported Document";
|
|
583
|
+
content = parsed.data.content;
|
|
584
|
+
folderId = parsed.data.folderId ?? null;
|
|
585
|
+
} else if (contentType.includes("multipart/form-data")) {
|
|
586
|
+
const formData = await request.formData();
|
|
587
|
+
const file = formData.get("file") as File | null;
|
|
588
|
+
folderId = (formData.get("folderId") as string) ?? null;
|
|
589
|
+
if (!file) {
|
|
590
|
+
set.status = 400;
|
|
591
|
+
return { error: "File is required" };
|
|
592
|
+
}
|
|
593
|
+
if (file.size > MAX_IMPORT_SIZE) {
|
|
594
|
+
set.status = 413;
|
|
595
|
+
return {
|
|
596
|
+
error: `File too large. Maximum size: ${MAX_IMPORT_SIZE / 1024 / 1024}MB`,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const ext = `.${file.name.split(".").pop()?.toLowerCase()}`;
|
|
600
|
+
if (!ALLOWED_IMPORT_EXTENSIONS.includes(ext)) {
|
|
601
|
+
set.status = 415;
|
|
602
|
+
return {
|
|
603
|
+
error: `Invalid file type. Allowed: ${ALLOWED_IMPORT_EXTENSIONS.join(", ")}`,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
if (folderId && !z.string().uuid().safeParse(folderId).success) {
|
|
607
|
+
set.status = 400;
|
|
608
|
+
return { error: "Invalid folderId" };
|
|
609
|
+
}
|
|
610
|
+
const text = await file.text();
|
|
611
|
+
const name = file.name;
|
|
612
|
+
if (name.endsWith(".json")) {
|
|
613
|
+
const jsonBody = JSON.parse(text);
|
|
614
|
+
const jsonParsed = importJsonSchema.safeParse(jsonBody);
|
|
615
|
+
if (!jsonParsed.success) {
|
|
616
|
+
set.status = 400;
|
|
617
|
+
return {
|
|
618
|
+
error: "Invalid JSON format",
|
|
619
|
+
details: jsonParsed.error.flatten(),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
title = jsonParsed.data.title ?? name.replace(/\.json$/, "");
|
|
623
|
+
content = jsonParsed.data.content;
|
|
624
|
+
} else {
|
|
625
|
+
title = name.replace(/\.(md|txt|markdown)$/, "");
|
|
626
|
+
content = text;
|
|
627
|
+
}
|
|
628
|
+
} else {
|
|
629
|
+
set.status = 415;
|
|
630
|
+
return {
|
|
631
|
+
error:
|
|
632
|
+
"Unsupported content type. Use application/json or multipart/form-data",
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const [created] = await db
|
|
637
|
+
.insert(documents)
|
|
638
|
+
.values({
|
|
639
|
+
ownerId: userId,
|
|
640
|
+
title,
|
|
641
|
+
content,
|
|
642
|
+
folderId: folderId ?? null,
|
|
643
|
+
})
|
|
644
|
+
.returning();
|
|
645
|
+
if (!created) {
|
|
646
|
+
set.status = 500;
|
|
647
|
+
return { error: "Failed to import document" };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
await db.insert(versions).values({
|
|
651
|
+
documentId: created.id,
|
|
652
|
+
content,
|
|
653
|
+
createdBy: userId,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
enqueueEmbedding(created.id);
|
|
657
|
+
set.status = 201;
|
|
658
|
+
return created;
|
|
659
|
+
} catch (err) {
|
|
660
|
+
logger.error({ err }, "Failed to import document");
|
|
661
|
+
set.status = 500;
|
|
662
|
+
return { error: "Failed to import document" };
|
|
663
|
+
}
|
|
664
|
+
});
|