@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,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
|
+
});
|