profileShare

rasmusjy / profileshare

Read-only snapshot

No repository description.

main default branch 54 files Expires Sep 13, 2026, 9:06 AM
app.js 31,904 bytes
1 import express from "express";
2 import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3 import { fileURLToPath } from "node:url";
4 import { dirname, join, posix } from "node:path";
5 import { marked } from "marked";
6 import sanitizeHtml from "sanitize-html";
7
8 const here = dirname(fileURLToPath(import.meta.url));
9
10 function randomId(bytes = 24) {
11 return randomBytes(bytes).toString("base64url");
12 }
13
14 function sameValue(left, right) {
15 const a = Buffer.from(left || "");
16 const b = Buffer.from(right || "");
17 return a.length === b.length && timingSafeEqual(a, b);
18 }
19
20 function cookieValue(header, name) {
21 const item = (header || "").split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`));
22 return item ? decodeURIComponent(item.slice(name.length + 1)) : undefined;
23 }
24
25 function foldersFor(files, currentPath) {
26 const prefix = currentPath ? `${currentPath}/` : "";
27 const folders = new Set();
28 const directFiles = [];
29 for (const file of files) {
30 if (!file.path.startsWith(prefix)) continue;
31 const rest = file.path.slice(prefix.length);
32 if (rest.includes("/")) folders.add(rest.split("/")[0]);
33 else directFiles.push(file);
34 }
35 return { folders: [...folders].sort(), files: directFiles.sort((a, b) => a.path.localeCompare(b.path)) };
36 }
37
38 function isMarkdownFile(path) {
39 return /\.(?:md|markdown|mdown|mkdn)$/i.test(path);
40 }
41
42 const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/;
43
44 /**
45 * Turns a unified patch into hunks with per-line old and new numbers.
46 *
47 * The marker character stays at the front of the line text so every line keeps
48 * the same one-character indent and the columns line up in the rendered table.
49 */
50 function parsePatch(patch) {
51 const hunks = [];
52 let current;
53 let oldNumber = 1;
54 let newNumber = 1;
55 for (const raw of String(patch || "").replace(/\n$/, "").split("\n")) {
56 const header = HUNK_HEADER.exec(raw);
57 if (header) {
58 oldNumber = Number(header[1]);
59 newNumber = Number(header[2]);
60 current = {
61 range: raw.slice(0, raw.indexOf("@@", 2) + 2),
62 heading: header[3].trim(),
63 lines: [],
64 };
65 hunks.push(current);
66 continue;
67 }
68 if (!current) {
69 // Nothing before the first hunk carries line information, and GitHub only
70 // sends it for a patch that was assembled outside the commit endpoints.
71 if (/^(?:diff |index |--- |\+\+\+ |old mode|new mode|new file|deleted file|similarity|rename |Binary )/.test(raw)) continue;
72 if (!raw) continue;
73 current = { range: "", heading: "", lines: [] };
74 hunks.push(current);
75 }
76 if (raw.startsWith("\\")) {
77 current.lines.push({ type: "note", text: raw.replace(/^\\\s*/, ""), oldNumber: null, newNumber: null });
78 } else if (raw.startsWith("+")) {
79 current.lines.push({ type: "addition", text: raw, oldNumber: null, newNumber });
80 newNumber += 1;
81 } else if (raw.startsWith("-")) {
82 current.lines.push({ type: "deletion", text: raw, oldNumber, newNumber: null });
83 oldNumber += 1;
84 } else {
85 current.lines.push({ type: "context", text: raw, oldNumber, newNumber });
86 oldNumber += 1;
87 newNumber += 1;
88 }
89 }
90 return hunks;
91 }
92
93 function diffBlocks(additions = 0, deletions = 0) {
94 const total = additions + deletions;
95 if (!total) return Array.from({ length: 5 }, () => "neutral");
96 let added = Math.round((additions / total) * 5);
97 let removed = Math.round((deletions / total) * 5);
98 if (additions && !added) added = 1;
99 if (deletions && !removed) removed = 1;
100 if (added + removed > 5) {
101 if (added >= removed) added = 5 - removed;
102 else removed = 5 - added;
103 }
104 return [
105 ...Array.from({ length: added }, () => "added"),
106 ...Array.from({ length: removed }, () => "removed"),
107 ...Array.from({ length: Math.max(0, 5 - added - removed) }, () => "neutral"),
108 ];
109 }
110
111 function repositoryReferences(repository) {
112 const branches = Array.isArray(repository.branches) && repository.branches.length
113 ? repository.branches
114 : repository.defaultBranch
115 ? [{ name: repository.defaultBranch, sha: repository.headSha, protected: false }]
116 : [];
117 return {
118 branches,
119 tags: Array.isArray(repository.tags) ? repository.tags : [],
120 };
121 }
122
123 function resolveRepositoryReference(repository, requestedName = "", requestedType = "") {
124 const references = repositoryReferences(repository);
125 if (!requestedName && !references.branches.length) {
126 return {
127 name: "No branch",
128 type: "branch",
129 sha: repository.headSha || null,
130 isDefault: true,
131 files: repository.files || [],
132 commits: repository.commits || [],
133 };
134 }
135
136 let reference;
137 let type;
138 if (!requestedName) {
139 reference = references.branches.find((item) => item.name === repository.defaultBranch)
140 || references.branches[0];
141 type = "branch";
142 } else if (requestedType === "tag") {
143 reference = references.tags.find((item) => item.name === requestedName);
144 type = "tag";
145 } else if (requestedType === "branch") {
146 reference = references.branches.find((item) => item.name === requestedName);
147 type = "branch";
148 } else {
149 reference = references.branches.find((item) => item.name === requestedName);
150 type = reference ? "branch" : "tag";
151 if (!reference) reference = references.tags.find((item) => item.name === requestedName);
152 }
153 if (!reference) return undefined;
154
155 const isDefault = type === "branch" && reference.name === repository.defaultBranch;
156 const stored = isDefault || reference.sha === repository.headSha
157 ? { files: repository.files || [], commits: repository.commits || [] }
158 : repository.refSnapshots?.find((item) => item.sha === reference.sha);
159 if (!stored) return undefined;
160 return {
161 ...reference,
162 type,
163 isDefault,
164 files: stored.files,
165 commits: stored.commits,
166 };
167 }
168
169 function repositoryUrl(shareId, repositoryId, reference, params = {}) {
170 const query = new URLSearchParams();
171 if (reference && !reference.isDefault && reference.name !== "No branch") {
172 query.set("ref", reference.name);
173 query.set("refType", reference.type);
174 }
175 for (const [name, value] of Object.entries(params)) {
176 if (value !== undefined && value !== null && value !== "") query.set(name, value);
177 }
178 const base = `/s/${encodeURIComponent(shareId)}/repositories/${encodeURIComponent(repositoryId)}`;
179 return query.size ? `${base}?${query}` : base;
180 }
181
182 function searchSnapshot(repositories, query) {
183 const needle = query.toLocaleLowerCase("en");
184 const repositoryResults = [];
185 const codeResults = [];
186 const commitResults = [];
187 const issueResults = [];
188 const pullRequestResults = [];
189 const releaseResults = [];
190
191 for (const repository of repositories) {
192 const repositoryText = [
193 repository.name,
194 repository.fullName,
195 repository.description,
196 repository.language,
197 ].filter(Boolean).join("\n").toLocaleLowerCase("en");
198 if (repositoryText.includes(needle)) repositoryResults.push(repository);
199
200 for (const file of repository.files) {
201 const pathMatches = file.path.toLocaleLowerCase("en").includes(needle);
202 const lines = typeof file.content === "string" ? file.content.split(/\r?\n/) : [];
203 const lineIndex = lines.findIndex((line) => line.toLocaleLowerCase("en").includes(needle));
204 if (!pathMatches && lineIndex === -1) continue;
205 codeResults.push({
206 repository,
207 file,
208 lineNumber: lineIndex === -1 ? 1 : lineIndex + 1,
209 snippet: lineIndex === -1 ? "" : lines[lineIndex].slice(0, 300),
210 view: isMarkdownFile(file.path) ? "source" : undefined,
211 });
212 }
213
214 for (const commit of repository.commits) {
215 const commitText = [
216 commit.sha,
217 commit.message,
218 commit.author,
219 ].filter(Boolean).join("\n").toLocaleLowerCase("en");
220 if (commitText.includes(needle)) commitResults.push({ repository, commit });
221 }
222
223 for (const issue of repository.issues || []) {
224 const issueText = [
225 issue.title,
226 issue.body,
227 issue.author?.login,
228 ...(issue.labels || []).map((label) => label.name),
229 ...(issue.comments || []).map((comment) => comment.body),
230 ].filter(Boolean).join("\n").toLocaleLowerCase("en");
231 if (issueText.includes(needle)) issueResults.push({ repository, issue });
232 }
233
234 for (const pullRequest of repository.pullRequests || []) {
235 const pullText = [
236 pullRequest.title,
237 pullRequest.body,
238 pullRequest.author?.login,
239 pullRequest.head,
240 pullRequest.base,
241 ...(pullRequest.labels || []).map((label) => label.name),
242 ...(pullRequest.conversation || []).map((comment) => comment.body),
243 ...(pullRequest.files || []).map((file) => file.filename),
244 ].filter(Boolean).join("\n").toLocaleLowerCase("en");
245 if (pullText.includes(needle)) pullRequestResults.push({ repository, pullRequest });
246 }
247
248 for (const release of repository.releases || []) {
249 const releaseText = [
250 release.name,
251 release.tagName,
252 release.body,
253 release.author?.login,
254 ...(release.assets || []).map((asset) => asset.name),
255 ].filter(Boolean).join("\n").toLocaleLowerCase("en");
256 if (releaseText.includes(needle)) releaseResults.push({ repository, release });
257 }
258 }
259
260 return {
261 repositories: repositoryResults.slice(0, 50),
262 code: codeResults.slice(0, 100),
263 commits: commitResults.slice(0, 100),
264 issues: issueResults.slice(0, 100),
265 pulls: pullRequestResults.slice(0, 100),
266 releases: releaseResults.slice(0, 100),
267 };
268 }
269
270 export function createApp({ config, store, github, now = () => new Date() }) {
271 const app = express();
272 const tokenRefreshes = new Map();
273 app.disable("x-powered-by");
274 app.set("view engine", "ejs");
275 app.set("views", join(here, "views"));
276 app.locals.formatDate = (value) => new Intl.DateTimeFormat("en", {
277 dateStyle: "medium",
278 timeStyle: "short",
279 }).format(new Date(value));
280 app.locals.encodeURIComponent = encodeURIComponent;
281 app.locals.parseDiff = parsePatch;
282 app.locals.diffBlocks = diffBlocks;
283 app.locals.renderMarkdown = (value, options = {}) => {
284 const html = marked.parse(value || "", { gfm: true, breaks: false });
285 return sanitizeHtml(html, {
286 allowedTags: [
287 "h1", "h2", "h3", "h4", "h5", "h6", "p", "a", "blockquote", "pre", "code",
288 "ul", "ol", "li", "strong", "em", "del", "hr", "br", "table", "thead",
289 "tbody", "tr", "th", "td", "details", "summary",
290 ],
291 allowedAttributes: {
292 a: ["href", "title"],
293 code: ["class"],
294 },
295 allowedSchemes: ["http", "https", "mailto"],
296 transformTags: {
297 a(tagName, attributes) {
298 const href = attributes.href || "";
299 if (!options.baseUrl || !href || href.startsWith("#") || /^[a-z][a-z\d+.-]*:/i.test(href)) {
300 return { tagName, attribs: attributes };
301 }
302 const relativePath = href.split("#")[0].split("?")[0];
303 const target = posix.normalize(posix.join(
304 posix.dirname(options.readmePath || ""),
305 relativePath,
306 ));
307 if (target.startsWith("../")) return { tagName, attribs: { ...attributes, href: "#" } };
308 const file = options.files?.find((item) => item.path === target);
309 const folder = options.files?.some((item) => item.path.startsWith(`${target}/`));
310 if (file) {
311 return {
312 tagName,
313 attribs: {
314 ...attributes,
315 href: options.repositoryUrl
316 ? options.repositoryUrl({ file: target })
317 : `${options.baseUrl}?file=${encodeURIComponent(target)}`,
318 },
319 };
320 }
321 if (folder) {
322 return {
323 tagName,
324 attribs: {
325 ...attributes,
326 href: options.repositoryUrl
327 ? options.repositoryUrl({ path: target })
328 : `${options.baseUrl}?path=${encodeURIComponent(target)}`,
329 },
330 };
331 }
332 return { tagName, attribs: { ...attributes, href: "#" } };
333 },
334 },
335 });
336 };
337 app.use(express.urlencoded({ extended: false, limit: "100kb" }));
338 // Only sent when the app is actually served over TLS. Setting HSTS from a
339 // plain-HTTP dev origin would pin localhost to https for two years, which is
340 // a genuinely annoying thing to do to your own machine.
341 const overTls = config.baseUrl.startsWith("https://");
342
343 app.use((req, res, next) => {
344 res.set({
345 "Content-Security-Policy": "default-src 'self'; img-src 'self' https: data:; style-src 'self'; script-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
346 "Referrer-Policy": "no-referrer",
347 "X-Content-Type-Options": "nosniff",
348 "X-Frame-Options": "DENY",
349 ...(overTls ? { "Strict-Transport-Security": "max-age=31536000" } : {}),
350 });
351 next();
352 });
353 app.use("/assets", express.static(join(here, "public"), {
354 maxAge: 0,
355 etag: true,
356 }));
357
358 app.use((req, res, next) => {
359 if (req.path.startsWith("/s/")) {
360 res.set("Cache-Control", "private, no-store");
361 return next();
362 }
363 let sessionId = cookieValue(req.headers.cookie, "profileshare_session");
364 let session = sessionId && store.getSession(sessionId);
365 req.sessionId = sessionId;
366 req.session = session;
367 req.owner = session?.owner_id ? store.getOwner(session.owner_id) : undefined;
368 next();
369 });
370
371 function ensureSession(req, res) {
372 if (req.session) return;
373 store.pruneSessions(now());
374 req.sessionId = randomId();
375 store.createSession(req.sessionId, now());
376 req.session = store.getSession(req.sessionId);
377 res.cookie("profileshare_session", req.sessionId, {
378 httpOnly: true,
379 sameSite: "lax",
380 secure: config.baseUrl.startsWith("https://"),
381 maxAge: 30 * 24 * 60 * 60 * 1000,
382 });
383 }
384
385 function signOAuthState(state) {
386 return createHmac("sha256", config.sessionSecret).update(state).digest("base64url");
387 }
388
389 function setOAuthState(res, state) {
390 res.cookie("profileshare_oauth", `${state}.${signOAuthState(state)}`, {
391 httpOnly: true,
392 sameSite: "lax",
393 secure: config.baseUrl.startsWith("https://"),
394 maxAge: 10 * 60 * 1000,
395 });
396 }
397
398 function verifyOAuthState(req) {
399 const value = cookieValue(req.headers.cookie, "profileshare_oauth") || "";
400 const separator = value.lastIndexOf(".");
401 if (separator < 1) return false;
402 const state = value.slice(0, separator);
403 const signature = value.slice(separator + 1);
404 const issuedAt = Number(state.slice(state.lastIndexOf(".") + 1));
405 const age = now().getTime() - issuedAt;
406 return Number.isSafeInteger(issuedAt)
407 && age >= 0
408 && age <= 10 * 60 * 1000
409 && sameValue(req.query.state, state)
410 && sameValue(signature, signOAuthState(state));
411 }
412
413 async function ownerToken(req) {
414 req.owner = store.getOwner(req.owner.id);
415 const expiresAt = req.owner.token_expires_at && new Date(req.owner.token_expires_at);
416 if (!expiresAt || expiresAt.getTime() > now().getTime() + 60_000 || !req.owner.refresh_token) {
417 return req.owner.github_token;
418 }
419 let refresh = tokenRefreshes.get(req.owner.id);
420 if (!refresh) {
421 refresh = (async () => {
422 const current = store.getOwner(req.owner.id);
423 const currentExpiry = current.token_expires_at && new Date(current.token_expires_at);
424 if (!currentExpiry || currentExpiry.getTime() > now().getTime() + 60_000) return current;
425 const credentials = await github.refreshUserToken(current.refresh_token);
426 return store.updateOwnerTokens(current.id, credentials);
427 })();
428 tokenRefreshes.set(req.owner.id, refresh);
429 }
430 try {
431 req.owner = await refresh;
432 } finally {
433 if (tokenRefreshes.get(req.owner.id) === refresh) tokenRefreshes.delete(req.owner.id);
434 }
435 return req.owner.github_token;
436 }
437
438 app.get("/", async (req, res, next) => {
439 try {
440 let repos = [];
441 let repositoryError;
442 if (req.owner?.installation_id) {
443 try {
444 repos = await github.listRepositories(await ownerToken(req), req.owner.installation_id);
445 } catch (error) {
446 repositoryError = error.publicMessage
447 || "GitHub repositories could not be loaded. Existing snapshot links can still be managed below.";
448 }
449 }
450 const currentTime = now();
451 const shares = req.owner
452 ? store.listSharesForOwner(req.owner.id).map((share) => ({
453 ...share,
454 url: `${config.baseUrl}/s/${share.id}`,
455 status: share.revoked_at
456 ? "revoked"
457 : new Date(share.expires_at) <= currentTime
458 ? "expired"
459 : "active",
460 repositories: share.summary?.repositories || [],
461 }))
462 : [];
463 res.render("dashboard", {
464 owner: req.owner,
465 repos,
466 shares,
467 appConfigured: Boolean(config.github.clientId && config.github.clientSecret && config.github.appSlug),
468 error: req.query.error || repositoryError,
469 notice: req.query.notice === "revoked"
470 ? "Snapshot access was revoked and its stored repository content was removed."
471 : undefined,
472 });
473 } catch (error) {
474 next(error);
475 }
476 });
477
478 app.get("/auth/github", (req, res) => {
479 if (!config.github.clientId) return res.redirect("/?error=GitHub+is+not+configured");
480 const state = `${randomId()}.${now().getTime()}`;
481 setOAuthState(res, state);
482 res.redirect(github.authorizationUrl(state));
483 });
484
485 app.get("/auth/github/callback", async (req, res, next) => {
486 try {
487 if (!verifyOAuthState(req)) return res.status(400).render("error", { message: "The sign-in request could not be verified." });
488 const credentials = await github.exchangeCode(req.query.code);
489 const viewer = await github.getViewer(credentials.accessToken);
490 const owner = store.upsertOwner(viewer, credentials);
491 ensureSession(req, res);
492 store.attachOwner(req.sessionId, owner.id);
493 res.clearCookie("profileshare_oauth");
494 res.redirect("/");
495 } catch (error) {
496 next(error);
497 }
498 });
499
500 app.post("/auth/logout", (req, res) => {
501 if (req.sessionId) store.deleteSession(req.sessionId);
502 res.clearCookie("profileshare_session", {
503 httpOnly: true,
504 sameSite: "lax",
505 secure: config.baseUrl.startsWith("https://"),
506 });
507 res.redirect("/");
508 });
509
510 app.get("/github/install", (req, res) => {
511 if (!req.owner) return res.redirect("/auth/github");
512 const state = randomId();
513 store.setInstallationState(req.sessionId, state);
514 res.redirect(github.installationUrl(state));
515 });
516
517 app.get("/github/installed", async (req, res, next) => {
518 try {
519 if (!req.owner) return res.redirect("/");
520 const session = store.getSession(req.sessionId);
521 if (!sameValue(req.query.state, session?.installation_state)) {
522 return res.status(400).render("error", { message: "The repository access request could not be verified." });
523 }
524 const installationId = Number(req.query.installation_id);
525 if (!Number.isSafeInteger(installationId)) return res.status(400).render("error", { message: "The repository access selection was not valid." });
526 await github.listRepositories(await ownerToken(req), installationId);
527 store.setInstallation(req.owner.id, installationId);
528 store.clearInstallationState(req.sessionId);
529 res.redirect("/");
530 } catch (error) {
531 next(error);
532 }
533 });
534
535 app.post("/shares", async (req, res, next) => {
536 try {
537 if (!req.owner?.installation_id) return res.status(401).render("error", { message: "Connect GitHub and select repository access first." });
538 const ids = new Set([].concat(req.body.repositories || []).map(Number));
539 const days = Number(req.body.days);
540 if (!ids.size) return res.status(400).render("error", { message: "Select at least one repository." });
541 if (!Number.isInteger(days) || days < 1 || days > 365) return res.status(400).render("error", { message: "Choose an expiry from 1 to 365 days." });
542 const token = await ownerToken(req);
543 const [available, viewer] = await Promise.all([
544 github.listRepositories(token, req.owner.installation_id),
545 github.getViewer(token),
546 ]);
547 req.owner = store.updateOwnerProfile(viewer);
548 const selected = available.filter((repo) => ids.has(repo.id));
549 if (selected.length !== ids.size) return res.status(400).render("error", { message: "One or more selected repositories are not available." });
550 const repositories = await github.snapshotRepositories(token, selected);
551 const createdAt = now();
552 const expiresAt = new Date(createdAt.getTime() + days * 86_400_000);
553 const id = randomId(18);
554 store.createShare({
555 id,
556 ownerId: req.owner.id,
557 createdAt: createdAt.toISOString(),
558 expiresAt: expiresAt.toISOString(),
559 snapshot: {
560 profile: {
561 login: viewer.login,
562 name: viewer.name,
563 avatarUrl: viewer.avatar_url,
564 bio: viewer.bio,
565 },
566 repositories,
567 },
568 });
569 res.status(201).render("created", {
570 url: `${config.baseUrl}/s/${id}`,
571 expiresAt: expiresAt.toISOString(),
572 repositories,
573 });
574 } catch (error) {
575 next(error);
576 }
577 });
578
579 app.post("/shares/:shareId/revoke", (req, res) => {
580 if (!req.owner) return res.status(401).render("error", { message: "Sign in to manage snapshot links." });
581 const share = store.getShare(req.params.shareId);
582 if (!share || share.owner_id !== req.owner.id) {
583 return res.status(404).render("error", { message: "That snapshot link was not found." });
584 }
585 if (share.revoked_at) return res.redirect("/#shared-links");
586 const currentTime = now();
587 if (new Date(share.expires_at) <= currentTime) {
588 if (share.snapshot) store.purgeShareSnapshot(share.id);
589 return res.redirect("/#shared-links");
590 }
591 store.revokeShare(share.id, req.owner.id, currentTime.toISOString());
592 return res.redirect("/?notice=revoked#shared-links");
593 });
594
595 function loadShare(req, res, next) {
596 const share = store.getShare(req.params.shareId);
597 if (!share) return res.status(404).render("error", { message: "This shared URL does not exist." });
598 if (share.revoked_at) {
599 return res.status(410).render("expired", { reason: "revoked" });
600 }
601 if (new Date(share.expires_at) <= now()) {
602 if (share.snapshot) store.purgeShareSnapshot(share.id);
603 return res.status(410).render("expired", { reason: "expired" });
604 }
605 req.share = share;
606 next();
607 }
608
609 app.get("/s/:shareId", loadShare, (req, res) => {
610 const allRepositories = req.share.snapshot.repositories;
611 const commits = allRepositories
612 .flatMap((repo) => repo.commits.map((commit) => ({
613 ...commit,
614 repository: repo.name,
615 repositoryId: repo.id,
616 })))
617 .sort((a, b) => new Date(b.date) - new Date(a.date));
618 const tab = req.query.tab === "repositories" ? "repositories" : "overview";
619 const repositoryQuery = String(req.query.q || "").trim().slice(0, 100);
620 const selectedLanguage = String(req.query.language || "").slice(0, 50);
621 const sort = ["name", "commits"].includes(req.query.sort) ? req.query.sort : "updated";
622 const languages = [...new Set(allRepositories.map((repo) => repo.language).filter(Boolean))]
623 .sort((a, b) => a.localeCompare(b));
624 const queryNeedle = repositoryQuery.toLocaleLowerCase("en");
625 const repositories = allRepositories
626 .filter((repo) => !queryNeedle || [
627 repo.name,
628 repo.description,
629 repo.language,
630 ].filter(Boolean).join("\n").toLocaleLowerCase("en").includes(queryNeedle))
631 .filter((repo) => !selectedLanguage || repo.language === selectedLanguage)
632 .sort((a, b) => {
633 if (sort === "name") return a.name.localeCompare(b.name);
634 if (sort === "commits") return b.commits.length - a.commits.length || a.name.localeCompare(b.name);
635 return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0) || a.name.localeCompare(b.name);
636 });
637 res.render("profile", {
638 share: req.share,
639 commits,
640 tab,
641 repositories,
642 repositoryQuery,
643 selectedLanguage,
644 sort,
645 languages,
646 });
647 });
648
649 app.get("/s/:shareId/search", loadShare, (req, res) => {
650 const query = String(req.query.q || "").trim().slice(0, 100);
651 const repositoryId = String(req.query.repository || "");
652 const referenceName = String(req.query.ref || "");
653 const referenceType = String(req.query.refType || "");
654 const type = ["code", "commits", "issues", "pulls", "releases", "repositories"].includes(req.query.type)
655 ? req.query.type
656 : "all";
657 let repositories = req.share.snapshot.repositories;
658 let repository;
659 let selectedReference;
660 if (referenceName && !repositoryId) {
661 return res.status(400).render("error", { message: "Choose a repository before searching a branch or tag." });
662 }
663 if (repositoryId) {
664 repository = repositories.find((item) => String(item.id) === repositoryId);
665 if (!repository) return res.status(404).render("error", { message: "This repository is not part of the snapshot." });
666 selectedReference = resolveRepositoryReference(repository, referenceName, referenceType);
667 if (!selectedReference) {
668 return res.status(404).render("error", { message: "This branch or tag is not part of the snapshot." });
669 }
670 repositories = [{
671 ...repository,
672 files: selectedReference.files,
673 commits: selectedReference.commits,
674 }];
675 }
676 const results = query
677 ? searchSnapshot(repositories, query)
678 : { repositories: [], code: [], commits: [], issues: [], pulls: [], releases: [] };
679 res.render("search", {
680 share: req.share,
681 query,
682 repository,
683 selectedReference,
684 type,
685 results,
686 total: results.repositories.length
687 + results.code.length
688 + results.commits.length
689 + results.issues.length
690 + results.pulls.length
691 + results.releases.length,
692 resultHref: (repositoryId, params) => repositoryUrl(
693 req.share.id,
694 repositoryId,
695 repository && String(repository.id) === String(repositoryId) ? selectedReference : undefined,
696 params,
697 ),
698 globalResultHref: (repositoryId, params) => repositoryUrl(
699 req.share.id,
700 repositoryId,
701 undefined,
702 params,
703 ),
704 });
705 });
706
707 app.get("/s/:shareId/repositories/:repoId", loadShare, (req, res) => {
708 const storedRepository = req.share.snapshot.repositories.find((repo) => String(repo.id) === req.params.repoId);
709 if (!storedRepository) return res.status(404).render("error", { message: "This repository is not part of the snapshot." });
710 const referenceName = String(req.query.ref || "");
711 const referenceType = String(req.query.refType || "");
712 const selectedReference = resolveRepositoryReference(storedRepository, referenceName, referenceType);
713 if (!selectedReference) {
714 return res.status(404).render("error", { message: "This branch or tag is not part of the snapshot." });
715 }
716 const repository = {
717 ...storedRepository,
718 headSha: selectedReference.sha,
719 files: selectedReference.files,
720 commits: selectedReference.commits,
721 issues: storedRepository.issues || [],
722 pullRequests: storedRepository.pullRequests || [],
723 releases: storedRepository.releases || [],
724 languages: storedRepository.languages || [],
725 };
726 const repoHref = (params = {}) => repositoryUrl(req.share.id, repository.id, selectedReference, params);
727 const globalRepoHref = (params = {}) => repositoryUrl(req.share.id, repository.id, undefined, params);
728 const refHref = (reference) => repositoryUrl(req.share.id, repository.id, {
729 ...reference,
730 isDefault: reference.type === "branch" && reference.name === storedRepository.defaultBranch,
731 });
732 const path = String(req.query.path || "").replace(/^\/+|\/+$/g, "");
733 const filePath = req.query.file ? String(req.query.file) : "";
734 const historyPath = req.query.history ? String(req.query.history) : "";
735 const commitSha = req.query.commit ? String(req.query.commit) : "";
736 const issueNumber = req.query.issue ? String(req.query.issue) : "";
737 const pullNumber = req.query.pull ? String(req.query.pull) : "";
738 const releaseId = req.query.release ? String(req.query.release) : "";
739 const requestedTab = String(req.query.tab || "");
740 const tab = issueNumber
741 ? "issues"
742 : pullNumber
743 ? "pulls"
744 : releaseId
745 ? "releases"
746 : ["commits", "issues", "pulls", "releases"].includes(requestedTab)
747 ? requestedTab
748 : "code";
749 const file = filePath ? repository.files.find((item) => item.path === filePath) : undefined;
750 const markdownFile = Boolean(file && isMarkdownFile(file.path));
751 const fileView = markdownFile && req.query.view !== "source"
752 ? "preview"
753 : "source";
754 const historyFile = historyPath ? repository.files.find((item) => item.path === historyPath) : undefined;
755 const commit = commitSha ? repository.commits.find((item) => item.sha === commitSha) : undefined;
756 const issue = issueNumber
757 ? repository.issues.find((item) => String(item.number) === issueNumber)
758 : undefined;
759 const pullRequest = pullNumber
760 ? repository.pullRequests.find((item) => String(item.number) === pullNumber)
761 : undefined;
762 const release = releaseId
763 ? repository.releases.find((item) => String(item.id) === releaseId)
764 : undefined;
765 const stateFilter = ["open", "closed"].includes(req.query.state) ? req.query.state : "all";
766 const visibleIssues = repository.issues.filter((item) => stateFilter === "all" || item.state === stateFilter);
767 const visiblePullRequests = repository.pullRequests.filter(
768 (item) => stateFilter === "all" || item.state === stateFilter,
769 );
770 const fileHistory = historyFile
771 ? repository.commits
772 .map((item) => ({
773 ...item,
774 fileChange: item.files.find((changed) => changed.filename === historyFile.path),
775 }))
776 .filter((item) => item.fileChange)
777 : [];
778 const readme = !path && !file && !historyFile && !commit
779 ? repository.files.find((item) => /^readme(?:\.[^/]+)?$/i.test(item.path) && item.content)
780 : undefined;
781 if (
782 (filePath && !file)
783 || (historyPath && !historyFile)
784 || (commitSha && !commit)
785 || (issueNumber && !issue)
786 || (pullNumber && !pullRequest)
787 || (releaseId && !release)
788 ) {
789 return res.status(404).render("error", { message: "That item is not part of the snapshot." });
790 }
791 res.render("repository", {
792 share: req.share,
793 repository,
794 path,
795 browser: foldersFor(repository.files, path),
796 file,
797 markdownFile,
798 fileView,
799 historyFile,
800 fileHistory,
801 commit,
802 issue,
803 pullRequest,
804 release,
805 stateFilter,
806 visibleIssues,
807 visiblePullRequests,
808 readme,
809 tab,
810 selectedReference,
811 references: repositoryReferences(storedRepository),
812 repoHref,
813 globalRepoHref,
814 refHref,
815 });
816 });
817
818 app.use((error, req, res, next) => {
819 console.error(error);
820 if (res.headersSent) return next(error);
821 res.status(500).render("error", { message: error.publicMessage || "The request could not be completed." });
822 });
823
824 return app;
825 }
826