@scalemule/news-publication 0.0.34 → 0.0.36
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 +42 -0
- package/dist/chunk-IMYBSG7J.js +23 -0
- package/dist/community-maps-DKcYhQYN.d.cts +10 -0
- package/dist/community-maps-DKcYhQYN.d.ts +10 -0
- package/dist/community-maps-server.cjs +250 -0
- package/dist/community-maps-server.d.cts +16 -0
- package/dist/community-maps-server.d.ts +16 -0
- package/dist/community-maps-server.js +248 -0
- package/dist/community-maps-view.cjs +155 -0
- package/dist/community-maps-view.d.cts +22 -0
- package/dist/community-maps-view.d.ts +22 -0
- package/dist/community-maps-view.js +132 -0
- package/dist/index.cjs +43 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25 -5
- package/dist/story-review.cjs +145 -13
- package/dist/story-review.d.cts +5 -1
- package/dist/story-review.d.ts +5 -1
- package/dist/story-review.js +145 -13
- package/package.json +21 -5
- package/story-review.css +5 -0
package/README.md
CHANGED
|
@@ -111,3 +111,45 @@ The review endpoint returns the decision for the exact invited revision. Editors
|
|
|
111
111
|
### Publication framing for private previews (0.0.28)
|
|
112
112
|
|
|
113
113
|
Pass `publicationHeader` and `publicationFooter` React nodes to `StoryReview` to reuse a publication's real navigation, masthead, and footer. The private banner remains above the publication header and the review controls remain separate from the story. The standalone masthead remains the default. The host must suppress analytics on private review routes; public article indexing and sharing features are not mounted by these slots. This patch changes presentation only; review permissions and publication gates are unchanged.
|
|
114
|
+
|
|
115
|
+
### Private review sign-in and email options
|
|
116
|
+
|
|
117
|
+
`StoryReview` accepts `readerAccountUrl` and a client `renderSignIn(returnTo)`
|
|
118
|
+
function so the host publication can reuse its existing account and sign-in
|
|
119
|
+
components. Hosts must implement the `/api/news/review/:session/account`
|
|
120
|
+
server adapter to connect the authenticated, verified reader through Blog’s
|
|
121
|
+
`join-account` endpoint. Never trust a browser-supplied email or verification flag.
|
|
122
|
+
The private return path belongs in the host’s signed, HttpOnly OAuth flow cookie,
|
|
123
|
+
not the provider’s authorization URL. Contributor access does not imply subject
|
|
124
|
+
approval authority.
|
|
125
|
+
|
|
126
|
+
Optional questions can be browsed and copied before identity verification.
|
|
127
|
+
`feedbackEmail` enables mail-app links containing the story title, exact revision
|
|
128
|
+
reference and optional answers, never the invitation credential. Configure a
|
|
129
|
+
monitored mailbox; received email must be recorded by the newsroom. Online
|
|
130
|
+
answers retain their existing autosave and explicit-send behavior.
|
|
131
|
+
|
|
132
|
+
## Community Maps
|
|
133
|
+
|
|
134
|
+
The `community-maps-server` subpath exports `createPublicationCommunityMapsHandler`.
|
|
135
|
+
Supply a resolver that verifies the publication host using your existing site lookup,
|
|
136
|
+
then returns its server API key, gateway origin, canonical public origin, and trusted
|
|
137
|
+
reader context. Forward only the public allowlisted catch-all route. The handler
|
|
138
|
+
checks POST origins, bounds uploads, signs an HttpOnly reader cookie, and applies
|
|
139
|
+
no-store responses. It never proxies editorial routes or accepts a caller-selected
|
|
140
|
+
application ID. Use a Node server runtime; this module imports node:crypto.
|
|
141
|
+
|
|
142
|
+
The `community-maps-view` subpath exports `PublicationCommunityMapPage` and
|
|
143
|
+
`CommunityMapArticleEmbeds`. Supply the publication chrome and import the React map CSS.
|
|
144
|
+
The optional React peer is required only when consuming these views. Use the map
|
|
145
|
+
worker asset CLI from `@scalemule/react` before building the site.
|
|
146
|
+
|
|
147
|
+
Article integration uses the existing Blog metadata extension:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{"community_maps":[{"collection_slug":"halloween-2026","mode":"map"}]}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Other modes are `cards` and `features`; optional `area_id` / `feature_ids` select live
|
|
154
|
+
records. No copied feature snapshot is embedded in a story, so withdrawal takes effect
|
|
155
|
+
on the next read. Publishers should apply their caption policy to public media copy.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/community-maps.ts
|
|
2
|
+
function communityMapEmbeds(metadata) {
|
|
3
|
+
if (!metadata || typeof metadata !== "object") return [];
|
|
4
|
+
const value = metadata.community_maps;
|
|
5
|
+
if (!Array.isArray(value)) return [];
|
|
6
|
+
return value.slice(0, 5).flatMap((entry) => {
|
|
7
|
+
if (!entry || typeof entry !== "object" || typeof entry.collection_slug !== "string" || !/^[a-z0-9-]{1,191}$/.test(entry.collection_slug) || !["map", "cards", "features"].includes(entry.mode))
|
|
8
|
+
return [];
|
|
9
|
+
const ids = Array.isArray(entry.feature_ids) ? entry.feature_ids.filter(
|
|
10
|
+
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
|
|
11
|
+
).slice(0, 20) : void 0;
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
collection_slug: entry.collection_slug,
|
|
15
|
+
mode: entry.mode,
|
|
16
|
+
feature_ids: ids,
|
|
17
|
+
area_id: typeof entry.area_id === "string" && /^[0-9a-f-]{36}$/i.test(entry.area_id) ? entry.area_id : void 0
|
|
18
|
+
}
|
|
19
|
+
];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { communityMapEmbeds };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface CommunityMapArticleEmbed {
|
|
2
|
+
collection_slug: string;
|
|
3
|
+
feature_ids?: string[];
|
|
4
|
+
area_id?: string;
|
|
5
|
+
mode: "map" | "cards" | "features";
|
|
6
|
+
}
|
|
7
|
+
/** Article metadata extension: references only; records remain in Community Maps. */
|
|
8
|
+
declare function communityMapEmbeds(metadata: unknown): CommunityMapArticleEmbed[];
|
|
9
|
+
|
|
10
|
+
export { type CommunityMapArticleEmbed as C, communityMapEmbeds as c };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface CommunityMapArticleEmbed {
|
|
2
|
+
collection_slug: string;
|
|
3
|
+
feature_ids?: string[];
|
|
4
|
+
area_id?: string;
|
|
5
|
+
mode: "map" | "cards" | "features";
|
|
6
|
+
}
|
|
7
|
+
/** Article metadata extension: references only; records remain in Community Maps. */
|
|
8
|
+
declare function communityMapEmbeds(metadata: unknown): CommunityMapArticleEmbed[];
|
|
9
|
+
|
|
10
|
+
export { type CommunityMapArticleEmbed as C, communityMapEmbeds as c };
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
var sdk = require('@scalemule/sdk');
|
|
5
|
+
|
|
6
|
+
// src/community-maps-server.ts
|
|
7
|
+
var ROOT = "/v1/knowledge/community-maps";
|
|
8
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
9
|
+
var COOKIE = "sm_community_reader";
|
|
10
|
+
var json = (data, status = 200) => Response.json(data, {
|
|
11
|
+
status,
|
|
12
|
+
headers: {
|
|
13
|
+
"cache-control": "private, no-store",
|
|
14
|
+
"x-content-type-options": "nosniff"
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
function identity(request, key, origin) {
|
|
18
|
+
const sign = (id2) => crypto.createHmac("sha256", key).update("community-maps-reader:" + id2).digest("base64url");
|
|
19
|
+
const raw = request.headers.get("cookie")?.split(";").map((v) => v.trim()).find((v) => v.startsWith(COOKIE + "="))?.slice(COOKIE.length + 1);
|
|
20
|
+
if (raw) {
|
|
21
|
+
const [id2, signature] = raw.split(".");
|
|
22
|
+
if (id2 && signature && UUID.test(id2) && /^[A-Za-z0-9_-]{43}$/.test(signature)) {
|
|
23
|
+
const expected = sign(id2);
|
|
24
|
+
if (expected.length === signature.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)))
|
|
25
|
+
return { id: id2, cookie: null };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const id = crypto.randomUUID();
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
cookie: `${COOKIE}=${id}.${sign(id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${new URL(origin).protocol === "https:" ? "; Secure" : ""}`
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function boundedBody(request, max) {
|
|
35
|
+
if (Number(request.headers.get("content-length") || 0) > max)
|
|
36
|
+
throw new ProxyProblem(413, "The submission is too large.");
|
|
37
|
+
const reader = request.body?.getReader();
|
|
38
|
+
if (!reader) return new Uint8Array();
|
|
39
|
+
const chunks = [];
|
|
40
|
+
let length = 0;
|
|
41
|
+
try {
|
|
42
|
+
while (true) {
|
|
43
|
+
const { value, done } = await reader.read();
|
|
44
|
+
if (done) break;
|
|
45
|
+
length += value.length;
|
|
46
|
+
if (length > max) {
|
|
47
|
+
await reader.cancel();
|
|
48
|
+
throw new ProxyProblem(413, "The submission is too large.");
|
|
49
|
+
}
|
|
50
|
+
chunks.push(value);
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
reader.releaseLock();
|
|
54
|
+
}
|
|
55
|
+
return Buffer.concat(chunks);
|
|
56
|
+
}
|
|
57
|
+
var ProxyProblem = class extends Error {
|
|
58
|
+
constructor(status, message) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.status = status;
|
|
61
|
+
}
|
|
62
|
+
status;
|
|
63
|
+
};
|
|
64
|
+
function createPublicationCommunityMapsHandler(options) {
|
|
65
|
+
return async (request, path) => {
|
|
66
|
+
try {
|
|
67
|
+
if (path[0] !== "collections" || !path[1] || !/^[a-z0-9-]{1,191}$/.test(path[1]))
|
|
68
|
+
return json({ error: { message: "Guide not found" } }, 404);
|
|
69
|
+
const slug = path[1];
|
|
70
|
+
const rest = path.slice(2);
|
|
71
|
+
const method = request.method;
|
|
72
|
+
const read = method === "GET" && (rest.length === 0 || rest[0] === "features" && (rest.length === 1 || rest.length === 2 && UUID.test(rest[1])) || rest[0] === "media" && rest.length === 2 && UUID.test(rest[1]));
|
|
73
|
+
const upload = method === "POST" && rest.length === 3 && rest[0] === "submissions" && UUID.test(rest[1]) && rest[2] === "media";
|
|
74
|
+
const submission = method === "POST" && rest.length === 1 && rest[0] === "submissions";
|
|
75
|
+
const action = method === "POST" && rest.length === 3 && rest[0] === "features" && UUID.test(rest[1]) && ["reactions", "reports"].includes(rest[2]);
|
|
76
|
+
if (!read && !upload && !submission && !action)
|
|
77
|
+
return json({ error: { message: "Route not found" } }, 404);
|
|
78
|
+
const scope = await options.resolve(request);
|
|
79
|
+
if (method !== "GET" && request.headers.get("origin") !== new URL(scope.origin).origin)
|
|
80
|
+
return json(
|
|
81
|
+
{ error: { message: "Submit from this publication\u2019s website." } },
|
|
82
|
+
403
|
|
83
|
+
);
|
|
84
|
+
const gateway = new URL(scope.gatewayUrl);
|
|
85
|
+
if (gateway.protocol !== "https:" && !(gateway.protocol === "http:" && ["127.0.0.1", "localhost"].includes(gateway.hostname)))
|
|
86
|
+
throw new ProxyProblem(503, "The guide is unavailable.");
|
|
87
|
+
if (!scope.apiKey)
|
|
88
|
+
throw new ProxyProblem(503, "The guide is unavailable.");
|
|
89
|
+
const reader = identity(request, scope.apiKey, scope.origin);
|
|
90
|
+
const clientContext = { ...scope.clientContext, anonymousId: reader.id };
|
|
91
|
+
const sdk$1 = new sdk.ScaleMule({
|
|
92
|
+
apiKey: scope.apiKey,
|
|
93
|
+
baseUrl: scope.gatewayUrl
|
|
94
|
+
});
|
|
95
|
+
const callOptions = {
|
|
96
|
+
clientContext,
|
|
97
|
+
headers: scope.accessToken ? { authorization: `Bearer ${scope.accessToken}` } : {}
|
|
98
|
+
};
|
|
99
|
+
let response;
|
|
100
|
+
if (read && rest[0] === "media") {
|
|
101
|
+
const upstream = await fetch(
|
|
102
|
+
`${gateway.origin}${ROOT}/collections/${slug}/media/${rest[1]}`,
|
|
103
|
+
{
|
|
104
|
+
headers: {
|
|
105
|
+
...sdk.buildClientContextHeaders(clientContext),
|
|
106
|
+
"x-api-key": scope.apiKey,
|
|
107
|
+
...callOptions.headers
|
|
108
|
+
},
|
|
109
|
+
cache: "no-store",
|
|
110
|
+
redirect: "error",
|
|
111
|
+
signal: AbortSignal.timeout(3e4)
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
response = upstream.ok ? new Response(upstream.body, {
|
|
115
|
+
headers: {
|
|
116
|
+
"content-type": "image/png",
|
|
117
|
+
"cache-control": "private, no-store",
|
|
118
|
+
"x-content-type-options": "nosniff"
|
|
119
|
+
}
|
|
120
|
+
}) : json(
|
|
121
|
+
{ error: { message: "Photo unavailable" } },
|
|
122
|
+
upstream.status >= 500 ? 503 : upstream.status
|
|
123
|
+
);
|
|
124
|
+
} else {
|
|
125
|
+
let result;
|
|
126
|
+
if (read) {
|
|
127
|
+
if (!rest.length)
|
|
128
|
+
result = await sdk$1.communityMaps.getCollection(slug, callOptions);
|
|
129
|
+
else if (rest.length === 2)
|
|
130
|
+
result = await sdk$1.communityMaps.getFeature(
|
|
131
|
+
slug,
|
|
132
|
+
rest[1],
|
|
133
|
+
callOptions
|
|
134
|
+
);
|
|
135
|
+
else {
|
|
136
|
+
const query = {};
|
|
137
|
+
const params = new URL(request.url).searchParams;
|
|
138
|
+
for (const key of [
|
|
139
|
+
"after",
|
|
140
|
+
"area_id",
|
|
141
|
+
"category",
|
|
142
|
+
"geometry_type",
|
|
143
|
+
"active_at",
|
|
144
|
+
"bbox"
|
|
145
|
+
]) {
|
|
146
|
+
const value = params.get(key);
|
|
147
|
+
if (value) query[key] = value;
|
|
148
|
+
}
|
|
149
|
+
const limit = params.get("limit");
|
|
150
|
+
if (limit)
|
|
151
|
+
query.limit = Math.min(200, Math.max(1, Number(limit) || 50));
|
|
152
|
+
const filters = { ...query };
|
|
153
|
+
for (const key of ["featured", "verified"]) {
|
|
154
|
+
const value = params.get(key);
|
|
155
|
+
if (value === "true" || value === "false")
|
|
156
|
+
filters[key] = value === "true";
|
|
157
|
+
}
|
|
158
|
+
result = await sdk$1.communityMaps.listFeatures(
|
|
159
|
+
slug,
|
|
160
|
+
filters,
|
|
161
|
+
callOptions
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} else if (upload) {
|
|
165
|
+
const contentType = request.headers.get("content-type") || "";
|
|
166
|
+
if (!contentType.startsWith("multipart/form-data;"))
|
|
167
|
+
throw new ProxyProblem(415, "Choose a photograph to upload.");
|
|
168
|
+
const bytes = await boundedBody(request, 9 * 1024 * 1024);
|
|
169
|
+
const form = await new Response(bytes, {
|
|
170
|
+
headers: { "content-type": contentType }
|
|
171
|
+
}).formData();
|
|
172
|
+
const file = form.get("file");
|
|
173
|
+
if (!(file instanceof Blob))
|
|
174
|
+
throw new ProxyProblem(400, "Photo is required.");
|
|
175
|
+
result = await sdk$1.communityMaps.uploadPhoto(
|
|
176
|
+
slug,
|
|
177
|
+
rest[1],
|
|
178
|
+
file,
|
|
179
|
+
{
|
|
180
|
+
caption: String(form.get("caption") || ""),
|
|
181
|
+
attribution: String(form.get("attribution") || ""),
|
|
182
|
+
rights_confirmed: form.get("rights_confirmed") === "true"
|
|
183
|
+
},
|
|
184
|
+
callOptions
|
|
185
|
+
);
|
|
186
|
+
} else {
|
|
187
|
+
if (!request.headers.get("content-type")?.startsWith("application/json"))
|
|
188
|
+
throw new ProxyProblem(415, "Use the community submission form.");
|
|
189
|
+
let body;
|
|
190
|
+
try {
|
|
191
|
+
body = JSON.parse(
|
|
192
|
+
new TextDecoder().decode(await boundedBody(request, 65536))
|
|
193
|
+
);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
if (e instanceof ProxyProblem) throw e;
|
|
196
|
+
throw new ProxyProblem(400, "Invalid submission.");
|
|
197
|
+
}
|
|
198
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
199
|
+
throw new ProxyProblem(400, "Invalid submission.");
|
|
200
|
+
if (submission)
|
|
201
|
+
result = await sdk$1.communityMaps.createSubmission(
|
|
202
|
+
slug,
|
|
203
|
+
body,
|
|
204
|
+
callOptions
|
|
205
|
+
);
|
|
206
|
+
else if (rest[2] === "reactions")
|
|
207
|
+
result = await sdk$1.communityMaps.react(
|
|
208
|
+
slug,
|
|
209
|
+
rest[1],
|
|
210
|
+
body.reaction,
|
|
211
|
+
body.active !== false,
|
|
212
|
+
callOptions
|
|
213
|
+
);
|
|
214
|
+
else
|
|
215
|
+
result = await sdk$1.communityMaps.report(
|
|
216
|
+
slug,
|
|
217
|
+
rest[1],
|
|
218
|
+
body,
|
|
219
|
+
callOptions
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (result.error) {
|
|
223
|
+
const error = result.error;
|
|
224
|
+
const status = typeof error.status === "number" ? error.status : 400;
|
|
225
|
+
response = json(
|
|
226
|
+
{
|
|
227
|
+
error: {
|
|
228
|
+
message: status >= 500 ? "The guide is temporarily unavailable. Please try again." : error.message
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
status
|
|
232
|
+
);
|
|
233
|
+
} else response = json({ data: result.data });
|
|
234
|
+
}
|
|
235
|
+
if (reader.cookie) response.headers.set("set-cookie", reader.cookie);
|
|
236
|
+
return response;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return json(
|
|
239
|
+
{
|
|
240
|
+
error: {
|
|
241
|
+
message: error instanceof ProxyProblem ? error.message : "The guide is temporarily unavailable. Please try again."
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
error instanceof ProxyProblem ? error.status : 503
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
exports.createPublicationCommunityMapsHandler = createPublicationCommunityMapsHandler;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ClientContext } from '@scalemule/sdk';
|
|
2
|
+
|
|
3
|
+
interface PublicationCommunityMapScope {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
gatewayUrl: string;
|
|
6
|
+
origin: string;
|
|
7
|
+
clientContext: ClientContext;
|
|
8
|
+
accessToken?: string;
|
|
9
|
+
}
|
|
10
|
+
interface PublicationCommunityMapServerOptions {
|
|
11
|
+
resolve: (request: Request) => Promise<PublicationCommunityMapScope>;
|
|
12
|
+
}
|
|
13
|
+
/** Only public collection routes are accepted. The URL never selects tenant credentials. */
|
|
14
|
+
declare function createPublicationCommunityMapsHandler(options: PublicationCommunityMapServerOptions): (request: Request, path: string[]) => Promise<Response>;
|
|
15
|
+
|
|
16
|
+
export { type PublicationCommunityMapScope, type PublicationCommunityMapServerOptions, createPublicationCommunityMapsHandler };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ClientContext } from '@scalemule/sdk';
|
|
2
|
+
|
|
3
|
+
interface PublicationCommunityMapScope {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
gatewayUrl: string;
|
|
6
|
+
origin: string;
|
|
7
|
+
clientContext: ClientContext;
|
|
8
|
+
accessToken?: string;
|
|
9
|
+
}
|
|
10
|
+
interface PublicationCommunityMapServerOptions {
|
|
11
|
+
resolve: (request: Request) => Promise<PublicationCommunityMapScope>;
|
|
12
|
+
}
|
|
13
|
+
/** Only public collection routes are accepted. The URL never selects tenant credentials. */
|
|
14
|
+
declare function createPublicationCommunityMapsHandler(options: PublicationCommunityMapServerOptions): (request: Request, path: string[]) => Promise<Response>;
|
|
15
|
+
|
|
16
|
+
export { type PublicationCommunityMapScope, type PublicationCommunityMapServerOptions, createPublicationCommunityMapsHandler };
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { timingSafeEqual, randomUUID, createHmac } from 'crypto';
|
|
2
|
+
import { ScaleMule, buildClientContextHeaders } from '@scalemule/sdk';
|
|
3
|
+
|
|
4
|
+
// src/community-maps-server.ts
|
|
5
|
+
var ROOT = "/v1/knowledge/community-maps";
|
|
6
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
var COOKIE = "sm_community_reader";
|
|
8
|
+
var json = (data, status = 200) => Response.json(data, {
|
|
9
|
+
status,
|
|
10
|
+
headers: {
|
|
11
|
+
"cache-control": "private, no-store",
|
|
12
|
+
"x-content-type-options": "nosniff"
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
function identity(request, key, origin) {
|
|
16
|
+
const sign = (id2) => createHmac("sha256", key).update("community-maps-reader:" + id2).digest("base64url");
|
|
17
|
+
const raw = request.headers.get("cookie")?.split(";").map((v) => v.trim()).find((v) => v.startsWith(COOKIE + "="))?.slice(COOKIE.length + 1);
|
|
18
|
+
if (raw) {
|
|
19
|
+
const [id2, signature] = raw.split(".");
|
|
20
|
+
if (id2 && signature && UUID.test(id2) && /^[A-Za-z0-9_-]{43}$/.test(signature)) {
|
|
21
|
+
const expected = sign(id2);
|
|
22
|
+
if (expected.length === signature.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected)))
|
|
23
|
+
return { id: id2, cookie: null };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const id = randomUUID();
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
cookie: `${COOKIE}=${id}.${sign(id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${new URL(origin).protocol === "https:" ? "; Secure" : ""}`
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function boundedBody(request, max) {
|
|
33
|
+
if (Number(request.headers.get("content-length") || 0) > max)
|
|
34
|
+
throw new ProxyProblem(413, "The submission is too large.");
|
|
35
|
+
const reader = request.body?.getReader();
|
|
36
|
+
if (!reader) return new Uint8Array();
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let length = 0;
|
|
39
|
+
try {
|
|
40
|
+
while (true) {
|
|
41
|
+
const { value, done } = await reader.read();
|
|
42
|
+
if (done) break;
|
|
43
|
+
length += value.length;
|
|
44
|
+
if (length > max) {
|
|
45
|
+
await reader.cancel();
|
|
46
|
+
throw new ProxyProblem(413, "The submission is too large.");
|
|
47
|
+
}
|
|
48
|
+
chunks.push(value);
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
reader.releaseLock();
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat(chunks);
|
|
54
|
+
}
|
|
55
|
+
var ProxyProblem = class extends Error {
|
|
56
|
+
constructor(status, message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.status = status;
|
|
59
|
+
}
|
|
60
|
+
status;
|
|
61
|
+
};
|
|
62
|
+
function createPublicationCommunityMapsHandler(options) {
|
|
63
|
+
return async (request, path) => {
|
|
64
|
+
try {
|
|
65
|
+
if (path[0] !== "collections" || !path[1] || !/^[a-z0-9-]{1,191}$/.test(path[1]))
|
|
66
|
+
return json({ error: { message: "Guide not found" } }, 404);
|
|
67
|
+
const slug = path[1];
|
|
68
|
+
const rest = path.slice(2);
|
|
69
|
+
const method = request.method;
|
|
70
|
+
const read = method === "GET" && (rest.length === 0 || rest[0] === "features" && (rest.length === 1 || rest.length === 2 && UUID.test(rest[1])) || rest[0] === "media" && rest.length === 2 && UUID.test(rest[1]));
|
|
71
|
+
const upload = method === "POST" && rest.length === 3 && rest[0] === "submissions" && UUID.test(rest[1]) && rest[2] === "media";
|
|
72
|
+
const submission = method === "POST" && rest.length === 1 && rest[0] === "submissions";
|
|
73
|
+
const action = method === "POST" && rest.length === 3 && rest[0] === "features" && UUID.test(rest[1]) && ["reactions", "reports"].includes(rest[2]);
|
|
74
|
+
if (!read && !upload && !submission && !action)
|
|
75
|
+
return json({ error: { message: "Route not found" } }, 404);
|
|
76
|
+
const scope = await options.resolve(request);
|
|
77
|
+
if (method !== "GET" && request.headers.get("origin") !== new URL(scope.origin).origin)
|
|
78
|
+
return json(
|
|
79
|
+
{ error: { message: "Submit from this publication\u2019s website." } },
|
|
80
|
+
403
|
|
81
|
+
);
|
|
82
|
+
const gateway = new URL(scope.gatewayUrl);
|
|
83
|
+
if (gateway.protocol !== "https:" && !(gateway.protocol === "http:" && ["127.0.0.1", "localhost"].includes(gateway.hostname)))
|
|
84
|
+
throw new ProxyProblem(503, "The guide is unavailable.");
|
|
85
|
+
if (!scope.apiKey)
|
|
86
|
+
throw new ProxyProblem(503, "The guide is unavailable.");
|
|
87
|
+
const reader = identity(request, scope.apiKey, scope.origin);
|
|
88
|
+
const clientContext = { ...scope.clientContext, anonymousId: reader.id };
|
|
89
|
+
const sdk = new ScaleMule({
|
|
90
|
+
apiKey: scope.apiKey,
|
|
91
|
+
baseUrl: scope.gatewayUrl
|
|
92
|
+
});
|
|
93
|
+
const callOptions = {
|
|
94
|
+
clientContext,
|
|
95
|
+
headers: scope.accessToken ? { authorization: `Bearer ${scope.accessToken}` } : {}
|
|
96
|
+
};
|
|
97
|
+
let response;
|
|
98
|
+
if (read && rest[0] === "media") {
|
|
99
|
+
const upstream = await fetch(
|
|
100
|
+
`${gateway.origin}${ROOT}/collections/${slug}/media/${rest[1]}`,
|
|
101
|
+
{
|
|
102
|
+
headers: {
|
|
103
|
+
...buildClientContextHeaders(clientContext),
|
|
104
|
+
"x-api-key": scope.apiKey,
|
|
105
|
+
...callOptions.headers
|
|
106
|
+
},
|
|
107
|
+
cache: "no-store",
|
|
108
|
+
redirect: "error",
|
|
109
|
+
signal: AbortSignal.timeout(3e4)
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
response = upstream.ok ? new Response(upstream.body, {
|
|
113
|
+
headers: {
|
|
114
|
+
"content-type": "image/png",
|
|
115
|
+
"cache-control": "private, no-store",
|
|
116
|
+
"x-content-type-options": "nosniff"
|
|
117
|
+
}
|
|
118
|
+
}) : json(
|
|
119
|
+
{ error: { message: "Photo unavailable" } },
|
|
120
|
+
upstream.status >= 500 ? 503 : upstream.status
|
|
121
|
+
);
|
|
122
|
+
} else {
|
|
123
|
+
let result;
|
|
124
|
+
if (read) {
|
|
125
|
+
if (!rest.length)
|
|
126
|
+
result = await sdk.communityMaps.getCollection(slug, callOptions);
|
|
127
|
+
else if (rest.length === 2)
|
|
128
|
+
result = await sdk.communityMaps.getFeature(
|
|
129
|
+
slug,
|
|
130
|
+
rest[1],
|
|
131
|
+
callOptions
|
|
132
|
+
);
|
|
133
|
+
else {
|
|
134
|
+
const query = {};
|
|
135
|
+
const params = new URL(request.url).searchParams;
|
|
136
|
+
for (const key of [
|
|
137
|
+
"after",
|
|
138
|
+
"area_id",
|
|
139
|
+
"category",
|
|
140
|
+
"geometry_type",
|
|
141
|
+
"active_at",
|
|
142
|
+
"bbox"
|
|
143
|
+
]) {
|
|
144
|
+
const value = params.get(key);
|
|
145
|
+
if (value) query[key] = value;
|
|
146
|
+
}
|
|
147
|
+
const limit = params.get("limit");
|
|
148
|
+
if (limit)
|
|
149
|
+
query.limit = Math.min(200, Math.max(1, Number(limit) || 50));
|
|
150
|
+
const filters = { ...query };
|
|
151
|
+
for (const key of ["featured", "verified"]) {
|
|
152
|
+
const value = params.get(key);
|
|
153
|
+
if (value === "true" || value === "false")
|
|
154
|
+
filters[key] = value === "true";
|
|
155
|
+
}
|
|
156
|
+
result = await sdk.communityMaps.listFeatures(
|
|
157
|
+
slug,
|
|
158
|
+
filters,
|
|
159
|
+
callOptions
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
} else if (upload) {
|
|
163
|
+
const contentType = request.headers.get("content-type") || "";
|
|
164
|
+
if (!contentType.startsWith("multipart/form-data;"))
|
|
165
|
+
throw new ProxyProblem(415, "Choose a photograph to upload.");
|
|
166
|
+
const bytes = await boundedBody(request, 9 * 1024 * 1024);
|
|
167
|
+
const form = await new Response(bytes, {
|
|
168
|
+
headers: { "content-type": contentType }
|
|
169
|
+
}).formData();
|
|
170
|
+
const file = form.get("file");
|
|
171
|
+
if (!(file instanceof Blob))
|
|
172
|
+
throw new ProxyProblem(400, "Photo is required.");
|
|
173
|
+
result = await sdk.communityMaps.uploadPhoto(
|
|
174
|
+
slug,
|
|
175
|
+
rest[1],
|
|
176
|
+
file,
|
|
177
|
+
{
|
|
178
|
+
caption: String(form.get("caption") || ""),
|
|
179
|
+
attribution: String(form.get("attribution") || ""),
|
|
180
|
+
rights_confirmed: form.get("rights_confirmed") === "true"
|
|
181
|
+
},
|
|
182
|
+
callOptions
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
if (!request.headers.get("content-type")?.startsWith("application/json"))
|
|
186
|
+
throw new ProxyProblem(415, "Use the community submission form.");
|
|
187
|
+
let body;
|
|
188
|
+
try {
|
|
189
|
+
body = JSON.parse(
|
|
190
|
+
new TextDecoder().decode(await boundedBody(request, 65536))
|
|
191
|
+
);
|
|
192
|
+
} catch (e) {
|
|
193
|
+
if (e instanceof ProxyProblem) throw e;
|
|
194
|
+
throw new ProxyProblem(400, "Invalid submission.");
|
|
195
|
+
}
|
|
196
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
197
|
+
throw new ProxyProblem(400, "Invalid submission.");
|
|
198
|
+
if (submission)
|
|
199
|
+
result = await sdk.communityMaps.createSubmission(
|
|
200
|
+
slug,
|
|
201
|
+
body,
|
|
202
|
+
callOptions
|
|
203
|
+
);
|
|
204
|
+
else if (rest[2] === "reactions")
|
|
205
|
+
result = await sdk.communityMaps.react(
|
|
206
|
+
slug,
|
|
207
|
+
rest[1],
|
|
208
|
+
body.reaction,
|
|
209
|
+
body.active !== false,
|
|
210
|
+
callOptions
|
|
211
|
+
);
|
|
212
|
+
else
|
|
213
|
+
result = await sdk.communityMaps.report(
|
|
214
|
+
slug,
|
|
215
|
+
rest[1],
|
|
216
|
+
body,
|
|
217
|
+
callOptions
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (result.error) {
|
|
221
|
+
const error = result.error;
|
|
222
|
+
const status = typeof error.status === "number" ? error.status : 400;
|
|
223
|
+
response = json(
|
|
224
|
+
{
|
|
225
|
+
error: {
|
|
226
|
+
message: status >= 500 ? "The guide is temporarily unavailable. Please try again." : error.message
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
status
|
|
230
|
+
);
|
|
231
|
+
} else response = json({ data: result.data });
|
|
232
|
+
}
|
|
233
|
+
if (reader.cookie) response.headers.set("set-cookie", reader.cookie);
|
|
234
|
+
return response;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return json(
|
|
237
|
+
{
|
|
238
|
+
error: {
|
|
239
|
+
message: error instanceof ProxyProblem ? error.message : "The guide is temporarily unavailable. Please try again."
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
error instanceof ProxyProblem ? error.status : 503
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export { createPublicationCommunityMapsHandler };
|