@valbuild/server 0.136.9 → 0.137.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 +81 -0
- package/dist/declarations/src/ValOps.d.ts +44 -0
- package/dist/declarations/src/ValOpsFS.d.ts +2 -0
- package/dist/declarations/src/ValOpsHttp.d.ts +20 -1
- package/dist/declarations/src/ValServer.d.ts +6 -0
- package/dist/valbuild-server.cjs.dev.js +745 -15
- package/dist/valbuild-server.cjs.prod.js +745 -15
- package/dist/valbuild-server.esm.js +741 -14
- package/package.json +4 -4
|
@@ -14,12 +14,15 @@ var internal = require('@valbuild/shared/internal');
|
|
|
14
14
|
var server = require('@valbuild/ui/server');
|
|
15
15
|
var crypto$1 = require('crypto');
|
|
16
16
|
var z = require('zod');
|
|
17
|
+
var http = require('node:http');
|
|
18
|
+
var https = require('node:https');
|
|
19
|
+
var net = require('node:net');
|
|
17
20
|
var sizeOf = require('image-size');
|
|
18
21
|
var zodValidationError = require('zod-validation-error');
|
|
19
22
|
var os = require('os');
|
|
20
23
|
var sucrase = require('sucrase');
|
|
21
|
-
var http = require('http');
|
|
22
|
-
var https = require('https');
|
|
24
|
+
var http$1 = require('http');
|
|
25
|
+
var https$1 = require('https');
|
|
23
26
|
var fs$1 = require('fs/promises');
|
|
24
27
|
|
|
25
28
|
function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
|
|
@@ -48,10 +51,13 @@ var fs__default = /*#__PURE__*/_interopDefault(fs);
|
|
|
48
51
|
var vm__default = /*#__PURE__*/_interopDefault(vm);
|
|
49
52
|
var crypto__default = /*#__PURE__*/_interopDefault(crypto$1);
|
|
50
53
|
var z__default = /*#__PURE__*/_interopDefault(z);
|
|
51
|
-
var sizeOf__default = /*#__PURE__*/_interopDefault(sizeOf);
|
|
52
|
-
var os__default = /*#__PURE__*/_interopDefault(os);
|
|
53
54
|
var http__default = /*#__PURE__*/_interopDefault(http);
|
|
54
55
|
var https__default = /*#__PURE__*/_interopDefault(https);
|
|
56
|
+
var net__default = /*#__PURE__*/_interopDefault(net);
|
|
57
|
+
var sizeOf__default = /*#__PURE__*/_interopDefault(sizeOf);
|
|
58
|
+
var os__default = /*#__PURE__*/_interopDefault(os);
|
|
59
|
+
var http__default$1 = /*#__PURE__*/_interopDefault(http$1);
|
|
60
|
+
var https__default$1 = /*#__PURE__*/_interopDefault(https$1);
|
|
55
61
|
var fs__default$1 = /*#__PURE__*/_interopDefault(fs$1);
|
|
56
62
|
|
|
57
63
|
class ValSyntaxError {
|
|
@@ -2599,6 +2605,572 @@ function encodeJwt(payload, sessionKey) {
|
|
|
2599
2605
|
return `${jwtHeaderBase64}.${payloadBase64}.${crypto__default["default"].createHmac("sha256", sessionKey).update(`${jwtHeaderBase64}.${payloadBase64}`).digest("base64")}`;
|
|
2600
2606
|
}
|
|
2601
2607
|
|
|
2608
|
+
/**
|
|
2609
|
+
* Just the part of `ValServerConfig` this decision needs.
|
|
2610
|
+
*
|
|
2611
|
+
* Narrower than the whole thing on purpose: a `ValServerConfig` carries the api
|
|
2612
|
+
* key, the secret and the content URLs, and a function that took one would look
|
|
2613
|
+
* like it might consult them. It also makes this testable with an object
|
|
2614
|
+
* literal rather than a cast.
|
|
2615
|
+
*/
|
|
2616
|
+
|
|
2617
|
+
/**
|
|
2618
|
+
* The `val.config` the Studio is told about, which is not quite the one on disk.
|
|
2619
|
+
*
|
|
2620
|
+
* `gitBranch` is optional in `val.config`, and a proxy-mode server that mirrors
|
|
2621
|
+
* into a repository usually knows the branch when the file does not: setting it
|
|
2622
|
+
* in the environment is how a Vercel deployment does it
|
|
2623
|
+
* (`VERCEL_GIT_COMMIT_REF`), and the example app does not set it at all.
|
|
2624
|
+
*
|
|
2625
|
+
* Git is OPTIONAL in http mode — a project can run on credentials alone, with
|
|
2626
|
+
* no repository to mirror into — so "http" no longer implies a branch exists.
|
|
2627
|
+
* That changed under this function rather than being designed into it: the
|
|
2628
|
+
* original said proxy mode "refuses to start without `VAL_GIT_BRANCH`", which
|
|
2629
|
+
* was true when it was written and is not any more. With no mirror there is
|
|
2630
|
+
* nothing to fill in and nothing to say, which is the same answer fs mode gets.
|
|
2631
|
+
*
|
|
2632
|
+
* The Studio reads `config.gitBranch` and nothing else, so without this a
|
|
2633
|
+
* correctly configured project gets a History pane reading "this project has no
|
|
2634
|
+
* gitBranch configured" while the server behind it is busy committing to one,
|
|
2635
|
+
* and a status bar with no branch on it.
|
|
2636
|
+
*
|
|
2637
|
+
* Narrow on purpose:
|
|
2638
|
+
*
|
|
2639
|
+
* - **`val.config` wins.** A branch named in the file is the author's answer,
|
|
2640
|
+
* and the environment's is the fallback.
|
|
2641
|
+
* - **Every other mode is left alone.** Neither `fs` nor `memory` has a branch:
|
|
2642
|
+
* `ValOpsFS` has no commits of its own, and a memory host holds its own
|
|
2643
|
+
* source and never had any. Nor does an http project with no git mirror. The
|
|
2644
|
+
* shell hides what needs a branch rather than being handed a name that means
|
|
2645
|
+
* nothing.
|
|
2646
|
+
*
|
|
2647
|
+
* The modes are enumerated rather than written as "http, or anything else", and
|
|
2648
|
+
* that is worth keeping: `memory` arrived after this function did, and spelling
|
|
2649
|
+
* every mode out is what turned "does this one have a branch?" into a compile
|
|
2650
|
+
* error somebody had to answer instead of a default that silently applied.
|
|
2651
|
+
*/
|
|
2652
|
+
function clientConfig(options) {
|
|
2653
|
+
if (options.mode !== "http" || options.git === undefined) {
|
|
2654
|
+
return options.config;
|
|
2655
|
+
}
|
|
2656
|
+
if (options.config.gitBranch !== undefined) {
|
|
2657
|
+
return options.config;
|
|
2658
|
+
}
|
|
2659
|
+
return {
|
|
2660
|
+
...options.config,
|
|
2661
|
+
gitBranch: options.git.branch
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
/**
|
|
2666
|
+
* Which IP addresses this server may open a connection to.
|
|
2667
|
+
*
|
|
2668
|
+
* The link check makes outbound requests to addresses an editor supplies,
|
|
2669
|
+
* which is the definition of SSRF: without this, "check my external links"
|
|
2670
|
+
* is a button that turns the app's server into a probe for whatever is
|
|
2671
|
+
* reachable from inside the network it runs in - a database on a private
|
|
2672
|
+
* subnet, an admin panel on localhost, and on every major cloud the instance
|
|
2673
|
+
* metadata service on 169.254.169.254, which hands out credentials.
|
|
2674
|
+
*
|
|
2675
|
+
* So the rule is a DENY LIST of address ranges rather than an allow list of
|
|
2676
|
+
* hostnames: an editor is allowed to check any real site on the internet, and
|
|
2677
|
+
* nothing else. It is applied to the RESOLVED address, in the lookup the
|
|
2678
|
+
* socket then connects to - a public name is free to resolve to 127.0.0.1,
|
|
2679
|
+
* and checking the name would catch none of it.
|
|
2680
|
+
*/
|
|
2681
|
+
|
|
2682
|
+
/** Why an address may not be connected to. Reported, not just counted. */
|
|
2683
|
+
|
|
2684
|
+
const REASON_TEXT = {
|
|
2685
|
+
unspecified: "an unspecified address",
|
|
2686
|
+
loopback: "a loopback address",
|
|
2687
|
+
private: "a private address",
|
|
2688
|
+
"link-local": "a link-local address",
|
|
2689
|
+
"unique-local": "a unique local address",
|
|
2690
|
+
multicast: "a multicast address",
|
|
2691
|
+
reserved: "a reserved address",
|
|
2692
|
+
unparseable: "an address that could not be read"
|
|
2693
|
+
};
|
|
2694
|
+
function describeBlocked(reason) {
|
|
2695
|
+
return REASON_TEXT[reason];
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
/** `null` when the address is a public one this server may talk to. */
|
|
2699
|
+
function blockedAddressReason(address) {
|
|
2700
|
+
// A scope/zone id (`fe80::1%eth0`) is not part of the address, and leaving
|
|
2701
|
+
// it on would make the address unparseable — which fails closed, but for the
|
|
2702
|
+
// wrong reason and with the wrong message.
|
|
2703
|
+
const bare = address.split("%")[0];
|
|
2704
|
+
const v4 = parseIPv4(bare);
|
|
2705
|
+
if (v4 !== null) {
|
|
2706
|
+
return blockedIPv4(v4);
|
|
2707
|
+
}
|
|
2708
|
+
const v6 = parseIPv6(bare);
|
|
2709
|
+
if (v6 !== null) {
|
|
2710
|
+
return blockedIPv6(v6);
|
|
2711
|
+
}
|
|
2712
|
+
// Fail closed. Anything that reaches here is not something `dns.lookup`
|
|
2713
|
+
// produced, and connecting to an address this cannot read is exactly the
|
|
2714
|
+
// case a deny list must not wave through.
|
|
2715
|
+
return "unparseable";
|
|
2716
|
+
}
|
|
2717
|
+
function parseIPv4(text) {
|
|
2718
|
+
const parts = text.split(".");
|
|
2719
|
+
if (parts.length !== 4) {
|
|
2720
|
+
return null;
|
|
2721
|
+
}
|
|
2722
|
+
const octets = [];
|
|
2723
|
+
for (const part of parts) {
|
|
2724
|
+
// Deliberately strict: `010` is octal to `inet_aton` and decimal to
|
|
2725
|
+
// `Number`, and `1e2` parses as 100. A resolver never produces either, so
|
|
2726
|
+
// anything that is not plain digits is not an address - and a leading
|
|
2727
|
+
// zero is refused outright rather than read as decimal, because that is
|
|
2728
|
+
// the one form where this and `inet_aton` would read the SAME string as
|
|
2729
|
+
// two different addresses. `0` itself is an octet; `01` is not.
|
|
2730
|
+
if (!/^(0|[1-9]\d{0,2})$/.test(part)) {
|
|
2731
|
+
return null;
|
|
2732
|
+
}
|
|
2733
|
+
const value = Number(part);
|
|
2734
|
+
if (value > 255) {
|
|
2735
|
+
return null;
|
|
2736
|
+
}
|
|
2737
|
+
octets.push(value);
|
|
2738
|
+
}
|
|
2739
|
+
return [octets[0], octets[1], octets[2], octets[3]];
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
/** Eight hextets, with `::` expanded and a trailing IPv4 form accepted. */
|
|
2743
|
+
function parseIPv6(text) {
|
|
2744
|
+
if (!text.includes(":")) {
|
|
2745
|
+
return null;
|
|
2746
|
+
}
|
|
2747
|
+
// A trailing dotted quad is rewritten into the two hextets it IS, before
|
|
2748
|
+
// anything else looks at the string. Splitting the address around it
|
|
2749
|
+
// instead means handling a `::` that the split lands inside - which is
|
|
2750
|
+
// exactly where `::127.0.0.1` went wrong.
|
|
2751
|
+
let normalized = text;
|
|
2752
|
+
const lastColon = text.lastIndexOf(":");
|
|
2753
|
+
const tail = text.slice(lastColon + 1);
|
|
2754
|
+
if (tail.includes(".")) {
|
|
2755
|
+
const v4 = parseIPv4(tail);
|
|
2756
|
+
if (v4 === null) {
|
|
2757
|
+
return null;
|
|
2758
|
+
}
|
|
2759
|
+
const high = (v4[0] << 8 | v4[1]).toString(16);
|
|
2760
|
+
const low = (v4[2] << 8 | v4[3]).toString(16);
|
|
2761
|
+
normalized = `${text.slice(0, lastColon + 1)}${high}:${low}`;
|
|
2762
|
+
}
|
|
2763
|
+
const doubleColon = normalized.indexOf("::");
|
|
2764
|
+
if (doubleColon !== normalized.lastIndexOf("::")) {
|
|
2765
|
+
return null;
|
|
2766
|
+
}
|
|
2767
|
+
let groups;
|
|
2768
|
+
if (doubleColon === -1) {
|
|
2769
|
+
groups = normalized.split(":");
|
|
2770
|
+
if (groups.length !== 8) {
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
} else {
|
|
2774
|
+
const left = normalized.slice(0, doubleColon);
|
|
2775
|
+
const right = normalized.slice(doubleColon + 2);
|
|
2776
|
+
const before = left === "" ? [] : left.split(":");
|
|
2777
|
+
const after = right === "" ? [] : right.split(":");
|
|
2778
|
+
if (before.length + after.length > 7) {
|
|
2779
|
+
// `::` stands for AT LEAST one group of zeros, so eight written groups
|
|
2780
|
+
// beside it is one too many.
|
|
2781
|
+
return null;
|
|
2782
|
+
}
|
|
2783
|
+
groups = [...before, ...new Array(8 - before.length - after.length).fill("0"), ...after];
|
|
2784
|
+
}
|
|
2785
|
+
const hextets = [];
|
|
2786
|
+
for (const group of groups) {
|
|
2787
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) {
|
|
2788
|
+
return null;
|
|
2789
|
+
}
|
|
2790
|
+
hextets.push(parseInt(group, 16));
|
|
2791
|
+
}
|
|
2792
|
+
return hextets.length === 8 ? hextets : null;
|
|
2793
|
+
}
|
|
2794
|
+
function blockedIPv4(octets) {
|
|
2795
|
+
const [a, b, c] = octets;
|
|
2796
|
+
if (a === 0) return "unspecified"; // 0.0.0.0/8
|
|
2797
|
+
if (a === 10) return "private"; // 10.0.0.0/8
|
|
2798
|
+
if (a === 127) return "loopback"; // 127.0.0.0/8
|
|
2799
|
+
if (a === 100 && b >= 64 && b <= 127) return "private"; // 100.64.0.0/10 CGNAT
|
|
2800
|
+
if (a === 169 && b === 254) return "link-local"; // 169.254.0.0/16 — cloud metadata
|
|
2801
|
+
if (a === 172 && b >= 16 && b <= 31) return "private"; // 172.16.0.0/12
|
|
2802
|
+
if (a === 192 && b === 168) return "private"; // 192.168.0.0/16
|
|
2803
|
+
if (a === 192 && b === 0 && (c === 0 || c === 2)) return "reserved"; // 192.0.0.0/24, TEST-NET-1
|
|
2804
|
+
if (a === 192 && b === 88 && c === 99) return "reserved"; // 6to4 relay anycast
|
|
2805
|
+
if (a === 198 && (b === 18 || b === 19)) return "reserved"; // benchmarking
|
|
2806
|
+
if (a === 198 && b === 51 && c === 100) return "reserved"; // TEST-NET-2
|
|
2807
|
+
if (a === 203 && b === 0 && c === 113) return "reserved"; // TEST-NET-3
|
|
2808
|
+
if (a >= 240) return "reserved"; // 240.0.0.0/4, incl. 255.255.255.255
|
|
2809
|
+
if (a >= 224) return "multicast"; // 224.0.0.0/4
|
|
2810
|
+
return null;
|
|
2811
|
+
}
|
|
2812
|
+
function blockedIPv6(h) {
|
|
2813
|
+
const leadingZeros = h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0;
|
|
2814
|
+
if (leadingZeros && h[5] === 0xffff) {
|
|
2815
|
+
// ::ffff:0:0/96 — an IPv4 address wearing an IPv6 hat, and the form a
|
|
2816
|
+
// dual-stack resolver hands back. Judged as the IPv4 address it is, or
|
|
2817
|
+
// this whole list would be one `::ffff:` away from being bypassed.
|
|
2818
|
+
return blockedIPv4(v4Of(h[6], h[7]));
|
|
2819
|
+
}
|
|
2820
|
+
if (leadingZeros && h[5] === 0) {
|
|
2821
|
+
// ::/128 unspecified, ::1/128 loopback, and the deprecated
|
|
2822
|
+
// IPv4-compatible ::a.b.c.d — all of which are local or nothing.
|
|
2823
|
+
if (h[6] === 0 && h[7] === 0) return "unspecified";
|
|
2824
|
+
if (h[6] === 0 && h[7] === 1) return "loopback";
|
|
2825
|
+
return blockedIPv4(v4Of(h[6], h[7])) ?? "reserved";
|
|
2826
|
+
}
|
|
2827
|
+
if (h[0] === 0x64 && h[1] === 0xff9b) return "reserved"; // NAT64
|
|
2828
|
+
if (h[0] === 0x100 && h[1] === 0 && h[2] === 0 && h[3] === 0) {
|
|
2829
|
+
return "reserved"; // 100::/64 discard-only
|
|
2830
|
+
}
|
|
2831
|
+
if (h[0] === 0x2001 && h[1] === 0x0db8) return "reserved"; // documentation
|
|
2832
|
+
if ((h[0] & 0xfe00) === 0xfc00) return "unique-local"; // fc00::/7
|
|
2833
|
+
if ((h[0] & 0xffc0) === 0xfe80) return "link-local"; // fe80::/10
|
|
2834
|
+
if ((h[0] & 0xff00) === 0xff00) return "multicast"; // ff00::/8
|
|
2835
|
+
return null;
|
|
2836
|
+
}
|
|
2837
|
+
function v4Of(high, low) {
|
|
2838
|
+
return [high >> 8, high & 0xff, low >> 8, low & 0xff];
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
/**
|
|
2842
|
+
* Opening one external URL and reporting what answered.
|
|
2843
|
+
*
|
|
2844
|
+
* Mirrors `ExternalUrlProbeResult` in the Studio - the two are checked against
|
|
2845
|
+
* each other by the API route's zod schema, which is the only place they meet.
|
|
2846
|
+
*/
|
|
2847
|
+
|
|
2848
|
+
const DEFAULT_TIMEOUT_MS$1 = 5000;
|
|
2849
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
2850
|
+
|
|
2851
|
+
/**
|
|
2852
|
+
* A browser-ish identity.
|
|
2853
|
+
*
|
|
2854
|
+
* Not a disguise: plenty of sites answer 403 to a request with no `User-Agent`
|
|
2855
|
+
* at all, and reporting those as broken would be a check that cries wolf. It
|
|
2856
|
+
* says what it is and where to complain.
|
|
2857
|
+
*/
|
|
2858
|
+
const USER_AGENT = "Val-Studio-LinkCheck/1.0 (+https://val.build; link checker)";
|
|
2859
|
+
|
|
2860
|
+
/**
|
|
2861
|
+
* A `lookup` that refuses to hand back an address this server may not reach.
|
|
2862
|
+
*
|
|
2863
|
+
* This, rather than a check on the hostname or a check after the fact, is what
|
|
2864
|
+
* closes the hole. The socket connects to the address the lookup RETURNS, so
|
|
2865
|
+
* validating inside it leaves no window between the check and the connection —
|
|
2866
|
+
* a name that resolves to 93.184.216.34 once and 127.0.0.1 the next time gets
|
|
2867
|
+
* no second chance, because there is only ever one resolution and it is this
|
|
2868
|
+
* one.
|
|
2869
|
+
*/
|
|
2870
|
+
/**
|
|
2871
|
+
* What a refused lookup tells the CALLER.
|
|
2872
|
+
*
|
|
2873
|
+
* Deliberately the same words `ENETUNREACH` gets, and deliberately without the
|
|
2874
|
+
* address. The guard stops the connection, but the first version of it handed
|
|
2875
|
+
* back the DNS answer it had just refused to use - "vault.prod.svc resolves to
|
|
2876
|
+
* a private address (10.42.7.19)" - which travels through `readableError` into
|
|
2877
|
+
* the response and onto the screen. That turns an endpoint that cannot reach
|
|
2878
|
+
* the internal network into one that MAPS it: twenty names a request, existence
|
|
2879
|
+
* confirmed and address included, without a single connection being made.
|
|
2880
|
+
*
|
|
2881
|
+
* True rather than evasive: this server genuinely did not reach the host. And
|
|
2882
|
+
* because a real external host that is down produces the same sentence, the
|
|
2883
|
+
* answer no longer says which of the two happened. The operator still gets the
|
|
2884
|
+
* detail, in the log, where it belongs.
|
|
2885
|
+
*
|
|
2886
|
+
* A hostname the caller wrote as a literal address is different and keeps its
|
|
2887
|
+
* specific message (see the literal check in `probeUrl`): repeating
|
|
2888
|
+
* `127.0.0.1` to someone who just typed `127.0.0.1` reveals nothing.
|
|
2889
|
+
*/
|
|
2890
|
+
const REFUSED_MESSAGE = "the host could not be reached";
|
|
2891
|
+
function guardedLookup(lookup) {
|
|
2892
|
+
const guarded = (hostname, options, callback) => {
|
|
2893
|
+
// The overloads differ only in whether `options` was passed; `net` always
|
|
2894
|
+
// calls through with both, and this forwards whatever it was given.
|
|
2895
|
+
return lookup(hostname, options, (err, address, family) => {
|
|
2896
|
+
if (err) {
|
|
2897
|
+
callback(err, "", 0);
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
const addresses = Array.isArray(address) ? address : [{
|
|
2901
|
+
address,
|
|
2902
|
+
family: family ?? 0
|
|
2903
|
+
}];
|
|
2904
|
+
for (const entry of addresses) {
|
|
2905
|
+
const reason = blockedAddressReason(entry.address);
|
|
2906
|
+
if (reason !== null) {
|
|
2907
|
+
// Server-side only. This is the half an operator needs to work out
|
|
2908
|
+
// why a link check refused a URL, and the half a caller must not
|
|
2909
|
+
// be given.
|
|
2910
|
+
console.warn(`[val] link check refused ${hostname}: resolves to ${describeBlocked(reason)} (${entry.address})`);
|
|
2911
|
+
callback(new Error(REFUSED_MESSAGE), "", 0);
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
// Forwarded in the shape it arrived in: `net` reads it back according
|
|
2916
|
+
// to whether it asked for `all`.
|
|
2917
|
+
if (Array.isArray(address)) {
|
|
2918
|
+
callback(null, address, family);
|
|
2919
|
+
} else {
|
|
2920
|
+
callback(null, address, family ?? 0);
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
// The callback shape above is the union of both overloads, which the
|
|
2924
|
+
// published types split apart.
|
|
2925
|
+
);
|
|
2926
|
+
};
|
|
2927
|
+
return guarded;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
/** The statuses that mean "ask again with GET" rather than "no". */
|
|
2931
|
+
function needsGetFallback(code) {
|
|
2932
|
+
/*
|
|
2933
|
+
* Only where the server said the METHOD was the problem.
|
|
2934
|
+
*
|
|
2935
|
+
* A 403 is the tempting third case - some WAFs and proxies do refuse HEAD
|
|
2936
|
+
* with one, and this repo's own sandbox is an example - but retrying every
|
|
2937
|
+
* 403 with a GET means downloading a page for each of them, on a check whose
|
|
2938
|
+
* whole point is to be cheap enough to run over a few hundred URLs. And it
|
|
2939
|
+
* would buy little: 403 is already reported as "may be fine for a visitor,
|
|
2940
|
+
* cannot be checked from here" rather than as a broken link, which is the
|
|
2941
|
+
* right answer either way.
|
|
2942
|
+
*/
|
|
2943
|
+
return code === 405 || code === 501;
|
|
2944
|
+
}
|
|
2945
|
+
async function probeUrl(rawUrl, options = {}) {
|
|
2946
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
|
|
2947
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
2948
|
+
const now = options.now ?? (() => Date.now());
|
|
2949
|
+
const started = now();
|
|
2950
|
+
const deadline = started + timeoutMs;
|
|
2951
|
+
let url;
|
|
2952
|
+
try {
|
|
2953
|
+
url = new URL(rawUrl.trim());
|
|
2954
|
+
} catch {
|
|
2955
|
+
return {
|
|
2956
|
+
kind: "skipped",
|
|
2957
|
+
message: "Not opened: this is not a URL."
|
|
2958
|
+
};
|
|
2959
|
+
}
|
|
2960
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2961
|
+
return {
|
|
2962
|
+
kind: "skipped",
|
|
2963
|
+
message: "Not opened: only http:// and https:// can be checked."
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
let method = "HEAD";
|
|
2967
|
+
let redirects = 0;
|
|
2968
|
+
for (;;) {
|
|
2969
|
+
const remaining = deadline - now();
|
|
2970
|
+
if (remaining <= 0) {
|
|
2971
|
+
return {
|
|
2972
|
+
kind: "timeout",
|
|
2973
|
+
ms: timeoutMs
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
/*
|
|
2977
|
+
* A hostname that is ALREADY an address never reaches the lookup.
|
|
2978
|
+
*
|
|
2979
|
+
* `net.connect` checks for an IP literal first and connects straight to
|
|
2980
|
+
* it, so `guardedLookup` is simply not called for
|
|
2981
|
+
* `http://169.254.169.254/` — which is the single most valuable URL an
|
|
2982
|
+
* attacker could ask this server to open. Checked here, per hop, because a
|
|
2983
|
+
* redirect can introduce one at any point in the chain.
|
|
2984
|
+
*
|
|
2985
|
+
* This was a real hole, found by a test that asserted the local server
|
|
2986
|
+
* received nothing and watched it receive something.
|
|
2987
|
+
*/
|
|
2988
|
+
const literal = bareHost(url.hostname);
|
|
2989
|
+
if (net__default["default"].isIP(literal) !== 0) {
|
|
2990
|
+
const reason = blockedAddressReason(literal);
|
|
2991
|
+
if (reason !== null) {
|
|
2992
|
+
return {
|
|
2993
|
+
kind: "unreachable",
|
|
2994
|
+
message: `${literal} is ${describeBlocked(reason)}`
|
|
2995
|
+
};
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
let response;
|
|
2999
|
+
try {
|
|
3000
|
+
response = await requestOnce(url, method, remaining, options.request);
|
|
3001
|
+
} catch (error) {
|
|
3002
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3003
|
+
if (message === TIMED_OUT) {
|
|
3004
|
+
return {
|
|
3005
|
+
kind: "timeout",
|
|
3006
|
+
ms: timeoutMs
|
|
3007
|
+
};
|
|
3008
|
+
}
|
|
3009
|
+
return {
|
|
3010
|
+
kind: "unreachable",
|
|
3011
|
+
message
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
if (method === "HEAD" && needsGetFallback(response.code)) {
|
|
3015
|
+
method = "GET";
|
|
3016
|
+
continue;
|
|
3017
|
+
}
|
|
3018
|
+
const location = response.location;
|
|
3019
|
+
if (location !== undefined && response.code >= 300 && response.code < 400) {
|
|
3020
|
+
if (redirects >= maxRedirects) {
|
|
3021
|
+
return {
|
|
3022
|
+
kind: "unreachable",
|
|
3023
|
+
message: `too many redirects (${maxRedirects})`
|
|
3024
|
+
};
|
|
3025
|
+
}
|
|
3026
|
+
let next;
|
|
3027
|
+
try {
|
|
3028
|
+
next = new URL(location, url);
|
|
3029
|
+
} catch {
|
|
3030
|
+
return {
|
|
3031
|
+
kind: "unreachable",
|
|
3032
|
+
message: `redirected to something that is not a URL (${location})`
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
3036
|
+
// A redirect to `file:` or `gopher:` is a redirect out of what this
|
|
3037
|
+
// is allowed to open, and the address guard would never see it.
|
|
3038
|
+
return {
|
|
3039
|
+
kind: "unreachable",
|
|
3040
|
+
message: `redirected to ${next.protocol}//, which cannot be checked`
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
url = next;
|
|
3044
|
+
redirects++;
|
|
3045
|
+
// A redirect answers the method question too: a server that refused
|
|
3046
|
+
// HEAD at the old URL says nothing about the new one.
|
|
3047
|
+
method = "HEAD";
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
return {
|
|
3051
|
+
kind: "answered",
|
|
3052
|
+
code: response.code,
|
|
3053
|
+
finalUrl: url.toString(),
|
|
3054
|
+
ms: Math.max(0, Math.round(now() - started))
|
|
3055
|
+
};
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
/**
|
|
3060
|
+
* A hostname with the IPv6 brackets off.
|
|
3061
|
+
*
|
|
3062
|
+
* `new URL("http://[::1]/").hostname` is `"[::1]"`, brackets and all, and
|
|
3063
|
+
* `net.isIP` says 0 to that — so the bracketed form sailed past the literal
|
|
3064
|
+
* check and failed later with "host could not be found". Which looks like a
|
|
3065
|
+
* refusal, and is not one.
|
|
3066
|
+
*/
|
|
3067
|
+
function bareHost(hostname) {
|
|
3068
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
3069
|
+
}
|
|
3070
|
+
const TIMED_OUT = "val:link-check-timeout";
|
|
3071
|
+
function requestOnce(url, method, timeoutMs, requestImpl) {
|
|
3072
|
+
const impl = requestImpl ?? (url.protocol === "http:" ? http__default["default"].request : https__default["default"].request);
|
|
3073
|
+
return new Promise((resolve, reject) => {
|
|
3074
|
+
let settled = false;
|
|
3075
|
+
const finish = fn => {
|
|
3076
|
+
if (settled) return;
|
|
3077
|
+
settled = true;
|
|
3078
|
+
fn();
|
|
3079
|
+
};
|
|
3080
|
+
const req = impl({
|
|
3081
|
+
protocol: url.protocol,
|
|
3082
|
+
hostname: url.hostname,
|
|
3083
|
+
port: url.port === "" ? undefined : url.port,
|
|
3084
|
+
path: `${url.pathname}${url.search}`,
|
|
3085
|
+
method,
|
|
3086
|
+
headers: {
|
|
3087
|
+
// No cookies, no authorization, no forwarded headers: this request
|
|
3088
|
+
// is the server's own and carries none of its identity.
|
|
3089
|
+
"user-agent": USER_AGENT,
|
|
3090
|
+
accept: "*/*",
|
|
3091
|
+
// A redirect chain is followed by hand, one guarded hop at a time.
|
|
3092
|
+
"accept-encoding": "identity"
|
|
3093
|
+
},
|
|
3094
|
+
// Every DNS answer is checked before a socket is opened. See
|
|
3095
|
+
// `guardedLookup` — this is the line that makes the whole feature safe.
|
|
3096
|
+
lookup: guardedLookup(defaultLookup()),
|
|
3097
|
+
// Node's default agent keeps sockets alive between requests, and a
|
|
3098
|
+
// pooled socket was resolved by a PREVIOUS lookup. A fresh connection
|
|
3099
|
+
// per probe is what keeps the guard in front of every one of them.
|
|
3100
|
+
agent: false,
|
|
3101
|
+
timeout: timeoutMs
|
|
3102
|
+
}, res => {
|
|
3103
|
+
const location = res.headers.location;
|
|
3104
|
+
// The body is never read: a status and a `Location` is the whole
|
|
3105
|
+
// answer, and downloading a page to throw it away is bandwidth spent
|
|
3106
|
+
// on somebody else's server.
|
|
3107
|
+
res.destroy();
|
|
3108
|
+
finish(() => resolve({
|
|
3109
|
+
code: res.statusCode ?? 0,
|
|
3110
|
+
location: typeof location === "string" ? location : undefined
|
|
3111
|
+
}));
|
|
3112
|
+
});
|
|
3113
|
+
req.on("timeout", () => {
|
|
3114
|
+
req.destroy();
|
|
3115
|
+
finish(() => reject(new Error(TIMED_OUT)));
|
|
3116
|
+
});
|
|
3117
|
+
req.on("error", error => {
|
|
3118
|
+
finish(() => reject(new Error(readableError(error))));
|
|
3119
|
+
});
|
|
3120
|
+
req.end();
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
/** `dns.lookup`, read lazily so a test can swap `node:dns` wholesale. */
|
|
3125
|
+
function defaultLookup() {
|
|
3126
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
3127
|
+
const dns = require("node:dns");
|
|
3128
|
+
return dns.lookup;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
/** What went wrong, in words rather than in errno. */
|
|
3132
|
+
function readableError(error) {
|
|
3133
|
+
/*
|
|
3134
|
+
* The guard's own refusal, recognised by its message rather than by a code.
|
|
3135
|
+
* `net` destroys the socket with the Error the lookup gave it, and the
|
|
3136
|
+
* message survives that trip verbatim; whether a custom `code` does is an
|
|
3137
|
+
* implementation detail this does not need to bet on.
|
|
3138
|
+
*/
|
|
3139
|
+
if (error.message === REFUSED_MESSAGE) {
|
|
3140
|
+
return REFUSED_MESSAGE;
|
|
3141
|
+
}
|
|
3142
|
+
switch (error.code) {
|
|
3143
|
+
case "ENOTFOUND":
|
|
3144
|
+
case "EAI_AGAIN":
|
|
3145
|
+
return "the host could not be found";
|
|
3146
|
+
case "ECONNREFUSED":
|
|
3147
|
+
return "the connection was refused";
|
|
3148
|
+
case "ECONNRESET":
|
|
3149
|
+
return "the connection was reset";
|
|
3150
|
+
case "EHOSTUNREACH":
|
|
3151
|
+
case "ENETUNREACH":
|
|
3152
|
+
return "the host could not be reached";
|
|
3153
|
+
case "CERT_HAS_EXPIRED":
|
|
3154
|
+
return "its certificate has expired";
|
|
3155
|
+
case "ERR_TLS_CERT_ALTNAME_INVALID":
|
|
3156
|
+
return "its certificate is for a different host";
|
|
3157
|
+
case "DEPTH_ZERO_SELF_SIGNED_CERT":
|
|
3158
|
+
case "SELF_SIGNED_CERT_IN_CHAIN":
|
|
3159
|
+
return "its certificate is self-signed";
|
|
3160
|
+
default:
|
|
3161
|
+
/*
|
|
3162
|
+
* A fixed string, not `error.message`.
|
|
3163
|
+
*
|
|
3164
|
+
* Whatever Node or OpenSSL put in there is written for a server log, and
|
|
3165
|
+
* some of it names things the caller should not learn - a resolved
|
|
3166
|
+
* address, a certificate's subject, a path. The cases above cover what is
|
|
3167
|
+
* worth telling an editor; anything else is noise to them and a leak
|
|
3168
|
+
* waiting to happen.
|
|
3169
|
+
*/
|
|
3170
|
+
return "the request failed";
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
|
|
2602
3174
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2603
3175
|
const jsonOps = new patch.JSONOps();
|
|
2604
3176
|
/**
|
|
@@ -4554,6 +5126,9 @@ class ValOps {
|
|
|
4554
5126
|
// group whose id is undefined, all the way to the client.
|
|
4555
5127
|
...(saveRes.value.patchGroupId !== undefined ? {
|
|
4556
5128
|
patchGroupId: saveRes.value.patchGroupId
|
|
5129
|
+
} : {}),
|
|
5130
|
+
...(saveRes.value.headVersion !== undefined ? {
|
|
5131
|
+
headVersion: saveRes.value.headVersion
|
|
4557
5132
|
} : {})
|
|
4558
5133
|
});
|
|
4559
5134
|
}
|
|
@@ -7706,6 +8281,16 @@ const GetApplicablePatches = z.z.object({
|
|
|
7706
8281
|
commitSha: z.z.string()
|
|
7707
8282
|
}).nullable()
|
|
7708
8283
|
})),
|
|
8284
|
+
/**
|
|
8285
|
+
* The head of the chain. See `OrderedPatches.headPatchId`. Optional because
|
|
8286
|
+
* a content service that predates it sends nothing.
|
|
8287
|
+
*/
|
|
8288
|
+
headPatchId: internal.PatchId.nullable().optional(),
|
|
8289
|
+
/**
|
|
8290
|
+
* The chain version of that head. See `OrderedPatches.headVersion`.
|
|
8291
|
+
* Optional for the same reason.
|
|
8292
|
+
*/
|
|
8293
|
+
headVersion: z.z.number().optional(),
|
|
7709
8294
|
commits: z.z.array(z.z.object({
|
|
7710
8295
|
commitSha: z.z.string(),
|
|
7711
8296
|
/*
|
|
@@ -7781,15 +8366,39 @@ const SavePatchResponse = z.z.object({
|
|
|
7781
8366
|
* Optional: a content API that predates patch groups does not send one, and
|
|
7782
8367
|
* absence has to keep meaning "no groups here" rather than failing the save.
|
|
7783
8368
|
*/
|
|
7784
|
-
patchGroupId: z.z.string().optional()
|
|
8369
|
+
patchGroupId: z.z.string().optional(),
|
|
8370
|
+
/**
|
|
8371
|
+
* The chain version this write made, comparable with `headVersion` on the
|
|
8372
|
+
* listing. Optional: a content service that predates it sends nothing.
|
|
8373
|
+
*/
|
|
8374
|
+
headVersion: z.z.number().optional()
|
|
7785
8375
|
});
|
|
7786
8376
|
const DeletePatchesResponse = z.z.object({
|
|
7787
8377
|
deleted: z.z.array(PatchId),
|
|
7788
8378
|
errors: z.z.array(z.z.object({
|
|
7789
8379
|
message: z.z.string(),
|
|
7790
8380
|
patchId: PatchId
|
|
7791
|
-
})).optional()
|
|
8381
|
+
})).optional(),
|
|
8382
|
+
/**
|
|
8383
|
+
* The chain version each branch the delete changed is now at, keyed by
|
|
8384
|
+
* branch. Optional: a content service that predates it sends none.
|
|
8385
|
+
*/
|
|
8386
|
+
headVersions: z.z.record(z.z.string(), z.z.number()).optional()
|
|
7792
8387
|
});
|
|
8388
|
+
/**
|
|
8389
|
+
* This client's own branch's entry in a per-branch version map.
|
|
8390
|
+
*
|
|
8391
|
+
* A delete can span branches, so the content service answers per branch. With
|
|
8392
|
+
* no branch of its own (a build with no repository, where the service resolves
|
|
8393
|
+
* it), the only entry is ours when there is exactly one; with several, none can
|
|
8394
|
+
* be picked, and a wrong one would floor this client at another chain's count.
|
|
8395
|
+
*/
|
|
8396
|
+
function ownBranchVersion(headVersions, branch) {
|
|
8397
|
+
if (headVersions === undefined) return undefined;
|
|
8398
|
+
if (branch !== undefined) return headVersions[branch];
|
|
8399
|
+
const versions = Object.values(headVersions);
|
|
8400
|
+
return versions.length === 1 ? versions[0] : undefined;
|
|
8401
|
+
}
|
|
7793
8402
|
const SavePatchFileResponse = z.z.object({
|
|
7794
8403
|
patchId: PatchId,
|
|
7795
8404
|
filePath: ModuleFilePath
|
|
@@ -7809,7 +8418,9 @@ const CommitResponse = z.z.object({
|
|
|
7809
8418
|
*/
|
|
7810
8419
|
parent: z.z.string().optional(),
|
|
7811
8420
|
tree: z.z.string().optional(),
|
|
7812
|
-
branch: z.z.string()
|
|
8421
|
+
branch: z.z.string(),
|
|
8422
|
+
/** The chain version the publish moved the branch to; absent if it applied nothing. */
|
|
8423
|
+
headVersion: z.z.number().optional()
|
|
7813
8424
|
});
|
|
7814
8425
|
// #region history wire schemas
|
|
7815
8426
|
//
|
|
@@ -8173,7 +8784,9 @@ class ValOpsHttp extends ValOps {
|
|
|
8173
8784
|
* An allow list rather than a prefix check, because this method holds a
|
|
8174
8785
|
* credential and the browser chooses the path. `/publish/{id}` and its three
|
|
8175
8786
|
* steps are the publish conversation; `/build-target` is what a build needs
|
|
8176
|
-
* to know before it starts; `/project-source` is what it builds
|
|
8787
|
+
* to know before it starts; `/project-source` is what it builds;
|
|
8788
|
+
* `/update-target` is the same question as `/build-target` for a managed
|
|
8789
|
+
* project moving onto its template's current dependencies.
|
|
8177
8790
|
*
|
|
8178
8791
|
* `/project-source` is here rather than on a route of its own because it is
|
|
8179
8792
|
* one of the three things a publish asks for and none of them are useful
|
|
@@ -8187,7 +8800,7 @@ class ValOpsHttp extends ValOps {
|
|
|
8187
8800
|
* segment gets through.
|
|
8188
8801
|
*/
|
|
8189
8802
|
static publishApiPathAllowed(path) {
|
|
8190
|
-
if (path === "/build-target" || path === "/project-source" || path === "/publish") {
|
|
8803
|
+
if (path === "/build-target" || path === "/project-source" || path === "/update-target" || path === "/publish") {
|
|
8191
8804
|
return true;
|
|
8192
8805
|
}
|
|
8193
8806
|
return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
|
|
@@ -8431,6 +9044,16 @@ class ValOpsHttp extends ValOps {
|
|
|
8431
9044
|
deployments: allPatchData.deployments || [],
|
|
8432
9045
|
patches,
|
|
8433
9046
|
appliedPatches,
|
|
9047
|
+
/*
|
|
9048
|
+
* The CHAIN head, not the publish head below. Spread: absent is a content
|
|
9049
|
+
* service that does not report it, which is not the same as `null`.
|
|
9050
|
+
*/
|
|
9051
|
+
...(allPatchData.headPatchId !== undefined ? {
|
|
9052
|
+
headPatchId: allPatchData.headPatchId
|
|
9053
|
+
} : {}),
|
|
9054
|
+
...(allPatchData.headVersion !== undefined ? {
|
|
9055
|
+
headVersion: allPatchData.headVersion
|
|
9056
|
+
} : {}),
|
|
8434
9057
|
/*
|
|
8435
9058
|
* The PUBLISH head, which is not `commitSha`.
|
|
8436
9059
|
*
|
|
@@ -8555,6 +9178,10 @@ class ValOpsHttp extends ValOps {
|
|
|
8555
9178
|
* the same treatment and a home on `OrderedPatches` first.
|
|
8556
9179
|
*/
|
|
8557
9180
|
let commits;
|
|
9181
|
+
// A fact about the branch too, so taken from the first chunk that has it
|
|
9182
|
+
// for the same reason `commits` is.
|
|
9183
|
+
let headPatchId;
|
|
9184
|
+
let headVersion;
|
|
8558
9185
|
if (patchIds === undefined || patchIds.length === 0) {
|
|
8559
9186
|
return this.fetchPatchesInternal({
|
|
8560
9187
|
patchIds: patchIds,
|
|
@@ -8575,6 +9202,16 @@ class ValOpsHttp extends ValOps {
|
|
|
8575
9202
|
if (commits === undefined && res.commits !== undefined) {
|
|
8576
9203
|
commits = res.commits;
|
|
8577
9204
|
}
|
|
9205
|
+
/*
|
|
9206
|
+
* The NEWEST head among the chunks: each is its own request, so a later
|
|
9207
|
+
* one can have seen a write an earlier one did not. Taken with its own
|
|
9208
|
+
* version, so the two always describe one read. Without versions (an
|
|
9209
|
+
* older content service) the first head stands, as before.
|
|
9210
|
+
*/
|
|
9211
|
+
if (res.headPatchId !== undefined && (headPatchId === undefined || res.headVersion !== undefined && (headVersion === undefined || res.headVersion > headVersion))) {
|
|
9212
|
+
headPatchId = res.headPatchId;
|
|
9213
|
+
headVersion = res.headVersion;
|
|
9214
|
+
}
|
|
8578
9215
|
}
|
|
8579
9216
|
// Chunking is a query-string-length workaround, NOT a filter: the content
|
|
8580
9217
|
// api returns every applicable patch per request regardless of which
|
|
@@ -8607,6 +9244,12 @@ class ValOpsHttp extends ValOps {
|
|
|
8607
9244
|
// readers do — sees the same shape the unchunked path gives it.
|
|
8608
9245
|
...(commits !== undefined ? {
|
|
8609
9246
|
commits
|
|
9247
|
+
} : {}),
|
|
9248
|
+
...(headPatchId !== undefined ? {
|
|
9249
|
+
headPatchId
|
|
9250
|
+
} : {}),
|
|
9251
|
+
...(headVersion !== undefined ? {
|
|
9252
|
+
headVersion
|
|
8610
9253
|
} : {})
|
|
8611
9254
|
};
|
|
8612
9255
|
}
|
|
@@ -8702,7 +9345,13 @@ class ValOpsHttp extends ValOps {
|
|
|
8702
9345
|
commits,
|
|
8703
9346
|
deployments,
|
|
8704
9347
|
patches,
|
|
8705
|
-
errors
|
|
9348
|
+
errors,
|
|
9349
|
+
...(data.headPatchId !== undefined ? {
|
|
9350
|
+
headPatchId: data.headPatchId
|
|
9351
|
+
} : {}),
|
|
9352
|
+
...(data.headVersion !== undefined ? {
|
|
9353
|
+
headVersion: data.headVersion
|
|
9354
|
+
} : {})
|
|
8706
9355
|
};
|
|
8707
9356
|
}
|
|
8708
9357
|
console.error("Could not parse patches response. Error: " + zodValidationError.fromError(parsed.error));
|
|
@@ -9072,6 +9721,9 @@ class ValOpsHttp extends ValOps {
|
|
|
9072
9721
|
*/
|
|
9073
9722
|
...(parsed.data.patchGroupId !== undefined ? {
|
|
9074
9723
|
patchGroupId: parsed.data.patchGroupId
|
|
9724
|
+
} : {}),
|
|
9725
|
+
...(parsed.data.headVersion !== undefined ? {
|
|
9726
|
+
headVersion: parsed.data.headVersion
|
|
9075
9727
|
} : {})
|
|
9076
9728
|
});
|
|
9077
9729
|
}
|
|
@@ -9395,18 +10047,25 @@ class ValOpsHttp extends ValOps {
|
|
|
9395
10047
|
if (res.ok) {
|
|
9396
10048
|
const parsed = DeletePatchesResponse.safeParse(await res.json());
|
|
9397
10049
|
if (parsed.success) {
|
|
10050
|
+
var _this$git;
|
|
9398
10051
|
const errors = {};
|
|
9399
10052
|
for (const err of parsed.data.errors || []) {
|
|
9400
10053
|
errors[err.patchId] = err;
|
|
9401
10054
|
}
|
|
10055
|
+
const headVersion = ownBranchVersion(parsed.data.headVersions, (_this$git = this.git) === null || _this$git === void 0 ? void 0 : _this$git.branch);
|
|
10056
|
+
const version = headVersion !== undefined ? {
|
|
10057
|
+
headVersion
|
|
10058
|
+
} : {};
|
|
9402
10059
|
if (Object.keys(errors).length === 0) {
|
|
9403
10060
|
return {
|
|
9404
|
-
deleted: parsed.data.deleted
|
|
10061
|
+
deleted: parsed.data.deleted,
|
|
10062
|
+
...version
|
|
9405
10063
|
};
|
|
9406
10064
|
}
|
|
9407
10065
|
return {
|
|
9408
10066
|
deleted: parsed.data.deleted,
|
|
9409
|
-
errors
|
|
10067
|
+
errors,
|
|
10068
|
+
...version
|
|
9410
10069
|
};
|
|
9411
10070
|
}
|
|
9412
10071
|
return {
|
|
@@ -9536,6 +10195,9 @@ class ValOpsHttp extends ValOps {
|
|
|
9536
10195
|
} : {}),
|
|
9537
10196
|
...(parsed.data.tree !== undefined ? {
|
|
9538
10197
|
tree: parsed.data.tree
|
|
10198
|
+
} : {}),
|
|
10199
|
+
...(parsed.data.headVersion !== undefined ? {
|
|
10200
|
+
headVersion: parsed.data.headVersion
|
|
9539
10201
|
} : {})
|
|
9540
10202
|
};
|
|
9541
10203
|
}
|
|
@@ -12305,7 +12967,10 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
12305
12967
|
...(sourceMode ? {
|
|
12306
12968
|
sourceMode
|
|
12307
12969
|
} : {}),
|
|
12308
|
-
|
|
12970
|
+
// Not `options.config` verbatim: in proxy mode the branch the
|
|
12971
|
+
// server resolved is filled in where the file did not name one.
|
|
12972
|
+
// See `clientConfig`.
|
|
12973
|
+
config: clientConfig(options)
|
|
12309
12974
|
}
|
|
12310
12975
|
};
|
|
12311
12976
|
}
|
|
@@ -12398,6 +13063,46 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
12398
13063
|
};
|
|
12399
13064
|
}
|
|
12400
13065
|
},
|
|
13066
|
+
"/external-urls/check": {
|
|
13067
|
+
POST: async req => {
|
|
13068
|
+
/*
|
|
13069
|
+
* The same door as every other route, which in fs mode is an open one.
|
|
13070
|
+
*
|
|
13071
|
+
* `getAuth` returns anonymous success when there is no `valSecret` and
|
|
13072
|
+
* nothing requires auth — a developer's own machine, where there is no
|
|
13073
|
+
* credential to require and 29 other routes already behave this way.
|
|
13074
|
+
* So this is not a session gate in fs mode, and it is not meant to be
|
|
13075
|
+
* the thing that makes the endpoint safe: what does that is
|
|
13076
|
+
* `addressGuard`, which refuses every address that is not on the
|
|
13077
|
+
* public internet, on the RESOLVED address, at every redirect hop.
|
|
13078
|
+
*/
|
|
13079
|
+
const auth = getAuth(req.cookies);
|
|
13080
|
+
if (auth.error) {
|
|
13081
|
+
return {
|
|
13082
|
+
status: 401,
|
|
13083
|
+
json: {
|
|
13084
|
+
message: auth.error
|
|
13085
|
+
}
|
|
13086
|
+
};
|
|
13087
|
+
}
|
|
13088
|
+
/*
|
|
13089
|
+
* Every URL in the batch at once, and nothing beyond it.
|
|
13090
|
+
*
|
|
13091
|
+
* The concurrency here is the batch size the route caps at, which is
|
|
13092
|
+
* what keeps "check a thousand links" from becoming a thousand
|
|
13093
|
+
* simultaneous outbound sockets: the client sends ten, waits, sends
|
|
13094
|
+
* the next ten. Doing them in parallel WITHIN a batch is what makes a
|
|
13095
|
+
* batch worth having — ten five-second timeouts in series is a minute.
|
|
13096
|
+
*/
|
|
13097
|
+
const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
|
|
13098
|
+
return {
|
|
13099
|
+
status: 200,
|
|
13100
|
+
json: {
|
|
13101
|
+
results: Object.fromEntries(results)
|
|
13102
|
+
}
|
|
13103
|
+
};
|
|
13104
|
+
}
|
|
13105
|
+
},
|
|
12401
13106
|
"/direct-file-upload-settings": {
|
|
12402
13107
|
POST: async req => {
|
|
12403
13108
|
const cookies = req.cookies;
|
|
@@ -12705,6 +13410,13 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
12705
13410
|
* re-read when a fetch has missing ids to ask for.
|
|
12706
13411
|
*/
|
|
12707
13412
|
let patchGroupIdFromStore;
|
|
13413
|
+
/*
|
|
13414
|
+
* The chain version the LAST patch made — the head this batch leaves.
|
|
13415
|
+
* The client keeps the highest version it has seen, so a `/stat` read
|
|
13416
|
+
* before this write cannot put its parent back behind it. Absent in
|
|
13417
|
+
* `fs` mode and from an older content API.
|
|
13418
|
+
*/
|
|
13419
|
+
let headVersion;
|
|
12708
13420
|
for (const patch of patches) {
|
|
12709
13421
|
const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
|
|
12710
13422
|
if (fp.result.isErr(createPatchRes)) {
|
|
@@ -12741,6 +13453,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
12741
13453
|
if (createPatchRes.value.patchGroupId !== undefined) {
|
|
12742
13454
|
patchGroupIdFromStore = createPatchRes.value.patchGroupId;
|
|
12743
13455
|
}
|
|
13456
|
+
headVersion = createPatchRes.value.headVersion;
|
|
12744
13457
|
}
|
|
12745
13458
|
}
|
|
12746
13459
|
return {
|
|
@@ -12753,6 +13466,9 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
12753
13466
|
// client reads absence as "staging is not available here".
|
|
12754
13467
|
...(patchGroupIdFromStore !== undefined ? {
|
|
12755
13468
|
patchGroupId: patchGroupIdFromStore
|
|
13469
|
+
} : {}),
|
|
13470
|
+
...(headVersion !== undefined ? {
|
|
13471
|
+
headVersion
|
|
12756
13472
|
} : {})
|
|
12757
13473
|
}
|
|
12758
13474
|
};
|
|
@@ -12892,7 +13608,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
12892
13608
|
};
|
|
12893
13609
|
},
|
|
12894
13610
|
DELETE: async req => {
|
|
12895
|
-
var _req$body;
|
|
13611
|
+
var _req$body, _req$body2;
|
|
12896
13612
|
const query = req.query;
|
|
12897
13613
|
const cookies = req.cookies;
|
|
12898
13614
|
const auth = getAuth(cookies);
|
|
@@ -12945,6 +13661,17 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
12945
13661
|
}
|
|
12946
13662
|
};
|
|
12947
13663
|
}
|
|
13664
|
+
if (((_req$body2 = req.body) === null || _req$body2 === void 0 ? void 0 : _req$body2.reportHeadVersion) === true) {
|
|
13665
|
+
return {
|
|
13666
|
+
status: 200,
|
|
13667
|
+
json: {
|
|
13668
|
+
deleted: ids,
|
|
13669
|
+
...("headVersion" in deleteRes && deleteRes.headVersion !== undefined ? {
|
|
13670
|
+
headVersion: deleteRes.headVersion
|
|
13671
|
+
} : {})
|
|
13672
|
+
}
|
|
13673
|
+
};
|
|
13674
|
+
}
|
|
12948
13675
|
return {
|
|
12949
13676
|
status: 200,
|
|
12950
13677
|
json: ids
|
|
@@ -13991,6 +14718,9 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
13991
14718
|
...(managedBranch !== null ? {
|
|
13992
14719
|
branch: managedBranch
|
|
13993
14720
|
} : {}),
|
|
14721
|
+
...(commitRes.headVersion !== undefined ? {
|
|
14722
|
+
headVersion: commitRes.headVersion
|
|
14723
|
+
} : {}),
|
|
13994
14724
|
...(committedBinaries !== null ? {
|
|
13995
14725
|
binaryFiles: committedBinaries.files,
|
|
13996
14726
|
...(committedBinaries.unread.length > 0 ? {
|
|
@@ -16429,7 +17159,7 @@ async function getFileBufferFromRemote(ref, fileExt, currentFileHash, projectRoo
|
|
|
16429
17159
|
async function downloadFileFromRemote(ref, filePath) {
|
|
16430
17160
|
return new Promise((resolve, reject) => {
|
|
16431
17161
|
const url = new URL(ref);
|
|
16432
|
-
const client = url.protocol === "https:" ? https__default["default"] : http__default["default"];
|
|
17162
|
+
const client = url.protocol === "https:" ? https__default$1["default"] : http__default$1["default"];
|
|
16433
17163
|
const request = client.get(url, response => {
|
|
16434
17164
|
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
16435
17165
|
// Handle redirects
|