@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.
Files changed (216) hide show
  1. package/.all-contributorsrc +18 -0
  2. package/.claude/settings.local.json +61 -0
  3. package/.dockerignore +113 -0
  4. package/.env.example +68 -0
  5. package/.github/FUNDING.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
  8. package/.github/dependabot.yml +136 -0
  9. package/.github/pull_request_template.md +96 -0
  10. package/.github/workflows/ci.yml +283 -0
  11. package/AGENTS.md +237 -0
  12. package/CODE_OF_CONDUCT.md +134 -0
  13. package/CONTRIBUTING.md +77 -0
  14. package/Caddyfile +50 -0
  15. package/Dockerfile.backend +60 -0
  16. package/LICENSE +21 -0
  17. package/README.md +284 -0
  18. package/RELEASE_CHECKLIST.md +34 -0
  19. package/SECURITY.md +60 -0
  20. package/backend/package.json +43 -0
  21. package/backend/src/__tests__/auth-helpers.test.ts +51 -0
  22. package/backend/src/__tests__/chunker.test.ts +65 -0
  23. package/backend/src/__tests__/config.test.ts +91 -0
  24. package/backend/src/__tests__/csrf.test.ts +91 -0
  25. package/backend/src/__tests__/embedding.test.ts +48 -0
  26. package/backend/src/__tests__/rate-limit.test.ts +46 -0
  27. package/backend/src/__tests__/routes.test.ts +38 -0
  28. package/backend/src/__tests__/schema.test.ts +31 -0
  29. package/backend/src/__tests__/validation.test.ts +556 -0
  30. package/backend/src/api/middleware/auth.ts +56 -0
  31. package/backend/src/api/middleware/csrf.ts +91 -0
  32. package/backend/src/api/middleware/rate-limit.ts +77 -0
  33. package/backend/src/api/middleware/webhook-verify.ts +22 -0
  34. package/backend/src/api/routes/attachments.ts +280 -0
  35. package/backend/src/api/routes/auth.ts +52 -0
  36. package/backend/src/api/routes/collaboration.ts +121 -0
  37. package/backend/src/api/routes/documents.ts +664 -0
  38. package/backend/src/api/routes/folders.ts +226 -0
  39. package/backend/src/api/routes/search.ts +354 -0
  40. package/backend/src/api/routes/share.ts +512 -0
  41. package/backend/src/api/routes/tags.ts +247 -0
  42. package/backend/src/api/routes/versions.ts +99 -0
  43. package/backend/src/api/routes/webhooks.ts +43 -0
  44. package/backend/src/embedding/chunker.ts +74 -0
  45. package/backend/src/embedding/index.ts +117 -0
  46. package/backend/src/embedding/providers/ollama.ts +63 -0
  47. package/backend/src/embedding/providers/openrouter.ts +71 -0
  48. package/backend/src/embedding/utils.ts +13 -0
  49. package/backend/src/embedding/worker.ts +89 -0
  50. package/backend/src/index.ts +147 -0
  51. package/backend/src/lib/auth-helpers.ts +27 -0
  52. package/backend/src/lib/auth.ts +35 -0
  53. package/backend/src/lib/config.ts +73 -0
  54. package/backend/src/lib/db.ts +7 -0
  55. package/backend/src/lib/embedding-queue.ts +12 -0
  56. package/backend/src/lib/logger.ts +18 -0
  57. package/backend/src/lib/markdown-to-doc.ts +45 -0
  58. package/backend/src/lib/minio.ts +46 -0
  59. package/backend/src/lib/redis.ts +19 -0
  60. package/backend/src/lib/yjs-provider.ts +182 -0
  61. package/backend/tests/integration/_harness.ts +754 -0
  62. package/backend/tests/integration/auth.test.ts +296 -0
  63. package/backend/tests/integration/routes.documents.test.ts +459 -0
  64. package/backend/tests/integration/routes.folders.test.ts +337 -0
  65. package/backend/tests/integration/routes.search.test.ts +322 -0
  66. package/backend/tests/integration/routes.share.test.ts +773 -0
  67. package/backend/tests/integration/routes.tags.test.ts +425 -0
  68. package/backend/tests/integration/routes.versions.test.ts +233 -0
  69. package/backend/tsconfig.json +18 -0
  70. package/docker-compose.yml +218 -0
  71. package/docs/API.md +328 -0
  72. package/docs/ARCHITECTURE.md +75 -0
  73. package/docs/DEPLOYMENT.md +113 -0
  74. package/docs/PRODUCTION_STATUS.md +61 -0
  75. package/docs/openapi.json +385 -0
  76. package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
  77. package/frontend/.svelte-kit.old/env.d.ts +1 -0
  78. package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
  79. package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
  80. package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
  81. package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
  82. package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
  83. package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
  84. package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
  85. package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
  86. package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
  87. package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
  88. package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
  89. package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
  90. package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
  91. package/frontend/.svelte-kit.old/generated/root.js +3 -0
  92. package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
  93. package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
  94. package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
  95. package/frontend/.svelte-kit.old/tsconfig.json +59 -0
  96. package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
  97. package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
  98. package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
  99. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
  100. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
  101. package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
  102. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
  103. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
  104. package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
  105. package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
  106. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
  107. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
  108. package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
  109. package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
  110. package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
  111. package/frontend/Dockerfile +44 -0
  112. package/frontend/biome.json +40 -0
  113. package/frontend/components.json +18 -0
  114. package/frontend/messages/en.json +434 -0
  115. package/frontend/package.json +70 -0
  116. package/frontend/project.inlang/settings.json +12 -0
  117. package/frontend/src/app.css +6 -0
  118. package/frontend/src/app.d.ts +13 -0
  119. package/frontend/src/app.html +30 -0
  120. package/frontend/src/hooks.server.ts +10 -0
  121. package/frontend/src/hooks.ts +10 -0
  122. package/frontend/src/lib/api/attachments.ts +45 -0
  123. package/frontend/src/lib/api/client.test.ts +15 -0
  124. package/frontend/src/lib/api/client.ts +57 -0
  125. package/frontend/src/lib/api/documents.ts +83 -0
  126. package/frontend/src/lib/api/folders.ts +180 -0
  127. package/frontend/src/lib/api/search.test.ts +52 -0
  128. package/frontend/src/lib/api/search.ts +128 -0
  129. package/frontend/src/lib/api/settings.ts +95 -0
  130. package/frontend/src/lib/api/share.ts +71 -0
  131. package/frontend/src/lib/api/tags.test.ts +91 -0
  132. package/frontend/src/lib/api/tags.ts +87 -0
  133. package/frontend/src/lib/auth-client.ts +10 -0
  134. package/frontend/src/lib/collaboration.ts +63 -0
  135. package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
  136. package/frontend/src/lib/components/DatePicker.svelte +322 -0
  137. package/frontend/src/lib/components/DocumentCard.svelte +166 -0
  138. package/frontend/src/lib/components/EmptyState.svelte +49 -0
  139. package/frontend/src/lib/components/FolderCard.svelte +93 -0
  140. package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
  141. package/frontend/src/lib/components/SearchBar.svelte +47 -0
  142. package/frontend/src/lib/components/SearchResult.svelte +115 -0
  143. package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
  144. package/frontend/src/lib/components/ShareDialog.svelte +158 -0
  145. package/frontend/src/lib/components/ShareLink.svelte +98 -0
  146. package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
  147. package/frontend/src/lib/components/VersionDiff.svelte +55 -0
  148. package/frontend/src/lib/components/VersionHistory.svelte +96 -0
  149. package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
  150. package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
  151. package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
  152. package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
  153. package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
  154. package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
  155. package/frontend/src/lib/components/editor/markdown.ts +38 -0
  156. package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
  157. package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
  158. package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
  159. package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
  160. package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
  161. package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
  162. package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
  163. package/frontend/src/lib/stores/theme.svelte.ts +97 -0
  164. package/frontend/src/lib/svelte.d.ts +6 -0
  165. package/frontend/src/lib/types.ts +44 -0
  166. package/frontend/src/lib/utils/clipboard.ts +17 -0
  167. package/frontend/src/lib/utils/strip-markdown.ts +59 -0
  168. package/frontend/src/lib/utils.ts +33 -0
  169. package/frontend/src/routes/(app)/+layout.svelte +17 -0
  170. package/frontend/src/routes/(app)/+page.server.ts +10 -0
  171. package/frontend/src/routes/(app)/+page.svelte +303 -0
  172. package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
  173. package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
  174. package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
  175. package/frontend/src/routes/(app)/search/+page.svelte +593 -0
  176. package/frontend/src/routes/(app)/search/+page.ts +25 -0
  177. package/frontend/src/routes/+error.svelte +12 -0
  178. package/frontend/src/routes/+layout.svelte +18 -0
  179. package/frontend/src/routes/+layout.ts +2 -0
  180. package/frontend/src/routes/api/[...path]/+server.ts +111 -0
  181. package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
  182. package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
  183. package/frontend/src/routes/folders/[id]/+page.ts +14 -0
  184. package/frontend/src/routes/login/+page.svelte +90 -0
  185. package/frontend/src/routes/register/+page.svelte +97 -0
  186. package/frontend/src/routes/s/[token]/+page.svelte +496 -0
  187. package/frontend/src/routes/s/[token]/+page.ts +5 -0
  188. package/frontend/src/routes/settings/+page.svelte +175 -0
  189. package/frontend/static/favicon.png +0 -0
  190. package/frontend/static/logo.png +0 -0
  191. package/frontend/svelte.config.js +15 -0
  192. package/frontend/tsconfig.json +15 -0
  193. package/frontend/vite.config.ts +25 -0
  194. package/init.sql +9 -0
  195. package/logo.png +0 -0
  196. package/package.json +39 -0
  197. package/package.public.json +39 -0
  198. package/packages/db/drizzle.config.ts +10 -0
  199. package/packages/db/package.json +30 -0
  200. package/packages/db/src/client.ts +9 -0
  201. package/packages/db/src/index.ts +2 -0
  202. package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
  203. package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
  204. package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
  205. package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
  206. package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
  207. package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
  208. package/packages/db/src/migrations/meta/_journal.json +27 -0
  209. package/packages/db/src/schema.ts +378 -0
  210. package/packages/db/tsconfig.json +17 -0
  211. package/scripts/export-openapi.ts +37 -0
  212. package/scripts/health-check.sh +75 -0
  213. package/scripts/migrate.sh +135 -0
  214. package/scripts/prework_backup.sh +25 -0
  215. package/scripts/release.sh +83 -0
  216. package/tsconfig.json +25 -0
