excalidrop 0.1.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/LICENSE +21 -0
- package/README.md +411 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +533 -0
- package/dist/cli.js.map +1 -0
- package/dist/frontend/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
- package/dist/frontend/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
- package/dist/frontend/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
- package/dist/frontend/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
- package/dist/frontend/assets/Tableau10-B-NsZVaP.js +1 -0
- package/dist/frontend/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
- package/dist/frontend/assets/advancedFormat-bKUmsig-.js +1 -0
- package/dist/frontend/assets/ar-SA-G6X2FPQ2-C7K_UR3P.js +10 -0
- package/dist/frontend/assets/arc-BI_FJWE7.js +1 -0
- package/dist/frontend/assets/array-BKyUJesY.js +1 -0
- package/dist/frontend/assets/az-AZ-76LH7QW2-PkhuRuSr.js +1 -0
- package/dist/frontend/assets/band-dPffDWoQ.js +1 -0
- package/dist/frontend/assets/bg-BG-XCXSNQG7-CgLRSYTl.js +5 -0
- package/dist/frontend/assets/blockDiagram-38ab4fdb-BFdBRYSn.js +118 -0
- package/dist/frontend/assets/blockDiagram-68f4deed-Ynbqr7bQ.js +118 -0
- package/dist/frontend/assets/bn-BD-2XOGV67Q-CEvkU0mz.js +5 -0
- package/dist/frontend/assets/c4Diagram-15b5d702-Dm7F1Pn_.js +10 -0
- package/dist/frontend/assets/c4Diagram-3d4e48cf-COkK-e67.js +10 -0
- package/dist/frontend/assets/ca-ES-6MX7JW3Y-DzP6q9bE.js +8 -0
- package/dist/frontend/assets/channel-BqCiG5n3.js +1 -0
- package/dist/frontend/assets/classDiagram-70f12bd4-CRzulwvH.js +2 -0
- package/dist/frontend/assets/classDiagram-d40c83e7-DNbA-n-a.js +2 -0
- package/dist/frontend/assets/classDiagram-v2-d5a6b087-l-GglywH.js +2 -0
- package/dist/frontend/assets/classDiagram-v2-f2320105-B_TY1zoc.js +2 -0
- package/dist/frontend/assets/clone-B6Jqcrku.js +1 -0
- package/dist/frontend/assets/createText-2e5e7dd3-DB_k52Pn.js +5 -0
- package/dist/frontend/assets/createText-d213de94-D-G49giw.js +5 -0
- package/dist/frontend/assets/cs-CZ-2BRQDIVT-Bf4TOzpR.js +11 -0
- package/dist/frontend/assets/cytoscape-cose-bilkent-DQYypj0P.js +331 -0
- package/dist/frontend/assets/da-DK-5WZEPLOC-jO-5kyXb.js +5 -0
- package/dist/frontend/assets/de-DE-XR44H4JA-BkNXWOVh.js +8 -0
- package/dist/frontend/assets/directory-open-01563666-DWU9wJ6I.js +1 -0
- package/dist/frontend/assets/directory-open-4ed118d0-BzWybGaI.js +1 -0
- package/dist/frontend/assets/edges-332bd1c7-CVvMtkTa.js +4 -0
- package/dist/frontend/assets/edges-e0da2a9e-DQBAvC8-.js +4 -0
- package/dist/frontend/assets/el-GR-BZB4AONW-Dv7Y9hMk.js +10 -0
- package/dist/frontend/assets/elk.bundled-YVS3PsMH.js +26 -0
- package/dist/frontend/assets/erDiagram-880f2ed8-BjBeR38f.js +51 -0
- package/dist/frontend/assets/erDiagram-9861fffd-B2Bi3R45.js +51 -0
- package/dist/frontend/assets/es-ES-U4NZUMDT-CWhs0SQY.js +9 -0
- package/dist/frontend/assets/eu-ES-A7QVB2H4-BfkFwc3e.js +11 -0
- package/dist/frontend/assets/fa-IR-HGAKTJCU-BgWzSj46.js +8 -0
- package/dist/frontend/assets/fi-FI-Z5N7JZ37-CUyvkum-.js +6 -0
- package/dist/frontend/assets/file-open-002ab408-DIuFHtCF.js +1 -0
- package/dist/frontend/assets/file-open-7c801643-684qeFg4.js +1 -0
- package/dist/frontend/assets/file-save-3189631c-x92wctJd.js +1 -0
- package/dist/frontend/assets/file-save-745eba88-Bb9F9Kg7.js +1 -0
- package/dist/frontend/assets/flowDb-7c981674-BSZyyVBf.js +10 -0
- package/dist/frontend/assets/flowDb-956e92f1-BC3-CLkF.js +10 -0
- package/dist/frontend/assets/flowDiagram-66a62f08-H57DAvmM.js +4 -0
- package/dist/frontend/assets/flowDiagram-cbd28bf7-Bw4Z2nBD.js +4 -0
- package/dist/frontend/assets/flowDiagram-v2-96b9c2cf-CBjbm_oB.js +1 -0
- package/dist/frontend/assets/flowDiagram-v2-ffc7f31a-CoQufPp2.js +1 -0
- package/dist/frontend/assets/flowchart-elk-definition-36e2d292-1kZNdf3X.js +114 -0
- package/dist/frontend/assets/flowchart-elk-definition-4a651766-DDhG2Esi.js +114 -0
- package/dist/frontend/assets/fr-FR-RHASNOE6-7xzoRwbd.js +9 -0
- package/dist/frontend/assets/ganttDiagram-04f9e578-CtUSsXCz.js +257 -0
- package/dist/frontend/assets/ganttDiagram-c361ad54-K42AklFY.js +257 -0
- package/dist/frontend/assets/gitGraphDiagram-21fc4d3e-uW6XA6Y6.js +70 -0
- package/dist/frontend/assets/gitGraphDiagram-72cf32ee-CCNbhnBg.js +70 -0
- package/dist/frontend/assets/gl-ES-HMX3MZ6V-CXuDDwNP.js +10 -0
- package/dist/frontend/assets/graph-CYOmcSYN.js +1 -0
- package/dist/frontend/assets/graph-o42dOfI7.js +1 -0
- package/dist/frontend/assets/he-IL-6SHJWFNN-O9dzh9EM.js +10 -0
- package/dist/frontend/assets/hi-IN-IWLTKZ5I-DlrOXhWs.js +4 -0
- package/dist/frontend/assets/hu-HU-A5ZG7DT2-C3eNg-uI.js +7 -0
- package/dist/frontend/assets/id-ID-SAP4L64H-6iM4pI5c.js +10 -0
- package/dist/frontend/assets/image-blob-reduce.esm-B6b2_-a4.js +7 -0
- package/dist/frontend/assets/index-3862675e-DrvLhypC.js +1 -0
- package/dist/frontend/assets/index-6079d271-BgVTRqg2.js +1 -0
- package/dist/frontend/assets/index-BRA5OktX.css +1 -0
- package/dist/frontend/assets/index-D-cr7jpJ.js +349 -0
- package/dist/frontend/assets/index-DGmpr33w.js +3 -0
- package/dist/frontend/assets/index-HkUXslJy.js +87 -0
- package/dist/frontend/assets/infoDiagram-4a4f5b27-ZxmAcaW1.js +7 -0
- package/dist/frontend/assets/infoDiagram-f8f76790-Bn-PhiFG.js +7 -0
- package/dist/frontend/assets/init-Gi6I4Gst.js +1 -0
- package/dist/frontend/assets/it-IT-JPQ66NNP-B26NQzGp.js +11 -0
- package/dist/frontend/assets/ja-JP-DBVTYXUO-CB0fvnYJ.js +8 -0
- package/dist/frontend/assets/journeyDiagram-29694f62-ggDKs06f.js +139 -0
- package/dist/frontend/assets/journeyDiagram-49397b02-pdDS-n-z.js +139 -0
- package/dist/frontend/assets/kaa-6HZHGXH3-YNdBdcwJ.js +1 -0
- package/dist/frontend/assets/kab-KAB-ZGHBKWFO-D86pwZO0.js +8 -0
- package/dist/frontend/assets/katex-ChWnQ-fc.js +261 -0
- package/dist/frontend/assets/kk-KZ-P5N5QNE5-CpG5oheM.js +1 -0
- package/dist/frontend/assets/km-KH-HSX4SM5Z-DYOZ3Azw.js +11 -0
- package/dist/frontend/assets/ko-KR-MTYHY66A-BHorm6lC.js +9 -0
- package/dist/frontend/assets/ku-TR-6OUDTVRD-CM9RgEHv.js +9 -0
- package/dist/frontend/assets/layout-CCtkxrAf.js +1 -0
- package/dist/frontend/assets/layout-DW9Qf-At.js +1 -0
- package/dist/frontend/assets/line-DvZfgg9P.js +1 -0
- package/dist/frontend/assets/linear-FwFrGRfa.js +1 -0
- package/dist/frontend/assets/lt-LT-XHIRWOB4-DvriKXBj.js +3 -0
- package/dist/frontend/assets/lv-LV-5QDEKY6T-DzQpwk87.js +7 -0
- package/dist/frontend/assets/mindmap-definition-ac74a2e8-DsRo8f2w.js +95 -0
- package/dist/frontend/assets/mindmap-definition-fc14e90a-dQ9reluI.js +95 -0
- package/dist/frontend/assets/mr-IN-CRQNXWMA-C7RzxxP5.js +13 -0
- package/dist/frontend/assets/my-MM-5M5IBNSE-B-RRuy29.js +1 -0
- package/dist/frontend/assets/nb-NO-T6EIAALU-B47x16Ik.js +10 -0
- package/dist/frontend/assets/nl-NL-IS3SIHDZ-BxAKoQne.js +8 -0
- package/dist/frontend/assets/nn-NO-6E72VCQL-dfpbEDSZ.js +8 -0
- package/dist/frontend/assets/oc-FR-POXYY2M6-gv6geBjV.js +8 -0
- package/dist/frontend/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/frontend/assets/pa-IN-N4M65BXN-CY30-qH7.js +4 -0
- package/dist/frontend/assets/path-CbwjOpE9.js +1 -0
- package/dist/frontend/assets/pica-DWYGHLQV.js +7 -0
- package/dist/frontend/assets/pie-CDiWh1rj.js +1 -0
- package/dist/frontend/assets/pieDiagram-421022e6-Bgy-duyf.js +35 -0
- package/dist/frontend/assets/pieDiagram-8a3498a8-DH7FsbPK.js +35 -0
- package/dist/frontend/assets/pl-PL-T2D74RX3-TrHugsIA.js +9 -0
- package/dist/frontend/assets/pt-BR-5N22H2LF-CJxdFp9c.js +9 -0
- package/dist/frontend/assets/pt-PT-UZXXM6DQ-CB-1q6YJ.js +9 -0
- package/dist/frontend/assets/quadrantDiagram-0957ecba-DcwYI1oi.js +7 -0
- package/dist/frontend/assets/quadrantDiagram-120e2f19-CvHU5a9v.js +7 -0
- package/dist/frontend/assets/requirementDiagram-23d650b8-tdagN8HP.js +52 -0
- package/dist/frontend/assets/requirementDiagram-deff3bca-BMGM-fSI.js +52 -0
- package/dist/frontend/assets/ro-RO-JPDTUUEW-DQq9DBOd.js +11 -0
- package/dist/frontend/assets/roundRect-0PYZxl1G.js +1 -0
- package/dist/frontend/assets/ru-RU-B4JR7IUQ-CWX32rfh.js +9 -0
- package/dist/frontend/assets/sankeyDiagram-04a897e0-3i6zWpY2.js +8 -0
- package/dist/frontend/assets/sankeyDiagram-23345273-ncj1Ol7S.js +8 -0
- package/dist/frontend/assets/sankeyLinkHorizontal-DgqkLiUE.js +1 -0
- package/dist/frontend/assets/selectAll-DJwesbwT.js +1 -0
- package/dist/frontend/assets/sequenceDiagram-17ac3bff-ChyKKRZA.js +122 -0
- package/dist/frontend/assets/sequenceDiagram-704730f1-D57_wP9k.js +122 -0
- package/dist/frontend/assets/si-LK-N5RQ5JYF-Lkc5ZxyG.js +1 -0
- package/dist/frontend/assets/sk-SK-C5VTKIMK-DyvYXjqZ.js +6 -0
- package/dist/frontend/assets/sl-SI-NN7IZMDC-D_MvP9Rh.js +6 -0
- package/dist/frontend/assets/stateDiagram-587899a1-OjFTnFLS.js +1 -0
- package/dist/frontend/assets/stateDiagram-9c5f0230-BzU36S5C.js +1 -0
- package/dist/frontend/assets/stateDiagram-v2-51a3dcff-CfX1qo7U.js +1 -0
- package/dist/frontend/assets/stateDiagram-v2-d93cdb3a-BH7Ud4sU.js +1 -0
- package/dist/frontend/assets/styles-2ab5d517-CA8x9Nh5.js +116 -0
- package/dist/frontend/assets/styles-5f03d8d2-BnO2pLhV.js +160 -0
- package/dist/frontend/assets/styles-6aaf32cf-COnot1ea.js +207 -0
- package/dist/frontend/assets/styles-9a916d00-C3QkBgTF.js +160 -0
- package/dist/frontend/assets/styles-c10674c1-CPAG2_rr.js +116 -0
- package/dist/frontend/assets/styles-edf9a4b0-B8Q9fyu0.js +207 -0
- package/dist/frontend/assets/subset-shared.chunk.js +84 -0
- package/dist/frontend/assets/subset-worker.chunk.js +1 -0
- package/dist/frontend/assets/sv-SE-XGPEYMSR-t6voGQSI.js +10 -0
- package/dist/frontend/assets/svgDrawCommon-08f97a94-CdT21LWf.js +1 -0
- package/dist/frontend/assets/svgDrawCommon-3ba9043b-D1MU7jAE.js +1 -0
- package/dist/frontend/assets/ta-IN-2NMHFXQM-7VmfDDyV.js +9 -0
- package/dist/frontend/assets/th-TH-HPSO5L25-B5rLEY9P.js +2 -0
- package/dist/frontend/assets/timeline-definition-7e6b55e7-BIcL44O_.js +61 -0
- package/dist/frontend/assets/timeline-definition-85554ec2-TgrYYwpU.js +61 -0
- package/dist/frontend/assets/tr-TR-DEFEU3FU-0CgOHXTn.js +7 -0
- package/dist/frontend/assets/uk-UA-QMV73CPH-CzSFsIsx.js +6 -0
- package/dist/frontend/assets/union-CnQAJu0l.js +1 -0
- package/dist/frontend/assets/vi-VN-M7AON7JQ-Bhx3rYU9.js +5 -0
- package/dist/frontend/assets/xml-BOsq7VnW.js +1 -0
- package/dist/frontend/assets/xychartDiagram-b6496bcd-BMKDop6v.js +7 -0
- package/dist/frontend/assets/xychartDiagram-e933f94c-DAWithrN.js +7 -0
- package/dist/frontend/assets/zh-CN-LNUGB5OW-Br52_wH4.js +10 -0
- package/dist/frontend/assets/zh-HK-E62DVLB3-Cj6mnIQ9.js +1 -0
- package/dist/frontend/assets/zh-TW-RAJ6MFWO-yLEsVBbC.js +9 -0
- package/dist/frontend/assets/zipObject-BeXj5PVd.js +1 -0
- package/dist/frontend/index.html +339 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2305 -0
- package/dist/index.js.map +1 -0
- package/dist/remote.d.ts +4 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +133 -0
- package/dist/remote.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1217 -0
- package/dist/server.js.map +1 -0
- package/dist/target.d.ts +41 -0
- package/dist/target.d.ts.map +1 -0
- package/dist/target.js +244 -0
- package/dist/target.js.map +1 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +53 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/auth.d.ts +15 -0
- package/dist/utils/auth.d.ts.map +1 -0
- package/dist/utils/auth.js +68 -0
- package/dist/utils/auth.js.map +1 -0
- package/dist/utils/github.d.ts +26 -0
- package/dist/utils/github.d.ts.map +1 -0
- package/dist/utils/github.js +111 -0
- package/dist/utils/github.js.map +1 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +25 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/r2store.d.ts +19 -0
- package/dist/utils/r2store.d.ts.map +1 -0
- package/dist/utils/r2store.js +75 -0
- package/dist/utils/r2store.js.map +1 -0
- package/frontend/index.html +338 -0
- package/frontend/src/App.tsx +1091 -0
- package/frontend/src/main.tsx +15 -0
- package/frontend/src/utils/ghSync.ts +171 -0
- package/frontend/src/utils/mermaidConverter.ts +49 -0
- package/frontend/src/utils/webmcp.ts +285 -0
- package/package.json +101 -0
- package/scripts/check-local-bind.mjs +164 -0
- package/scripts/postinstall.mjs +23 -0
- package/scripts/publish-pages.sh +39 -0
- package/scripts/seed-r2.mjs +9 -0
- package/skills/excalidraw-skill/SKILL.md +303 -0
- package/skills/excalidraw-skill/evals/evals.json +23 -0
- package/skills/excalidraw-skill/references/cheatsheet.md +142 -0
- package/skills/excalidraw-skill/scripts/clear-canvas.cjs +38 -0
- package/skills/excalidraw-skill/scripts/create-element.cjs +68 -0
- package/skills/excalidraw-skill/scripts/delete-element.cjs +48 -0
- package/skills/excalidraw-skill/scripts/export-elements.cjs +53 -0
- package/skills/excalidraw-skill/scripts/healthcheck.cjs +35 -0
- package/skills/excalidraw-skill/scripts/import-elements.cjs +81 -0
- package/skills/excalidraw-skill/scripts/update-element.cjs +70 -0
- package/src/cli.ts +559 -0
- package/src/index.ts +2557 -0
- package/src/remote.ts +96 -0
- package/src/server.ts +1349 -0
- package/src/target.ts +208 -0
- package/src/types.ts +338 -0
- package/src/utils/auth.ts +61 -0
- package/src/utils/github.ts +93 -0
- package/src/utils/logger.ts +35 -0
- package/src/utils/r2store.ts +76 -0
- package/tsconfig.json +50 -0
- package/vite.config.js +37 -0
package/src/target.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/** Dynamic backend target: local canvas (HTTP) or remote GitHub repo.
|
|
2
|
+
* Agents switch with the switch_remote tool; writes commit straight to GitHub.
|
|
3
|
+
* Token resolution (no user setup): GITHUB_TOKEN env → `gh auth token` →
|
|
4
|
+
* ~/.config/excalidrop/gh_token (written by github_login device flow).
|
|
5
|
+
*/
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import logger from './utils/logger.js';
|
|
11
|
+
import { generateId, ServerElement } from './types.js';
|
|
12
|
+
|
|
13
|
+
export type Target = { kind: 'local' } | { kind: 'remote'; repo: string };
|
|
14
|
+
let active: Target = { kind: 'local' };
|
|
15
|
+
export const getTarget = (): Target => active;
|
|
16
|
+
|
|
17
|
+
/** Shared Excalidrop GitHub App Client ID (public, not a secret). */
|
|
18
|
+
export const APP_CLIENT_ID = process.env.GITHUB_OAUTH_CLIENT_ID || 'Iv23liuS2fx3QOEIoDmx';
|
|
19
|
+
|
|
20
|
+
const CONFIG_NAME = '.excalidrop.json';
|
|
21
|
+
/** Walk up from cwd to find the project config (same pattern as the CLI's ports). */
|
|
22
|
+
function findConfigDir(): string | null {
|
|
23
|
+
let dir = process.cwd();
|
|
24
|
+
const root = path.parse(dir).root;
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, CONFIG_NAME), 'utf8'));
|
|
28
|
+
if (raw && typeof raw === 'object') return dir;
|
|
29
|
+
} catch { /* keep walking */ }
|
|
30
|
+
if (dir === root) return null;
|
|
31
|
+
dir = path.dirname(dir);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Remember the remote target per-project so agents don't pass it every call. */
|
|
35
|
+
function persistTarget(repo: string | null): void {
|
|
36
|
+
const dir = findConfigDir();
|
|
37
|
+
if (!dir) return;
|
|
38
|
+
try {
|
|
39
|
+
const file = path.join(dir, CONFIG_NAME);
|
|
40
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
41
|
+
if (repo) raw.remote = repo; else delete raw.remote;
|
|
42
|
+
fs.writeFileSync(file, JSON.stringify(raw, null, 2) + '\n');
|
|
43
|
+
} catch (e) { logger.warn('could not persist remote target: ' + (e as Error).message); }
|
|
44
|
+
}
|
|
45
|
+
/** Restore the remembered target (called once at MCP startup). */
|
|
46
|
+
export function loadSavedTarget(): string | null {
|
|
47
|
+
const dir = findConfigDir();
|
|
48
|
+
if (!dir) return null;
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, CONFIG_NAME), 'utf8'));
|
|
51
|
+
return typeof raw.remote === 'string' ? raw.remote : null;
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TOKEN_FILE = path.join(os.homedir(), '.config', 'excalidrop', 'gh_token');
|
|
56
|
+
export function resolveToken(): string {
|
|
57
|
+
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
|
|
58
|
+
try {
|
|
59
|
+
const t = execSync('gh auth token', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
60
|
+
if (t) return t;
|
|
61
|
+
} catch { /* no gh */ }
|
|
62
|
+
try {
|
|
63
|
+
const t = fs.readFileSync(TOKEN_FILE, 'utf8').trim();
|
|
64
|
+
if (t) return t;
|
|
65
|
+
} catch { /* no stored token */ }
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
export function storeToken(t: string): void {
|
|
69
|
+
fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true });
|
|
70
|
+
fs.writeFileSync(TOKEN_FILE, t + '\n', { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Parse owner/repo from "owner/repo" or a https://<owner>.github.io/<repo>/ URL. */
|
|
74
|
+
export function parseRepo(input: string): string {
|
|
75
|
+
const s = input.trim().replace(/\/$/, '');
|
|
76
|
+
const m = s.match(/^https?:\/\/([a-z0-9-]+)\.github\.io\/([a-z0-9_.-]+)/i);
|
|
77
|
+
if (m) return `${m[1]}/${m[2]}`.toLowerCase();
|
|
78
|
+
if (/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(s)) return s.toLowerCase();
|
|
79
|
+
throw new Error(`Cannot parse repo from "${input}" — use owner/repo or a *.github.io/<repo> URL`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── remote scene store (per-target memory + debounced commit) ───
|
|
83
|
+
const scenes = new Map<string, { elements: Map<string, ServerElement>; sha: string | null; dirty: boolean }>();
|
|
84
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
const SCENE_PATH = process.env.SCENE_PATH || 'canvas.excalidraw';
|
|
86
|
+
|
|
87
|
+
function headers(): Record<string, string> {
|
|
88
|
+
return { Authorization: `Bearer ${resolveToken()}`, Accept: 'application/vnd.github+json', 'User-Agent': 'excalidrop', 'Content-Type': 'application/json' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function switchRemote(input?: string): Promise<{ target: Target; elements: number }> {
|
|
92
|
+
if (!input) { active = { kind: 'local' }; persistTarget(null); return { target: active, elements: 0 }; }
|
|
93
|
+
const repo = parseRepo(input);
|
|
94
|
+
active = { kind: 'remote', repo };
|
|
95
|
+
const st = await ensureLoaded(repo);
|
|
96
|
+
persistTarget(repo);
|
|
97
|
+
return { target: active, elements: st.elements.size };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function ensureLoaded(repo: string) {
|
|
101
|
+
let st = scenes.get(repo);
|
|
102
|
+
if (st) return st;
|
|
103
|
+
const tok = resolveToken();
|
|
104
|
+
if (!tok) throw new Error('No GitHub token. Run github_login or `gh auth login` first.');
|
|
105
|
+
const r = await fetch(`https://api.github.com/repos/${repo}/contents/${SCENE_PATH}?ref=main`, { headers: headers(), signal: AbortSignal.timeout(20000) });
|
|
106
|
+
st = { elements: new Map(), sha: null, dirty: false };
|
|
107
|
+
if (r.ok) {
|
|
108
|
+
const j = await r.json() as any;
|
|
109
|
+
const doc = JSON.parse(Buffer.from(j.content, 'base64').toString('utf8'));
|
|
110
|
+
for (const el of doc.elements || []) st.elements.set(el.id, el);
|
|
111
|
+
st.sha = j.sha;
|
|
112
|
+
}
|
|
113
|
+
scenes.set(repo, st);
|
|
114
|
+
return st;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function current(): { elements: Map<string, ServerElement>; sha: string | null; dirty: boolean } {
|
|
118
|
+
if (active.kind !== 'remote') throw new Error('not in remote mode');
|
|
119
|
+
const st = scenes.get(active.repo);
|
|
120
|
+
if (!st) throw new Error('call switch_remote first');
|
|
121
|
+
return st;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scheduleCommit(): void {
|
|
125
|
+
if (active.kind !== 'remote') return;
|
|
126
|
+
if (timer) clearTimeout(timer);
|
|
127
|
+
timer = setTimeout(() => { void commitNow('excalidrop: autosync from MCP'); }, 10000);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function commitNow(message?: string): Promise<{ sha: string; count: number }> {
|
|
131
|
+
if (active.kind !== 'remote') throw new Error('not in remote mode');
|
|
132
|
+
const repo = active.repo;
|
|
133
|
+
const st = current();
|
|
134
|
+
const elements = Array.from(st.elements.values());
|
|
135
|
+
const doc = { type: 'excalidraw', version: 2, source: 'excalidrop', elements };
|
|
136
|
+
// Shared putFile handles: sha optimistic concurrency (409) + bootstrapping
|
|
137
|
+
// main on empty repos (forks off gh-pages) — same path as the remote server.
|
|
138
|
+
const gh = await import('./utils/github.js');
|
|
139
|
+
const newSha = await gh.putFile(repo, SCENE_PATH, doc, message || `excalidrop: update ${elements.length} elements`, 'main', st.sha || undefined);
|
|
140
|
+
st.sha = newSha;
|
|
141
|
+
st.dirty = false;
|
|
142
|
+
// mirror to gh-pages viewer copy
|
|
143
|
+
try {
|
|
144
|
+
const cur = await gh.getFile(repo, SCENE_PATH, 'gh-pages').catch(() => null);
|
|
145
|
+
await gh.putFile(repo, SCENE_PATH, doc, 'excalidrop: sync viewer', 'gh-pages', cur?.sha);
|
|
146
|
+
} catch (e) { logger.warn('viewer mirror failed: ' + (e as Error).message); }
|
|
147
|
+
logger.info(`Committed ${elements.length} elements to ${repo}`);
|
|
148
|
+
return { sha: st.sha as string, count: elements.length };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Element ops (remote side)
|
|
152
|
+
export function rCreate(el: ServerElement): ServerElement {
|
|
153
|
+
const st = current();
|
|
154
|
+
const full = { ...el, id: el.id || generateId() };
|
|
155
|
+
st.elements.set(full.id, full); st.dirty = true; scheduleCommit();
|
|
156
|
+
return full;
|
|
157
|
+
}
|
|
158
|
+
export function rBatch(els: ServerElement[]): ServerElement[] { return els.map(rCreate); }
|
|
159
|
+
export function rUpdate(id: string, updates: Partial<ServerElement>): ServerElement {
|
|
160
|
+
const st = current();
|
|
161
|
+
const cur = st.elements.get(id);
|
|
162
|
+
if (!cur) throw new Error(`Element ${id} not found`);
|
|
163
|
+
const u = { ...cur, ...updates };
|
|
164
|
+
st.elements.set(id, u); st.dirty = true; scheduleCommit();
|
|
165
|
+
return u;
|
|
166
|
+
}
|
|
167
|
+
export function rDelete(id: string): void { const st = current(); st.elements.delete(id); st.dirty = true; scheduleCommit(); }
|
|
168
|
+
export function rGet(id: string): ServerElement | null { return current().elements.get(id) || null; }
|
|
169
|
+
export function rList(type?: string): ServerElement[] {
|
|
170
|
+
return Array.from(current().elements.values()).filter(e => !type || e.type === type);
|
|
171
|
+
}
|
|
172
|
+
export function rClear(): number { const st = current(); const n = st.elements.size; st.elements.clear(); st.dirty = true; scheduleCommit(); return n; }
|
|
173
|
+
export const isRemote = (): boolean => active.kind === 'remote';
|
|
174
|
+
|
|
175
|
+
// ─── GitHub App device flow (Node — no CORS issues here) ───
|
|
176
|
+
// NOTE: deliberate device-flow (not web flow): static hosting has nowhere to
|
|
177
|
+
// keep a client_secret, and device flow is the only secretless path per docs.
|
|
178
|
+
// App user-tokens carry no scopes — permissions = user ∩ app. Optional
|
|
179
|
+
// repository_id restricts the token to a single repo (least privilege).
|
|
180
|
+
export async function deviceStart(clientId: string): Promise<{ user_code: string; verification_uri: string; device_code: string; interval: number }> {
|
|
181
|
+
const r = await fetch('https://github.com/login/device/code', {
|
|
182
|
+
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
183
|
+
body: JSON.stringify({ client_id: clientId }),
|
|
184
|
+
});
|
|
185
|
+
if (!r.ok) throw new Error('device flow start failed');
|
|
186
|
+
return r.json() as any;
|
|
187
|
+
}
|
|
188
|
+
export async function repoId(owner: string, repo: string): Promise<number | null> {
|
|
189
|
+
try {
|
|
190
|
+
const r = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'excalidrop' } });
|
|
191
|
+
if (!r.ok) return null;
|
|
192
|
+
return ((await r.json()) as any).id ?? null;
|
|
193
|
+
} catch { return null; }
|
|
194
|
+
}
|
|
195
|
+
export async function devicePoll(clientId: string, deviceCode: string, intervalSec: number, timeoutSec = 300, repositoryId?: number): Promise<string> {
|
|
196
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
197
|
+
while (Date.now() < deadline) {
|
|
198
|
+
await new Promise(r => setTimeout(r, intervalSec * 1000));
|
|
199
|
+
const r = await fetch('https://github.com/login/oauth/access_token', {
|
|
200
|
+
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
201
|
+
body: JSON.stringify({ client_id: clientId, device_code: deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code', ...(repositoryId ? { repository_id: repositoryId } : {}) }),
|
|
202
|
+
});
|
|
203
|
+
const j = await r.json() as any;
|
|
204
|
+
if (j.access_token) { storeToken(j.access_token); return j.access_token; }
|
|
205
|
+
if (j.error && j.error !== 'authorization_pending' && j.error !== 'slow_down') throw new Error(j.error_description || j.error);
|
|
206
|
+
}
|
|
207
|
+
throw new Error('device login timed out');
|
|
208
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
export interface ExcalidrawElementBase {
|
|
2
|
+
id: string;
|
|
3
|
+
type: ExcalidrawElementType;
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
angle?: number;
|
|
9
|
+
strokeColor?: string;
|
|
10
|
+
backgroundColor?: string;
|
|
11
|
+
fillStyle?: string;
|
|
12
|
+
strokeWidth?: number;
|
|
13
|
+
strokeStyle?: string;
|
|
14
|
+
roughness?: number;
|
|
15
|
+
opacity?: number;
|
|
16
|
+
groupIds?: string[];
|
|
17
|
+
frameId?: string | null;
|
|
18
|
+
roundness?: {
|
|
19
|
+
type: number;
|
|
20
|
+
value?: number;
|
|
21
|
+
} | null;
|
|
22
|
+
seed?: number;
|
|
23
|
+
versionNonce?: number;
|
|
24
|
+
isDeleted?: boolean;
|
|
25
|
+
locked?: boolean;
|
|
26
|
+
link?: string | null;
|
|
27
|
+
customData?: Record<string, any> | null;
|
|
28
|
+
boundElements?: readonly ExcalidrawBoundElement[] | null;
|
|
29
|
+
updated?: number;
|
|
30
|
+
containerId?: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ExcalidrawTextElement extends ExcalidrawElementBase {
|
|
34
|
+
type: 'text';
|
|
35
|
+
text: string;
|
|
36
|
+
fontSize?: number;
|
|
37
|
+
fontFamily?: number;
|
|
38
|
+
textAlign?: string;
|
|
39
|
+
verticalAlign?: string;
|
|
40
|
+
baseline?: number;
|
|
41
|
+
lineHeight?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ExcalidrawRectangleElement extends ExcalidrawElementBase {
|
|
45
|
+
type: 'rectangle';
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ExcalidrawEllipseElement extends ExcalidrawElementBase {
|
|
51
|
+
type: 'ellipse';
|
|
52
|
+
width: number;
|
|
53
|
+
height: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ExcalidrawDiamondElement extends ExcalidrawElementBase {
|
|
57
|
+
type: 'diamond';
|
|
58
|
+
width: number;
|
|
59
|
+
height: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ExcalidrawArrowElement extends ExcalidrawElementBase {
|
|
63
|
+
type: 'arrow';
|
|
64
|
+
points: readonly [number, number][];
|
|
65
|
+
lastCommittedPoint?: readonly [number, number] | null;
|
|
66
|
+
startBinding?: ExcalidrawBinding | null;
|
|
67
|
+
endBinding?: ExcalidrawBinding | null;
|
|
68
|
+
startArrowhead?: string | null;
|
|
69
|
+
endArrowhead?: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ExcalidrawLineElement extends ExcalidrawElementBase {
|
|
73
|
+
type: 'line';
|
|
74
|
+
points: readonly [number, number][];
|
|
75
|
+
lastCommittedPoint?: readonly [number, number] | null;
|
|
76
|
+
startBinding?: ExcalidrawBinding | null;
|
|
77
|
+
endBinding?: ExcalidrawBinding | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ExcalidrawFreedrawElement extends ExcalidrawElementBase {
|
|
81
|
+
type: 'freedraw';
|
|
82
|
+
points: readonly [number, number][];
|
|
83
|
+
pressures?: readonly number[];
|
|
84
|
+
simulatePressure?: boolean;
|
|
85
|
+
lastCommittedPoint?: readonly [number, number] | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ExcalidrawElement =
|
|
89
|
+
| ExcalidrawTextElement
|
|
90
|
+
| ExcalidrawRectangleElement
|
|
91
|
+
| ExcalidrawEllipseElement
|
|
92
|
+
| ExcalidrawDiamondElement
|
|
93
|
+
| ExcalidrawArrowElement
|
|
94
|
+
| ExcalidrawLineElement
|
|
95
|
+
| ExcalidrawFreedrawElement;
|
|
96
|
+
|
|
97
|
+
export interface ExcalidrawBoundElement {
|
|
98
|
+
id: string;
|
|
99
|
+
type: 'text' | 'arrow';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ExcalidrawBinding {
|
|
103
|
+
elementId: string;
|
|
104
|
+
focus: number;
|
|
105
|
+
gap: number;
|
|
106
|
+
fixedPoint?: readonly [number, number] | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'image';
|
|
110
|
+
|
|
111
|
+
// Excalidraw element types
|
|
112
|
+
export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
|
|
113
|
+
RECTANGLE: 'rectangle',
|
|
114
|
+
ELLIPSE: 'ellipse',
|
|
115
|
+
DIAMOND: 'diamond',
|
|
116
|
+
ARROW: 'arrow',
|
|
117
|
+
TEXT: 'text',
|
|
118
|
+
FREEDRAW: 'freedraw',
|
|
119
|
+
LINE: 'line',
|
|
120
|
+
IMAGE: 'image'
|
|
121
|
+
} as const;
|
|
122
|
+
|
|
123
|
+
// Server-side element with metadata
|
|
124
|
+
export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
|
|
125
|
+
id: string;
|
|
126
|
+
type: ExcalidrawElementType;
|
|
127
|
+
createdAt?: string;
|
|
128
|
+
updatedAt?: string;
|
|
129
|
+
version?: number;
|
|
130
|
+
syncedAt?: string;
|
|
131
|
+
source?: string;
|
|
132
|
+
syncTimestamp?: string;
|
|
133
|
+
text?: string;
|
|
134
|
+
originalText?: string;
|
|
135
|
+
fontSize?: number;
|
|
136
|
+
fontFamily?: string | number;
|
|
137
|
+
label?: {
|
|
138
|
+
text: string;
|
|
139
|
+
};
|
|
140
|
+
points?: any;
|
|
141
|
+
// Arrow element binding: connect arrows to shapes by element ID
|
|
142
|
+
start?: { id: string };
|
|
143
|
+
end?: { id: string };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// API Response types
|
|
147
|
+
export interface ApiResponse<T = any> {
|
|
148
|
+
success: boolean;
|
|
149
|
+
data?: T;
|
|
150
|
+
error?: string;
|
|
151
|
+
message?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface ElementsResponse extends ApiResponse {
|
|
155
|
+
elements: ServerElement[];
|
|
156
|
+
count: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ElementResponse extends ApiResponse {
|
|
160
|
+
element: ServerElement;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface SyncResponse extends ApiResponse {
|
|
164
|
+
count: number;
|
|
165
|
+
syncedAt: string;
|
|
166
|
+
beforeCount: number;
|
|
167
|
+
afterCount: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// WebSocket message types
|
|
171
|
+
export interface WebSocketMessage {
|
|
172
|
+
type: WebSocketMessageType;
|
|
173
|
+
[key: string]: any;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type WebSocketMessageType =
|
|
177
|
+
| 'initial_elements'
|
|
178
|
+
| 'element_created'
|
|
179
|
+
| 'element_updated'
|
|
180
|
+
| 'element_deleted'
|
|
181
|
+
| 'elements_batch_created'
|
|
182
|
+
| 'elements_synced'
|
|
183
|
+
| 'sync_status'
|
|
184
|
+
| 'mermaid_convert'
|
|
185
|
+
| 'canvas_cleared'
|
|
186
|
+
| 'export_image_request'
|
|
187
|
+
| 'set_viewport'
|
|
188
|
+
| 'files_added'
|
|
189
|
+
| 'file_deleted';
|
|
190
|
+
|
|
191
|
+
export interface InitialElementsMessage extends WebSocketMessage {
|
|
192
|
+
type: 'initial_elements';
|
|
193
|
+
elements: ServerElement[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface ElementCreatedMessage extends WebSocketMessage {
|
|
197
|
+
type: 'element_created';
|
|
198
|
+
element: ServerElement;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface ElementUpdatedMessage extends WebSocketMessage {
|
|
202
|
+
type: 'element_updated';
|
|
203
|
+
element: ServerElement;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface ElementDeletedMessage extends WebSocketMessage {
|
|
207
|
+
type: 'element_deleted';
|
|
208
|
+
elementId: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface BatchCreatedMessage extends WebSocketMessage {
|
|
212
|
+
type: 'elements_batch_created';
|
|
213
|
+
elements: ServerElement[];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface SyncStatusMessage extends WebSocketMessage {
|
|
217
|
+
type: 'sync_status';
|
|
218
|
+
elementCount: number;
|
|
219
|
+
timestamp: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface MermaidConvertMessage extends WebSocketMessage {
|
|
223
|
+
type: 'mermaid_convert';
|
|
224
|
+
mermaidDiagram: string;
|
|
225
|
+
config?: MermaidConfig;
|
|
226
|
+
timestamp: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Mermaid conversion types
|
|
230
|
+
export interface MermaidConfig {
|
|
231
|
+
startOnLoad?: boolean;
|
|
232
|
+
flowchart?: {
|
|
233
|
+
curve?: 'linear' | 'basis';
|
|
234
|
+
};
|
|
235
|
+
themeVariables?: {
|
|
236
|
+
fontSize?: string;
|
|
237
|
+
};
|
|
238
|
+
maxEdges?: number;
|
|
239
|
+
maxTextSize?: number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface MermaidConversionRequest {
|
|
243
|
+
mermaidDiagram: string;
|
|
244
|
+
config?: MermaidConfig;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface MermaidConversionResponse extends ApiResponse {
|
|
248
|
+
elements: ServerElement[];
|
|
249
|
+
files?: any;
|
|
250
|
+
count: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Canvas cleared message
|
|
254
|
+
export interface CanvasClearedMessage extends WebSocketMessage {
|
|
255
|
+
type: 'canvas_cleared';
|
|
256
|
+
timestamp: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Image export types
|
|
260
|
+
export interface ExportImageRequestMessage extends WebSocketMessage {
|
|
261
|
+
type: 'export_image_request';
|
|
262
|
+
requestId: string;
|
|
263
|
+
format: 'png' | 'svg';
|
|
264
|
+
background?: boolean;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Viewport control types
|
|
268
|
+
export interface SetViewportMessage extends WebSocketMessage {
|
|
269
|
+
type: 'set_viewport';
|
|
270
|
+
requestId: string;
|
|
271
|
+
scrollToContent?: boolean;
|
|
272
|
+
scrollToElementId?: string;
|
|
273
|
+
zoom?: number;
|
|
274
|
+
offsetX?: number;
|
|
275
|
+
offsetY?: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Snapshot types
|
|
279
|
+
export interface Snapshot {
|
|
280
|
+
name: string;
|
|
281
|
+
elements: ServerElement[];
|
|
282
|
+
createdAt: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// In-memory storage for Excalidraw elements
|
|
286
|
+
export const elements = new Map<string, ServerElement>();
|
|
287
|
+
|
|
288
|
+
// In-memory storage for snapshots
|
|
289
|
+
export const snapshots = new Map<string, Snapshot>();
|
|
290
|
+
|
|
291
|
+
// In-memory file storage for image elements (Excalidraw BinaryFiles)
|
|
292
|
+
export interface ExcalidrawFile {
|
|
293
|
+
id: string;
|
|
294
|
+
dataURL: string;
|
|
295
|
+
mimeType: string;
|
|
296
|
+
created: number;
|
|
297
|
+
}
|
|
298
|
+
export const files = new Map<string, ExcalidrawFile>();
|
|
299
|
+
|
|
300
|
+
// Validation function for Excalidraw elements
|
|
301
|
+
export function validateElement(element: Partial<ServerElement>): element is ServerElement {
|
|
302
|
+
const requiredFields: (keyof ServerElement)[] = ['type', 'x', 'y'];
|
|
303
|
+
const hasRequiredFields = requiredFields.every(field => field in element);
|
|
304
|
+
|
|
305
|
+
if (!hasRequiredFields) {
|
|
306
|
+
throw new Error(`Missing required fields: ${requiredFields.join(', ')}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!Object.values(EXCALIDRAW_ELEMENT_TYPES).includes(element.type as ExcalidrawElementType)) {
|
|
310
|
+
throw new Error(`Invalid element type: ${element.type}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Helper function to generate unique IDs
|
|
317
|
+
export function generateId(): string {
|
|
318
|
+
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Normalize fontFamily from string names to numeric values that Excalidraw expects
|
|
322
|
+
// Excalidraw uses: 1 = Virgil (handwritten), 2 = Helvetica (sans-serif), 3 = Cascadia (monospace)
|
|
323
|
+
// 5 = Excalifont, 6 = Nunito, 7 = Lilita One, 8 = Comic Shanns
|
|
324
|
+
export function normalizeFontFamily(fontFamily: string | number | undefined): number | undefined {
|
|
325
|
+
if (fontFamily === undefined) return undefined;
|
|
326
|
+
if (typeof fontFamily === 'number') return fontFamily;
|
|
327
|
+
const map: Record<string, number> = {
|
|
328
|
+
'virgil': 1, 'hand': 1, 'handwritten': 1,
|
|
329
|
+
'helvetica': 2, 'sans': 2, 'sans-serif': 2,
|
|
330
|
+
'cascadia': 3, 'mono': 3, 'monospace': 3,
|
|
331
|
+
'excalifont': 5,
|
|
332
|
+
'nunito': 6,
|
|
333
|
+
'lilita': 7, 'lilita one': 7,
|
|
334
|
+
'comic shanns': 8, 'comic': 8,
|
|
335
|
+
'1': 1, '2': 2, '3': 3, '5': 5, '6': 6, '7': 7, '8': 8,
|
|
336
|
+
};
|
|
337
|
+
return map[fontFamily.toLowerCase()];
|
|
338
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
3
|
+
|
|
4
|
+
const SECRET = () => process.env.AUTH_SECRET || process.env.GITHUB_CLIENT_SECRET || 'excalidrop-dev-secret';
|
|
5
|
+
|
|
6
|
+
export interface Session { login: string; id: number; iat: number; }
|
|
7
|
+
|
|
8
|
+
function b64u(buf: Buffer | string): string {
|
|
9
|
+
return Buffer.from(buf as any).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
10
|
+
}
|
|
11
|
+
function unb64u(s: string): Buffer { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; return Buffer.from(s, 'base64'); }
|
|
12
|
+
|
|
13
|
+
export function signSession(s: Session): string {
|
|
14
|
+
const payload = b64u(JSON.stringify(s));
|
|
15
|
+
const sig = b64u(crypto.createHmac('sha256', SECRET()).update(payload).digest());
|
|
16
|
+
return `${payload}.${sig}`;
|
|
17
|
+
}
|
|
18
|
+
export function verifySession(token: string): Session | null {
|
|
19
|
+
try {
|
|
20
|
+
const [p, sig] = token.split('.');
|
|
21
|
+
if (!p || !sig) return null;
|
|
22
|
+
const expect = b64u(crypto.createHmac('sha256', SECRET()).update(p).digest());
|
|
23
|
+
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
|
|
24
|
+
const s = JSON.parse(unb64u(p).toString()) as Session;
|
|
25
|
+
if (Date.now() / 1000 - s.iat > 30 * 24 * 3600) return null;
|
|
26
|
+
return s;
|
|
27
|
+
} catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getToken(req: Request): string | null {
|
|
31
|
+
const h = req.headers.authorization;
|
|
32
|
+
if (h?.startsWith('Bearer ')) return h.slice(7);
|
|
33
|
+
const cookie = req.headers.cookie || '';
|
|
34
|
+
const m = cookie.match(/excalidrop_token=([^;]+)/);
|
|
35
|
+
return m ? decodeURIComponent(m[1] as string) : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function authOptional(req: Request, _res: Response, next: NextFunction) {
|
|
39
|
+
const t = getToken(req);
|
|
40
|
+
(req as any).user = t ? verifySession(t) : null;
|
|
41
|
+
next();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Writes always require auth when GitHub OAuth is configured. Reads are public iff PUBLIC_READ=true. */
|
|
45
|
+
export function requireWriteAuth(req: Request, res: Response, next: NextFunction) {
|
|
46
|
+
if (!process.env.GITHUB_CLIENT_ID) return next(); // local dev: open
|
|
47
|
+
const tok = getToken(req);
|
|
48
|
+
const user = tok ? verifySession(tok) : null;
|
|
49
|
+
if (user) { (req as any).user = user; return next(); }
|
|
50
|
+
return res.status(401).json({ success: false, error: 'Authentication required. Login with GitHub.' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function requireReadAuth(req: Request, res: Response, next: NextFunction) {
|
|
54
|
+
if (String(process.env.PUBLIC_READ || 'false').toLowerCase() === 'true') { const t = getToken(req); (req as any).user = t ? verifySession(t) : null; return next(); }
|
|
55
|
+
if (!process.env.GITHUB_CLIENT_ID) return next();
|
|
56
|
+
return requireWriteAuth(req, res, next);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function allowedLogins(): string[] {
|
|
60
|
+
return (process.env.ALLOWED_GITHUB_USERS || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
61
|
+
}
|