@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,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter embedding provider.
|
|
3
|
+
* POST to https://openrouter.ai/api/v1/embeddings using OPENROUTER_API_KEY.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { logger } from "../../lib/logger";
|
|
7
|
+
import { normalizeDimensions } from "../utils";
|
|
8
|
+
|
|
9
|
+
const EMBEDDING_DIMENSIONS = 1024;
|
|
10
|
+
const TIMEOUT_MS = 30_000;
|
|
11
|
+
const OPENROUTER_URL = "https://openrouter.ai/api/v1/embeddings";
|
|
12
|
+
|
|
13
|
+
interface OpenRouterEmbeddingResponse {
|
|
14
|
+
data: Array<{ embedding: number[] }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get an embedding vector from OpenRouter.
|
|
19
|
+
* @param text - Text to embed
|
|
20
|
+
* @param model - Model name (default: openai/text-embedding-3-small)
|
|
21
|
+
* @param apiKey - OpenRouter API key
|
|
22
|
+
* @returns number[] of length 1024
|
|
23
|
+
*/
|
|
24
|
+
export async function getOpenRouterEmbedding(
|
|
25
|
+
text: string,
|
|
26
|
+
model: string,
|
|
27
|
+
apiKey: string,
|
|
28
|
+
): Promise<number[]> {
|
|
29
|
+
if (!apiKey) {
|
|
30
|
+
throw new Error("OPENROUTER_API_KEY is required for OpenRouter embeddings");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(OPENROUTER_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
Authorization: `Bearer ${apiKey}`,
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({ model, input: text }),
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const body = await response.text().catch(() => "unknown");
|
|
49
|
+
throw new Error(
|
|
50
|
+
`OpenRouter embedding failed: ${response.status} ${body}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const data = (await response.json()) as OpenRouterEmbeddingResponse;
|
|
55
|
+
const embedding = data.data?.[0]?.embedding;
|
|
56
|
+
|
|
57
|
+
if (!Array.isArray(embedding) || embedding.length === 0) {
|
|
58
|
+
throw new Error("OpenRouter returned empty or invalid embedding");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return normalizeDimensions(embedding, EMBEDDING_DIMENSIONS);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
64
|
+
logger.error({ model }, "OpenRouter embedding request timed out");
|
|
65
|
+
throw new Error(`OpenRouter embedding timed out after ${TIMEOUT_MS}ms`);
|
|
66
|
+
}
|
|
67
|
+
throw err;
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timeout);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared embedding utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pad or truncate a vector to exactly `dims` dimensions.
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeDimensions(vec: number[], dims: number): number[] {
|
|
9
|
+
if (vec.length === dims) return vec;
|
|
10
|
+
if (vec.length > dims) return vec.slice(0, dims);
|
|
11
|
+
// Pad with zeros
|
|
12
|
+
return [...vec, ...new Array(dims - vec.length).fill(0)];
|
|
13
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { documentEmbeddings, documents } from "@hiai-docs/db/schema";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { db } from "../lib/db";
|
|
4
|
+
import { logger } from "../lib/logger";
|
|
5
|
+
import { redis } from "../lib/redis";
|
|
6
|
+
import { embedDocument } from "./index";
|
|
7
|
+
|
|
8
|
+
const QUEUE_KEY = "hiai-docs:embedding-queue";
|
|
9
|
+
|
|
10
|
+
export function startEmbeddingWorker(): void {
|
|
11
|
+
logger.info("Embedding worker started");
|
|
12
|
+
|
|
13
|
+
const processLoop = async (): Promise<void> => {
|
|
14
|
+
while (true) {
|
|
15
|
+
try {
|
|
16
|
+
const result = await redis.brpop(QUEUE_KEY, 1);
|
|
17
|
+
if (!result) continue;
|
|
18
|
+
const documentId = result[1];
|
|
19
|
+
await processDocument(documentId);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
logger.error({ err }, "Embedding worker error");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
processLoop();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function processDocument(documentId: string): Promise<void> {
|
|
30
|
+
logger.info({ documentId }, "Processing embedding for document");
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const doc = await db.query.documents.findFirst({
|
|
34
|
+
where: eq(documents.id, documentId),
|
|
35
|
+
columns: {
|
|
36
|
+
id: true,
|
|
37
|
+
title: true,
|
|
38
|
+
content: true,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!doc) {
|
|
43
|
+
logger.warn({ documentId }, "Document not found, skipping embedding");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const content = doc.content ?? "";
|
|
48
|
+
if (!content && doc.title === "Untitled") {
|
|
49
|
+
logger.debug(
|
|
50
|
+
{ documentId },
|
|
51
|
+
"Document has no content, skipping embedding",
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const embeddings = await embedDocument(doc.title, content);
|
|
57
|
+
|
|
58
|
+
if (embeddings.length === 0) {
|
|
59
|
+
logger.warn({ documentId }, "No embeddings produced for document");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await db.transaction(async (tx) => {
|
|
64
|
+
await tx
|
|
65
|
+
.delete(documentEmbeddings)
|
|
66
|
+
.where(eq(documentEmbeddings.documentId, documentId));
|
|
67
|
+
|
|
68
|
+
const rows = embeddings.map((embedding, index) => ({
|
|
69
|
+
documentId,
|
|
70
|
+
chunkIndex: index,
|
|
71
|
+
chunkText: "",
|
|
72
|
+
embedding,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
await tx.insert(documentEmbeddings).values(rows);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
logger.info(
|
|
79
|
+
{
|
|
80
|
+
documentId,
|
|
81
|
+
chunks: embeddings.length,
|
|
82
|
+
dimensions: embeddings[0]?.length,
|
|
83
|
+
},
|
|
84
|
+
"All chunk embeddings stored for document",
|
|
85
|
+
);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logger.error({ err, documentId }, "Failed to process document embedding");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { cors } from "@elysiajs/cors";
|
|
2
|
+
import { swagger } from "@elysiajs/swagger";
|
|
3
|
+
import { Elysia } from "elysia";
|
|
4
|
+
import { authMiddleware } from "./api/middleware/auth";
|
|
5
|
+
import { csrfMiddleware } from "./api/middleware/csrf";
|
|
6
|
+
import {
|
|
7
|
+
healthRateLimiter,
|
|
8
|
+
rateLimitHeaders,
|
|
9
|
+
} from "./api/middleware/rate-limit";
|
|
10
|
+
import { attachmentRoutes } from "./api/routes/attachments";
|
|
11
|
+
import { authRoutes } from "./api/routes/auth";
|
|
12
|
+
import { collaborationRoutes } from "./api/routes/collaboration";
|
|
13
|
+
import { documentRoutes } from "./api/routes/documents";
|
|
14
|
+
import { folderRoutes } from "./api/routes/folders";
|
|
15
|
+
import { searchRoutes } from "./api/routes/search";
|
|
16
|
+
import { shareRoutes } from "./api/routes/share";
|
|
17
|
+
import { tagRoutes } from "./api/routes/tags";
|
|
18
|
+
import { versionRoutes } from "./api/routes/versions";
|
|
19
|
+
import { webhookRoutes } from "./api/routes/webhooks";
|
|
20
|
+
import { config } from "./lib/config";
|
|
21
|
+
import { startEmbeddingWorker } from "./lib/embedding-queue";
|
|
22
|
+
import { logger } from "./lib/logger";
|
|
23
|
+
import { BUCKET, ensureBucket, minio } from "./lib/minio";
|
|
24
|
+
|
|
25
|
+
// Start background embedding worker
|
|
26
|
+
startEmbeddingWorker();
|
|
27
|
+
|
|
28
|
+
ensureBucket(minio, BUCKET).catch((err) => {
|
|
29
|
+
logger.error({ err }, "Failed to ensure MinIO bucket");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const MAX_BODY_SIZE_BYTES = 10 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
const CSP_POLICY = [
|
|
35
|
+
"default-src 'self'",
|
|
36
|
+
"script-src 'self' 'unsafe-inline'",
|
|
37
|
+
"style-src 'self' 'unsafe-inline'",
|
|
38
|
+
"img-src 'self' data: blob: http://localhost:9020 http://localhost:9000 http://minio:9000",
|
|
39
|
+
"connect-src 'self' http://localhost:50700 ws://localhost:50700",
|
|
40
|
+
"font-src 'self' data:",
|
|
41
|
+
"frame-ancestors 'none'",
|
|
42
|
+
"form-action 'self'",
|
|
43
|
+
].join("; ");
|
|
44
|
+
|
|
45
|
+
const HSTS_POLICY = "max-age=31536000; includeSubDomains";
|
|
46
|
+
|
|
47
|
+
const bodySizeLimit = new Elysia().onBeforeHandle(({ request, set }) => {
|
|
48
|
+
const contentLength = request.headers.get("content-length");
|
|
49
|
+
if (contentLength !== null) {
|
|
50
|
+
const length = Number(contentLength);
|
|
51
|
+
if (Number.isFinite(length) && length > MAX_BODY_SIZE_BYTES) {
|
|
52
|
+
set.status = 413;
|
|
53
|
+
set.headers["X-Content-Type-Options"] = "nosniff";
|
|
54
|
+
set.headers["X-Frame-Options"] = "DENY";
|
|
55
|
+
return { error: "Request body too large (max 10MB)" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const securityHeaders = new Elysia().onAfterHandle(({ set }) => {
|
|
61
|
+
set.headers["Content-Security-Policy"] = CSP_POLICY;
|
|
62
|
+
set.headers["Strict-Transport-Security"] = HSTS_POLICY;
|
|
63
|
+
set.headers["X-Content-Type-Options"] = "nosniff";
|
|
64
|
+
set.headers["X-Frame-Options"] = "DENY";
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const swaggerConfig = {
|
|
68
|
+
path: "/api/docs",
|
|
69
|
+
documentation: {
|
|
70
|
+
info: {
|
|
71
|
+
title: "hiai-docs API",
|
|
72
|
+
version: "0.1.0",
|
|
73
|
+
description:
|
|
74
|
+
"Self-hosted AI-first documentation platform. Full-text + semantic search, version history, sharing, and folder organization.",
|
|
75
|
+
contact: { name: "hiai-gg", url: "https://github.com/hiai-gg/hiai-docs" },
|
|
76
|
+
license: { name: "MIT", url: "https://opensource.org/licenses/MIT" },
|
|
77
|
+
},
|
|
78
|
+
tags: [
|
|
79
|
+
{ name: "Auth", description: "Authentication endpoints" },
|
|
80
|
+
{ name: "Documents", description: "Document CRUD and search" },
|
|
81
|
+
{ name: "Folders", description: "Folder management" },
|
|
82
|
+
{ name: "Tags", description: "Tag management" },
|
|
83
|
+
{ name: "Versions", description: "Document version history" },
|
|
84
|
+
{ name: "Share", description: "Sharing and guest access" },
|
|
85
|
+
{ name: "Search", description: "Hybrid full-text + semantic search" },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const app = new Elysia()
|
|
91
|
+
.use(bodySizeLimit)
|
|
92
|
+
.use(securityHeaders)
|
|
93
|
+
.use(
|
|
94
|
+
cors({
|
|
95
|
+
origin: config.CORS_ORIGINS?.split(",") ?? [config.BETTER_AUTH_URL],
|
|
96
|
+
credentials: true,
|
|
97
|
+
maxAge: 86400,
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
.use(
|
|
101
|
+
config.NODE_ENV !== "production"
|
|
102
|
+
? swagger(swaggerConfig)
|
|
103
|
+
: (e: Elysia) => e,
|
|
104
|
+
)
|
|
105
|
+
.get("/api/health", async ({ request }) => {
|
|
106
|
+
const ip =
|
|
107
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
108
|
+
request.headers.get("x-real-ip") ??
|
|
109
|
+
"unknown";
|
|
110
|
+
const rl = await healthRateLimiter(ip);
|
|
111
|
+
const headers = rateLimitHeaders(rl.remaining, rl.retryAfter);
|
|
112
|
+
return Object.assign(
|
|
113
|
+
{
|
|
114
|
+
status: "ok",
|
|
115
|
+
service: "hiai-docs",
|
|
116
|
+
timestamp: new Date().toISOString(),
|
|
117
|
+
},
|
|
118
|
+
headers,
|
|
119
|
+
);
|
|
120
|
+
})
|
|
121
|
+
.use(csrfMiddleware)
|
|
122
|
+
.use(authMiddleware)
|
|
123
|
+
.use(authRoutes)
|
|
124
|
+
.use(tagRoutes)
|
|
125
|
+
.use(attachmentRoutes)
|
|
126
|
+
.use(shareRoutes)
|
|
127
|
+
.use(searchRoutes)
|
|
128
|
+
.use(documentRoutes)
|
|
129
|
+
.use(folderRoutes)
|
|
130
|
+
.use(versionRoutes)
|
|
131
|
+
.use(webhookRoutes)
|
|
132
|
+
.use(collaborationRoutes)
|
|
133
|
+
.listen(config.API_PORT);
|
|
134
|
+
|
|
135
|
+
logger.info({ port: config.API_PORT }, "hiai-docs API started");
|
|
136
|
+
|
|
137
|
+
// Graceful shutdown
|
|
138
|
+
const shutdown = async () => {
|
|
139
|
+
logger.info("Shutting down...");
|
|
140
|
+
app.stop();
|
|
141
|
+
process.exit(0);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
process.on("SIGTERM", shutdown);
|
|
145
|
+
process.on("SIGINT", shutdown);
|
|
146
|
+
|
|
147
|
+
export type App = typeof app;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { auth } from "./auth";
|
|
2
|
+
import { config } from "./config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract user ID from request headers.
|
|
6
|
+
* Checks API key first (Bearer token), then falls back to Better Auth session.
|
|
7
|
+
* Returns null if no valid session.
|
|
8
|
+
*/
|
|
9
|
+
export async function getSessionUserId(
|
|
10
|
+
headers: Headers,
|
|
11
|
+
): Promise<string | null> {
|
|
12
|
+
// Check API key first
|
|
13
|
+
const apiKey = config.HIAI_DOCS_API_KEY;
|
|
14
|
+
if (apiKey) {
|
|
15
|
+
const authHeader = headers.get("authorization");
|
|
16
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
17
|
+
const token = authHeader.slice(7);
|
|
18
|
+
if (token === apiKey) {
|
|
19
|
+
return config.OWNER_ID;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fall back to Better Auth session check
|
|
25
|
+
const session = await auth.api.getSession({ headers });
|
|
26
|
+
return session?.user?.id ?? null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { db } from "@hiai-docs/db";
|
|
2
|
+
import { accounts, sessions, users, verifications } from "@hiai-docs/db/schema";
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
5
|
+
import { config } from "./config";
|
|
6
|
+
|
|
7
|
+
export const auth = betterAuth({
|
|
8
|
+
database: drizzleAdapter(db, {
|
|
9
|
+
provider: "pg",
|
|
10
|
+
schema: {
|
|
11
|
+
user: users,
|
|
12
|
+
session: sessions,
|
|
13
|
+
account: accounts,
|
|
14
|
+
verification: verifications,
|
|
15
|
+
},
|
|
16
|
+
}),
|
|
17
|
+
secret: config.BETTER_AUTH_SECRET,
|
|
18
|
+
baseURL: config.BETTER_AUTH_URL,
|
|
19
|
+
trustedOrigins: process.env.TRUSTED_ORIGINS
|
|
20
|
+
? process.env.TRUSTED_ORIGINS.split(",").map((s) => s.trim())
|
|
21
|
+
: ["http://localhost:50701", "http://127.0.0.1:50701"],
|
|
22
|
+
emailAndPassword: {
|
|
23
|
+
enabled: true,
|
|
24
|
+
},
|
|
25
|
+
session: {
|
|
26
|
+
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
|
27
|
+
updateAge: 60 * 60 * 24, // 1 day
|
|
28
|
+
},
|
|
29
|
+
advanced: {
|
|
30
|
+
database: {
|
|
31
|
+
generateId: false,
|
|
32
|
+
},
|
|
33
|
+
disableCSRFCheck: true,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { logger } from "./logger";
|
|
3
|
+
|
|
4
|
+
const envSchema = z.object({
|
|
5
|
+
DATABASE_URL: z
|
|
6
|
+
.string()
|
|
7
|
+
.default("postgresql://aiuser:aipassword@localhost:5433/hiai_docs"),
|
|
8
|
+
REDIS_URL: z.string().default("redis://localhost:6380"),
|
|
9
|
+
MINIO_ENDPOINT: z.string().default("localhost"),
|
|
10
|
+
MINIO_PORT: z.coerce.number().default(9010),
|
|
11
|
+
MINIO_PUBLIC_ENDPOINT: z.string().default("localhost"),
|
|
12
|
+
MINIO_PUBLIC_PORT: z.coerce.number().default(9020),
|
|
13
|
+
MINIO_ACCESS_KEY: z.string().default("minioadmin"),
|
|
14
|
+
MINIO_SECRET_KEY: z.string().default("change-me-to-random-32-chars"),
|
|
15
|
+
MINIO_BUCKET: z.string().default("hiai-docs"),
|
|
16
|
+
BETTER_AUTH_SECRET: z
|
|
17
|
+
.string()
|
|
18
|
+
.default("change-me-to-random-32-chars")
|
|
19
|
+
.refine(
|
|
20
|
+
(val) =>
|
|
21
|
+
process.env.NODE_ENV !== "production" ||
|
|
22
|
+
val !== "change-me-to-random-32-chars",
|
|
23
|
+
"BETTER_AUTH_SECRET must be set in production",
|
|
24
|
+
),
|
|
25
|
+
// CSRF: dedicated signing key — must NOT equal BETTER_AUTH_SECRET
|
|
26
|
+
CSRF_SECRET: z.string().default("change-me-to-random-32-chars"),
|
|
27
|
+
// Webhook: dedicated HMAC key — must NOT equal MINIO_SECRET_KEY
|
|
28
|
+
WEBHOOK_SECRET: z.string().default("change-me-to-random-32-chars"),
|
|
29
|
+
BETTER_AUTH_URL: z.string().default("http://localhost:50700"),
|
|
30
|
+
CORS_ORIGINS: z.string().optional(),
|
|
31
|
+
EMBEDDING_PROVIDER: z
|
|
32
|
+
.enum(["ollama", "openrouter", "voyage"])
|
|
33
|
+
.default("ollama"),
|
|
34
|
+
EMBEDDING_MODEL: z.string().default("nomic-embed-text"),
|
|
35
|
+
EMBEDDING_OLLAMA_URL: z.string().default("http://localhost:11434"),
|
|
36
|
+
EMBEDDING_FALLBACK_PROVIDER: z.string().default("openrouter"),
|
|
37
|
+
EMBEDDING_FALLBACK_MODEL: z.string().default("openai/text-embedding-3-small"),
|
|
38
|
+
OPENROUTER_API_KEY: z.string().optional(),
|
|
39
|
+
API_PORT: z.coerce.number().default(50700),
|
|
40
|
+
NODE_ENV: z
|
|
41
|
+
.enum(["development", "production", "test"])
|
|
42
|
+
.default("development"),
|
|
43
|
+
LOG_LEVEL: z
|
|
44
|
+
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
|
45
|
+
.default("info"),
|
|
46
|
+
HIAI_DOCS_API_KEY: z.string().optional(),
|
|
47
|
+
OWNER_ID: z.string().default("api-key-user"),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let config: z.infer<typeof envSchema>;
|
|
51
|
+
try {
|
|
52
|
+
config = envSchema.parse(process.env);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
logger.error({ err }, "FATAL: Invalid environment configuration");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (config.NODE_ENV !== "production") {
|
|
59
|
+
if (!process.env.CSRF_SECRET) {
|
|
60
|
+
logger.warn(
|
|
61
|
+
"[config] CSRF_SECRET is not set — using insecure dev fallback. " +
|
|
62
|
+
"Set CSRF_SECRET in .env for any non-development environment.",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (!process.env.WEBHOOK_SECRET) {
|
|
66
|
+
logger.warn(
|
|
67
|
+
"[config] WEBHOOK_SECRET is not set — using insecure dev fallback. " +
|
|
68
|
+
"Set WEBHOOK_SECRET in .env for any non-development environment.",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { config };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as schema from "@hiai-docs/db/schema";
|
|
2
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
3
|
+
import postgres from "postgres";
|
|
4
|
+
import { config } from "./config";
|
|
5
|
+
|
|
6
|
+
const client = postgres(config.DATABASE_URL);
|
|
7
|
+
export const db = drizzle(client, { schema });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { logger } from "./logger";
|
|
2
|
+
import { redis } from "./redis";
|
|
3
|
+
|
|
4
|
+
const QUEUE_KEY = "hiai-docs:embedding-queue";
|
|
5
|
+
|
|
6
|
+
export function enqueueEmbedding(documentId: string): void {
|
|
7
|
+
redis.lpush(QUEUE_KEY, documentId).catch((err) => {
|
|
8
|
+
logger.error({ err, documentId }, "Failed to enqueue embedding job");
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { startEmbeddingWorker } from "../embedding/worker";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
|
|
3
|
+
// Read env directly to avoid circular dependency with config.ts
|
|
4
|
+
const level = (process.env.LOG_LEVEL ?? "info") as
|
|
5
|
+
| "trace"
|
|
6
|
+
| "debug"
|
|
7
|
+
| "info"
|
|
8
|
+
| "warn"
|
|
9
|
+
| "error"
|
|
10
|
+
| "fatal";
|
|
11
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
12
|
+
|
|
13
|
+
export const logger = pino({
|
|
14
|
+
level,
|
|
15
|
+
transport: isDev
|
|
16
|
+
? { target: "pino-pretty", options: { colorize: true } }
|
|
17
|
+
: undefined,
|
|
18
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Highlight from "@tiptap/extension-highlight";
|
|
2
|
+
import Image from "@tiptap/extension-image";
|
|
3
|
+
import Link from "@tiptap/extension-link";
|
|
4
|
+
import { generateJSON } from "@tiptap/html/server";
|
|
5
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
6
|
+
import { marked } from "marked";
|
|
7
|
+
import { logger } from "./logger";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* TipTap extension set used by the editor on the frontend
|
|
11
|
+
* (see frontend/src/lib/components/editor/HiAiEditor.svelte).
|
|
12
|
+
* Mirrored here so imported `.md`/`.txt`/`.markdown` files produce
|
|
13
|
+
* ProseMirror JSON the editor renders with full formatting.
|
|
14
|
+
*
|
|
15
|
+
* Excludes extensions that have no markdown equivalent or that need
|
|
16
|
+
* runtime resources the backend does not load (Collaboration, CodeBlockLowlight).
|
|
17
|
+
*/
|
|
18
|
+
const editorExtensions = [
|
|
19
|
+
StarterKit.configure({
|
|
20
|
+
heading: { levels: [1, 2, 3] },
|
|
21
|
+
codeBlock: false,
|
|
22
|
+
link: false,
|
|
23
|
+
}),
|
|
24
|
+
Link.configure({ openOnClick: false }),
|
|
25
|
+
Image.configure({ inline: false, allowBase64: false }),
|
|
26
|
+
Highlight.configure({ multicolor: true }),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert raw markdown text to TipTap/ProseMirror JSON that the editor
|
|
31
|
+
* accepts as `contentJson`. Returns `null` for empty input or on failure
|
|
32
|
+
* so the import handler can fall back to storing the raw text only.
|
|
33
|
+
*/
|
|
34
|
+
export async function markdownToDocJson(
|
|
35
|
+
markdown: string,
|
|
36
|
+
): Promise<unknown | null> {
|
|
37
|
+
if (!markdown.trim()) return null;
|
|
38
|
+
try {
|
|
39
|
+
const html = await marked.parse(markdown, { async: true });
|
|
40
|
+
return generateJSON(html, editorExtensions);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
logger.error({ err }, "markdownToDocJson failed");
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Client } from "minio";
|
|
2
|
+
import { config } from "./config";
|
|
3
|
+
import { logger } from "./logger";
|
|
4
|
+
|
|
5
|
+
export const minio = new Client({
|
|
6
|
+
endPoint: config.MINIO_ENDPOINT,
|
|
7
|
+
port: config.MINIO_PORT,
|
|
8
|
+
useSSL: false,
|
|
9
|
+
accessKey: config.MINIO_ACCESS_KEY,
|
|
10
|
+
secretKey: config.MINIO_SECRET_KEY,
|
|
11
|
+
region: "us-east-1",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Public-facing MinIO client used to sign presigned URLs.
|
|
16
|
+
*
|
|
17
|
+
* MinIO validates the URL signature against the Host header at request time,
|
|
18
|
+
* so a URL signed for the Docker-internal `minio:9000` endpoint is rejected
|
|
19
|
+
* (403) when the browser fetches it via `localhost:9020`. This client signs
|
|
20
|
+
* against the browser-resolvable host/port instead.
|
|
21
|
+
*
|
|
22
|
+
* Note: `region` is required explicitly. Without it, minio-js issues a HEAD
|
|
23
|
+
* request for region auto-detection, which fail with ECONNREFUSED inside the
|
|
24
|
+
* container because the public host/port (localhost:9020) is unreachable.
|
|
25
|
+
*/
|
|
26
|
+
export const minioPublic = new Client({
|
|
27
|
+
endPoint: config.MINIO_PUBLIC_ENDPOINT,
|
|
28
|
+
port: config.MINIO_PUBLIC_PORT,
|
|
29
|
+
useSSL: false,
|
|
30
|
+
accessKey: config.MINIO_ACCESS_KEY,
|
|
31
|
+
secretKey: config.MINIO_SECRET_KEY,
|
|
32
|
+
region: "us-east-1",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const BUCKET = config.MINIO_BUCKET;
|
|
36
|
+
|
|
37
|
+
export async function ensureBucket(
|
|
38
|
+
client: Client,
|
|
39
|
+
bucket: string,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const exists = await client.bucketExists(bucket);
|
|
42
|
+
if (!exists) {
|
|
43
|
+
await client.makeBucket(bucket, "us-east-1");
|
|
44
|
+
logger.info({ bucket }, "Created MinIO bucket");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Redis from "ioredis";
|
|
2
|
+
import { config } from "./config";
|
|
3
|
+
import { logger } from "./logger";
|
|
4
|
+
|
|
5
|
+
export const redis = new Redis(config.REDIS_URL, {
|
|
6
|
+
maxRetriesPerRequest: 3,
|
|
7
|
+
retryStrategy(times) {
|
|
8
|
+
const delay = Math.min(times * 200, 2000);
|
|
9
|
+
return delay;
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
redis.on("error", (err) => {
|
|
14
|
+
logger.error({ err }, "Redis connection error");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
redis.on("connect", () => {
|
|
18
|
+
logger.info("Redis connected");
|
|
19
|
+
});
|