revdeck 0.1.0 → 0.2.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/CHANGELOG.md +25 -0
- package/CONTRIBUTING.md +17 -7
- package/README.md +45 -6
- package/README.ru.md +44 -5
- package/dist/cli.js +626 -69
- package/dist/cli.js.map +4 -4
- package/dist/daemon.js +338 -51
- package/dist/daemon.js.map +4 -4
- package/dist/mcp.js +20 -4
- package/dist/mcp.js.map +2 -2
- package/dist/ui/assets/index-BAejn_X6.js +10 -0
- package/dist/ui/assets/{index-ByfGHwCb.css → index-xv-zA2Sf.css} +1 -1
- package/dist/ui/index.html +2 -2
- package/docs/adr/0001-daemon-first.md +2 -1
- package/docs/architecture.md +2 -1
- package/docs/configuration.md +2 -0
- package/docs/integrations.md +50 -11
- package/docs/protocol.md +6 -0
- package/docs/ru/adr/0001-daemon-first.md +2 -1
- package/docs/ru/architecture.md +2 -1
- package/docs/ru/configuration.md +2 -0
- package/docs/ru/integrations.md +50 -10
- package/docs/ru/protocol.md +6 -0
- package/docs/ru/threat-model.md +3 -1
- package/docs/threat-model.md +3 -1
- package/package.json +1 -1
- package/skills/revdeck/en/agents/openai.yaml +4 -0
- package/skills/revdeck/ru/agents/openai.yaml +4 -0
- package/dist/ui/assets/index-mvPA8Avw.js +0 -10
package/dist/daemon.js
CHANGED
|
@@ -74,6 +74,16 @@ var init_locale = __esm({
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
// src/core/types.ts
|
|
78
|
+
var LEVELS, REACTIONS;
|
|
79
|
+
var init_types = __esm({
|
|
80
|
+
"src/core/types.ts"() {
|
|
81
|
+
"use strict";
|
|
82
|
+
LEVELS = ["blocker", "major", "minor", "question"];
|
|
83
|
+
REACTIONS = ["thumbsup", "thumbsdown", "smile", "tada", "confused", "heart", "rocket", "eyes"];
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
77
87
|
// src/core/errors.ts
|
|
78
88
|
function messageOf(error) {
|
|
79
89
|
if (error instanceof Error) return error.message;
|
|
@@ -289,11 +299,11 @@ function diffStats(files) {
|
|
|
289
299
|
function changedPaths(files) {
|
|
290
300
|
return files.map((file) => file.path);
|
|
291
301
|
}
|
|
292
|
-
function findFile(files,
|
|
293
|
-
return files.find((file) => file.path ===
|
|
302
|
+
function findFile(files, path9) {
|
|
303
|
+
return files.find((file) => file.path === path9) ?? files.find((file) => file.oldPath === path9);
|
|
294
304
|
}
|
|
295
|
-
function findNewLine(files,
|
|
296
|
-
const file = findFile(files,
|
|
305
|
+
function findNewLine(files, path9, line) {
|
|
306
|
+
const file = findFile(files, path9);
|
|
297
307
|
if (!file) return void 0;
|
|
298
308
|
for (const hunk of file.hunks) {
|
|
299
309
|
for (const item of hunk.lines) {
|
|
@@ -302,8 +312,8 @@ function findNewLine(files, path8, line) {
|
|
|
302
312
|
}
|
|
303
313
|
return void 0;
|
|
304
314
|
}
|
|
305
|
-
function snippetAround(files,
|
|
306
|
-
const file = findFile(files,
|
|
315
|
+
function snippetAround(files, path9, line, radius = 6) {
|
|
316
|
+
const file = findFile(files, path9);
|
|
307
317
|
if (!file || line == null) return null;
|
|
308
318
|
const flat = file.hunks.flatMap((hunk) => hunk.lines);
|
|
309
319
|
const target = flat.findIndex((item) => item.newLine === line);
|
|
@@ -316,9 +326,9 @@ function snippetAround(files, path8, line, radius = 6) {
|
|
|
316
326
|
target: item.newLine === line
|
|
317
327
|
}));
|
|
318
328
|
}
|
|
319
|
-
function linePosition(files,
|
|
320
|
-
const file = findFile(files,
|
|
321
|
-
const found = findNewLine(files,
|
|
329
|
+
function linePosition(files, path9, line) {
|
|
330
|
+
const file = findFile(files, path9);
|
|
331
|
+
const found = findNewLine(files, path9, line);
|
|
322
332
|
if (!file || !found) return null;
|
|
323
333
|
return { newPath: file.path, newLine: line, oldPath: file.oldPath, oldLine: found.kind === " " ? found.oldLine : null };
|
|
324
334
|
}
|
|
@@ -429,8 +439,8 @@ async function withTempJson(data, fn) {
|
|
|
429
439
|
await rm(dir, { recursive: true, force: true });
|
|
430
440
|
}
|
|
431
441
|
}
|
|
432
|
-
function query(
|
|
433
|
-
const url = new URL(
|
|
442
|
+
function query(path9, params) {
|
|
443
|
+
const url = new URL(path9.startsWith("/") ? path9 : `/${path9}`, "http://local");
|
|
434
444
|
for (const [key, value] of Object.entries(params)) {
|
|
435
445
|
if (value !== void 0 && value !== "") url.searchParams.set(key, String(value));
|
|
436
446
|
}
|
|
@@ -459,6 +469,7 @@ var EMPTY_SHA, GitLabProvider;
|
|
|
459
469
|
var init_gitlab = __esm({
|
|
460
470
|
"src/providers/gitlab.ts"() {
|
|
461
471
|
"use strict";
|
|
472
|
+
init_types();
|
|
462
473
|
init_diff();
|
|
463
474
|
init_errors();
|
|
464
475
|
init_redact();
|
|
@@ -475,7 +486,7 @@ var init_gitlab = __esm({
|
|
|
475
486
|
ref;
|
|
476
487
|
options;
|
|
477
488
|
platform = "gitlab";
|
|
478
|
-
capabilities = { reactions: true, resolve: true, nativeDrafts: true };
|
|
489
|
+
capabilities = { reactions: true, resolve: true, nativeDrafts: true, attachments: true };
|
|
479
490
|
run;
|
|
480
491
|
me = null;
|
|
481
492
|
infoCache = null;
|
|
@@ -643,6 +654,24 @@ rename to ${newPath}
|
|
|
643
654
|
await this.call(["--method", "PUT", this.endpoint(`/discussions/${encodeURIComponent(threadId)}`), "--input", file]);
|
|
644
655
|
});
|
|
645
656
|
}
|
|
657
|
+
async reactions(noteId) {
|
|
658
|
+
const base = this.endpoint(`/notes/${encodeURIComponent(noteId)}/award_emoji`);
|
|
659
|
+
const items = await this.paged(base);
|
|
660
|
+
const me = await this.currentUser();
|
|
661
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
662
|
+
for (const item of items) {
|
|
663
|
+
if (!REACTIONS.includes(item.name)) continue;
|
|
664
|
+
const reaction = grouped.get(item.name) ?? { name: item.name, count: 0, users: [], mine: false };
|
|
665
|
+
reaction.count += 1;
|
|
666
|
+
const username = item.user?.username;
|
|
667
|
+
if (username) {
|
|
668
|
+
reaction.users.push(username);
|
|
669
|
+
if (username === me.username) reaction.mine = true;
|
|
670
|
+
}
|
|
671
|
+
grouped.set(item.name, reaction);
|
|
672
|
+
}
|
|
673
|
+
return [...grouped.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
674
|
+
}
|
|
646
675
|
async react(noteId, emoji) {
|
|
647
676
|
const base = this.endpoint(`/notes/${encodeURIComponent(noteId)}/award_emoji`);
|
|
648
677
|
const existing = await this.json([query(base, { per_page: 100 })]);
|
|
@@ -657,6 +686,16 @@ rename to ${newPath}
|
|
|
657
686
|
});
|
|
658
687
|
return { active: true };
|
|
659
688
|
}
|
|
689
|
+
async upload(input) {
|
|
690
|
+
const endpoint = `projects/${this.projectRef}/uploads`;
|
|
691
|
+
const uploaded = await this.json(["--method", "POST", endpoint, "--form", `file=@${input.path}`]);
|
|
692
|
+
const raw = uploaded.full_path || uploaded.url || "";
|
|
693
|
+
if (!raw) throw new ProviderError(tr("GitLab \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043D\u044B\u0439 \u0444\u0430\u0439\u043B", "GitLab did not return a link to the uploaded file"));
|
|
694
|
+
const absolute = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${this.ref.host}${raw.startsWith("/") ? "" : "/"}${raw}`;
|
|
695
|
+
const label = input.name.replace(/[[\]\r\n]/g, " ").trim() || "attachment";
|
|
696
|
+
const markdown = input.contentType.startsWith("image/") || input.contentType.startsWith("video/") ? `` : `[${label}](${absolute})`;
|
|
697
|
+
return { name: input.name, url: absolute, markdown };
|
|
698
|
+
}
|
|
660
699
|
async createThread(input) {
|
|
661
700
|
const info = await this.info();
|
|
662
701
|
if (!info.commit.head || info.commit.head === EMPTY_SHA) {
|
|
@@ -693,7 +732,7 @@ __export(github_exports, {
|
|
|
693
732
|
GitHubProvider: () => GitHubProvider
|
|
694
733
|
});
|
|
695
734
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
696
|
-
var THREADS_QUERY,
|
|
735
|
+
var THREADS_QUERY, REACTIONS2, CONTENT_TO_NAME, GitHubProvider;
|
|
697
736
|
var init_github = __esm({
|
|
698
737
|
"src/providers/github.ts"() {
|
|
699
738
|
"use strict";
|
|
@@ -717,7 +756,7 @@ var init_github = __esm({
|
|
|
717
756
|
}
|
|
718
757
|
}
|
|
719
758
|
}`;
|
|
720
|
-
|
|
759
|
+
REACTIONS2 = {
|
|
721
760
|
thumbsup: "+1",
|
|
722
761
|
thumbsdown: "-1",
|
|
723
762
|
smile: "laugh",
|
|
@@ -727,6 +766,7 @@ var init_github = __esm({
|
|
|
727
766
|
rocket: "rocket",
|
|
728
767
|
eyes: "eyes"
|
|
729
768
|
};
|
|
769
|
+
CONTENT_TO_NAME = Object.fromEntries(Object.entries(REACTIONS2).map(([name, content]) => [content, name]));
|
|
730
770
|
GitHubProvider = class {
|
|
731
771
|
constructor(ref, options) {
|
|
732
772
|
this.ref = ref;
|
|
@@ -742,7 +782,7 @@ var init_github = __esm({
|
|
|
742
782
|
ref;
|
|
743
783
|
options;
|
|
744
784
|
platform = "github";
|
|
745
|
-
capabilities = { reactions: true, resolve: true, nativeDrafts: false };
|
|
785
|
+
capabilities = { reactions: true, resolve: true, nativeDrafts: false, attachments: false };
|
|
746
786
|
run;
|
|
747
787
|
owner;
|
|
748
788
|
name;
|
|
@@ -945,8 +985,27 @@ rename to ${newPath}
|
|
|
945
985
|
const mutation = resolved ? "mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }" : "mutation($id: ID!) { unresolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }";
|
|
946
986
|
await this.graphql(mutation, { id: threadId });
|
|
947
987
|
}
|
|
988
|
+
async reactions(noteId) {
|
|
989
|
+
const base = `repos/${this.repo}/pulls/comments/${encodeURIComponent(noteId)}/reactions`;
|
|
990
|
+
const items = await this.paged(base);
|
|
991
|
+
const me = await this.currentUser();
|
|
992
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
993
|
+
for (const item of items) {
|
|
994
|
+
const name = CONTENT_TO_NAME[item.content];
|
|
995
|
+
if (!name) continue;
|
|
996
|
+
const reaction = grouped.get(name) ?? { name, count: 0, users: [], mine: false };
|
|
997
|
+
reaction.count += 1;
|
|
998
|
+
const login = item.user?.login;
|
|
999
|
+
if (login) {
|
|
1000
|
+
reaction.users.push(login);
|
|
1001
|
+
if (login === me.username) reaction.mine = true;
|
|
1002
|
+
}
|
|
1003
|
+
grouped.set(name, reaction);
|
|
1004
|
+
}
|
|
1005
|
+
return [...grouped.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
1006
|
+
}
|
|
948
1007
|
async react(noteId, emoji) {
|
|
949
|
-
const content =
|
|
1008
|
+
const content = REACTIONS2[emoji];
|
|
950
1009
|
if (!content) throw new ProviderError(tr(`GitHub \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0440\u0435\u0430\u043A\u0446\u0438\u044E ${emoji}`, `GitHub does not support reaction ${emoji}`));
|
|
951
1010
|
const base = `repos/${this.repo}/pulls/comments/${noteId}/reactions`;
|
|
952
1011
|
const existing = await this.json([query(base, { per_page: 100 })]);
|
|
@@ -961,6 +1020,12 @@ rename to ${newPath}
|
|
|
961
1020
|
});
|
|
962
1021
|
return { active: true };
|
|
963
1022
|
}
|
|
1023
|
+
async upload(_input) {
|
|
1024
|
+
throw new ProviderError(
|
|
1025
|
+
tr("GitHub \u043D\u0435 \u0434\u0430\u0451\u0442 \u043F\u0443\u0431\u043B\u0438\u0447\u043D\u043E\u0433\u043E API \u0434\u043B\u044F \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439", "GitHub has no public API for uploading attachments"),
|
|
1026
|
+
tr("\u0432\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u0443\u044E \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0444\u0430\u0439\u043B \u0432\u0440\u0443\u0447\u043D\u0443\u044E; \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F GitLab", "paste an absolute file URL manually; uploads are supported for GitLab only")
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
964
1029
|
async createThread(input) {
|
|
965
1030
|
if (input.line === null) {
|
|
966
1031
|
const comment2 = await withTempJson(
|
|
@@ -992,7 +1057,7 @@ rename to ${newPath}
|
|
|
992
1057
|
import { existsSync as existsSync4, readFileSync } from "node:fs";
|
|
993
1058
|
import { appendFile, open as open2, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
|
|
994
1059
|
import net2 from "node:net";
|
|
995
|
-
import
|
|
1060
|
+
import path8 from "node:path";
|
|
996
1061
|
import { fileURLToPath } from "node:url";
|
|
997
1062
|
import { parseArgs } from "node:util";
|
|
998
1063
|
|
|
@@ -1084,7 +1149,7 @@ async function writeFileAtomic(file, data, mode = 384) {
|
|
|
1084
1149
|
init_redact();
|
|
1085
1150
|
|
|
1086
1151
|
// src/version.ts
|
|
1087
|
-
var VERSION = "0.
|
|
1152
|
+
var VERSION = "0.2.0";
|
|
1088
1153
|
var PROTOCOL_VERSION = 1;
|
|
1089
1154
|
|
|
1090
1155
|
// src/daemon/main.ts
|
|
@@ -1095,10 +1160,8 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
1095
1160
|
import { mkdirSync } from "node:fs";
|
|
1096
1161
|
import path2 from "node:path";
|
|
1097
1162
|
|
|
1098
|
-
// src/core/types.ts
|
|
1099
|
-
var LEVELS = ["blocker", "major", "minor", "question"];
|
|
1100
|
-
|
|
1101
1163
|
// src/core/refs.ts
|
|
1164
|
+
init_types();
|
|
1102
1165
|
function reviewId(ref) {
|
|
1103
1166
|
return `${ref.platform}:${ref.host}/${ref.project}!${ref.number}`;
|
|
1104
1167
|
}
|
|
@@ -1639,6 +1702,9 @@ init_redact();
|
|
|
1639
1702
|
init_errors();
|
|
1640
1703
|
import { EventEmitter } from "node:events";
|
|
1641
1704
|
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
1705
|
+
import { mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1706
|
+
import { tmpdir as tmpdir4 } from "node:os";
|
|
1707
|
+
import path6 from "node:path";
|
|
1642
1708
|
|
|
1643
1709
|
// src/core/config.ts
|
|
1644
1710
|
import { z } from "zod";
|
|
@@ -1691,8 +1757,8 @@ function globToRegExp(glob) {
|
|
|
1691
1757
|
function escapeRegExp(value) {
|
|
1692
1758
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1693
1759
|
}
|
|
1694
|
-
function classifyFile(
|
|
1695
|
-
const lower =
|
|
1760
|
+
function classifyFile(path9) {
|
|
1761
|
+
const lower = path9.toLowerCase();
|
|
1696
1762
|
if (/(^|\/)(node_modules|dist|build|coverage|vendor)\//.test(lower)) return "generated";
|
|
1697
1763
|
if (/\.(md|mdx|rst|txt|adoc)$/.test(lower) || /(^|\/)(docs?|adr)\//.test(lower)) return "docs";
|
|
1698
1764
|
if (/(^|\/)(__tests__|tests?|spec|e2e)\//.test(lower) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(lower) || /_test\.go$/.test(lower)) return "test";
|
|
@@ -2004,7 +2070,8 @@ init_locale();
|
|
|
2004
2070
|
var CONTROL_OR_HIDDEN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u061c\u115f\u1160\u180e\u200b-\u200f\u2028-\u202e\u2060-\u206f\u2800\u3164\ufeff\uffa0]/;
|
|
2005
2071
|
var QUICK_ACTION = /^[\s\p{Z}\p{Cf}]*\/[a-z_]+(?=[\s\p{Z}\p{Cf}]|$)/imu;
|
|
2006
2072
|
var MASS_MENTION = /(^|[^\w@./-])@(all|here|everyone)(?![\w-])/i;
|
|
2007
|
-
var
|
|
2073
|
+
var MD_IMAGE_REF = /!\[[^\]]*\]\s*\[/;
|
|
2074
|
+
var MD_IMAGE_LINK = /!\[[^\]]*\]\s*\(\s*<?([^)\s>]+)>?(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
2008
2075
|
var HTML_TAG = /<(?:img|script|iframe|style|video|audio|object|embed|svg|math)\b/i;
|
|
2009
2076
|
var HTML_COMMENT = /<!--/;
|
|
2010
2077
|
var MAX_LENGTH = 64 * 1024;
|
|
@@ -2017,15 +2084,113 @@ function sanitizeBody(body, platform, options = {}) {
|
|
|
2017
2084
|
if (CONTROL_OR_HIDDEN.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u044E\u0449\u0438\u0435 \u0438\u043B\u0438 \u043D\u0435\u0432\u0438\u0434\u0438\u043C\u044B\u0435 \u0441\u0438\u043C\u0432\u043E\u043B\u044B", "the text contains control or invisible characters"), text: text2 };
|
|
2018
2085
|
if (HTML_COMMENT.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C HTML-\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439", "the text contains an HTML comment"), text: text2 };
|
|
2019
2086
|
if (HTML_TAG.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C HTML-\u0442\u0435\u0433", "the text contains an HTML tag"), text: text2 };
|
|
2020
|
-
|
|
2087
|
+
const imageProblem = imageIssue(text2, options.host);
|
|
2088
|
+
if (imageProblem) return { ok: false, reason: imageProblem, text: text2 };
|
|
2021
2089
|
if (MASS_MENTION.test(text2)) return { ok: false, reason: tr("\u043C\u0430\u0441\u0441\u043E\u0432\u043E\u0435 \u0443\u043F\u043E\u043C\u0438\u043D\u0430\u043D\u0438\u0435: \u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043A\u043E\u043D\u043A\u0440\u0435\u0442\u043D\u044B\u0445 \u043B\u044E\u0434\u0435\u0439", "mass mention: name specific people"), text: text2 };
|
|
2022
2090
|
if (checkQuickActions && QUICK_ACTION.test(text2)) {
|
|
2023
2091
|
return { ok: false, reason: tr("\u0441\u0442\u0440\u043E\u043A\u0430-\u043A\u043E\u043C\u0430\u043D\u0434\u0430 GitLab (/approve, /merge \u2026): \u043F\u0435\u0440\u0435\u0444\u0440\u0430\u0437\u0438\u0440\u0443\u0439\u0442\u0435", "GitLab quick action line (/approve, /merge \u2026): rephrase it"), text: text2 };
|
|
2024
2092
|
}
|
|
2025
2093
|
return { ok: true, reason: "", text: text2 };
|
|
2026
2094
|
}
|
|
2095
|
+
function imageIssue(text2, host) {
|
|
2096
|
+
if (MD_IMAGE_REF.test(text2)) {
|
|
2097
|
+
return tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430 \u0441\u043E \u0441\u0441\u044B\u043B\u043E\u0447\u043D\u043E\u0439 \u0441\u043D\u043E\u0441\u043A\u043E\u0439: \u0432\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u0443\u044E \u0441\u0441\u044B\u043B\u043A\u0443", "the text contains a reference-style image: use an absolute link");
|
|
2098
|
+
}
|
|
2099
|
+
for (const match of text2.matchAll(MD_IMAGE_LINK)) {
|
|
2100
|
+
const url = match[1];
|
|
2101
|
+
if (!host) {
|
|
2102
|
+
return tr("\u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0438 \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B \u0442\u043E\u043B\u044C\u043A\u043E \u0441 \u0445\u043E\u0441\u0442\u0430 \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u044B", "images are allowed only from the platform host");
|
|
2103
|
+
}
|
|
2104
|
+
if (url.startsWith("/uploads/")) continue;
|
|
2105
|
+
try {
|
|
2106
|
+
const parsed = new URL(url);
|
|
2107
|
+
if ((parsed.protocol === "https:" || parsed.protocol === "http:") && parsed.host === host) continue;
|
|
2108
|
+
} catch {
|
|
2109
|
+
}
|
|
2110
|
+
return tr(`\u0432\u043D\u0435\u0448\u043D\u044F\u044F \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430 ${url}: \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B \u0442\u043E\u043B\u044C\u043A\u043E \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438 \u0441 ${host}`, `external image ${url}: only uploads from ${host} are allowed`);
|
|
2111
|
+
}
|
|
2112
|
+
return null;
|
|
2113
|
+
}
|
|
2114
|
+
function normalizeMediaLinks(body, platform, host) {
|
|
2115
|
+
if (platform !== "gitlab" || !host) return body;
|
|
2116
|
+
const base = `https://${host}`;
|
|
2117
|
+
return body.replace(/(!?\[[^\]]*\]\(\s*)(\/?uploads\/[^)\s]+)/g, (_match, prefix, path9) => {
|
|
2118
|
+
return `${prefix}${path9.startsWith("/") ? base : `${base}/`}${path9}`;
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// src/core/attachments.ts
|
|
2123
|
+
var MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
2124
|
+
var MIME_BY_EXTENSION = {
|
|
2125
|
+
".png": "image/png",
|
|
2126
|
+
".jpg": "image/jpeg",
|
|
2127
|
+
".jpeg": "image/jpeg",
|
|
2128
|
+
".gif": "image/gif",
|
|
2129
|
+
".webp": "image/webp",
|
|
2130
|
+
".avif": "image/avif",
|
|
2131
|
+
".mp4": "video/mp4",
|
|
2132
|
+
".m4v": "video/mp4",
|
|
2133
|
+
".webm": "video/webm",
|
|
2134
|
+
".ogv": "video/ogg",
|
|
2135
|
+
".ogg": "video/ogg",
|
|
2136
|
+
".mov": "video/quicktime"
|
|
2137
|
+
};
|
|
2138
|
+
function extensionOf(name) {
|
|
2139
|
+
const index = name.lastIndexOf(".");
|
|
2140
|
+
return index >= 0 ? name.slice(index).toLowerCase() : "";
|
|
2141
|
+
}
|
|
2142
|
+
function attachmentSpec(name, contentType = "") {
|
|
2143
|
+
if (/[/\\]/.test(name)) return null;
|
|
2144
|
+
const base = name.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
2145
|
+
if (!base || base.length > 240) return null;
|
|
2146
|
+
const extension = extensionOf(base);
|
|
2147
|
+
const expected = MIME_BY_EXTENSION[extension];
|
|
2148
|
+
if (!expected) return null;
|
|
2149
|
+
const provided = normalizeContentType(String(contentType));
|
|
2150
|
+
if (provided && provided !== expected) return null;
|
|
2151
|
+
return {
|
|
2152
|
+
name: base,
|
|
2153
|
+
contentType: expected,
|
|
2154
|
+
extension,
|
|
2155
|
+
kind: expected.startsWith("image/") ? "image" : "video"
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
var CONTENT_TYPE_ALIASES = {
|
|
2159
|
+
"image/jpg": "image/jpeg",
|
|
2160
|
+
"image/pjpeg": "image/jpeg",
|
|
2161
|
+
"video/x-m4v": "video/mp4",
|
|
2162
|
+
"video/x-ogg": "video/ogg"
|
|
2163
|
+
};
|
|
2164
|
+
function normalizeContentType(value) {
|
|
2165
|
+
const type = value.split(";")[0].trim().toLowerCase();
|
|
2166
|
+
return CONTENT_TYPE_ALIASES[type] ?? type;
|
|
2167
|
+
}
|
|
2168
|
+
function decodeAttachment(data) {
|
|
2169
|
+
const clean = String(data ?? "").replace(/\s+/g, "");
|
|
2170
|
+
if (!clean || clean.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(clean)) {
|
|
2171
|
+
throw new Error("\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435: \u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F base64");
|
|
2172
|
+
}
|
|
2173
|
+
const buffer = Buffer.from(clean, "base64");
|
|
2174
|
+
if (!buffer.length) throw new Error("\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043F\u0443\u0441\u0442\u043E\u0435");
|
|
2175
|
+
if (buffer.length > MAX_ATTACHMENT_BYTES) {
|
|
2176
|
+
throw new Error(`\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u0431\u043E\u043B\u044C\u0448\u0435 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))} \u041C\u0411`);
|
|
2177
|
+
}
|
|
2178
|
+
return buffer;
|
|
2179
|
+
}
|
|
2180
|
+
function matchesSignature(buffer, contentType) {
|
|
2181
|
+
if (contentType === "image/png") return buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
2182
|
+
if (contentType === "image/jpeg") return buffer.length > 3 && buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255;
|
|
2183
|
+
if (contentType === "image/gif") return buffer.subarray(0, 4).toString("latin1") === "GIF8";
|
|
2184
|
+
if (contentType === "image/webp") return buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP";
|
|
2185
|
+
if (contentType === "image/avif") return buffer.subarray(4, 8).toString("latin1") === "ftyp";
|
|
2186
|
+
if (contentType === "video/webm") return buffer.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]));
|
|
2187
|
+
if (contentType === "video/ogg") return buffer.subarray(0, 4).toString("latin1") === "OggS";
|
|
2188
|
+
if (contentType === "video/mp4" || contentType === "video/quicktime") return buffer.subarray(4, 8).toString("latin1") === "ftyp";
|
|
2189
|
+
return false;
|
|
2190
|
+
}
|
|
2027
2191
|
|
|
2028
2192
|
// src/core/findings.ts
|
|
2193
|
+
init_types();
|
|
2029
2194
|
import { createHash } from "node:crypto";
|
|
2030
2195
|
init_locale();
|
|
2031
2196
|
var LEVEL_ORDER = { blocker: 0, major: 1, minor: 2, question: 3 };
|
|
@@ -2159,6 +2324,7 @@ function normalize(value) {
|
|
|
2159
2324
|
|
|
2160
2325
|
// src/daemon/service.ts
|
|
2161
2326
|
init_locale();
|
|
2327
|
+
init_types();
|
|
2162
2328
|
|
|
2163
2329
|
// src/core/git.ts
|
|
2164
2330
|
init_exec();
|
|
@@ -2368,6 +2534,14 @@ var ReviewService = class {
|
|
|
2368
2534
|
await this.refresh(id, meta.row.root, provider, loaded);
|
|
2369
2535
|
return this.getReview(id);
|
|
2370
2536
|
}
|
|
2537
|
+
async providerFor(id) {
|
|
2538
|
+
const stored = this.store.getReview(id);
|
|
2539
|
+
if (!stored) throw new Error(tr(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `review ${id} not found`));
|
|
2540
|
+
return this.providerFactory(
|
|
2541
|
+
{ platform: stored.row.platform, host: stored.row.host, project: stored.row.project, number: stored.row.number },
|
|
2542
|
+
stored.row.root
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2371
2545
|
async refresh(id, root, provider, loaded) {
|
|
2372
2546
|
const errors = [...loaded.warnings];
|
|
2373
2547
|
const previous = this.store.getReview(id)?.snapshot;
|
|
@@ -2581,6 +2755,77 @@ ${errors.join("\n")}`));
|
|
|
2581
2755
|
this.emit(id);
|
|
2582
2756
|
return next;
|
|
2583
2757
|
}
|
|
2758
|
+
// ---------------------------------------------------------------- reactions
|
|
2759
|
+
/** Реакции для набора комментариев; отсутствующие id пропускаются. */
|
|
2760
|
+
async reactions(id, noteIds) {
|
|
2761
|
+
const review = this.getReview(id);
|
|
2762
|
+
const known = new Set(review.threads.flatMap((thread) => thread.notes.map((note) => note.id)));
|
|
2763
|
+
const requested = [...new Set(noteIds.filter((noteId) => known.has(noteId)))].slice(0, 500);
|
|
2764
|
+
if (!requested.length) return {};
|
|
2765
|
+
const provider = await this.providerFor(id);
|
|
2766
|
+
const result = {};
|
|
2767
|
+
for (const noteId of requested) {
|
|
2768
|
+
try {
|
|
2769
|
+
result[noteId] = await provider.reactions(noteId);
|
|
2770
|
+
} catch {
|
|
2771
|
+
result[noteId] = [];
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
return result;
|
|
2775
|
+
}
|
|
2776
|
+
async react(id, noteId, emoji) {
|
|
2777
|
+
if (!REACTIONS.includes(emoji)) {
|
|
2778
|
+
throw new Error(tr(`\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u0430\u044F \u0440\u0435\u0430\u043A\u0446\u0438\u044F ${emoji}: ${REACTIONS.join(", ")}`, `unknown reaction ${emoji}: ${REACTIONS.join(", ")}`));
|
|
2779
|
+
}
|
|
2780
|
+
const review = this.getReview(id);
|
|
2781
|
+
const note = review.threads.flatMap((thread) => thread.notes).find((item) => item.id === noteId);
|
|
2782
|
+
if (!note) throw new Error(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u044F ${noteId} \u043D\u0435\u0442 \u0432 \u044D\u0442\u043E\u043C \u0440\u0435\u0432\u044C\u044E`, `comment ${noteId} is not in this review`));
|
|
2783
|
+
const provider = await this.providerFor(id);
|
|
2784
|
+
const result = await provider.react(noteId, emoji);
|
|
2785
|
+
let reactions = [];
|
|
2786
|
+
try {
|
|
2787
|
+
reactions = await provider.reactions(noteId);
|
|
2788
|
+
} catch {
|
|
2789
|
+
}
|
|
2790
|
+
this.store.audit(id, "reaction.toggle", { noteId, emoji, active: result.active });
|
|
2791
|
+
return { active: result.active, reactions };
|
|
2792
|
+
}
|
|
2793
|
+
async uploadAttachment(id, input) {
|
|
2794
|
+
const stored = this.store.getReview(id);
|
|
2795
|
+
if (!stored) throw new Error(tr(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `review ${id} not found`));
|
|
2796
|
+
const spec = attachmentSpec(String(input.name ?? ""), String(input.contentType ?? ""));
|
|
2797
|
+
if (!spec) {
|
|
2798
|
+
throw new Error(
|
|
2799
|
+
tr(
|
|
2800
|
+
"\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435: \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B PNG, JPEG, GIF, WebP, AVIF, MP4, WebM, OGV \u0438 MOV",
|
|
2801
|
+
"attachment: PNG, JPEG, GIF, WebP, AVIF, MP4, WebM, OGV and MOV are allowed"
|
|
2802
|
+
)
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
const provider = await this.providerFor(id);
|
|
2806
|
+
if (!provider.capabilities.attachments) {
|
|
2807
|
+
throw new Error(
|
|
2808
|
+
tr(
|
|
2809
|
+
"\u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u0430 \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439 \u0438\u0437 revdeck: \u0432\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u0443\u044E \u0441\u0441\u044B\u043B\u043A\u0443 \u0432\u0440\u0443\u0447\u043D\u0443\u044E",
|
|
2810
|
+
"the platform does not support attachments from revdeck: paste an absolute URL manually"
|
|
2811
|
+
)
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2814
|
+
const buffer = decodeAttachment(input.data);
|
|
2815
|
+
if (!matchesSignature(buffer, spec.contentType)) {
|
|
2816
|
+
throw new Error(tr("\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435: \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u043C\u043E\u0435 \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0435\u0442 \u0441 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043D\u0438\u0435\u043C", "attachment: content does not match the extension"));
|
|
2817
|
+
}
|
|
2818
|
+
const dir = await mkdtemp2(path6.join(tmpdir4(), "revdeck-attachment-"));
|
|
2819
|
+
const file = path6.join(dir, spec.name);
|
|
2820
|
+
try {
|
|
2821
|
+
await writeFile2(file, buffer, { mode: 384, flag: "wx" });
|
|
2822
|
+
const uploaded = await provider.upload({ path: file, name: spec.name, contentType: spec.contentType });
|
|
2823
|
+
this.store.audit(id, "attachment.upload", { name: spec.name, contentType: spec.contentType, bytes: buffer.length, url: uploaded.url });
|
|
2824
|
+
return uploaded;
|
|
2825
|
+
} finally {
|
|
2826
|
+
await rm2(dir, { recursive: true, force: true });
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2584
2829
|
// ---------------------------------------------------------------- notes
|
|
2585
2830
|
addNote(id, input) {
|
|
2586
2831
|
const review = this.getReview(id);
|
|
@@ -2653,25 +2898,27 @@ ${errors.join("\n")}`));
|
|
|
2653
2898
|
warnings.push(tr(`\u0442\u0440\u0435\u0434 ${itemId} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`, `thread ${itemId} not found`));
|
|
2654
2899
|
continue;
|
|
2655
2900
|
}
|
|
2656
|
-
const
|
|
2657
|
-
const
|
|
2901
|
+
const rawBody = draft.body.trim();
|
|
2902
|
+
const body2 = normalizeMediaLinks(rawBody, review.info.ref.platform, review.info.ref.host);
|
|
2903
|
+
const posted = normalizeMediaLinks(draft.posted.trim(), review.info.ref.platform, review.info.ref.host);
|
|
2904
|
+
const alreadyPosted = rawBody !== "" && posted === body2;
|
|
2658
2905
|
const resolveChanged = draft.resolve !== thread.resolved;
|
|
2659
|
-
if (!
|
|
2906
|
+
if (!body2 && !resolveChanged) {
|
|
2660
2907
|
warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: \u043D\u0435\u0447\u0435\u0433\u043E \u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u0442\u044C`, `thread ${thread.id.substring(0, 8)}: nothing to publish`));
|
|
2661
2908
|
continue;
|
|
2662
2909
|
}
|
|
2663
|
-
if (
|
|
2910
|
+
if (body2 && alreadyPosted && !resolveChanged) {
|
|
2664
2911
|
warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: \u043E\u0442\u0432\u0435\u0442 \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D`, `thread ${thread.id.substring(0, 8)}: reply already published`));
|
|
2665
2912
|
continue;
|
|
2666
2913
|
}
|
|
2667
|
-
if (
|
|
2668
|
-
const check2 = sanitizeBody(
|
|
2914
|
+
if (body2) {
|
|
2915
|
+
const check2 = sanitizeBody(body2, review.info.ref.platform, { host: review.info.ref.host });
|
|
2669
2916
|
if (!check2.ok) {
|
|
2670
2917
|
warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: ${check2.reason}`, `thread ${thread.id.substring(0, 8)}: ${check2.reason}`));
|
|
2671
2918
|
continue;
|
|
2672
2919
|
}
|
|
2673
2920
|
}
|
|
2674
|
-
items.push({ kind, id: thread.id, body, resolve: draft.resolve });
|
|
2921
|
+
items.push({ kind, id: thread.id, body: body2, resolve: draft.resolve });
|
|
2675
2922
|
continue;
|
|
2676
2923
|
}
|
|
2677
2924
|
if (kind === "finding") {
|
|
@@ -2684,13 +2931,13 @@ ${errors.join("\n")}`));
|
|
|
2684
2931
|
warnings.push(tr(`\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 ${finding.id} \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D\u043E`, `finding ${finding.id} is already published`));
|
|
2685
2932
|
continue;
|
|
2686
2933
|
}
|
|
2687
|
-
const
|
|
2688
|
-
const check2 = sanitizeBody(
|
|
2934
|
+
const body2 = normalizeMediaLinks(finding.comment.trim(), review.info.ref.platform, review.info.ref.host);
|
|
2935
|
+
const check2 = sanitizeBody(body2, review.info.ref.platform, { host: review.info.ref.host });
|
|
2689
2936
|
if (!check2.ok) {
|
|
2690
2937
|
warnings.push(tr(`\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 ${finding.id}: ${check2.reason}`, `finding ${finding.id}: ${check2.reason}`));
|
|
2691
2938
|
continue;
|
|
2692
2939
|
}
|
|
2693
|
-
const finalBody = finding.file && finding.line === null ? `\`${finding.file}\`: ${
|
|
2940
|
+
const finalBody = finding.file && finding.line === null ? `\`${finding.file}\`: ${body2}` : body2;
|
|
2694
2941
|
items.push({
|
|
2695
2942
|
kind,
|
|
2696
2943
|
id: finding.id,
|
|
@@ -2710,12 +2957,13 @@ ${errors.join("\n")}`));
|
|
|
2710
2957
|
warnings.push(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 ${note.id} \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D`, `comment ${note.id} is already published`));
|
|
2711
2958
|
continue;
|
|
2712
2959
|
}
|
|
2713
|
-
const
|
|
2960
|
+
const body = normalizeMediaLinks(note.text, review.info.ref.platform, review.info.ref.host);
|
|
2961
|
+
const check = sanitizeBody(body, review.info.ref.platform, { host: review.info.ref.host });
|
|
2714
2962
|
if (!check.ok) {
|
|
2715
2963
|
warnings.push(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 ${note.id}: ${check.reason}`, `comment ${note.id}: ${check.reason}`));
|
|
2716
2964
|
continue;
|
|
2717
2965
|
}
|
|
2718
|
-
items.push({ kind, id: note.id, body
|
|
2966
|
+
items.push({ kind, id: note.id, body, file: note.file, line: note.line, side: note.side });
|
|
2719
2967
|
}
|
|
2720
2968
|
return { plan: this.makePlan(id, kind, items), warnings };
|
|
2721
2969
|
}
|
|
@@ -2778,7 +3026,7 @@ ${errors.join("\n")}`));
|
|
|
2778
3026
|
if (!thread) throw new Error(tr("\u0442\u0440\u0435\u0434 \u0431\u043E\u043B\u044C\u0448\u0435 \u043D\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442", "the thread no longer exists"));
|
|
2779
3027
|
const body = String(item.body ?? "").trim();
|
|
2780
3028
|
if (body && draft.posted.trim() !== body) {
|
|
2781
|
-
const check = sanitizeBody(body, platform);
|
|
3029
|
+
const check = sanitizeBody(body, platform, { host: review.info.ref.host });
|
|
2782
3030
|
if (!check.ok) throw new Error(check.reason);
|
|
2783
3031
|
await provider.reply(thread, body);
|
|
2784
3032
|
draft.posted = body;
|
|
@@ -3045,7 +3293,7 @@ init_errors();
|
|
|
3045
3293
|
import http from "node:http";
|
|
3046
3294
|
import { createReadStream } from "node:fs";
|
|
3047
3295
|
import { readFile as readFile2, stat } from "node:fs/promises";
|
|
3048
|
-
import
|
|
3296
|
+
import path7 from "node:path";
|
|
3049
3297
|
import { timingSafeEqual } from "node:crypto";
|
|
3050
3298
|
|
|
3051
3299
|
// src/daemon/rpc.ts
|
|
@@ -3053,7 +3301,7 @@ import net from "node:net";
|
|
|
3053
3301
|
import { chmod, unlink as unlink2 } from "node:fs/promises";
|
|
3054
3302
|
init_redact();
|
|
3055
3303
|
init_errors();
|
|
3056
|
-
var MAX_LINE =
|
|
3304
|
+
var MAX_LINE = 40 * 1024 * 1024;
|
|
3057
3305
|
async function dispatch(service, method, params) {
|
|
3058
3306
|
switch (method) {
|
|
3059
3307
|
case "ping":
|
|
@@ -3091,6 +3339,23 @@ async function dispatch(service, method, params) {
|
|
|
3091
3339
|
const file = service.diffFile(String(params.review ?? ""), String(params.file ?? ""));
|
|
3092
3340
|
return { file };
|
|
3093
3341
|
}
|
|
3342
|
+
case "reactions.list":
|
|
3343
|
+
return {
|
|
3344
|
+
reactions: await service.reactions(
|
|
3345
|
+
String(params.review ?? ""),
|
|
3346
|
+
Array.isArray(params.noteIds) ? params.noteIds.map(String) : []
|
|
3347
|
+
)
|
|
3348
|
+
};
|
|
3349
|
+
case "reactions.toggle":
|
|
3350
|
+
return service.react(String(params.review ?? ""), String(params.noteId ?? ""), String(params.emoji ?? ""));
|
|
3351
|
+
case "attachments.upload":
|
|
3352
|
+
return {
|
|
3353
|
+
attachment: await service.uploadAttachment(String(params.review ?? ""), {
|
|
3354
|
+
name: String(params.name ?? ""),
|
|
3355
|
+
contentType: String(params.contentType ?? ""),
|
|
3356
|
+
data: String(params.data ?? "")
|
|
3357
|
+
})
|
|
3358
|
+
};
|
|
3094
3359
|
case "drafts.put":
|
|
3095
3360
|
return { draft: service.setDraft(String(params.review ?? ""), String(params.threadId ?? ""), params.patch ?? {}) };
|
|
3096
3361
|
case "findings.put":
|
|
@@ -3223,7 +3488,7 @@ async function respond(service, request) {
|
|
|
3223
3488
|
}
|
|
3224
3489
|
|
|
3225
3490
|
// src/daemon/http.ts
|
|
3226
|
-
var MAX_BODY =
|
|
3491
|
+
var MAX_BODY = 40 * 1024 * 1024;
|
|
3227
3492
|
var MIME = {
|
|
3228
3493
|
".html": "text/html; charset=utf-8",
|
|
3229
3494
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -3317,15 +3582,15 @@ async function startHttpServer(options) {
|
|
|
3317
3582
|
res.end('<meta charset="utf-8"><h1>\u041D\u0443\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u043A\u0430 \u0437\u0430\u043F\u0443\u0441\u043A\u0430</h1><p>\u041E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u0438\u0437 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u0430: \u043E\u043D \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 \u043E\u0434\u043D\u043E\u0440\u0430\u0437\u043E\u0432\u044B\u0439 \u0442\u043E\u043A\u0435\u043D.</p>');
|
|
3318
3583
|
return;
|
|
3319
3584
|
}
|
|
3320
|
-
const html = await readFile2(
|
|
3585
|
+
const html = await readFile2(path7.join(options.uiDir, "index.html"));
|
|
3321
3586
|
res.writeHead(200, { ...SECURITY_HEADERS, "content-type": MIME[".html"], "cache-control": "no-store" });
|
|
3322
3587
|
res.end(html);
|
|
3323
3588
|
return;
|
|
3324
3589
|
}
|
|
3325
3590
|
if (req.method === "GET" && (url.pathname.startsWith("/assets/") || url.pathname === "/favicon.svg")) {
|
|
3326
3591
|
const relative = url.pathname === "/favicon.svg" ? "favicon.svg" : url.pathname.slice("/assets/".length);
|
|
3327
|
-
const target =
|
|
3328
|
-
if (!target.startsWith(
|
|
3592
|
+
const target = path7.resolve(options.uiDir, url.pathname === "/favicon.svg" ? "" : "assets", relative);
|
|
3593
|
+
if (!target.startsWith(path7.resolve(options.uiDir) + path7.sep) || relative.includes("..")) {
|
|
3329
3594
|
reply(res, 404, { ok: false, error: "\u043D\u0435\u0442 \u0444\u0430\u0439\u043B\u0430" });
|
|
3330
3595
|
return;
|
|
3331
3596
|
}
|
|
@@ -3336,7 +3601,7 @@ async function startHttpServer(options) {
|
|
|
3336
3601
|
}
|
|
3337
3602
|
res.writeHead(200, {
|
|
3338
3603
|
...SECURITY_HEADERS,
|
|
3339
|
-
"content-type": MIME[
|
|
3604
|
+
"content-type": MIME[path7.extname(target)] ?? "application/octet-stream",
|
|
3340
3605
|
"cache-control": "public, max-age=31536000, immutable"
|
|
3341
3606
|
});
|
|
3342
3607
|
createReadStream(target).pipe(res);
|
|
@@ -3414,6 +3679,12 @@ data: ${JSON.stringify(event)}
|
|
|
3414
3679
|
reply(res, 200, { ok: true, rules: await service.rules(review) });
|
|
3415
3680
|
return;
|
|
3416
3681
|
}
|
|
3682
|
+
if (req.method === "GET" && url.pathname === "/api/reactions") {
|
|
3683
|
+
const review = url.searchParams.get("review") ?? "";
|
|
3684
|
+
const noteIds = (url.searchParams.get("notes") ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
3685
|
+
reply(res, 200, { ok: true, reactions: await service.reactions(review, noteIds) });
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
3417
3688
|
if (req.method === "POST") {
|
|
3418
3689
|
const body = await readBody(req);
|
|
3419
3690
|
const review = String(body.review ?? "");
|
|
@@ -3436,6 +3707,20 @@ data: ${JSON.stringify(event)}
|
|
|
3436
3707
|
reply(res, 200, { ok: true, review: service.getView(updated.id), reviews: service.listReviews() });
|
|
3437
3708
|
return;
|
|
3438
3709
|
}
|
|
3710
|
+
case "/api/attachments": {
|
|
3711
|
+
const attachment = await service.uploadAttachment(review, {
|
|
3712
|
+
name: String(body.name ?? ""),
|
|
3713
|
+
contentType: String(body.type ?? ""),
|
|
3714
|
+
data: String(body.data ?? "")
|
|
3715
|
+
});
|
|
3716
|
+
reply(res, 200, { ok: true, attachment });
|
|
3717
|
+
return;
|
|
3718
|
+
}
|
|
3719
|
+
case "/api/reactions": {
|
|
3720
|
+
const result = await service.react(review, String(body.noteId ?? ""), String(body.emoji ?? ""));
|
|
3721
|
+
reply(res, 200, { ok: true, ...result });
|
|
3722
|
+
return;
|
|
3723
|
+
}
|
|
3439
3724
|
case "/api/drafts": {
|
|
3440
3725
|
const draft = service.setDraft(review, String(body.threadId ?? ""), body.patch ?? {});
|
|
3441
3726
|
reply(res, 200, { ok: true, draft });
|
|
@@ -3540,6 +3825,7 @@ async function main() {
|
|
|
3540
3825
|
const { values } = parseArgs({
|
|
3541
3826
|
options: {
|
|
3542
3827
|
port: { type: "string" },
|
|
3828
|
+
host: { type: "string" },
|
|
3543
3829
|
"state-dir": { type: "string" },
|
|
3544
3830
|
"runtime-dir": { type: "string" },
|
|
3545
3831
|
"ui-dir": { type: "string" },
|
|
@@ -3582,12 +3868,13 @@ async function main() {
|
|
|
3582
3868
|
});
|
|
3583
3869
|
const socket = socketPath();
|
|
3584
3870
|
const rpc = await startRpcServer(service, socket);
|
|
3585
|
-
const uiDir = values["ui-dir"] ?
|
|
3871
|
+
const uiDir = values["ui-dir"] ? path8.resolve(values["ui-dir"]) : path8.join(packageRoot(), "dist", "ui");
|
|
3586
3872
|
const http2 = await startHttpServer({
|
|
3587
3873
|
service,
|
|
3588
3874
|
token,
|
|
3589
3875
|
uiDir,
|
|
3590
3876
|
...values.port ? { port: Number(values.port) } : {},
|
|
3877
|
+
...values.host ? { host: String(values.host) } : {},
|
|
3591
3878
|
version: VERSION
|
|
3592
3879
|
});
|
|
3593
3880
|
const info = {
|
|
@@ -3625,7 +3912,7 @@ async function main() {
|
|
|
3625
3912
|
service.close();
|
|
3626
3913
|
await unlink3(daemonInfoPath()).catch(() => void 0);
|
|
3627
3914
|
await lock.close().catch(() => void 0);
|
|
3628
|
-
await unlink3(
|
|
3915
|
+
await unlink3(path8.join(stateDir(), "daemon.lock")).catch(() => void 0);
|
|
3629
3916
|
process.exit(0);
|
|
3630
3917
|
}
|
|
3631
3918
|
};
|
|
@@ -3637,9 +3924,9 @@ async function main() {
|
|
|
3637
3924
|
}
|
|
3638
3925
|
}
|
|
3639
3926
|
function packageRoot() {
|
|
3640
|
-
let dir =
|
|
3927
|
+
let dir = path8.dirname(fileURLToPath(import.meta.url));
|
|
3641
3928
|
for (; ; ) {
|
|
3642
|
-
const candidate =
|
|
3929
|
+
const candidate = path8.join(dir, "package.json");
|
|
3643
3930
|
if (existsSync4(candidate)) {
|
|
3644
3931
|
try {
|
|
3645
3932
|
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
|
|
@@ -3647,7 +3934,7 @@ function packageRoot() {
|
|
|
3647
3934
|
} catch {
|
|
3648
3935
|
}
|
|
3649
3936
|
}
|
|
3650
|
-
const parent =
|
|
3937
|
+
const parent = path8.dirname(dir);
|
|
3651
3938
|
if (parent === dir) return process.cwd();
|
|
3652
3939
|
dir = parent;
|
|
3653
3940
|
}
|
|
@@ -3686,7 +3973,7 @@ async function runningDaemon() {
|
|
|
3686
3973
|
return reachable ? info : null;
|
|
3687
3974
|
}
|
|
3688
3975
|
async function acquireLock() {
|
|
3689
|
-
const file =
|
|
3976
|
+
const file = path8.join(stateDir(), "daemon.lock");
|
|
3690
3977
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3691
3978
|
try {
|
|
3692
3979
|
const handle = await open2(file, "wx", 384);
|