filegrc 0.1.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/LICENSE +21 -0
- package/README.md +42 -0
- package/bin/filegrc.js +8 -0
- package/model/index.js +13 -0
- package/model/v1.json +1378 -0
- package/package.json +29 -0
- package/src/agent.js +247 -0
- package/src/audit-preparation.js +906 -0
- package/src/build.js +27 -0
- package/src/cli.js +623 -0
- package/src/evidence-packet.js +1642 -0
- package/src/favicon.js +109 -0
- package/src/files.js +533 -0
- package/src/git.js +289 -0
- package/src/id.js +19 -0
- package/src/index.js +47 -0
- package/src/markdown.js +123 -0
- package/src/model-docs.js +119 -0
- package/src/mutation.js +15 -0
- package/src/obligations.js +595 -0
- package/src/paths.js +137 -0
- package/src/recurrence.js +89 -0
- package/src/resource-markdown.js +63 -0
- package/src/search.js +27 -0
- package/src/server.js +300 -0
- package/src/state.js +84 -0
- package/src/time.js +62 -0
- package/src/validate.js +496 -0
- package/src/web.js +2328 -0
- package/src/workspace.js +90 -0
package/src/paths.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function resolveWorkspaceRoot(input = process.cwd()) {
|
|
5
|
+
let current = resolve(input);
|
|
6
|
+
if (existsSync(current) && !isDirectory(current)) current = dirname(current);
|
|
7
|
+
|
|
8
|
+
while (true) {
|
|
9
|
+
if (existsSync(join(current, "data", "workspace.json"))) return canonicalWorkspaceRoot(current);
|
|
10
|
+
if (existsSync(join(current, "workspace.json")) && current.endsWith(`${sep}data`)) {
|
|
11
|
+
return canonicalWorkspaceRoot(dirname(current));
|
|
12
|
+
}
|
|
13
|
+
const parent = dirname(current);
|
|
14
|
+
if (parent === current) break;
|
|
15
|
+
current = parent;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
throw new Error("No FileGRC workspace was found from the requested path.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isWithin(parent, candidate) {
|
|
22
|
+
const path = relative(resolve(parent), resolve(candidate));
|
|
23
|
+
return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveDataPath(root, dataRelativePath) {
|
|
27
|
+
if (typeof dataRelativePath !== "string" || !dataRelativePath) {
|
|
28
|
+
throw new Error("A non-empty data-relative path is required");
|
|
29
|
+
}
|
|
30
|
+
if (!isCanonicalDataPath(dataRelativePath)) {
|
|
31
|
+
throw new Error(`Unsafe data path: ${dataRelativePath}`);
|
|
32
|
+
}
|
|
33
|
+
const dataRoot = join(resolveWorkspaceRoot(root), "data");
|
|
34
|
+
const target = resolve(dataRoot, dataRelativePath);
|
|
35
|
+
if (!isWithin(dataRoot, target)) throw new Error(`Path leaves data/: ${dataRelativePath}`);
|
|
36
|
+
const realDataRoot = realpathSync(dataRoot);
|
|
37
|
+
const existing = nearestExistingPath(target);
|
|
38
|
+
if (!isWithin(realDataRoot, realExistingPath(existing, dataRelativePath))) {
|
|
39
|
+
throw new Error(`Path resolves outside data/: ${dataRelativePath}`);
|
|
40
|
+
}
|
|
41
|
+
assertNoSymlinkComponents(dataRoot, target, dataRelativePath);
|
|
42
|
+
return target;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isCanonicalDataPath(value) {
|
|
46
|
+
return typeof value === "string"
|
|
47
|
+
&& Boolean(value)
|
|
48
|
+
&& !isAbsolute(value)
|
|
49
|
+
&& !value.includes("\0")
|
|
50
|
+
&& !value.includes("\\")
|
|
51
|
+
&& posix.normalize(value) === value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveWorkspacePath(root, workspacePath) {
|
|
55
|
+
if (typeof workspacePath !== "string" || !workspacePath) {
|
|
56
|
+
throw new Error("A non-empty workspace path is required");
|
|
57
|
+
}
|
|
58
|
+
const workspaceRoot = resolveWorkspaceRoot(root);
|
|
59
|
+
const target = resolve(workspaceRoot, workspacePath);
|
|
60
|
+
if (!isWithin(workspaceRoot, target)) throw new Error(`Path leaves the workspace: ${workspacePath}`);
|
|
61
|
+
const existing = nearestExistingPath(target);
|
|
62
|
+
if (!isWithin(workspaceRoot, realExistingPath(existing, workspacePath))) {
|
|
63
|
+
throw new Error(`Path resolves outside the workspace: ${workspacePath}`);
|
|
64
|
+
}
|
|
65
|
+
assertNoSymlinkComponents(workspaceRoot, target, workspacePath);
|
|
66
|
+
return target;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function relativeToWorkspace(root, path) {
|
|
70
|
+
return relative(resolveWorkspaceRoot(root), path).split(sep).join("/");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isDirectory(path) {
|
|
74
|
+
try {
|
|
75
|
+
return statSync(path).isDirectory();
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function canonicalWorkspaceRoot(path) {
|
|
82
|
+
const root = realpathSync(path);
|
|
83
|
+
const dataPath = join(root, "data");
|
|
84
|
+
const dataRoot = realpathSync(dataPath);
|
|
85
|
+
if (!isWithin(root, dataRoot)) {
|
|
86
|
+
throw new Error("The data directory resolves outside the workspace.");
|
|
87
|
+
}
|
|
88
|
+
if (lstatSync(dataPath).isSymbolicLink()) {
|
|
89
|
+
throw new Error("The data directory must be a real directory, not a symbolic link.");
|
|
90
|
+
}
|
|
91
|
+
return root;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertNoSymlinkComponents(parent, target, displayPath) {
|
|
95
|
+
const path = relative(parent, target);
|
|
96
|
+
if (!path) return;
|
|
97
|
+
let current = parent;
|
|
98
|
+
for (const segment of path.split(sep)) {
|
|
99
|
+
current = join(current, segment);
|
|
100
|
+
try {
|
|
101
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
102
|
+
throw new Error(`Path contains a symbolic link: ${displayPath}`);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code === "ENOENT") return;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nearestExistingPath(path) {
|
|
112
|
+
let current = path;
|
|
113
|
+
while (!entryExists(current)) {
|
|
114
|
+
const parent = dirname(current);
|
|
115
|
+
if (parent === current) break;
|
|
116
|
+
current = parent;
|
|
117
|
+
}
|
|
118
|
+
return current;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function entryExists(path) {
|
|
122
|
+
try {
|
|
123
|
+
lstatSync(path);
|
|
124
|
+
return true;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function realExistingPath(path, displayPath) {
|
|
131
|
+
try {
|
|
132
|
+
return realpathSync(path);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error.code === "ENOENT") throw new Error(`Path contains an unavailable symlink: ${displayPath}`);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export function nextCalendarOccurrence(recurrence, asOf) {
|
|
2
|
+
const boundary = parseCalendarDate(asOf);
|
|
3
|
+
if (!validCalendarRecurrence(recurrence) || !boundary) return null;
|
|
4
|
+
if (recurrence.anchorDate >= asOf) return recurrence.anchorDate;
|
|
5
|
+
const current = calendarOccurrenceIndex(recurrence, asOf);
|
|
6
|
+
const candidate = calendarOccurrence(recurrence, Math.max(0, current));
|
|
7
|
+
return candidate >= asOf ? candidate : calendarOccurrence(recurrence, current + 1);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function validCalendarRecurrence(recurrence) {
|
|
11
|
+
return Boolean(
|
|
12
|
+
recurrence
|
|
13
|
+
&& recurrence.mode === "calendar"
|
|
14
|
+
&& Number.isSafeInteger(recurrence.interval)
|
|
15
|
+
&& recurrence.interval > 0
|
|
16
|
+
&& ["day", "week", "month", "year"].includes(recurrence.unit)
|
|
17
|
+
&& parseCalendarDate(recurrence.anchorDate)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function calendarOccurrence(recurrence, index) {
|
|
22
|
+
if (!validCalendarRecurrence(recurrence) || !Number.isInteger(index) || index < 0) return null;
|
|
23
|
+
const anchor = parseCalendarDate(recurrence.anchorDate);
|
|
24
|
+
if (recurrence.unit === "day" || recurrence.unit === "week") {
|
|
25
|
+
const step = recurrence.interval * (recurrence.unit === "week" ? 7 : 1);
|
|
26
|
+
return formatCalendarDateUtc(new Date(anchor.date.getTime() + index * step * 86_400_000));
|
|
27
|
+
}
|
|
28
|
+
const step = recurrence.interval * (recurrence.unit === "year" ? 12 : 1);
|
|
29
|
+
const monthIndex = anchor.month - 1 + index * step;
|
|
30
|
+
const year = anchor.year + Math.floor(monthIndex / 12);
|
|
31
|
+
const month = ((monthIndex % 12) + 12) % 12;
|
|
32
|
+
const lastDay = utcCalendarDate(year, month + 1, 0).getUTCDate();
|
|
33
|
+
return formatCalendarDateUtc(utcCalendarDate(year, month, Math.min(anchor.day, lastDay)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function calendarOccurrenceIndex(recurrence, date) {
|
|
37
|
+
const anchor = validCalendarRecurrence(recurrence) ? parseCalendarDate(recurrence.anchorDate) : null;
|
|
38
|
+
const boundary = parseCalendarDate(date);
|
|
39
|
+
if (!anchor || !boundary || date < recurrence.anchorDate) return -1;
|
|
40
|
+
if (recurrence.unit === "day" || recurrence.unit === "week") {
|
|
41
|
+
const step = recurrence.interval * (recurrence.unit === "week" ? 7 : 1);
|
|
42
|
+
return Math.floor((boundary.date - anchor.date) / (step * 86_400_000));
|
|
43
|
+
}
|
|
44
|
+
const step = recurrence.interval * (recurrence.unit === "year" ? 12 : 1);
|
|
45
|
+
const elapsedMonths = (boundary.year - anchor.year) * 12 + boundary.month - anchor.month;
|
|
46
|
+
let index = Math.max(0, Math.floor(elapsedMonths / step));
|
|
47
|
+
while (index > 0 && calendarOccurrence(recurrence, index) > date) index -= 1;
|
|
48
|
+
while (calendarOccurrence(recurrence, index + 1) <= date) index += 1;
|
|
49
|
+
return index;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function addCalendarDays(value, days) {
|
|
53
|
+
const parsed = parseCalendarDate(value);
|
|
54
|
+
if (!parsed || !Number.isInteger(days)) return null;
|
|
55
|
+
return formatCalendarDateUtc(new Date(parsed.date.getTime() + days * 86_400_000));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function calendarDayDifference(from, to) {
|
|
59
|
+
const start = parseCalendarDate(from);
|
|
60
|
+
const end = parseCalendarDate(to);
|
|
61
|
+
return start && end ? Math.round((end.date - start.date) / 86_400_000) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseCalendarDate(value) {
|
|
65
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || "")) return null;
|
|
66
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
67
|
+
if (year < 1) return null;
|
|
68
|
+
const date = utcCalendarDate(year, month - 1, day);
|
|
69
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
70
|
+
? { year, month, day, date }
|
|
71
|
+
: null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function utcCalendarDate(year, monthIndex, day) {
|
|
75
|
+
const date = new Date(0);
|
|
76
|
+
date.setUTCFullYear(year, monthIndex, day);
|
|
77
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
78
|
+
return date;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatCalendarDateUtc(date) {
|
|
82
|
+
const year = date.getUTCFullYear();
|
|
83
|
+
if (!Number.isInteger(year) || year < 1 || year > 9999) return null;
|
|
84
|
+
return [
|
|
85
|
+
String(year).padStart(4, "0"),
|
|
86
|
+
String(date.getUTCMonth() + 1).padStart(2, "0"),
|
|
87
|
+
String(date.getUTCDate()).padStart(2, "0")
|
|
88
|
+
].join("-");
|
|
89
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
2
|
+
import { getResourceDefinition } from "../model/index.js";
|
|
3
|
+
|
|
4
|
+
export function resourceDataPath(model, record) {
|
|
5
|
+
const definition = getResourceDefinition(model, record.type);
|
|
6
|
+
if (definition.singleton) return definition.singleton;
|
|
7
|
+
const recordPath = (definition.recordPath ?? "{id}.json").replaceAll("{id}", record.id);
|
|
8
|
+
return join(definition.collection, recordPath).replaceAll("\\", "/");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function markdownSlots(model, type) {
|
|
12
|
+
const definition = getResourceDefinition(model, type);
|
|
13
|
+
const dedicated = Object.entries(definition.markdown ?? {}).map(([name, slot]) => ({
|
|
14
|
+
name,
|
|
15
|
+
label: slot.label ?? humanize(name),
|
|
16
|
+
primary: Boolean(slot.primary),
|
|
17
|
+
required: Boolean(slot.required)
|
|
18
|
+
}));
|
|
19
|
+
if (dedicated.length) return dedicated;
|
|
20
|
+
return [{
|
|
21
|
+
name: model.recordContent.slot,
|
|
22
|
+
label: model.recordContent.label,
|
|
23
|
+
primary: true,
|
|
24
|
+
required: false
|
|
25
|
+
}];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function markdownDataPath(model, record, slotName) {
|
|
29
|
+
const slot = markdownSlots(model, record.type).find(({ name }) => name === slotName);
|
|
30
|
+
if (!slot) throw new Error(`Unknown Markdown slot "${slotName}" for ${record.type}.`);
|
|
31
|
+
const recordPath = resourceDataPath(model, record);
|
|
32
|
+
const extension = extname(recordPath);
|
|
33
|
+
const stem = basename(recordPath, extension);
|
|
34
|
+
const suffix = slot.primary ? "" : `-${kebabCase(slot.name)}`;
|
|
35
|
+
return join(dirname(recordPath), `${stem}${suffix}.md`).replaceAll("\\", "/");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function markdownEntries(model, record) {
|
|
39
|
+
return markdownSlots(model, record.type)
|
|
40
|
+
.map((slot) => ({ ...slot, path: markdownDataPath(model, record, slot.name) }))
|
|
41
|
+
.filter(({ path }) => path);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isMarkdownChoice(value) {
|
|
45
|
+
return typeof value === "string" && value.startsWith("$markdown:");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function kebabCase(value) {
|
|
49
|
+
return String(value)
|
|
50
|
+
.replace(/Path$/, "")
|
|
51
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
52
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
53
|
+
.replace(/^-|-$/g, "")
|
|
54
|
+
.toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function humanize(value) {
|
|
58
|
+
return String(value)
|
|
59
|
+
.replace(/Path$/, "")
|
|
60
|
+
.replace(/[-_]+/g, " ")
|
|
61
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
62
|
+
.replace(/^./, (letter) => letter.toUpperCase());
|
|
63
|
+
}
|
package/src/search.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function searchResources(resources, model, options = {}) {
|
|
2
|
+
const query = String(options.query ?? "").trim().toLowerCase();
|
|
3
|
+
const filters = options.filters ?? {};
|
|
4
|
+
return resources.filter((resource) => {
|
|
5
|
+
if (options.type && resource.type !== options.type) return false;
|
|
6
|
+
for (const [field, expected] of Object.entries(filters)) {
|
|
7
|
+
if (expected === undefined || expected === "") continue;
|
|
8
|
+
const value = resource[field];
|
|
9
|
+
if (Array.isArray(value) ? !value.includes(expected) : String(value ?? "") !== String(expected)) return false;
|
|
10
|
+
}
|
|
11
|
+
if (!query) return true;
|
|
12
|
+
return searchableValues(resource, model).some((value) => value.includes(query));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function searchableValues(resource, model) {
|
|
17
|
+
const definition = model.resources[resource.type];
|
|
18
|
+
if (!definition) return [resource.id, resource.type].map((value) => String(value ?? "").toLowerCase());
|
|
19
|
+
const fields = { ...model.commonFields, ...definition.fields };
|
|
20
|
+
const values = [resource.id, resource.type];
|
|
21
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
22
|
+
if (!field.search || resource[name] === undefined) continue;
|
|
23
|
+
const value = resource[name];
|
|
24
|
+
values.push(...(Array.isArray(value) ? value : [value]));
|
|
25
|
+
}
|
|
26
|
+
return values.map((value) => String(value).toLowerCase());
|
|
27
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from "node:http";
|
|
2
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import { extname, resolve } from "node:path";
|
|
4
|
+
import { getResourceDefinition } from "../model/index.js";
|
|
5
|
+
import { prepareAuditWorkspace } from "./audit-preparation.js";
|
|
6
|
+
import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
|
|
7
|
+
import { FAVICON_PNG } from "./favicon.js";
|
|
8
|
+
import { createResource, deleteResource, updateContent, updateResource } from "./files.js";
|
|
9
|
+
import { commitAndPushWorkspace, getFileHistory, pullWorkspace, pushWorkspace } from "./git.js";
|
|
10
|
+
import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.js";
|
|
11
|
+
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
12
|
+
import { createAppState } from "./state.js";
|
|
13
|
+
import { loadWorkspace } from "./workspace.js";
|
|
14
|
+
import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
|
|
15
|
+
|
|
16
|
+
export function createFileGRCServer(input = process.cwd(), options = {}) {
|
|
17
|
+
return createHttpServer(async (request, response) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!expectedHost(request, options.allowedHosts)) {
|
|
20
|
+
return json(response, 403, { error: "The request host is not allowed." });
|
|
21
|
+
}
|
|
22
|
+
const url = new URL(request.url, "http://localhost");
|
|
23
|
+
if (["POST", "PUT", "DELETE"].includes(request.method) && !sameOrigin(request)) {
|
|
24
|
+
return json(response, 403, { error: "Cross-origin writes are not allowed." });
|
|
25
|
+
}
|
|
26
|
+
if (request.method === "GET" && url.pathname === "/api/state") {
|
|
27
|
+
return json(response, 200, await createAppState(input));
|
|
28
|
+
}
|
|
29
|
+
if (request.method === "GET" && url.pathname === "/api/history") {
|
|
30
|
+
const path = url.searchParams.get("path");
|
|
31
|
+
if (!path || path.includes("..") || !path.startsWith("data/")) return json(response, 400, { error: "A safe data path is required." });
|
|
32
|
+
return json(response, 200, getFileHistory(input, path));
|
|
33
|
+
}
|
|
34
|
+
if (request.method === "GET" && url.pathname === "/api/obligations") {
|
|
35
|
+
const loaded = await loadWorkspace(input);
|
|
36
|
+
return json(response, 200, planObligations(loaded.resources, {
|
|
37
|
+
asOf: url.searchParams.get("asOf") || undefined,
|
|
38
|
+
from: url.searchParams.get("from") || undefined,
|
|
39
|
+
through: url.searchParams.get("through") || undefined,
|
|
40
|
+
now: url.searchParams.get("now") || undefined,
|
|
41
|
+
includeComplete: url.searchParams.get("includeComplete") === "true"
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
if (request.method === "POST" && url.pathname === "/api/obligation-events") {
|
|
45
|
+
return json(response, 201, await createObligationEvent(input, await readJson(request)));
|
|
46
|
+
}
|
|
47
|
+
if (request.method === "POST" && url.pathname === "/api/obligation-completions") {
|
|
48
|
+
const payload = await readJson(request);
|
|
49
|
+
if (!safeSegment(payload.obligationId)) return json(response, 400, { error: "A safe obligation ID is required." });
|
|
50
|
+
const result = await completeObligationOccurrence(input, {
|
|
51
|
+
obligationId: payload.obligationId,
|
|
52
|
+
record: payload.record,
|
|
53
|
+
content: payload.content,
|
|
54
|
+
expectedRevision: payload.revision
|
|
55
|
+
});
|
|
56
|
+
return json(response, 201, result);
|
|
57
|
+
}
|
|
58
|
+
if (request.method === "GET" && url.pathname === "/api/evidence-packet") {
|
|
59
|
+
return json(response, 200, await prepareEvidencePacket(input, {
|
|
60
|
+
start: url.searchParams.get("start"),
|
|
61
|
+
end: url.searchParams.get("end"),
|
|
62
|
+
auditId: url.searchParams.get("auditId") || undefined
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
if (request.method === "POST" && url.pathname === "/api/evidence-packet") {
|
|
66
|
+
const payload = await readJson(request);
|
|
67
|
+
const { packet, output: writtenOutput, files } = await generateEvidencePacket(input, payload);
|
|
68
|
+
const output = relativeToWorkspace(input, writtenOutput);
|
|
69
|
+
const outputSegments = output.split("/");
|
|
70
|
+
const packetUrl = outputSegments.length === 3
|
|
71
|
+
&& outputSegments[0] === ".filegrc"
|
|
72
|
+
&& outputSegments[1] === "evidence-packets"
|
|
73
|
+
? `/packet/${outputSegments.map(encodeURIComponent).join("/")}/index.html`
|
|
74
|
+
: null;
|
|
75
|
+
return json(response, 201, {
|
|
76
|
+
packet,
|
|
77
|
+
output,
|
|
78
|
+
packetUrl,
|
|
79
|
+
files
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (request.method === "POST" && url.pathname === "/api/audit-preparation") {
|
|
83
|
+
return json(response, 201, await prepareAuditWorkspace(input, await readJson(request)));
|
|
84
|
+
}
|
|
85
|
+
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
86
|
+
const payload = await readJson(request);
|
|
87
|
+
const record = payload.record ?? payload;
|
|
88
|
+
const result = await createResource(input, record, { content: payload.record ? payload.content : undefined });
|
|
89
|
+
return json(response, 201, { record: result.record });
|
|
90
|
+
}
|
|
91
|
+
if (request.method === "POST" && url.pathname === "/api/commit") {
|
|
92
|
+
const payload = await readJson(request);
|
|
93
|
+
return json(response, 201, await commitAndPushWorkspace(input, payload.message));
|
|
94
|
+
}
|
|
95
|
+
if (request.method === "POST" && url.pathname === "/api/git/pull") {
|
|
96
|
+
return json(response, 200, await pullWorkspace(input));
|
|
97
|
+
}
|
|
98
|
+
if (request.method === "POST" && url.pathname === "/api/git/push") {
|
|
99
|
+
return json(response, 200, await pushWorkspace(input));
|
|
100
|
+
}
|
|
101
|
+
if (request.method === "PUT" && url.pathname === "/api/content") {
|
|
102
|
+
const payload = await readJson(request);
|
|
103
|
+
const result = await updateContent(input, payload.path, payload.source, { expectedRevision: payload.revision });
|
|
104
|
+
return json(response, 200, { path: result.dataRelativePath });
|
|
105
|
+
}
|
|
106
|
+
const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
107
|
+
if (match) {
|
|
108
|
+
const type = decodeURIComponent(match[1]);
|
|
109
|
+
const id = decodeURIComponent(match[2]);
|
|
110
|
+
if (!safeSegment(type) || !safeSegment(id)) return json(response, 400, { error: "Unsafe resource identifier." });
|
|
111
|
+
if (request.method === "GET") {
|
|
112
|
+
const state = await createAppState(input);
|
|
113
|
+
const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
|
|
114
|
+
return entry ? json(response, 200, entry) : json(response, 404, { error: "Resource not found." });
|
|
115
|
+
}
|
|
116
|
+
if (request.method === "PUT") {
|
|
117
|
+
const payload = await readJson(request);
|
|
118
|
+
const record = payload.record ?? payload;
|
|
119
|
+
const result = await updateResource(input, type, id, record, {
|
|
120
|
+
content: payload.record ? payload.content : undefined,
|
|
121
|
+
expectedRevision: payload.revision,
|
|
122
|
+
expectedContentRevisions: payload.contentRevisions
|
|
123
|
+
});
|
|
124
|
+
return json(response, 200, { record: result.record });
|
|
125
|
+
}
|
|
126
|
+
if (request.method === "DELETE") {
|
|
127
|
+
const result = await deleteResource(input, type, id, { expectedRevision: url.searchParams.get("revision") });
|
|
128
|
+
return json(response, 200, { deleted: true, type, id, deletedContent: result.deletedContent });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (request.method === "GET" && url.pathname === "/favicon.png") return text(response, 200, FAVICON_PNG, "image/png");
|
|
132
|
+
if (request.method === "GET" && url.pathname === "/filegrc-app.js") return text(response, 200, APP_SCRIPT, "text/javascript; charset=utf-8");
|
|
133
|
+
if (request.method === "GET" && url.pathname === "/filegrc.css") return text(response, 200, APP_STYLES, "text/css; charset=utf-8");
|
|
134
|
+
if (request.method === "GET" && url.pathname.startsWith("/packet/")) {
|
|
135
|
+
const segments = url.pathname.slice("/packet/".length).split("/").map(decodeURIComponent);
|
|
136
|
+
if (
|
|
137
|
+
segments.some((segment) => (
|
|
138
|
+
!segment
|
|
139
|
+
|| segment === "."
|
|
140
|
+
|| segment === ".."
|
|
141
|
+
|| segment.includes("/")
|
|
142
|
+
|| segment.includes("\\")
|
|
143
|
+
|| segment.includes("\0")
|
|
144
|
+
))
|
|
145
|
+
|| segments[0] !== ".filegrc"
|
|
146
|
+
|| segments[1] !== "evidence-packets"
|
|
147
|
+
) {
|
|
148
|
+
return json(response, 400, { error: "A generated evidence-packet path is required." });
|
|
149
|
+
}
|
|
150
|
+
const relativePath = segments.join("/");
|
|
151
|
+
const path = resolveWorkspacePath(input, relativePath);
|
|
152
|
+
const packetRoot = resolveWorkspacePath(input, ".filegrc/evidence-packets");
|
|
153
|
+
if (!isWithin(packetRoot, path)) return json(response, 400, { error: "A generated evidence-packet path is required." });
|
|
154
|
+
const [realPacketRoot, realPath] = await Promise.all([realpath(packetRoot), realpath(path)]);
|
|
155
|
+
if (realPacketRoot !== resolve(packetRoot) || !isWithin(realPacketRoot, realPath)) {
|
|
156
|
+
return json(response, 400, { error: "A generated evidence-packet path is required." });
|
|
157
|
+
}
|
|
158
|
+
const isPacketIndex = segments.length === 4 && segments.at(-1) === "index.html";
|
|
159
|
+
return text(response, 200, await readFile(path), packetContentType(path, isPacketIndex));
|
|
160
|
+
}
|
|
161
|
+
if (request.method === "GET" && !url.pathname.startsWith("/api/")) return text(response, 200, renderIndex(), "text/html; charset=utf-8");
|
|
162
|
+
json(response, 404, { error: "Not found." });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const status = statusFor(error);
|
|
165
|
+
json(response, status, { error: publicErrorMessage(error, status) });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
171
|
+
const host = String(options.host ?? "127.0.0.1").trim();
|
|
172
|
+
const port = Number(options.port ?? 8787);
|
|
173
|
+
if (!host) throw new Error("The server host must be a non-empty string.");
|
|
174
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
175
|
+
throw new Error("The server port must be an integer from 0 through 65535.");
|
|
176
|
+
}
|
|
177
|
+
const loaded = await loadWorkspace(input);
|
|
178
|
+
getResourceDefinition(loaded.model, "workspace");
|
|
179
|
+
const server = createFileGRCServer(loaded.root, { allowedHosts: [host] });
|
|
180
|
+
await new Promise((resolve, reject) => {
|
|
181
|
+
server.once("error", reject);
|
|
182
|
+
server.listen(port, host, resolve);
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
server,
|
|
186
|
+
root: loaded.root,
|
|
187
|
+
address: server.address(),
|
|
188
|
+
url: `http://${urlHost(host)}:${server.address().port}`
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function readJson(request) {
|
|
193
|
+
const chunks = [];
|
|
194
|
+
let size = 0;
|
|
195
|
+
for await (const chunk of request) {
|
|
196
|
+
size += chunk.length;
|
|
197
|
+
if (size > 2_000_000) throw new Error("Request body exceeds 2 MB.");
|
|
198
|
+
chunks.push(chunk);
|
|
199
|
+
}
|
|
200
|
+
const source = Buffer.concat(chunks).toString("utf8");
|
|
201
|
+
if (!source) throw new Error("A JSON request body is required.");
|
|
202
|
+
const value = JSON.parse(source);
|
|
203
|
+
if (!value || Array.isArray(value) || typeof value !== "object") {
|
|
204
|
+
throw new Error("The JSON request body must be an object.");
|
|
205
|
+
}
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function json(response, status, value) {
|
|
210
|
+
text(response, status, `${JSON.stringify(value, null, 2)}\n`, "application/json; charset=utf-8");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function text(response, status, value, contentType) {
|
|
214
|
+
response.writeHead(status, {
|
|
215
|
+
"content-type": contentType,
|
|
216
|
+
"cache-control": "no-store",
|
|
217
|
+
"x-content-type-options": "nosniff",
|
|
218
|
+
"x-frame-options": "DENY",
|
|
219
|
+
"referrer-policy": "no-referrer",
|
|
220
|
+
"permissions-policy": "camera=(), geolocation=(), microphone=()",
|
|
221
|
+
"cross-origin-resource-policy": "same-origin",
|
|
222
|
+
"content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
|
|
223
|
+
});
|
|
224
|
+
response.end(value);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function packetContentType(path, isPacketIndex = false) {
|
|
228
|
+
if (isPacketIndex) return "text/html; charset=utf-8";
|
|
229
|
+
return {
|
|
230
|
+
".json": "application/json; charset=utf-8",
|
|
231
|
+
".md": "text/markdown; charset=utf-8",
|
|
232
|
+
".txt": "text/plain; charset=utf-8",
|
|
233
|
+
".pdf": "application/pdf",
|
|
234
|
+
".png": "image/png",
|
|
235
|
+
".jpg": "image/jpeg",
|
|
236
|
+
".jpeg": "image/jpeg",
|
|
237
|
+
".csv": "text/csv; charset=utf-8"
|
|
238
|
+
}[extname(path).toLowerCase()] || "application/octet-stream";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function safeSegment(value) {
|
|
242
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function sameOrigin(request) {
|
|
246
|
+
const origin = request.headers.origin;
|
|
247
|
+
if (!origin) return true;
|
|
248
|
+
try {
|
|
249
|
+
const expectedProtocol = request.socket.encrypted ? "https:" : "http:";
|
|
250
|
+
return new URL(origin).origin === `${expectedProtocol}//${request.headers.host}`;
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function expectedHost(request, allowedHosts = []) {
|
|
257
|
+
const host = request.headers.host;
|
|
258
|
+
const localAddress = normalizeAddress(request.socket.localAddress);
|
|
259
|
+
if (typeof host !== "string" || !host || !localAddress) return false;
|
|
260
|
+
try {
|
|
261
|
+
const requested = normalizeAddress(new URL(`http://${host}`).hostname);
|
|
262
|
+
const allowed = new Set(allowedHosts.map(normalizeAddress));
|
|
263
|
+
return allowed.has(requested)
|
|
264
|
+
|| requested === localAddress
|
|
265
|
+
|| (isLoopback(localAddress) && (requested === "localhost" || isLoopback(requested)));
|
|
266
|
+
} catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function normalizeAddress(value) {
|
|
272
|
+
return String(value ?? "").replace(/^\[|\]$/g, "").replace(/^::ffff:/, "").toLowerCase();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isLoopback(value) {
|
|
276
|
+
return value === "::1" || /^127(?:\.\d{1,3}){3}$/.test(value);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function urlHost(host) {
|
|
280
|
+
const normalized = normalizeAddress(host);
|
|
281
|
+
const display = normalized === "0.0.0.0" ? "127.0.0.1" : normalized === "::" ? "::1" : host;
|
|
282
|
+
return display.includes(":") && !display.startsWith("[") ? `[${display}]` : display;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function statusFor(error) {
|
|
286
|
+
if (error instanceof SyntaxError || error instanceof URIError) return 400;
|
|
287
|
+
if (/exceeds 2 MB/i.test(error.message)) return 413;
|
|
288
|
+
if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
|
|
289
|
+
if (/already exists|target file already exists/i.test(error.message)) return 409;
|
|
290
|
+
if (/Git could not (?:pull|push)|upstream branch|multiple remotes|no Git remote|check out a branch|before trying to (?:pull|push)/i.test(error.message)) return 409;
|
|
291
|
+
if (/not found|ENOENT/i.test(error.message)) return 404;
|
|
292
|
+
if (/invalid|required|unsafe|match|workspace|singleton|commit message|no changes|git history|git user|unknown resource type|must use|must be|content path|data path|path leaves|valid .*date|not found|no active obligations|end date|through date|already exists|EEXIST/i.test(error.message)) return 400;
|
|
293
|
+
return 500;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function publicErrorMessage(error, status) {
|
|
297
|
+
if (error?.code === "ENOENT") return "The requested file was not found.";
|
|
298
|
+
if (status === 500) return "The server could not complete the request.";
|
|
299
|
+
return error.message;
|
|
300
|
+
}
|