Compare commits

..
3 Commits
Author SHA1 Message Date
Mustafa 11ca29104a Chore: send Gitea/Forgejo API key as Authorization header (#7245)
Docker CI / Docker Build & Push (push) Canceled after 0s
Lint / Linting Checks (push) Canceled after 0s
Release Drafter / Update Release Draft (push) Canceled after 0s
Release Drafter / Auto Label PR (push) Canceled after 0s
Tests / vitest (1) (push) Canceled after 0s
Tests / vitest (2) (push) Canceled after 0s
Tests / vitest (3) (push) Canceled after 0s
Tests / vitest (4) (push) Canceled after 0s
2026-10-14 16:29:58 -07:00
Anirudh SharmaandCodex 6bf81a78f3 Fix: preserve dollar sequences in environment and file secrets (#7242)
Docker CI / Docker Build & Push (push) Canceled after 0s
Lint / Linting Checks (push) Canceled after 0s
Release Drafter / Update Release Draft (push) Canceled after 0s
Release Drafter / Auto Label PR (push) Canceled after 0s
Tests / vitest (1) (push) Canceled after 0s
Tests / vitest (2) (push) Canceled after 0s
Tests / vitest (3) (push) Canceled after 0s
Tests / vitest (4) (push) Canceled after 0s
Co-authored-by: Codex <codex@openai.com>
2026-10-13 06:44:40 -07:00
shamoon 4a38eea1b0 Fix api page tests creating routes
Docker CI / Docker Build & Push (push) Canceled after 0s
Lint / Linting Checks (push) Canceled after 0s
Release Drafter / Update Release Draft (push) Canceled after 0s
Release Drafter / Auto Label PR (push) Canceled after 0s
Tests / vitest (1) (push) Canceled after 0s
Tests / vitest (2) (push) Canceled after 0s
Tests / vitest (3) (push) Canceled after 0s
Tests / vitest (4) (push) Canceled after 0s
2026-10-10 23:12:49 -07:00
8 changed files with 91 additions and 10 deletions
@@ -34,7 +34,7 @@ function mockResponse() {
async function loadHandler() {
vi.resetModules();
return (await import("./index")).default;
return (await import("pages/api/mcp/index")).default;
}
describe("pages/api/mcp", () => {
@@ -57,7 +57,7 @@ vi.mock("widgets/widgets", () => ({
},
}));
import handler from "./proxy";
import handler from "pages/api/services/proxy";
function createReq({ method = "GET", body, query = {} } = {}) {
return { method, body, query };
@@ -0,0 +1,19 @@
import { readdirSync } from "fs";
import { expect, it } from "vitest";
// Next compiles everything under src/pages into routes, so a colocated test ships
// to users and drags its dev dependencies into the runtime image.
function filesIn(dir, found = []) {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory()) filesIn(path, found);
else found.push(path);
});
return found;
}
it("keeps test files out of src/pages", () => {
const tests = filesIn("src/pages").filter((path) => /\.(test|spec)\.[jt]sx?$/.test(path));
expect(tests).toEqual([]);
});
+2 -2
View File
@@ -69,11 +69,11 @@ export function substituteEnvironmentVars(str) {
const cachedVars = getCachedEnvironmentVars();
cachedVars.forEach(([key, value]) => {
if (key.startsWith(homepageVarPrefix)) {
result = result.replaceAll(`{{${key}}}`, value);
result = result.replaceAll(`{{${key}}}`, () => value);
} else if (key.startsWith(homepageFilePrefix)) {
const filename = value;
const fileContents = readFileSync(filename, "utf8");
result = result.replaceAll(`{{${key}}}`, fileContents);
result = result.replaceAll(`{{${key}}}`, () => fileContents);
}
});
}
+34 -1
View File
@@ -1,4 +1,4 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -37,6 +37,39 @@ describe("utils/config/config", () => {
expect(mod.substituteEnvironmentVars("token={{HOMEPAGE_FILE_SECRET}}")).toBe("token=secret");
});
it.each(["pa$$word", "pa$&word", "pa$`word", "pa$'word", "pa$1word"])(
"substituteEnvironmentVars preserves literal environment value %j in every occurrence",
async (secret) => {
process.env.HOMEPAGE_VAR_SECRET = secret;
const mod = await import("./config");
expect(
mod.substituteEnvironmentVars("before {{HOMEPAGE_VAR_SECRET}} between {{HOMEPAGE_VAR_SECRET}} after"),
).toBe(`before ${secret} between ${secret} after`);
},
);
it.each(["pa$$word", "pa$&word", "pa$`word", "pa$'word", "pa$1word"])(
"substituteEnvironmentVars preserves literal secret file contents %j in every occurrence",
async (secret) => {
const dir = mkdtempSync(path.join(tmpdir(), "homepage-config-test-"));
const secretPath = path.join(dir, "secret.txt");
writeFileSync(secretPath, secret, "utf8");
try {
process.env.HOMEPAGE_FILE_SECRET = secretPath;
const mod = await import("./config");
expect(
mod.substituteEnvironmentVars("before {{HOMEPAGE_FILE_SECRET}} between {{HOMEPAGE_FILE_SECRET}} after"),
).toBe(`before ${secret} between ${secret} after`);
} finally {
unlinkSync(secretPath);
rmdirSync(dir);
}
},
);
it("getSettings reads from HOMEPAGE_CONFIG_DIR and converts layout list to an object", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "homepage-settings-test-"));
process.env.HOMEPAGE_CONFIG_DIR = dir;
+3 -2
View File
@@ -55,6 +55,7 @@ export default async function credentialedProxyHandler(req, res, map) {
"authentik",
"cloudflared",
"ghostfolio",
"gitea",
"headscale",
"hoarder",
"karakeep",
@@ -143,7 +144,7 @@ export default async function credentialedProxyHandler(req, res, map) {
headers["X-API-Key"] = `${widget.key}`;
}
const [status, contentType, data] = await httpProxy(url, {
const [status, contentType, data, responseHeaders] = await httpProxy(url, {
method: req.method,
withCredentials: true,
credentials: "include",
@@ -178,7 +179,7 @@ export default async function credentialedProxyHandler(req, res, map) {
.status(500)
.json({ error: { message: "Invalid data", url: sanitizeErrorURL(url), data: resultData } });
}
if (map) resultData = map(resultData);
if (map) resultData = map(resultData, responseHeaders);
}
if (contentType) res.setHeader("Content-Type", contentType);
@@ -45,6 +45,7 @@ vi.mock("widgets/widgets", () => ({
autobrr: { api: "{url}/{endpoint}" },
jellystat: { api: "{url}/{endpoint}" },
trilium: { api: "{url}/{endpoint}" },
gitea: { api: "{url}/api/v1/{endpoint}" },
gitlab: { api: "{url}/{endpoint}" },
azuredevops: { api: "{url}/{endpoint}" },
glances: { api: "{url}/{endpoint}" },
@@ -135,6 +136,33 @@ describe("utils/proxy/handlers/credentialed", () => {
expect(res.body).toEqual({ ok: true });
});
it("uses Bearer auth for gitea widgets and keeps the key out of the URL", async () => {
getServiceWidget.mockResolvedValue({ type: "gitea", url: "http://example", key: "token" });
httpProxy.mockResolvedValue([200, "application/json", []]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "notifications?limit=1", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [url, params] = httpProxy.mock.calls[0];
expect(params.headers.Authorization).toBe("Bearer token");
expect(url.toString()).toBe("http://example/api/v1/notifications?limit=1");
});
it("passes response headers to the mapping function", async () => {
getServiceWidget.mockResolvedValue({ type: "gitea", url: "http://example", key: "token" });
httpProxy.mockResolvedValue([200, "application/json", [], { "x-total-count": "7" }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "notifications?limit=1", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res, (data, headers) => ({ count: Number(headers["x-total-count"]) }));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ count: 7 });
});
it("uses NC-Token auth for nextcloud widgets when key is provided", async () => {
getServiceWidget.mockResolvedValue({ type: "nextcloud", url: "http://example", key: "nc-token" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
+3 -3
View File
@@ -1,4 +1,4 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
// Get total from header to avoid pagination limit
export function totalCount(data, headers) {
@@ -7,8 +7,8 @@ export function totalCount(data, headers) {
}
const widget = {
api: "{url}/api/v1/{endpoint}?access_token={key}",
proxyHandler: genericProxyHandler,
api: "{url}/api/v1/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
notifications: {