@@ -0,0 +1,77 @@
1
+ import { redis } from "../../lib/redis";
2
+
3
+ interface RateLimitConfig {
4
+ windowSec: number;
5
+ max: number;
6
+ keyPrefix: string;
7
+ }
8
+
9
+ function _getClientIp(request: Request): string {
10
+ return (
11
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
12
+ request.headers.get("x-real-ip") ??
13
+ "unknown"
14
+ );
15
+ }
16
+
17
+ export function createRateLimiter(config: RateLimitConfig) {
18
+ return async (
19
+ ip: string,
20
+ ): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> => {
21
+ const key = `hiai-docs:${config.keyPrefix}:${ip}`;
22
+ try {
23
+ const count = await redis.incr(key);
24
+ if (count === 1) {
25
+ await redis.expire(key, config.windowSec);
26
+ }
27
+ const remaining = Math.max(0, config.max - count);
28
+ if (count > config.max) {
29
+ const ttl = await redis.ttl(key);
30
+ return {
31
+ allowed: false,
32
+ remaining: 0,
33
+ retryAfter: ttl > 0 ? ttl : config.windowSec,
34
+ };
35
+ }
36
+ return { allowed: true, remaining };
37
+ } catch {
38
+ return { allowed: false, remaining: 0, retryAfter: 60 };
39
+ }
40
+ };
41
+ }
42
+
43
+ export const searchRateLimiter = createRateLimiter({
44
+ windowSec: 60,
45
+ max: 20,
46
+ keyPrefix: "search",
47
+ });
48
+ export const documentRateLimiter = createRateLimiter({
49
+ windowSec: 60,
50
+ max: 60,
51
+ keyPrefix: "docs",
52
+ });
53
+ export const writeRateLimiter = createRateLimiter({
54
+ windowSec: 60,
55
+ max: 60,
56
+ keyPrefix: "write",
57
+ });
58
+ export const shareRateLimiter = createRateLimiter({
59
+ windowSec: 60,
60
+ max: 5,
61
+ keyPrefix: "share",
62
+ });
63
+ export const healthRateLimiter = createRateLimiter({
64
+ windowSec: 60,
65
+ max: 120,
66
+ keyPrefix: "health",
67
+ });
68
+
69
+ export function rateLimitHeaders(remaining: number, retryAfter?: number) {
70
+ const headers: Record<string, string> = {
71
+ "X-RateLimit-Remaining": String(remaining),
72
+ };
73
+ if (retryAfter) {
74
+ headers["Retry-After"] = String(retryAfter);
75
+ }
76
+ return headers;
77
+ }
@@ -0,0 +1,22 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { config } from "../../lib/config";
3
+
4
+ const WEBHOOK_SECRET = config.WEBHOOK_SECRET;
5
+
6
+ export function verifyWebhookSignature(
7
+ body: string,
8
+ signature: string | null,
9
+ ): boolean {
10
+ if (!signature || !WEBHOOK_SECRET) return false;
11
+ const expected = createHmac("sha256", WEBHOOK_SECRET)
12
+ .update(body)
13
+ .digest("hex");
14
+ try {
15
+ return timingSafeEqual(
16
+ Buffer.from(signature, "hex"),
17
+ Buffer.from(expected, "hex"),
18
+ );
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
@@ -0,0 +1,280 @@
1
+ import { attachments, documents } from "@hiai-docs/db/schema";
2
+ import { and, eq } from "drizzle-orm";
3
+ import { Elysia } from "elysia";
4
+ import { nanoid } from "nanoid";
5
+ import { getSessionUserId } from "../../lib/auth-helpers";
6
+ import { db } from "../../lib/db";
7
+ import { logger } from "../../lib/logger";
8
+ import { BUCKET, minio } from "../../lib/minio";
9
+ import { rateLimitHeaders, writeRateLimiter } from "../middleware/rate-limit";
10
+
11
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
12
+ const INTEGRITY_PROBE_BYTES = 8;
13
+
14
+ /**
15
+ * Read the first few bytes of an uploaded object back from MinIO and
16
+ * compare them to the source buffer. Returns true on match, false on
17
+ * mismatch, and true (treated as success) if the readback itself fails
18
+ * — we never want a transient readback error to reject a successful
19
+ * upload, but we DO want to catch a real byte-level corruption in the
20
+ * put → get round trip.
21
+ */
22
+ async function verifyUploadIntegrity(
23
+ source: Buffer,
24
+ key: string,
25
+ ): Promise<boolean> {
26
+ const probeLen = Math.min(INTEGRITY_PROBE_BYTES, source.length);
27
+ if (probeLen === 0) return true;
28
+ const expected = source.subarray(0, probeLen);
29
+
30
+ try {
31
+ const stream = await minio.getPartialObject(BUCKET, key, 0, probeLen);
32
+ const chunks: Buffer[] = [];
33
+ for await (const chunk of stream) {
34
+ chunks.push(chunk as Buffer);
35
+ }
36
+ const actual = Buffer.concat(chunks);
37
+ if (actual.length !== probeLen) {
38
+ logger.warn(
39
+ { key, expected: probeLen, got: actual.length },
40
+ "Integrity probe: length mismatch (readback skipped)",
41
+ );
42
+ return true;
43
+ }
44
+ if (!actual.equals(expected)) {
45
+ logger.error(
46
+ {
47
+ key,
48
+ expected: expected.toString("hex"),
49
+ got: actual.toString("hex"),
50
+ },
51
+ "Integrity probe: byte mismatch — upload is corrupted",
52
+ );
53
+ return false;
54
+ }
55
+ return true;
56
+ } catch (err) {
57
+ logger.warn(
58
+ { err, key },
59
+ "Integrity probe: readback failed (treated as success)",
60
+ );
61
+ return true;
62
+ }
63
+ }
64
+
65
+ async function getClientIp(request: Request): Promise<string> {
66
+ return (
67
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
68
+ request.headers.get("x-real-ip") ??
69
+ "unknown"
70
+ );
71
+ }
72
+
73
+ export const attachmentRoutes = new Elysia({ prefix: "/api" })
74
+
75
+ // POST /api/documents/:id/attachments — Upload image attachment
76
+ .post("/documents/:id/attachments", async ({ params, request, set }) => {
77
+ const ip = await getClientIp(request);
78
+ const rl = await writeRateLimiter(ip);
79
+ if (!rl.allowed) {
80
+ set.status = 429;
81
+ set.headers = rateLimitHeaders(0, rl.retryAfter);
82
+ return { error: "Too many requests" };
83
+ }
84
+ set.headers = rateLimitHeaders(rl.remaining);
85
+
86
+ const userId = await getSessionUserId(request.headers);
87
+ if (!userId) {
88
+ set.status = 401;
89
+ return { error: "Unauthorized" };
90
+ }
91
+
92
+ const documentId = params.id;
93
+
94
+ // Verify document exists and user owns it
95
+ const doc = await db
96
+ .select({ id: documents.id })
97
+ .from(documents)
98
+ .where(and(eq(documents.id, documentId), eq(documents.ownerId, userId)))
99
+ .limit(1);
100
+
101
+ if (!doc.length) {
102
+ set.status = 404;
103
+ return { error: "Document not found" };
104
+ }
105
+
106
+ // Parse multipart form data
107
+ let file: File | null;
108
+ try {
109
+ const formData = await request.formData();
110
+ file = formData.get("file") as File | null;
111
+ } catch {
112
+ set.status = 400;
113
+ return { error: "Failed to parse form data" };
114
+ }
115
+
116
+ if (!file) {
117
+ set.status = 400;
118
+ return { error: "No file provided" };
119
+ }
120
+
121
+ if (!file.type.startsWith("image/")) {
122
+ set.status = 415;
123
+ return { error: "Only image files are allowed" };
124
+ }
125
+
126
+ if (file.size > MAX_FILE_SIZE) {
127
+ set.status = 413;
128
+ return {
129
+ error: `File too large. Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB`,
130
+ };
131
+ }
132
+
133
+ // Generate MinIO key
134
+ const ext = file.name.split(".").pop() ?? "bin";
135
+ const key = `${userId}/${documentId}/${nanoid()}.${ext}`;
136
+
137
+ try {
138
+ // Upload to MinIO
139
+ const arrayBuffer = await file.arrayBuffer();
140
+ const buffer = Buffer.from(arrayBuffer);
141
+ await minio.putObject(BUCKET, key, buffer, file.size, {
142
+ "Content-Type": file.type,
143
+ });
144
+
145
+ // Defensive integrity check: read the first 8 bytes back from
146
+ // MinIO and compare to the source buffer. The current pipeline
147
+ // (Bun Request.formData() + Buffer.from(arrayBuffer) +
148
+ // minio.putObject) is binary-safe — empirical round-trip tests
149
+ // confirm no corruption — but a non-text-mode regression in any
150
+ // of those layers would surface as 0x89 being replaced with
151
+ // 0xEF 0xBF 0xBD (UTF-8 U+FFFD) for PNG/JPEG high-bit bytes.
152
+ // The check is best-effort: a readback failure (e.g. transient
153
+ // network blip) logs a warning but does not fail the upload.
154
+ const integrityOk = await verifyUploadIntegrity(buffer, key);
155
+ if (!integrityOk) {
156
+ await minio.removeObject(BUCKET, key).catch((removeErr) => {
157
+ logger.error(
158
+ { err: removeErr, key },
159
+ "Failed to clean up corrupted upload",
160
+ );
161
+ });
162
+ set.status = 500;
163
+ return { error: "Upload integrity check failed" };
164
+ }
165
+
166
+ // Insert attachment row
167
+ const [created] = await db
168
+ .insert(attachments)
169
+ .values({
170
+ documentId,
171
+ filename: file.name,
172
+ mimeType: file.type,
173
+ size: file.size,
174
+ minioKey: key,
175
+ })
176
+ .returning();
177
+
178
+ if (!created) {
179
+ set.status = 500;
180
+ return { error: "Failed to save attachment record" };
181
+ }
182
+
183
+ // Return a stable, same-origin streaming URL instead of a 24h
184
+ // presigned URL. The presigned URL would expire (breaking images
185
+ // embedded in saved documents) and would not be reachable from the
186
+ // public share view. `/api/attachments/:id/raw` is permanent and
187
+ // public.
188
+ set.status = 201;
189
+ return {
190
+ id: created.id,
191
+ filename: created.filename,
192
+ mimeType: created.mimeType,
193
+ size: created.size,
194
+ url: `/api/attachments/${created.id}/raw`,
195
+ };
196
+ } catch (err) {
197
+ logger.error({ err }, "Failed to upload attachment");
198
+ set.status = 500;
199
+ return { error: "Failed to upload attachment" };
200
+ }
201
+ })
202
+
203
+ // GET /api/documents/:id/attachments — List attachments for a document
204
+ .get("/documents/:id/attachments", async ({ params, set, request }) => {
205
+ const userId = await getSessionUserId(request.headers);
206
+ if (!userId) {
207
+ set.status = 401;
208
+ return { error: "Unauthorized" };
209
+ }
210
+
211
+ const documentId = params.id;
212
+
213
+ // Verify document exists and user owns it
214
+ const doc = await db
215
+ .select({ id: documents.id })
216
+ .from(documents)
217
+ .where(and(eq(documents.id, documentId), eq(documents.ownerId, userId)))
218
+ .limit(1);
219
+
220
+ if (!doc.length) {
221
+ set.status = 404;
222
+ return { error: "Document not found" };
223
+ }
224
+
225
+ try {
226
+ const rows = await db
227
+ .select()
228
+ .from(attachments)
229
+ .where(eq(attachments.documentId, documentId));
230
+
231
+ // Stable same-origin streaming URLs (see POST handler note).
232
+ const result = rows.map((row) => ({
233
+ id: row.id,
234
+ filename: row.filename,
235
+ mimeType: row.mimeType,
236
+ size: row.size,
237
+ url: `/api/attachments/${row.id}/raw`,
238
+ }));
239
+
240
+ return { items: result };
241
+ } catch (err) {
242
+ logger.error({ err }, "Failed to list attachments");
243
+ set.status = 500;
244
+ return { error: "Failed to list attachments" };
245
+ }
246
+ })
247
+
248
+ // GET /api/attachments/:id/raw — Stream attachment bytes (PUBLIC, no auth).
249
+ // Intentionally public so images embedded in shared documents load without
250
+ // a session. The attachment id is a UUID, so it is unguessable.
251
+ .get("/attachments/:id/raw", async ({ params, set }) => {
252
+ try {
253
+ const [row] = await db
254
+ .select()
255
+ .from(attachments)
256
+ .where(eq(attachments.id, params.id))
257
+ .limit(1);
258
+ if (!row) {
259
+ set.status = 404;
260
+ return { error: "Attachment not found" };
261
+ }
262
+
263
+ const stream = await minio.getObject(BUCKET, row.minioKey);
264
+ const chunks: Buffer[] = [];
265
+ for await (const chunk of stream) {
266
+ chunks.push(chunk as Buffer);
267
+ }
268
+ const buffer = Buffer.concat(chunks);
269
+
270
+ set.headers = {
271
+ "Content-Type": row.mimeType,
272
+ "Cache-Control": "public, max-age=31536000, immutable",
273
+ };
274
+ return buffer;
275
+ } catch (err) {
276
+ logger.error({ err }, "Failed to stream attachment");
277
+ set.status = 500;
278
+ return { error: "Failed to stream attachment" };
279
+ }
280
+ });
@@ -0,0 +1,52 @@
1
+ import { Elysia } from "elysia";
2
+ import { auth } from "../../lib/auth";
3
+
4
+ // Rate limiting for auth endpoints (5 attempts per minute per IP)
5
+ const authRateLimit = new Map<string, { count: number; resetAt: number }>();
6
+ const AUTH_RATE_MAX = 5;
7
+ const AUTH_RATE_WINDOW = 60_000;
8
+
9
+ // Cleanup stale entries every 5 minutes
10
+ setInterval(() => {
11
+ const now = Date.now();
12
+ for (const [key, value] of authRateLimit.entries()) {
13
+ if (now > value.resetAt) authRateLimit.delete(key);
14
+ }
15
+ }, 300_000);
16
+
17
+ function checkAuthRateLimit(ip: string): boolean {
18
+ const now = Date.now();
19
+ const entry = authRateLimit.get(ip);
20
+ if (!entry || now > entry.resetAt) {
21
+ authRateLimit.set(ip, { count: 1, resetAt: now + AUTH_RATE_WINDOW });
22
+ return true;
23
+ }
24
+ if (entry.count >= AUTH_RATE_MAX) return false;
25
+ entry.count++;
26
+ return true;
27
+ }
28
+
29
+ export const authRoutes = new Elysia({ prefix: "/api/auth" }).all(
30
+ "/*",
31
+ async ({ request, set }) => {
32
+ // Rate limit sign-in/sign-up attempts
33
+ const url = new URL(request.url);
34
+ if (
35
+ url.pathname.includes("/sign-in") ||
36
+ url.pathname.includes("/sign-up") ||
37
+ url.pathname.includes("/login")
38
+ ) {
39
+ const ip =
40
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
41
+ request.headers.get("x-real-ip") ??
42
+ "unknown";
43
+ if (!checkAuthRateLimit(ip)) {
44
+ set.status = 429;
45
+ return { error: "Too many login attempts. Try again later." };
46
+ }
47
+ }
48
+
49
+ // Delegate all /api/auth/* requests to Better Auth's handler
50
+ return auth.handler(request);
51
+ },
52
+ );
@@ -0,0 +1,121 @@
1
+ import { Elysia } from "elysia";
2
+ import * as Y from "yjs";
3
+ import { auth } from "../../lib/auth";
4
+ import { config } from "../../lib/config";
5
+ import { logger } from "../../lib/logger";
6
+ import {
7
+ addClient,
8
+ broadcastUpdate,
9
+ getYjsDoc,
10
+ removeClient,
11
+ } from "../../lib/yjs-provider";
12
+
13
+ interface CollabSession {
14
+ docId: string;
15
+ clientId: number;
16
+ }
17
+
18
+ interface CollabMessage {
19
+ type: "update" | "ping" | "sync";
20
+ update?: string;
21
+ }
22
+
23
+ interface CollabWebSocket {
24
+ data: { documentId?: string; query?: Record<string, string> };
25
+ send: (data: string) => void;
26
+ close: (code: number, reason: string) => void;
27
+ }
28
+
29
+ const sessions = new WeakMap<CollabWebSocket, CollabSession>();
30
+
31
+ async function verifyWsAuth(token: string | null): Promise<string | null> {
32
+ if (!token) return null;
33
+ const apiKey = config.HIAI_DOCS_API_KEY;
34
+ if (apiKey && token === apiKey) return config.OWNER_ID;
35
+ try {
36
+ const session = await auth.api.getSession({
37
+ headers: new Headers({ cookie: `better-auth.session_token=${token}` }),
38
+ });
39
+ return session?.user?.id ?? null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export const collaborationRoutes = new Elysia();
46
+
47
+ collaborationRoutes.ws("/ws/collab/:documentId", {
48
+ open: async (rawWs) => {
49
+ const ws = rawWs as unknown as CollabWebSocket;
50
+ const documentId = ws.data.documentId;
51
+ if (!documentId) {
52
+ ws.close(1008, "Missing documentId");
53
+ return;
54
+ }
55
+
56
+ const token = ws.data.query?.token ?? null;
57
+ const userId = await verifyWsAuth(token);
58
+ if (!userId) {
59
+ ws.close(1008, "Authentication required");
60
+ return;
61
+ }
62
+
63
+ const doc = await getYjsDoc(documentId);
64
+ const clientId = doc.clientID;
65
+ addClient(documentId);
66
+ sessions.set(ws, { docId: documentId, clientId });
67
+
68
+ const state = Y.encodeStateAsUpdate(doc);
69
+ ws.send(
70
+ JSON.stringify({
71
+ type: "sync",
72
+ state: Buffer.from(state).toString("base64"),
73
+ clientId,
74
+ }),
75
+ );
76
+ logger.debug({ documentId, clientId }, "WebSocket client connected");
77
+ },
78
+
79
+ message: async (rawWs, message) => {
80
+ const ws = rawWs as unknown as CollabWebSocket;
81
+ try {
82
+ const raw =
83
+ typeof message === "string"
84
+ ? message
85
+ : Buffer.isBuffer(message)
86
+ ? message.toString("utf-8")
87
+ : String(message);
88
+ const data = JSON.parse(raw) as CollabMessage;
89
+ const session = sessions.get(ws);
90
+ if (!session) return;
91
+
92
+ const doc = await getYjsDoc(session.docId);
93
+
94
+ if (data.type === "update" && data.update) {
95
+ const update = Buffer.from(data.update, "base64");
96
+ Y.applyUpdate(doc, update);
97
+ broadcastUpdate(session.docId, update, session.clientId);
98
+ } else if (data.type === "ping") {
99
+ ws.send(JSON.stringify({ type: "pong" }));
100
+ }
101
+ } catch (err) {
102
+ logger.error({ err }, "WebSocket message error");
103
+ }
104
+ },
105
+
106
+ close: (rawWs) => {
107
+ const ws = rawWs as unknown as CollabWebSocket;
108
+ const session = sessions.get(ws);
109
+ if (!session) return;
110
+ removeClient(session.docId);
111
+ sessions.delete(ws);
112
+ logger.debug(
113
+ { documentId: session.docId, clientId: session.clientId },
114
+ "WebSocket client disconnected",
115
+ );
116
+ },
117
+
118
+ drain: () => {
119
+ logger.debug("WebSocket backpressure relieved");
120
+ },
121
+ });