@tequila99/githuman 0.8.0 → 0.9.0
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/README.md +39 -3
- package/dist/server/app.js +1 -0
- package/dist/server/db/migrations.js +6 -0
- package/dist/server/errors/note-assets.js +3 -0
- package/dist/server/repositories/notes-catalog.repo.js +4 -3
- package/dist/server/repositories/notes.repo.js +69 -27
- package/dist/server/routes/agent.js +1 -0
- package/dist/server/routes/notes.js +48 -2
- package/dist/server/services/agent/context-builder.js +13 -0
- package/dist/server/services/notes-external-assets.service.js +177 -0
- package/dist/server/services/notes-identity.service.js +112 -27
- package/dist/server/services/notes-operations.service.js +3 -5
- package/dist/server/services/notes.service.js +29 -4
- package/dist/server/utils/note-asset-format.js +1 -1
- package/dist/server/utils/remote-asset-request.js +193 -0
- package/dist/server/utils/request-abort.js +20 -0
- package/dist/shared/agents/schemas.js +14 -0
- package/dist/shared/notes/asset-formats.js +19 -1
- package/dist/shared/notes/constants.js +5 -0
- package/dist/shared/notes/external-assets.js +153 -0
- package/dist/shared/notes/note-path.js +17 -0
- package/dist/shared/notes/reference-path.js +2 -1
- package/dist/shared/notes/references.js +1 -1
- package/dist/shared/notes/schemas.js +60 -2
- package/dist/shared/utils/web-url.js +15 -0
- package/dist/web/assets/AppPage-DdmqiV2R.js +1 -0
- package/dist/web/assets/{ClosePopup-D2_HzWXW.js → ClosePopup-lpH_1Ju2.js} +1 -1
- package/dist/web/assets/{CreateButton-pmDRGwna.js → CreateButton-Dnpwobd9.js} +2 -2
- package/dist/web/assets/{CreateReviewFabButton-CcXWnmSN.js → CreateReviewFabButton-KvKPEJzI.js} +1 -1
- package/dist/web/assets/IndexPage-DE-IkAhK.js +1 -0
- package/dist/web/assets/IndexPage-F8MMAAkn.css +1 -0
- package/dist/web/assets/{MainLayout-DOGoQ-1m.js → MainLayout-wWA8ckBg.js} +4 -4
- package/dist/web/assets/NoteVisualEditor-Bj13_oEl.js +1054 -0
- package/dist/web/assets/{NoteVisualEditor-CClIKW0X.css → NoteVisualEditor-IH3s3d3W.css} +1 -1
- package/dist/web/assets/NotesPage-FidBg83M.js +4 -0
- package/dist/web/assets/NotesPage-KaCEnfdz.css +1 -0
- package/dist/web/assets/PanelResize-C51WwwLD.css +1 -0
- package/dist/web/assets/PanelResize-DY0Nzmql.js +1 -0
- package/dist/web/assets/{PdfPreview-CdgeqqEv.js → PdfPreview-C7HomxEg.js} +1 -1
- package/dist/web/assets/{QFab-DyUFgj0W.js → QFab-C-YZWgmW.js} +1 -1
- package/dist/web/assets/{QForm-BiRkV9BY.js → QForm-DpEhYVCa.js} +1 -1
- package/dist/web/assets/QResizeObserver-DeRFR_WF.js +1 -0
- package/dist/web/assets/{QScrollArea-DfPW35JQ.js → QScrollArea-BSSZoaV5.js} +1 -1
- package/dist/web/assets/{QSelect-BpZ5KfWP.js → QSelect-2iOBkp1K.js} +1 -1
- package/dist/web/assets/QSpace-B5NneHAd.js +1 -0
- package/dist/web/assets/QVirtualScroll-CZJMBpmR.js +1 -0
- package/dist/web/assets/ReviewDetailPage-BI1iAhSf.js +1 -0
- package/dist/web/assets/{ReviewsPage-BaxUZzUZ.js → ReviewsPage-RyST0FlH.js} +1 -1
- package/dist/web/assets/StatusSwitcher-DAeNzNP7.js +1 -0
- package/dist/web/assets/{TerminalView-DMJTXE-G.js → TerminalView-BAzJgUTq.js} +2 -2
- package/dist/web/assets/{abnfDiagram-O67JEVCF-CiOkMSRz.js → abnfDiagram-O67JEVCF-BNZQ7duT.js} +1 -1
- package/dist/web/assets/agent-store-DP6Vrw6L.js +113 -0
- package/dist/web/assets/app-info-store-u6ZVFjv5.js +1 -0
- package/dist/web/assets/architecture-7GRP2DOG-BSdnjp9Z.js +1 -0
- package/dist/web/assets/{architectureDiagram-NJMV4G6O-BexHJffj.js → architectureDiagram-NJMV4G6O-Bkj0kfFn.js} +1 -1
- package/dist/web/assets/{blockDiagram-BEXU5L5S-CEyB_5a1.js → blockDiagram-BEXU5L5S-CXO3zN2c.js} +1 -1
- package/dist/web/assets/{c4Diagram-YGBWAQC7-BRNWA7xI.js → c4Diagram-YGBWAQC7-BHfMeZNf.js} +1 -1
- package/dist/web/assets/channel-DmoOM6AB.js +1 -0
- package/dist/web/assets/{chunk-3FUC2YCW-BcQR1JDF.js → chunk-3FUC2YCW-z1WbH7Gi.js} +1 -1
- package/dist/web/assets/{chunk-5DYCD2WN-D6aQ1hFt.js → chunk-5DYCD2WN-K9HPch97.js} +1 -1
- package/dist/web/assets/{chunk-742MDFTN-BRgrFtkf.js → chunk-742MDFTN-Bhjp9fC-.js} +1 -1
- package/dist/web/assets/{chunk-7INBJB4K-3RBjXeq6.js → chunk-7INBJB4K-Do0EmU14.js} +1 -1
- package/dist/web/assets/{chunk-7M6MHVWA-Malugc_N.js → chunk-7M6MHVWA-veeEjsOh.js} +1 -1
- package/dist/web/assets/{chunk-7PRAP22T-Bb3gaISA.js → chunk-7PRAP22T-CPqB5tCK.js} +1 -1
- package/dist/web/assets/{chunk-GTNCS2PH-Cps7XhUC.js → chunk-GTNCS2PH-Civ1V52P.js} +1 -1
- package/dist/web/assets/{chunk-GWA4HPMP-Bfp2Tg3_.js → chunk-GWA4HPMP-C7LzAlyZ.js} +1 -1
- package/dist/web/assets/{chunk-MBY4JIJT-IfXbX1Ur.js → chunk-MBY4JIJT-DfmJy0o_.js} +1 -1
- package/dist/web/assets/{chunk-NETBCI7D-r2fmahwk.js → chunk-NETBCI7D-BZIKmMvL.js} +1 -1
- package/dist/web/assets/{chunk-O7XYJQB3-sadsiWmA.js → chunk-O7XYJQB3-DcHeu6b7.js} +1 -1
- package/dist/web/assets/{chunk-UA2S7LBM-DibfwtDn.js → chunk-UA2S7LBM-0SR2og6x.js} +1 -1
- package/dist/web/assets/{chunk-WEXAMYUT-DBouMgEO.js → chunk-WEXAMYUT-CJMSoM-Z.js} +1 -1
- package/dist/web/assets/{chunk-Z7XXMR3K-fAHToON5.js → chunk-Z7XXMR3K-q6fce66x.js} +1 -1
- package/dist/web/assets/{chunk-ZIGJFQKS-CJG1UIHO.js → chunk-ZIGJFQKS-DsPzen4Y.js} +1 -1
- package/dist/web/assets/{classDiagram-v2-NBCMYWYE-CPHP5ynj.js → classDiagram-v2-NBCMYWYE-Cpm_bvzb.js} +1 -1
- package/dist/web/assets/{cynefin-OW5HDTMX-BU7_LYGR.js → cynefin-OW5HDTMX-ZTr5XZMW.js} +1 -1
- package/dist/web/assets/{cynefinDiagram-VND7K2PF-BXFEy9Io.js → cynefinDiagram-VND7K2PF-ByUmUx58.js} +1 -1
- package/dist/web/assets/{dagre-6A5THRUB-aKpdpQ18.js → dagre-6A5THRUB-mQOy0E3r.js} +1 -1
- package/dist/web/assets/{diagram-22UHCM2B-ddbRgIQ9.js → diagram-22UHCM2B-BW0LPtyk.js} +1 -1
- package/dist/web/assets/{diagram-3UASUU5V-vNYy6WP_.js → diagram-3UASUU5V-jo148ec5.js} +1 -1
- package/dist/web/assets/{diagram-ATOU4E4O-B9Z3zFfp.js → diagram-ATOU4E4O-CJt4F0i4.js} +1 -1
- package/dist/web/assets/{diagram-CDSNMT55-D3TaDPXU.js → diagram-CDSNMT55-DEShJv56.js} +1 -1
- package/dist/web/assets/{diagram-MLGK6HIB-DOXcXm2Y.js → diagram-MLGK6HIB-DOFOADfs.js} +1 -1
- package/dist/web/assets/{diagram-MPIPVDR6-B5E2J8pO.js → diagram-MPIPVDR6-ux537BMl.js} +1 -1
- package/dist/web/assets/{dist-DSPzgfNo.js → dist-7xWvOKEv.js} +1 -1
- package/dist/web/assets/{dist-C9cJQvJH.js → dist-BThMRW5U.js} +1 -1
- package/dist/web/assets/{dist-BXUopzfH.js → dist-BWa3ecCV.js} +1 -1
- package/dist/web/assets/{dist-BX8UA0fl.js → dist-BbdrgL5V.js} +1 -1
- package/dist/web/assets/{dist-Kd0W7Y_b.js → dist-Bo7anpPj.js} +1 -1
- package/dist/web/assets/{dist-BFbA030x.js → dist-Bu9yRqTU.js} +1 -1
- package/dist/web/assets/{dist-Cxd9qKh7.js → dist-Bvnostf5.js} +1 -1
- package/dist/web/assets/{dist-C9STut2J.js → dist-CK1va5C_.js} +1 -1
- package/dist/web/assets/{dist-BGzvKGEu.js → dist-CLv-grWO.js} +1 -1
- package/dist/web/assets/{dist-CRoJr3t6.js → dist-D1EUrS6y.js} +1 -1
- package/dist/web/assets/{dist-PDsGXJi3.js → dist-DL0dBIkt.js} +1 -1
- package/dist/web/assets/{ebnfDiagram-ZINNZB2B-D-OCB_ud.js → ebnfDiagram-ZINNZB2B-DEybuZle.js} +1 -1
- package/dist/web/assets/{elk-276RUBZZ-CzjP8RHy.js → elk-276RUBZZ-CZXZRpGb.js} +1 -1
- package/dist/web/assets/{erDiagram-OPXOYQCR-Clohps7p.js → erDiagram-OPXOYQCR-BgRqGAt5.js} +1 -1
- package/dist/web/assets/eventmodeling-NTZA5JFV-ZuRq6Sae.js +1 -0
- package/dist/web/assets/flowDiagram-KWPJA3E3-DRyHAdLb.js +1 -0
- package/dist/web/assets/{ganttDiagram-FUAMR5RP-ZWH4HYq5.js → ganttDiagram-FUAMR5RP-CBJwX1gk.js} +1 -1
- package/dist/web/assets/{gitGraph-4MIJSDKK-C3AfRnC8.js → gitGraph-4MIJSDKK-Dw-XA3G1.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-X574FWY7-BobkQARX.js → gitGraphDiagram-X574FWY7-BpGccoWp.js} +1 -1
- package/dist/web/assets/i18n-sfJvmpVb.js +1 -0
- package/dist/web/assets/index-B2CzafCr.js +2 -0
- package/dist/web/assets/{info-A6RAGUB7-Dh_SAe09.js → info-A6RAGUB7-Dj5RfJsg.js} +1 -1
- package/dist/web/assets/{infoDiagram-VRGFBTTK-izDlhNTw.js → infoDiagram-VRGFBTTK-DyVSH991.js} +1 -1
- package/dist/web/assets/{init-app-C1a6BWWN.js → init-app-DSS44aRI.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-OU5B5YK6-BJ89XzVJ.js → ishikawaDiagram-OU5B5YK6-Cu0n2oqT.js} +1 -1
- package/dist/web/assets/{journeyDiagram-ZHPQQLJL-poV-f08K.js → journeyDiagram-ZHPQQLJL-Bz1Ot6Zg.js} +1 -1
- package/dist/web/assets/{kanban-definition-PNTS6WVX-Cn3FEnH7.js → kanban-definition-PNTS6WVX-C42vuY7H.js} +1 -1
- package/dist/web/assets/{line-bE9Mqcqy.js → line-C5WKurbS.js} +1 -1
- package/dist/web/assets/markdown-DBTEhbhQ.js +15 -0
- package/dist/web/assets/{mermaid-parser.core-Cua7H5pF.js → mermaid-parser.core-B7l8qh8B.js} +3 -3
- package/dist/web/assets/{mermaid.core-BMPOA8T-.js → mermaid.core-Drs2mjGg.js} +4 -4
- package/dist/web/assets/{mindmap-definition-NLK3R4M7-DaTGr8Fs.js → mindmap-definition-NLK3R4M7-BV1FNwCn.js} +1 -1
- package/dist/web/assets/{packet-AYTQ26CC-DmLfghd9.js → packet-AYTQ26CC-BPmUY1Ig.js} +1 -1
- package/dist/web/assets/{pegDiagram-GJSIUBJH-CTlxXcbf.js → pegDiagram-GJSIUBJH-D6NPxPdr.js} +1 -1
- package/dist/web/assets/{pie-WAS4IAKB-52oNsEtc.js → pie-WAS4IAKB-CTp2jNAc.js} +1 -1
- package/dist/web/assets/{pieDiagram-5QR66LMP-CbJtB1te.js → pieDiagram-5QR66LMP-BVkABrg7.js} +1 -1
- package/dist/web/assets/pinia-BVWaJ8NX.js +1 -0
- package/dist/web/assets/{quadrantDiagram-O4NWA36T-DCg-uD08.js → quadrantDiagram-O4NWA36T-LZCjVd08.js} +1 -1
- package/dist/web/assets/{radar-RG4KPBEZ-9uA3AOZR.js → radar-RG4KPBEZ-CCiUrmsg.js} +1 -1
- package/dist/web/assets/{railroad-74A4TZTK-BPxQfpxv.js → railroad-74A4TZTK-CZn8VJbK.js} +1 -1
- package/dist/web/assets/railroad-abnf-HS5TGJTU-B-9tG9pW.js +1 -0
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-D7lUyGOq.js +1 -0
- package/dist/web/assets/railroad-peg-WCYAUIDC-ja94t32K.js +1 -0
- package/dist/web/assets/{railroadDiagram-XR7U4H2S-CwBNr6fr.js → railroadDiagram-XR7U4H2S-nopy-mdw.js} +1 -1
- package/dist/web/assets/{requirementDiagram-PLB6GJNP-BbtZt0St.js → requirementDiagram-PLB6GJNP-BOeMzf-x.js} +1 -1
- package/dist/web/assets/{safe-storage-Bn-sHAYw.js → safe-storage-aRoyeFxv.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-IPEJSGJF-DRWdMnFl.js → sankeyDiagram-IPEJSGJF-Bbk0AQUe.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-PO4LG4MO-C4tWeWpt.js → sequenceDiagram-PO4LG4MO-ZpubjKFc.js} +1 -1
- package/dist/web/assets/{stateDiagram-v2-GCMORJYK-pE0T1U5E.js → stateDiagram-v2-GCMORJYK-DNjDh9yZ.js} +1 -1
- package/dist/web/assets/{swimlanes-2SLR337P-DKky6E3p.js → swimlanes-2SLR337P-C-_UzZF_.js} +1 -1
- package/dist/web/assets/swimlanesDiagram-TC7HE7FX-CVSZNvS2.js +8 -0
- package/dist/web/assets/theme-CeQhGDjh.js +1 -0
- package/dist/web/assets/{timeline-definition-EJHVYXUP-CsRzcMLr.js → timeline-definition-EJHVYXUP-BiaJyHdJ.js} +1 -1
- package/dist/web/assets/{treeView-Q6P3EWNA-CQYqoPG3.js → treeView-Q6P3EWNA-CENjlerX.js} +1 -1
- package/dist/web/assets/{treemap-WGGIJYW6-BzFw4gFs.js → treemap-WGGIJYW6-CBpdf3L2.js} +1 -1
- package/dist/web/assets/use-add-agent-context-SApxn9fW.js +1 -0
- package/dist/web/assets/{use-app-theme-9qFZUBqz.js → use-app-theme-DP8Ymwwb.js} +1 -1
- package/dist/web/assets/use-panel-width-CTXD_csP.js +1 -0
- package/dist/web/assets/use-server-events-C13Sw-rU.js +1 -0
- package/dist/web/assets/{usecaseDiagram-POWQR4AR-IMfiBw6h.js → usecaseDiagram-POWQR4AR-CcJ83_Ve.js} +1 -1
- package/dist/web/assets/{vennDiagram-UO4OBE2U-JKdzpNy2.js → vennDiagram-UO4OBE2U-DBjzpbFr.js} +1 -1
- package/dist/web/assets/{wardley-WFR3VGLG-B9Yiwlp8.js → wardley-WFR3VGLG-DmlbUWUM.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-VNRHLVJA-BVoc2hGS.js → wardleyDiagram-VNRHLVJA-CWSZCqf4.js} +1 -1
- package/dist/web/assets/window-store-DNnKUhxs.js +1 -0
- package/dist/web/assets/{xychartDiagram-PMCCYNJV-B_XV1aim.js → xychartDiagram-PMCCYNJV-CMnNSaZv.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +3 -1
- package/dist/web/assets/AppPage-cl-3VfAl.js +0 -1
- package/dist/web/assets/IndexPage-CYFIGWy9.css +0 -1
- package/dist/web/assets/IndexPage-CmBmgu4A.js +0 -1
- package/dist/web/assets/NoteVisualEditor-zjZr9aod.js +0 -1056
- package/dist/web/assets/NotesPage-C5VXA9Bb.css +0 -1
- package/dist/web/assets/NotesPage-Jeva3Jta.js +0 -114
- package/dist/web/assets/QSpace-BRLIXkHB.js +0 -1
- package/dist/web/assets/ReviewDetailPage-CpJPrSKO.js +0 -1
- package/dist/web/assets/StatusSwitcher-D5UG_YWP.js +0 -1
- package/dist/web/assets/agent-icon-Dx8WBYX_.js +0 -1
- package/dist/web/assets/agent-store-B1VK6S0D.js +0 -1
- package/dist/web/assets/app-info-store-D76h_OdJ.js +0 -1
- package/dist/web/assets/architecture-7GRP2DOG-Byc4Oz95.js +0 -1
- package/dist/web/assets/channel-DbRAFIx1.js +0 -1
- package/dist/web/assets/equal-arrays-GA_7ZCAb.js +0 -1
- package/dist/web/assets/eventmodeling-NTZA5JFV-FWK4tnN_.js +0 -1
- package/dist/web/assets/flowDiagram-KWPJA3E3-C10B5_6J.js +0 -1
- package/dist/web/assets/i18n-CC_rvfan.js +0 -1
- package/dist/web/assets/index-Ic8yFbJg.js +0 -2
- package/dist/web/assets/markdown-D85iiL_9.js +0 -15
- package/dist/web/assets/pinia-BlCzIRX-.js +0 -1
- package/dist/web/assets/railroad-abnf-HS5TGJTU-CPVHNg05.js +0 -1
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-DAjTaOUg.js +0 -1
- package/dist/web/assets/railroad-peg-WCYAUIDC-DkxyKsj4.js +0 -1
- package/dist/web/assets/swimlanesDiagram-TC7HE7FX-CPhibEqM.js +0 -8
- package/dist/web/assets/theme-B-g7wQEs.js +0 -1
- package/dist/web/assets/use-panel-width-Dyh49bod.js +0 -1
- package/dist/web/assets/use-server-events-24_soFuj.js +0 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import PQueue from 'p-queue';
|
|
2
|
+
import { noteBasename, noteChildPath, noteParentPath } from "../../shared/notes/note-path.js";
|
|
1
3
|
import { lstat } from 'node:fs/promises';
|
|
2
4
|
import { isSystemError } from "../utils/system-error.js";
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
@@ -13,6 +15,8 @@ import { sha256Hex } from "../utils/hash.js";
|
|
|
13
15
|
import { safeDecodeURIComponent } from "../../shared/utils/safe-decode.js";
|
|
14
16
|
// A second complete scan confirms absence, instead of treating an atomic save as deletion.
|
|
15
17
|
const ABSENCE_GRACE_MS = 2500;
|
|
18
|
+
// Longest file name that common file systems accept, in bytes.
|
|
19
|
+
const MAX_FILENAME_BYTES = 255;
|
|
16
20
|
function newRecord(id, path, version) {
|
|
17
21
|
const now = new Date().toISOString();
|
|
18
22
|
return {
|
|
@@ -22,6 +26,7 @@ function newRecord(id, path, version) {
|
|
|
22
26
|
presence: 'present',
|
|
23
27
|
createdAt: now,
|
|
24
28
|
updatedAt: now,
|
|
29
|
+
deletedAt: null,
|
|
25
30
|
redirectId: null
|
|
26
31
|
};
|
|
27
32
|
}
|
|
@@ -35,11 +40,26 @@ function portablePath(path) {
|
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Path a deleted note moves to. The suffix frees the original name for a new
|
|
45
|
+
* note. The stem is cut by bytes so the name fits the file system limit.
|
|
46
|
+
*/
|
|
47
|
+
function deletedNotePath(path, uuid) {
|
|
48
|
+
const suffix = `_deleted_${uuid}.md`;
|
|
49
|
+
const stem = noteBasename(path).replace(/\.md$/i, '');
|
|
50
|
+
const room = MAX_FILENAME_BYTES - Buffer.byteLength(suffix);
|
|
51
|
+
let cut = stem;
|
|
52
|
+
while (Buffer.byteLength(cut) > room) {
|
|
53
|
+
cut = cut.slice(0, -1);
|
|
54
|
+
}
|
|
55
|
+
return noteChildPath(noteParentPath(path), cut + suffix);
|
|
56
|
+
}
|
|
38
57
|
export class NotesIdentityService {
|
|
39
58
|
catalog;
|
|
40
59
|
writer;
|
|
41
60
|
operations;
|
|
42
|
-
|
|
61
|
+
// One action at a time: catalog and file changes must not interleave.
|
|
62
|
+
queue = new PQueue({ concurrency: 1 });
|
|
43
63
|
cache = new Map();
|
|
44
64
|
absentSince = new Map();
|
|
45
65
|
// Signature of each file that failed to read, so a poll does not read it again.
|
|
@@ -72,19 +92,15 @@ export class NotesIdentityService {
|
|
|
72
92
|
});
|
|
73
93
|
}
|
|
74
94
|
run(action) {
|
|
75
|
-
|
|
76
|
-
.catch(() => { })
|
|
77
|
-
.then(async () => {
|
|
95
|
+
return this.queue.add(async () => {
|
|
78
96
|
if (this.closed)
|
|
79
97
|
throw new BadRequestError('Notes storage is closing.');
|
|
80
98
|
return action();
|
|
81
99
|
});
|
|
82
|
-
this.queue = next;
|
|
83
|
-
return next;
|
|
84
100
|
}
|
|
85
101
|
async close() {
|
|
86
102
|
this.closed = true;
|
|
87
|
-
await this.queue.
|
|
103
|
+
await this.queue.onIdle();
|
|
88
104
|
await this.writer.close();
|
|
89
105
|
}
|
|
90
106
|
record(id) {
|
|
@@ -167,7 +183,8 @@ export class NotesIdentityService {
|
|
|
167
183
|
const records = this.catalog.all();
|
|
168
184
|
const byId = new Map(records.map(record => [record.id, record]));
|
|
169
185
|
const presentByPath = new Map(records
|
|
170
|
-
.filter(record => record.presence === 'present'
|
|
186
|
+
.filter(record => (record.presence === 'present' || record.deletedAt) &&
|
|
187
|
+
!record.redirectId)
|
|
171
188
|
.map(record => [record.path, record]));
|
|
172
189
|
const livePaths = new Set(documents.map(doc => doc.path));
|
|
173
190
|
const counts = new Map();
|
|
@@ -245,7 +262,7 @@ export class NotesIdentityService {
|
|
|
245
262
|
(identity.id && identity.id !== record.id
|
|
246
263
|
? 'The YAML ID conflicts with this note or is duplicated. The catalog identity is retained.'
|
|
247
264
|
: null);
|
|
248
|
-
if (warning) {
|
|
265
|
+
if (warning && !record.deletedAt) {
|
|
249
266
|
this.issue('metadata:' + record.id, warning, record.id, null, 'metadata');
|
|
250
267
|
}
|
|
251
268
|
else {
|
|
@@ -268,7 +285,7 @@ export class NotesIdentityService {
|
|
|
268
285
|
group.push(candidate);
|
|
269
286
|
byVersion.set(candidate.version, group);
|
|
270
287
|
}
|
|
271
|
-
for (const old of current.filter(note => note.presence === 'missing' && !note.redirectId)) {
|
|
288
|
+
for (const old of current.filter(note => note.presence === 'missing' && !note.redirectId && !note.deletedAt)) {
|
|
272
289
|
const samePath = currentByPath.get(old.path);
|
|
273
290
|
const matches = (byVersion.get(old.version) ?? []).filter(candidate => !documentsByPath.get(candidate.path)?.yamlId);
|
|
274
291
|
const candidate = samePath ?? (matches.length === 1 ? matches[0] : undefined);
|
|
@@ -278,7 +295,7 @@ export class NotesIdentityService {
|
|
|
278
295
|
}
|
|
279
296
|
for (const doc of documents) {
|
|
280
297
|
const record = currentByPath.get(doc.path);
|
|
281
|
-
if (!record || !changed.has(record.id))
|
|
298
|
+
if (!record || record.deletedAt || !changed.has(record.id))
|
|
282
299
|
continue;
|
|
283
300
|
const references = changes.length
|
|
284
301
|
? noteReferences((await this.files.read(doc.path)).text)
|
|
@@ -290,7 +307,9 @@ export class NotesIdentityService {
|
|
|
290
307
|
this.cache.delete(path);
|
|
291
308
|
}
|
|
292
309
|
// Unreadable files stay visible through their issue, not as openable entries.
|
|
293
|
-
|
|
310
|
+
const hiddenPaths = new Set(current.filter(note => note.deletedAt).map(note => note.path));
|
|
311
|
+
return entries.filter(entry => !hiddenPaths.has(entry.path) &&
|
|
312
|
+
this.unreadable.get(entry.path) !== entry.signature);
|
|
294
313
|
}
|
|
295
314
|
indexLinks(id, text, snapshot) {
|
|
296
315
|
this.indexReferences(id, noteReferences(text), snapshot);
|
|
@@ -367,7 +386,13 @@ export class NotesIdentityService {
|
|
|
367
386
|
entries,
|
|
368
387
|
this.catalog
|
|
369
388
|
.all()
|
|
370
|
-
.map(note => [
|
|
389
|
+
.map(note => [
|
|
390
|
+
note.id,
|
|
391
|
+
note.path,
|
|
392
|
+
note.version,
|
|
393
|
+
note.presence,
|
|
394
|
+
note.deletedAt
|
|
395
|
+
])
|
|
371
396
|
]));
|
|
372
397
|
}
|
|
373
398
|
index() {
|
|
@@ -381,7 +406,9 @@ export class NotesIdentityService {
|
|
|
381
406
|
storage: this.files.root,
|
|
382
407
|
storageId: this.catalog.storageId,
|
|
383
408
|
revision: this.revision(entries),
|
|
384
|
-
issues: this.catalog
|
|
409
|
+
issues: this.catalog
|
|
410
|
+
.issues()
|
|
411
|
+
.filter(issue => !issue.noteId || !this.record(issue.noteId).deletedAt),
|
|
385
412
|
entries: entries.map(entry => {
|
|
386
413
|
const record = recordsByPath.get(entry.path);
|
|
387
414
|
const metadata = entry.folder
|
|
@@ -405,13 +432,16 @@ export class NotesIdentityService {
|
|
|
405
432
|
this.assertStorage(storageId);
|
|
406
433
|
await this.synchronize();
|
|
407
434
|
const registered = this.record(id);
|
|
408
|
-
const note = registered.
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
435
|
+
const note = registered.deletedAt
|
|
436
|
+
? { ...registered, presence: 'deleted' }
|
|
437
|
+
: registered.presence === 'present' &&
|
|
438
|
+
!(await this.files.exists(registered.path))
|
|
439
|
+
? { ...registered, presence: 'missing' }
|
|
440
|
+
: registered;
|
|
412
441
|
const backlinks = this.catalog
|
|
413
442
|
.all()
|
|
414
443
|
.filter(source => source.presence === 'present' &&
|
|
444
|
+
!source.deletedAt &&
|
|
415
445
|
this.catalog
|
|
416
446
|
.links(source.id)
|
|
417
447
|
.some(link => !!link.targetId && this.record(link.targetId).id === note.id));
|
|
@@ -432,7 +462,10 @@ export class NotesIdentityService {
|
|
|
432
462
|
if (candidates.size !== 1) {
|
|
433
463
|
throw new BadRequestError('This path belongs to more than one note over time. Open a stable UUID link instead.');
|
|
434
464
|
}
|
|
435
|
-
|
|
465
|
+
const record = this.record([...candidates][0]);
|
|
466
|
+
return record.deletedAt
|
|
467
|
+
? { ...record, presence: 'deleted' }
|
|
468
|
+
: record;
|
|
436
469
|
});
|
|
437
470
|
}
|
|
438
471
|
assertStorage(id) {
|
|
@@ -445,7 +478,7 @@ export class NotesIdentityService {
|
|
|
445
478
|
this.assertStorage(storageId);
|
|
446
479
|
await this.synchronize();
|
|
447
480
|
const record = this.record(id);
|
|
448
|
-
if (record.presence !== 'present') {
|
|
481
|
+
if (record.presence !== 'present' || record.deletedAt) {
|
|
449
482
|
throw new NotFoundError('The note file is missing or deleted.');
|
|
450
483
|
}
|
|
451
484
|
return {
|
|
@@ -463,8 +496,9 @@ export class NotesIdentityService {
|
|
|
463
496
|
.find(note => note.path === path &&
|
|
464
497
|
note.presence === 'present' &&
|
|
465
498
|
!note.redirectId);
|
|
466
|
-
if (!record)
|
|
499
|
+
if (!record || record.deletedAt) {
|
|
467
500
|
throw new NotFoundError('Note not found.');
|
|
501
|
+
}
|
|
468
502
|
return {
|
|
469
503
|
...(await this.files.read(path)),
|
|
470
504
|
id: record.id,
|
|
@@ -480,13 +514,21 @@ export class NotesIdentityService {
|
|
|
480
514
|
await this.files.path(path);
|
|
481
515
|
await this.writer.acquire(true);
|
|
482
516
|
await this.synchronize();
|
|
483
|
-
if (id)
|
|
484
|
-
|
|
517
|
+
if (id) {
|
|
518
|
+
const identified = this.record(id);
|
|
519
|
+
if (identified.deletedAt) {
|
|
520
|
+
throw new NotFoundError('This note was deleted.');
|
|
521
|
+
}
|
|
522
|
+
path = identified.path;
|
|
523
|
+
}
|
|
485
524
|
let record = this.catalog
|
|
486
525
|
.all()
|
|
487
526
|
.find(note => note.path === path &&
|
|
488
527
|
note.presence === 'present' &&
|
|
489
528
|
!note.redirectId);
|
|
529
|
+
if (this.catalog.all().some(note => note.path === path && note.deletedAt)) {
|
|
530
|
+
throw new NoteExistsError();
|
|
531
|
+
}
|
|
490
532
|
if (version === undefined) {
|
|
491
533
|
portablePath(path);
|
|
492
534
|
if (record)
|
|
@@ -528,6 +570,10 @@ export class NotesIdentityService {
|
|
|
528
570
|
}
|
|
529
571
|
if (id && this.record(id).path !== path)
|
|
530
572
|
throw new NoteConflictError();
|
|
573
|
+
if (!folder &&
|
|
574
|
+
this.catalog.all().some(note => note.path === path && note.deletedAt)) {
|
|
575
|
+
throw new NotFoundError('This note was deleted.');
|
|
576
|
+
}
|
|
531
577
|
if (!target &&
|
|
532
578
|
(name !== name.trim() ||
|
|
533
579
|
name.includes('/') ||
|
|
@@ -543,12 +589,17 @@ export class NotesIdentityService {
|
|
|
543
589
|
if (folder ? !sourceStat.isDirectory() : !sourceStat.isFile()) {
|
|
544
590
|
throw new BadRequestError('Entry type changed.');
|
|
545
591
|
}
|
|
546
|
-
const destination = target ??
|
|
592
|
+
const destination = target ?? noteChildPath(noteParentPath(path), name);
|
|
547
593
|
portablePath(destination);
|
|
548
594
|
await this.files.path(destination, !folder);
|
|
549
595
|
if (destination === path) {
|
|
550
596
|
return { operationId: randomUUID(), complete: true };
|
|
551
597
|
}
|
|
598
|
+
if (this.catalog
|
|
599
|
+
.all()
|
|
600
|
+
.some(note => note.deletedAt && note.path === destination)) {
|
|
601
|
+
throw new NoteExistsError();
|
|
602
|
+
}
|
|
552
603
|
if (await this.files.exists(destination)) {
|
|
553
604
|
const caseOnly = process.platform === 'win32' &&
|
|
554
605
|
path.toLowerCase() === destination.toLowerCase() &&
|
|
@@ -585,6 +636,36 @@ export class NotesIdentityService {
|
|
|
585
636
|
return { operationId: operation.id, complete };
|
|
586
637
|
});
|
|
587
638
|
}
|
|
639
|
+
deleteId(id, storageId) {
|
|
640
|
+
return this.run(async () => {
|
|
641
|
+
this.assertStorage(storageId);
|
|
642
|
+
await this.synchronize();
|
|
643
|
+
const note = this.record(id);
|
|
644
|
+
if (note.deletedAt)
|
|
645
|
+
return;
|
|
646
|
+
await this.writer.acquire(true);
|
|
647
|
+
await this.writer.assertOwner();
|
|
648
|
+
if (this.catalog
|
|
649
|
+
.operations()
|
|
650
|
+
.some(operation => operation.moves.some(move => move.id === note.id) ||
|
|
651
|
+
operation.writes.some(write => write.id === note.id && !write.done))) {
|
|
652
|
+
throw new NoteConflictError();
|
|
653
|
+
}
|
|
654
|
+
// The note leaves its name, so a new note can use it. The catalog keeps the identity.
|
|
655
|
+
// Only a present file exists on disk and needs the move.
|
|
656
|
+
const path = deletedNotePath(note.path, randomUUID());
|
|
657
|
+
if (note.presence === 'present' && !note.redirectId) {
|
|
658
|
+
await this.files.moveEntry(note.path, path, false);
|
|
659
|
+
}
|
|
660
|
+
this.catalog.put({
|
|
661
|
+
...note,
|
|
662
|
+
path,
|
|
663
|
+
deletedAt: new Date().toISOString()
|
|
664
|
+
});
|
|
665
|
+
this.catalog.dismiss('missing:' + note.id, true);
|
|
666
|
+
this.catalog.dismiss('metadata:' + note.id);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
588
669
|
retry() {
|
|
589
670
|
return this.run(() => this.operations.retry());
|
|
590
671
|
}
|
|
@@ -644,9 +725,11 @@ export class NotesIdentityService {
|
|
|
644
725
|
this.assertStorage(storageId);
|
|
645
726
|
await this.synchronize();
|
|
646
727
|
const source = this.record(sourceId);
|
|
728
|
+
if (source.deletedAt)
|
|
729
|
+
throw new NotFoundError('This note was deleted.');
|
|
647
730
|
const paths = this.catalog
|
|
648
731
|
.all()
|
|
649
|
-
.filter(note => note.presence === 'present' && !note.redirectId)
|
|
732
|
+
.filter(note => note.presence === 'present' && !note.redirectId && !note.deletedAt)
|
|
650
733
|
.map(note => note.path);
|
|
651
734
|
const refs = noteReferences(text)
|
|
652
735
|
.flatMap(ref => (ref.displayStarts ?? [ref.start]).map(displayPosition => ({
|
|
@@ -709,10 +792,12 @@ export class NotesIdentityService {
|
|
|
709
792
|
graph() {
|
|
710
793
|
return this.run(async () => {
|
|
711
794
|
await this.synchronize();
|
|
712
|
-
const nodes = this.catalog
|
|
795
|
+
const nodes = this.catalog
|
|
796
|
+
.all()
|
|
797
|
+
.filter(note => !note.redirectId && !note.deletedAt);
|
|
713
798
|
const edges = nodes
|
|
714
799
|
.filter(note => note.presence === 'present')
|
|
715
|
-
.flatMap(note => this.catalog.links(note.id).flatMap(link => link.targetId
|
|
800
|
+
.flatMap(note => this.catalog.links(note.id).flatMap(link => link.targetId && !this.record(link.targetId).deletedAt
|
|
716
801
|
? [
|
|
717
802
|
{
|
|
718
803
|
sourceId: note.id,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { noteChildPath, noteParentPath } from "../../shared/notes/note-path.js";
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { lstat } from 'node:fs/promises';
|
|
3
4
|
import { noteReferences, referenceTarget, rewriteReferences, sameReference } from "../../shared/notes/references.js";
|
|
@@ -42,7 +43,7 @@ export class NotesOperationsService {
|
|
|
42
43
|
};
|
|
43
44
|
if (restore)
|
|
44
45
|
operation.restore = restore;
|
|
45
|
-
const records = (preview ?? this.catalog.all()).filter(note => note.presence === 'present' && !note.redirectId);
|
|
46
|
+
const records = (preview ?? this.catalog.all()).filter(note => note.presence === 'present' && !note.redirectId && !note.deletedAt);
|
|
46
47
|
const oldPath = (note) => moves.find(move => move.id === note.id)?.from ?? note.path;
|
|
47
48
|
const newPath = (note) => moves.find(move => move.id === note.id)?.to ?? note.path;
|
|
48
49
|
const oldPaths = records.map(oldPath);
|
|
@@ -111,10 +112,7 @@ export class NotesOperationsService {
|
|
|
111
112
|
operation.source !== operation.target &&
|
|
112
113
|
operation.source.toLowerCase() === operation.target.toLowerCase();
|
|
113
114
|
if (caseOnly && !operation.temporary) {
|
|
114
|
-
operation.temporary =
|
|
115
|
-
...operation.source.split('/').slice(0, -1),
|
|
116
|
-
'__ght-case-' + operation.id + (operation.folder ? '' : '.md')
|
|
117
|
-
].join('/');
|
|
115
|
+
operation.temporary = noteChildPath(noteParentPath(operation.source), '__ght-case-' + operation.id + (operation.folder ? '' : '.md'));
|
|
118
116
|
this.catalog.operation(operation);
|
|
119
117
|
}
|
|
120
118
|
let fromPath = operation.source;
|
|
@@ -10,6 +10,8 @@ import { BadRequestError } from "../errors/http.js";
|
|
|
10
10
|
import { errorMessage } from "../../shared/utils/error-message.js";
|
|
11
11
|
import { MAX_NOTE_ASSET_BYTES } from "../../shared/notes/constants.js";
|
|
12
12
|
import { detectNoteAssetFormat } from "../utils/note-asset-format.js";
|
|
13
|
+
// Git error text depends on the locale. The "not a git repository" check below needs English.
|
|
14
|
+
const GIT_ENV = { ...process.env, LC_ALL: 'C' };
|
|
13
15
|
const execFileAsync = promisify(execFile);
|
|
14
16
|
export class NotesService {
|
|
15
17
|
repository;
|
|
@@ -32,18 +34,18 @@ export class NotesService {
|
|
|
32
34
|
}
|
|
33
35
|
async visibility(ensureIgnored = false) {
|
|
34
36
|
try {
|
|
35
|
-
const { stdout: gitRoot } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: this.projectPath });
|
|
37
|
+
const { stdout: gitRoot } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: this.projectPath, env: GIT_ENV });
|
|
36
38
|
const path = relative(gitRoot.trim(), this.root).replaceAll('\\', '/');
|
|
37
39
|
if (path.startsWith('../'))
|
|
38
40
|
return;
|
|
39
|
-
const { stdout: tracked } = await execFileAsync('git', ['ls-files', '--', path + '/'], { cwd: gitRoot.trim() });
|
|
41
|
+
const { stdout: tracked } = await execFileAsync('git', ['ls-files', '--', path + '/'], { cwd: gitRoot.trim(), env: GIT_ENV });
|
|
40
42
|
if (tracked && this.local) {
|
|
41
43
|
this.warning =
|
|
42
44
|
'Some notes are already tracked by Git. Ignoring them does not remove them from Git.';
|
|
43
45
|
}
|
|
44
46
|
if (!this.local || !ensureIgnored)
|
|
45
47
|
return;
|
|
46
|
-
const { stdout: exclude } = await execFileAsync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: this.projectPath });
|
|
48
|
+
const { stdout: exclude } = await execFileAsync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: this.projectPath, env: GIT_ENV });
|
|
47
49
|
const target = resolve(this.projectPath, exclude.trim());
|
|
48
50
|
const rule = '/' + path.replace(/[*?[\]#! ]/g, char => '\\' + char) + '/';
|
|
49
51
|
const content = await readFile(target, 'utf8').catch(() => '');
|
|
@@ -67,7 +69,7 @@ export class NotesService {
|
|
|
67
69
|
index,
|
|
68
70
|
this.identity.catalog
|
|
69
71
|
.all()
|
|
70
|
-
.map(note => [note.id, note.version, note.presence]),
|
|
72
|
+
.map(note => [note.id, note.version, note.presence, note.deletedAt]),
|
|
71
73
|
this.warning
|
|
72
74
|
]);
|
|
73
75
|
return { ...index, warning: this.warning };
|
|
@@ -92,11 +94,18 @@ export class NotesService {
|
|
|
92
94
|
this.bus.publish({ type: 'notes:changed' });
|
|
93
95
|
return note;
|
|
94
96
|
}
|
|
97
|
+
async delete(id, storageId) {
|
|
98
|
+
await this.identity.deleteId(id, storageId);
|
|
99
|
+
this.bus.publish({ type: 'notes:changed' });
|
|
100
|
+
}
|
|
95
101
|
async uploadAsset(bytes) {
|
|
96
102
|
if (!bytes.length || bytes.length > MAX_NOTE_ASSET_BYTES) {
|
|
97
103
|
throw new BadRequestError('Image must be under 10 MB');
|
|
98
104
|
}
|
|
99
105
|
const format = await detectNoteAssetFormat(bytes);
|
|
106
|
+
if (format.display !== 'image') {
|
|
107
|
+
throw new BadRequestError('Only image uploads are supported');
|
|
108
|
+
}
|
|
100
109
|
this.exclusion ??= this.visibility(true);
|
|
101
110
|
await this.exclusion;
|
|
102
111
|
const id = await this.identity.run(async () => {
|
|
@@ -107,6 +116,22 @@ export class NotesService {
|
|
|
107
116
|
});
|
|
108
117
|
return { url: '/api/notes/assets/' + id };
|
|
109
118
|
}
|
|
119
|
+
async importAsset(chunks, extension, signal) {
|
|
120
|
+
this.exclusion ??= this.visibility(true);
|
|
121
|
+
await this.exclusion;
|
|
122
|
+
const staged = await this.repository.stageAsset(chunks, extension, signal);
|
|
123
|
+
try {
|
|
124
|
+
const id = await this.identity.run(async () => {
|
|
125
|
+
await this.identity.writer.acquire(true);
|
|
126
|
+
await this.identity.writer.assertOwner();
|
|
127
|
+
return staged.commit();
|
|
128
|
+
});
|
|
129
|
+
return '/api/notes/assets/' + id;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await staged.dispose();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
110
135
|
readAsset(id) {
|
|
111
136
|
return this.repository.readAsset(id);
|
|
112
137
|
}
|
|
@@ -13,7 +13,7 @@ export async function detectNoteAssetFormat(bytes) {
|
|
|
13
13
|
NOTE_ASSET_FORMATS.find(candidate => candidate.extension === detected.ext ||
|
|
14
14
|
candidate.aliases.some(alias => alias === detected.ext));
|
|
15
15
|
if (!format) {
|
|
16
|
-
throw new BadRequestError('Supported
|
|
16
|
+
throw new BadRequestError('Supported attachments: ' + SUPPORTED_NOTE_ASSETS);
|
|
17
17
|
}
|
|
18
18
|
return format;
|
|
19
19
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { BlockList, isIP } from 'node:net';
|
|
3
|
+
import { Agent, request } from 'undici';
|
|
4
|
+
import { BadRequestError } from "../errors/http.js";
|
|
5
|
+
import { isCredentialFreeWebUrl } from "../../shared/utils/web-url.js";
|
|
6
|
+
// Stop connection attempts that cannot establish a socket promptly.
|
|
7
|
+
const CONNECTION_TIMEOUT_MS = 10000;
|
|
8
|
+
// Stop responses that send no headers or no body data.
|
|
9
|
+
const RESPONSE_IDLE_TIMEOUT_MS = 15000;
|
|
10
|
+
// Bound the size of response headers.
|
|
11
|
+
const MAX_HEADER_BYTES = 16384;
|
|
12
|
+
// Stop redirect chains and loops.
|
|
13
|
+
const MAX_REDIRECTS = 5;
|
|
14
|
+
// Only these statuses ask the client to follow a Location header.
|
|
15
|
+
const REDIRECT_STATUSES = [301, 302, 303, 307, 308];
|
|
16
|
+
// Network failures that mean the remote server cannot serve the attachment.
|
|
17
|
+
const UNREACHABLE_CODES = new Set([
|
|
18
|
+
'ENOTFOUND',
|
|
19
|
+
'ECONNREFUSED',
|
|
20
|
+
'ECONNRESET',
|
|
21
|
+
'EHOSTUNREACH',
|
|
22
|
+
'ENETUNREACH',
|
|
23
|
+
'ETIMEDOUT',
|
|
24
|
+
'UND_ERR_SOCKET'
|
|
25
|
+
]);
|
|
26
|
+
// Ask servers for the exact bytes: compressed bodies are not supported.
|
|
27
|
+
const REQUEST_HEADERS = {
|
|
28
|
+
'accept-encoding': 'identity',
|
|
29
|
+
'user-agent': 'githuman-note-assets'
|
|
30
|
+
};
|
|
31
|
+
// Exclude non-public IPv4 ranges before opening any socket.
|
|
32
|
+
const BLOCKED_IPV4 = new BlockList();
|
|
33
|
+
// IPv6 routing must be public and must exclude special-purpose ranges.
|
|
34
|
+
const PUBLIC_IPV6 = new BlockList();
|
|
35
|
+
const BLOCKED_IPV6 = new BlockList();
|
|
36
|
+
for (const [address, prefix] of [
|
|
37
|
+
['0.0.0.0', 8],
|
|
38
|
+
['10.0.0.0', 8],
|
|
39
|
+
['100.64.0.0', 10],
|
|
40
|
+
['127.0.0.0', 8],
|
|
41
|
+
['169.254.0.0', 16],
|
|
42
|
+
['172.16.0.0', 12],
|
|
43
|
+
['192.0.0.0', 24],
|
|
44
|
+
['192.0.2.0', 24],
|
|
45
|
+
['192.88.99.0', 24],
|
|
46
|
+
['192.168.0.0', 16],
|
|
47
|
+
['198.18.0.0', 15],
|
|
48
|
+
['198.51.100.0', 24],
|
|
49
|
+
['203.0.113.0', 24],
|
|
50
|
+
['224.0.0.0', 3]
|
|
51
|
+
]) {
|
|
52
|
+
BLOCKED_IPV4.addSubnet(address, prefix, 'ipv4');
|
|
53
|
+
}
|
|
54
|
+
// Only globally routable IPv6 is eligible, with special-purpose ranges excluded.
|
|
55
|
+
PUBLIC_IPV6.addSubnet('2000::', 3, 'ipv6');
|
|
56
|
+
BLOCKED_IPV6.addSubnet('2001::', 23, 'ipv6');
|
|
57
|
+
BLOCKED_IPV6.addSubnet('2001:db8::', 32, 'ipv6');
|
|
58
|
+
BLOCKED_IPV6.addSubnet('2002::', 16, 'ipv6');
|
|
59
|
+
BLOCKED_IPV6.addSubnet('3fff::', 20, 'ipv6');
|
|
60
|
+
export function isPublicAssetAddress(address) {
|
|
61
|
+
const family = isIP(address);
|
|
62
|
+
if (family === 4)
|
|
63
|
+
return !BLOCKED_IPV4.check(address, 'ipv4');
|
|
64
|
+
return (family === 6 &&
|
|
65
|
+
PUBLIC_IPV6.check(address, 'ipv6') &&
|
|
66
|
+
!BLOCKED_IPV6.check(address, 'ipv6'));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolves a host name and refuses non-public results. The socket connects to
|
|
70
|
+
* the checked address, so no second lookup can change the target.
|
|
71
|
+
*/
|
|
72
|
+
export function createPublicLookup(resolveHost = lookup) {
|
|
73
|
+
return (hostname, options, callback) => {
|
|
74
|
+
void (async () => {
|
|
75
|
+
let addresses;
|
|
76
|
+
try {
|
|
77
|
+
addresses = await resolveHost(hostname, { all: true, verbatim: true });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
callback(error instanceof Error ? error : new Error(String(error)), '');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const first = addresses[0];
|
|
84
|
+
if (!first ||
|
|
85
|
+
addresses.some(item => !isPublicAssetAddress(item.address))) {
|
|
86
|
+
callback(new BadRequestError('Attachment address is not public'), '');
|
|
87
|
+
}
|
|
88
|
+
else if (options.all) {
|
|
89
|
+
callback(null, addresses);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
callback(null, first.address, first.family);
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function createAgent(resolveHost) {
|
|
98
|
+
return new Agent({
|
|
99
|
+
connect: {
|
|
100
|
+
lookup: createPublicLookup(resolveHost),
|
|
101
|
+
timeout: CONNECTION_TIMEOUT_MS
|
|
102
|
+
},
|
|
103
|
+
connectTimeout: CONNECTION_TIMEOUT_MS,
|
|
104
|
+
headersTimeout: RESPONSE_IDLE_TIMEOUT_MS,
|
|
105
|
+
bodyTimeout: RESPONSE_IDLE_TIMEOUT_MS,
|
|
106
|
+
maxHeaderSize: MAX_HEADER_BYTES
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// Undici ignores an abort while it waits for a socket, so the wait ends here.
|
|
110
|
+
async function untilAborted(work, signal, discard) {
|
|
111
|
+
let abort;
|
|
112
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
113
|
+
abort = () => reject(signal.reason);
|
|
114
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
115
|
+
});
|
|
116
|
+
try {
|
|
117
|
+
return await Promise.race([work, aborted]);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// A request that finishes after the abort still owns a socket.
|
|
121
|
+
work.then(discard, () => undefined);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
if (abort)
|
|
126
|
+
signal.removeEventListener('abort', abort);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
let sharedAgent;
|
|
130
|
+
function parseAssetUrl(value, message, base) {
|
|
131
|
+
let url;
|
|
132
|
+
try {
|
|
133
|
+
url = new URL(value, base);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
throw new BadRequestError(message);
|
|
137
|
+
}
|
|
138
|
+
if (!isCredentialFreeWebUrl(url)) {
|
|
139
|
+
throw new BadRequestError('Only public HTTP and HTTPS attachments are supported');
|
|
140
|
+
}
|
|
141
|
+
return url;
|
|
142
|
+
}
|
|
143
|
+
export async function remoteAssetRequest(source, method, signal, options = {}) {
|
|
144
|
+
const dispatcher = options.dispatcher ??
|
|
145
|
+
(options.resolveHost
|
|
146
|
+
? createAgent(options.resolveHost)
|
|
147
|
+
: (sharedAgent ??= createAgent()));
|
|
148
|
+
let url = parseAssetUrl(source, 'Invalid attachment URL');
|
|
149
|
+
for (let redirects = 0;; redirects++) {
|
|
150
|
+
signal.throwIfAborted();
|
|
151
|
+
// The socket layer skips the lookup for IP literals, so check them here.
|
|
152
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
153
|
+
if (isIP(host) && !isPublicAssetAddress(host)) {
|
|
154
|
+
throw new BadRequestError('Attachment address is not public');
|
|
155
|
+
}
|
|
156
|
+
const { statusCode, headers, body } = await untilAborted(request(url, { method, signal, dispatcher, headers: REQUEST_HEADERS }), signal, late => late.body.destroy());
|
|
157
|
+
if (!REDIRECT_STATUSES.includes(statusCode)) {
|
|
158
|
+
return Object.assign(body, { statusCode, headers });
|
|
159
|
+
}
|
|
160
|
+
body.destroy();
|
|
161
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
162
|
+
throw new BadRequestError('Too many attachment redirects');
|
|
163
|
+
}
|
|
164
|
+
const location = headers.location;
|
|
165
|
+
if (!location || Array.isArray(location)) {
|
|
166
|
+
throw new BadRequestError('Attachment redirect has no valid location');
|
|
167
|
+
}
|
|
168
|
+
const next = parseAssetUrl(location, 'Invalid attachment redirect', url);
|
|
169
|
+
if (url.protocol === 'https:' && next.protocol !== 'https:') {
|
|
170
|
+
throw new BadRequestError('Insecure attachment redirect');
|
|
171
|
+
}
|
|
172
|
+
url = next;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Maps transport failures to client errors; other errors pass through. */
|
|
176
|
+
export function translateRemoteAssetError(error) {
|
|
177
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
178
|
+
? error.code
|
|
179
|
+
: undefined;
|
|
180
|
+
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
|
181
|
+
return new BadRequestError('Attachment download took too long');
|
|
182
|
+
}
|
|
183
|
+
if (code === 'UND_ERR_CONNECT_TIMEOUT') {
|
|
184
|
+
return new BadRequestError('Attachment connection timed out');
|
|
185
|
+
}
|
|
186
|
+
if (code === 'UND_ERR_HEADERS_TIMEOUT' || code === 'UND_ERR_BODY_TIMEOUT') {
|
|
187
|
+
return new BadRequestError('Attachment download stalled');
|
|
188
|
+
}
|
|
189
|
+
if (typeof code === 'string' && UNREACHABLE_CODES.has(code)) {
|
|
190
|
+
return new BadRequestError('Attachment server could not be reached');
|
|
191
|
+
}
|
|
192
|
+
return error;
|
|
193
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `work` with a signal that aborts when the client closes the connection
|
|
3
|
+
* before the response ends. A normal finish does not abort the signal.
|
|
4
|
+
*/
|
|
5
|
+
export async function withRequestSignal(reply, work) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const abort = () => {
|
|
8
|
+
if (!reply.raw.writableFinished)
|
|
9
|
+
controller.abort();
|
|
10
|
+
};
|
|
11
|
+
if (reply.raw.destroyed)
|
|
12
|
+
abort();
|
|
13
|
+
reply.raw.once('close', abort);
|
|
14
|
+
try {
|
|
15
|
+
return await work(controller.signal);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
reply.raw.removeListener('close', abort);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -37,6 +37,20 @@ export const FileSearchQuery = Type.Object({
|
|
|
37
37
|
}))
|
|
38
38
|
});
|
|
39
39
|
export const ContextItem = Type.Union([
|
|
40
|
+
Type.Object({
|
|
41
|
+
kind: Type.Literal('note', { description: 'A project note.' }),
|
|
42
|
+
noteId: Type.String({ minLength: 1, description: 'Stable note UUID.' }),
|
|
43
|
+
storageId: Type.String({
|
|
44
|
+
minLength: 1,
|
|
45
|
+
description: 'Notes storage identity.'
|
|
46
|
+
}),
|
|
47
|
+
name: Type.String({
|
|
48
|
+
minLength: 1,
|
|
49
|
+
description: 'Display name when attached.'
|
|
50
|
+
})
|
|
51
|
+
}, {
|
|
52
|
+
description: 'The current Markdown of a note, including YAML properties.'
|
|
53
|
+
}),
|
|
40
54
|
Type.Object({
|
|
41
55
|
kind: Type.Literal('diff', { description: 'Live working-tree diff.' }),
|
|
42
56
|
source: DiffSourceNameSchema,
|