mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix(media): remove express from media host (#71436)
* fix(media): remove express from media host * fix(media): harden media host responses * fix(msteams): stage express runtime dependency * fix(browser): align profile facade exports * fix(msteams): keep setup entry narrow * fix(types): satisfy extension setup gates * fix(msteams): use generic setup config type
This commit is contained in:
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/config: support per-profile `browser.profiles.<name>.headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu.
|
||||
- Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc.
|
||||
- Dependencies/memory: stop installing `node-llama-cpp` by default; local embeddings now load it only when operators install the optional runtime package. Thanks @vincentkoc.
|
||||
- Dependencies/media: replace the tiny core media host's Express server with `node:http`, so Express is no longer a root runtime dependency. Thanks @vincentkoc.
|
||||
- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras.
|
||||
- WebChat/sessions: keep runtime-only prompt context out of visible transcript history and scrub legacy wrappers from session history surfaces. Thanks @91wan.
|
||||
- Gradium: add a bundled text-to-speech provider with voice-note and telephony output support. (#64958) Thanks @LaurentMazare.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"commander": "^14.0.3",
|
||||
"express": "^5.2.1",
|
||||
"express": "5.2.1",
|
||||
"playwright-core": "1.59.1",
|
||||
"typebox": "1.1.31",
|
||||
"undici": "8.1.0",
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@azure/identity": "^4.13.1",
|
||||
"@azure/identity": "4.13.1",
|
||||
"@microsoft/teams.api": "2.0.8",
|
||||
"@microsoft/teams.apps": "2.0.8",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^4.0.1",
|
||||
"express": "5.2.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"jwks-rsa": "4.0.1",
|
||||
"typebox": "1.1.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -59,6 +59,9 @@
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.20"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -3,8 +3,8 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
exportName: "msteamsPlugin",
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "msteamsSetupPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
|
||||
3
extensions/msteams/setup-plugin-api.ts
Normal file
3
extensions/msteams/setup-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep bundled setup entry imports narrow so setup loads do not pull the
|
||||
// broader Teams channel plugin surface.
|
||||
export { msteamsSetupPlugin } from "./src/channel.setup.js";
|
||||
77
extensions/msteams/src/channel.setup.ts
Normal file
77
extensions/msteams/src/channel.setup.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { MSTeamsChannelConfigSchema } from "./config-schema.js";
|
||||
import { msteamsSetupAdapter } from "./setup-core.js";
|
||||
import { msteamsSetupWizard } from "./setup-surface.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
type ResolvedMSTeamsAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams (Bot Framework)",
|
||||
docsPath: "/channels/msteams",
|
||||
docsLabel: "msteams",
|
||||
blurb: "Teams SDK; enterprise support.",
|
||||
aliases: ["teams"],
|
||||
order: 60,
|
||||
} as const;
|
||||
|
||||
const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({
|
||||
allowFrom: cfg.channels?.msteams?.allowFrom,
|
||||
defaultTo: cfg.channels?.msteams?.defaultTo,
|
||||
});
|
||||
|
||||
const msteamsConfigAdapter = createTopLevelChannelConfigAdapter<
|
||||
ResolvedMSTeamsAccount,
|
||||
{
|
||||
allowFrom?: Array<string | number>;
|
||||
defaultTo?: string;
|
||||
}
|
||||
>({
|
||||
sectionKey: "msteams",
|
||||
resolveAccount: (cfg) => ({
|
||||
accountId: "default",
|
||||
enabled: cfg.channels?.msteams?.enabled !== false,
|
||||
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
||||
}),
|
||||
resolveAccessorAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg),
|
||||
resolveAllowFrom: (account) => account.allowFrom,
|
||||
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
|
||||
resolveDefaultTo: (account) => account.defaultTo,
|
||||
});
|
||||
|
||||
export const msteamsSetupPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
id: "msteams",
|
||||
meta: {
|
||||
...meta,
|
||||
aliases: [...meta.aliases],
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "thread"],
|
||||
polls: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.msteams"] },
|
||||
configSchema: MSTeamsChannelConfigSchema,
|
||||
config: {
|
||||
...msteamsConfigAdapter,
|
||||
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
}),
|
||||
},
|
||||
setupWizard: msteamsSetupWizard,
|
||||
setup: msteamsSetupAdapter,
|
||||
};
|
||||
1
extensions/qa-lab/web/src/assets.d.ts
vendored
Normal file
1
extensions/qa-lab/web/src/assets.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
@@ -1615,7 +1615,6 @@
|
||||
"commander": "^14.0.3",
|
||||
"croner": "^10.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"file-type": "22.0.1",
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -84,9 +84,6 @@ importers:
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
file-type:
|
||||
specifier: 22.0.1
|
||||
version: 22.0.1
|
||||
@@ -333,7 +330,7 @@ importers:
|
||||
specifier: ^14.0.3
|
||||
version: 14.0.3
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
playwright-core:
|
||||
specifier: 1.59.1
|
||||
@@ -893,7 +890,7 @@ importers:
|
||||
extensions/msteams:
|
||||
dependencies:
|
||||
'@azure/identity':
|
||||
specifier: ^4.13.1
|
||||
specifier: 4.13.1
|
||||
version: 4.13.1
|
||||
'@microsoft/teams.api':
|
||||
specifier: 2.0.8
|
||||
@@ -902,13 +899,13 @@ importers:
|
||||
specifier: 2.0.8
|
||||
version: 2.0.8
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
specifier: 9.0.3
|
||||
version: 9.0.3
|
||||
jwks-rsa:
|
||||
specifier: ^4.0.1
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
typebox:
|
||||
specifier: 1.1.31
|
||||
|
||||
@@ -71,7 +71,6 @@ describe("tsdown config", () => {
|
||||
"plugin-sdk/compat",
|
||||
"plugin-sdk/index",
|
||||
bundledEntry("openai"),
|
||||
bundledEntry("msteams"),
|
||||
"bundled/boot-md/handler",
|
||||
]),
|
||||
);
|
||||
@@ -88,6 +87,7 @@ describe("tsdown config", () => {
|
||||
true,
|
||||
);
|
||||
expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/discord")).toBe(true);
|
||||
expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/msteams")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not emit plugin-sdk or hooks from a separate dist graph", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { request } from "node:http";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
@@ -57,6 +58,10 @@ describe("media server", () => {
|
||||
await expect(fs.stat(filePath)).rejects.toThrow();
|
||||
}
|
||||
|
||||
async function expectExistingMediaFile(filePath: string) {
|
||||
await expect(fs.stat(filePath)).resolves.toEqual(expect.anything());
|
||||
}
|
||||
|
||||
function expectFetchedResponse(
|
||||
response: Awaited<ReturnType<MediaServerTestHarness["fetch"]>>,
|
||||
expected: { status: number; noSniff?: boolean },
|
||||
@@ -107,6 +112,23 @@ describe("media server", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAndAbort(url: string) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = request(url, (res) => {
|
||||
res.destroy();
|
||||
resolve();
|
||||
});
|
||||
req.on("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ECONNRESET") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ MEDIA_MAX_BYTES } = await import("./store.js"));
|
||||
mediaHarness = await startMediaServerTestHarness({
|
||||
@@ -152,6 +174,64 @@ describe("media server", () => {
|
||||
await expectMediaFileLifecycleCase(testCase);
|
||||
});
|
||||
|
||||
it("sets safe fallback headers for untyped media bytes", async () => {
|
||||
if (mediaHarness?.listenBlocked) {
|
||||
return;
|
||||
}
|
||||
await writeMediaFile("raw", "hello");
|
||||
|
||||
const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => mediaHarness!.fetch(mediaUrl("raw")));
|
||||
|
||||
expectFetchedResponse(res, { status: 200, noSniff: true });
|
||||
expect(res.headers.get("content-type")).toBe("application/octet-stream");
|
||||
expect(res.headers.get("content-length")).toBe("5");
|
||||
expect(await res.text()).toBe("hello");
|
||||
});
|
||||
|
||||
it("answers HEAD media probes without consuming the media file", async () => {
|
||||
if (mediaHarness?.listenBlocked) {
|
||||
return;
|
||||
}
|
||||
const file = await writeMediaFile("head-probe", "hello");
|
||||
|
||||
const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () =>
|
||||
mediaHarness!.fetch(mediaUrl("head-probe"), { method: "HEAD" }),
|
||||
);
|
||||
|
||||
expectFetchedResponse(res, { status: 200, noSniff: true });
|
||||
expect(res.headers.get("content-type")).toBe("application/octet-stream");
|
||||
expect(res.headers.get("content-length")).toBe("5");
|
||||
expect(await res.text()).toBe("");
|
||||
await expectExistingMediaFile(file);
|
||||
});
|
||||
|
||||
it("forces active text media to download as opaque bytes", async () => {
|
||||
if (mediaHarness?.listenBlocked) {
|
||||
return;
|
||||
}
|
||||
await writeMediaFile("page.html", "<script>alert(1)</script>");
|
||||
|
||||
const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () =>
|
||||
mediaHarness!.fetch(mediaUrl("page.html")),
|
||||
);
|
||||
|
||||
expectFetchedResponse(res, { status: 200, noSniff: true });
|
||||
expect(res.headers.get("content-type")).toBe("application/octet-stream");
|
||||
expect(res.headers.get("content-disposition")).toBe('attachment; filename="page.html"');
|
||||
expect(await res.text()).toBe("<script>alert(1)</script>");
|
||||
});
|
||||
|
||||
it("cleans up served media when the client aborts the response", async () => {
|
||||
if (mediaHarness?.listenBlocked) {
|
||||
return;
|
||||
}
|
||||
const file = await writeMediaFile("abort", "hello");
|
||||
|
||||
await withEnvAsync(LOOPBACK_FETCH_ENV, () => requestAndAbort(mediaUrl("abort")));
|
||||
|
||||
await waitForFileRemoval(file);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
testName: "blocks path traversal attempts",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Server } from "node:http";
|
||||
import express, { type Express, type RequestHandler } from "express";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { detectMime } from "./mime.js";
|
||||
@@ -16,12 +15,15 @@ const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||
const MAX_MEDIA_ID_CHARS = 200;
|
||||
const MEDIA_ID_PATTERN = /^[\p{L}\p{N}._-]+$/u;
|
||||
const MAX_MEDIA_BYTES = MEDIA_MAX_BYTES;
|
||||
|
||||
function asyncMediaRoute(handler: RequestHandler): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(handler(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
const DEFAULT_MEDIA_CONTENT_TYPE = "application/octet-stream";
|
||||
const ACTIVE_CONTENT_MIME_TYPES = new Set([
|
||||
"application/xhtml+xml",
|
||||
"application/xml",
|
||||
"image/svg+xml",
|
||||
"text/html",
|
||||
"text/javascript",
|
||||
"text/xml",
|
||||
]);
|
||||
|
||||
const isValidMediaId = (id: string) => {
|
||||
if (!id) {
|
||||
@@ -36,20 +38,106 @@ const isValidMediaId = (id: string) => {
|
||||
return MEDIA_ID_PATTERN.test(id);
|
||||
};
|
||||
|
||||
export function attachMediaRoutes(
|
||||
app: Express,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
function sendText(res: ServerResponse, statusCode: number, body: string): void {
|
||||
const data = Buffer.from(body);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.setHeader("Content-Length", String(data.byteLength));
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
function resolveMediaId(req: IncomingMessage): {
|
||||
routeMatched: boolean;
|
||||
id?: string;
|
||||
method?: string;
|
||||
} {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
return { routeMatched: false };
|
||||
}
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const prefix = "/media/";
|
||||
if (!url.pathname.startsWith(prefix)) {
|
||||
return { routeMatched: false };
|
||||
}
|
||||
const encodedId = url.pathname.slice(prefix.length);
|
||||
if (!encodedId || encodedId.includes("/")) {
|
||||
return { routeMatched: false };
|
||||
}
|
||||
try {
|
||||
return { routeMatched: true, id: decodeURIComponent(encodedId), method: req.method };
|
||||
} catch {
|
||||
return { routeMatched: true, id: "", method: req.method };
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveContentMime(mime?: string): boolean {
|
||||
const normalized = mime?.split(";")[0]?.trim().toLowerCase();
|
||||
return normalized ? ACTIVE_CONTENT_MIME_TYPES.has(normalized) : false;
|
||||
}
|
||||
|
||||
function sanitizeAttachmentFilename(id: string): string {
|
||||
const name = id.replace(/["\\\r\n]/g, "_").trim();
|
||||
return name || "media";
|
||||
}
|
||||
|
||||
function setMediaHeaders(
|
||||
res: ServerResponse,
|
||||
params: { id: string; mime?: string; bytes: number },
|
||||
): void {
|
||||
const activeContent = isActiveContentMime(params.mime);
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
activeContent ? DEFAULT_MEDIA_CONTENT_TYPE : (params.mime ?? DEFAULT_MEDIA_CONTENT_TYPE),
|
||||
);
|
||||
res.setHeader("Content-Length", String(params.bytes));
|
||||
if (activeContent) {
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${sanitizeAttachmentFilename(params.id)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleMediaCleanup(realPath: string): void {
|
||||
const cleanup = () => {
|
||||
void fs.rm(realPath).catch(() => {});
|
||||
};
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
queueMicrotask(cleanup);
|
||||
return;
|
||||
}
|
||||
setTimeout(cleanup, 50);
|
||||
}
|
||||
|
||||
function cleanupAfterGetResponse(res: ServerResponse, realPath: string): void {
|
||||
let scheduled = false;
|
||||
const scheduleOnce = () => {
|
||||
if (scheduled) {
|
||||
return;
|
||||
}
|
||||
scheduled = true;
|
||||
scheduleMediaCleanup(realPath);
|
||||
};
|
||||
res.once("finish", scheduleOnce);
|
||||
res.once("close", scheduleOnce);
|
||||
res.once("error", scheduleOnce);
|
||||
}
|
||||
|
||||
export function createMediaRequestHandler(ttlMs = DEFAULT_TTL_MS) {
|
||||
const mediaDir = getMediaDir();
|
||||
|
||||
app.get(
|
||||
"/media/:id",
|
||||
asyncMediaRoute(async (req, res) => {
|
||||
return (req: IncomingMessage, res: ServerResponse) => {
|
||||
const route = resolveMediaId(req);
|
||||
if (!route.routeMatched) {
|
||||
sendText(res, 404, "not found");
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
const id = typeof req.params.id === "string" ? req.params.id : "";
|
||||
const id = route.id ?? "";
|
||||
if (!isValidMediaId(id)) {
|
||||
res.status(400).send("invalid path");
|
||||
sendText(res, 400, "invalid path");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -64,50 +152,54 @@ export function attachMediaRoutes(
|
||||
});
|
||||
if (Date.now() - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(realPath).catch(() => {});
|
||||
res.status(410).send("expired");
|
||||
sendText(res, 410, "expired");
|
||||
return;
|
||||
}
|
||||
const mime = await detectMime({ buffer: data, filePath: realPath });
|
||||
if (mime) {
|
||||
res.type(mime);
|
||||
setMediaHeaders(res, { id, mime, bytes: data.byteLength });
|
||||
res.statusCode = 200;
|
||||
if (route.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.send(data);
|
||||
// best-effort single-use cleanup after response ends
|
||||
res.on("finish", () => {
|
||||
const cleanup = () => {
|
||||
void fs.rm(realPath).catch(() => {});
|
||||
};
|
||||
// Tests should not pay for time-based cleanup delays.
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
queueMicrotask(cleanup);
|
||||
return;
|
||||
}
|
||||
setTimeout(cleanup, 50);
|
||||
});
|
||||
cleanupAfterGetResponse(res, realPath);
|
||||
if (req.aborted || res.destroyed || res.writableEnded) {
|
||||
scheduleMediaCleanup(realPath);
|
||||
return;
|
||||
}
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
if (isSafeOpenError(err)) {
|
||||
if (err.code === "outside-workspace") {
|
||||
res.status(400).send("file is outside workspace root");
|
||||
sendText(res, 400, "file is outside workspace root");
|
||||
return;
|
||||
}
|
||||
if (err.code === "invalid-path") {
|
||||
res.status(400).send("invalid path");
|
||||
sendText(res, 400, "invalid path");
|
||||
return;
|
||||
}
|
||||
if (err.code === "not-found") {
|
||||
res.status(404).send("not found");
|
||||
sendText(res, 404, "not found");
|
||||
return;
|
||||
}
|
||||
if (err.code === "too-large") {
|
||||
res.status(413).send("too large");
|
||||
sendText(res, 413, "too large");
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.status(404).send("not found");
|
||||
sendText(res, 404, "not found");
|
||||
}
|
||||
}),
|
||||
);
|
||||
})().catch(() => {
|
||||
if (!res.headersSent) {
|
||||
sendText(res, 404, "not found");
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function startMediaCleanupInterval(ttlMs: number): void {
|
||||
// periodic cleanup
|
||||
setInterval(() => {
|
||||
void cleanOldMedia(ttlMs, { recursive: false });
|
||||
@@ -119,10 +211,10 @@ export async function startMediaServer(
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<Server> {
|
||||
const app = express();
|
||||
attachMediaRoutes(app, ttlMs, runtime);
|
||||
const server = createServer(createMediaRequestHandler(ttlMs));
|
||||
startMediaCleanupInterval(ttlMs);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port, "127.0.0.1");
|
||||
server.listen(port, "127.0.0.1");
|
||||
server.once("listening", () => resolve(server));
|
||||
server.once("error", (err) => {
|
||||
runtime.error(danger(`Media server failed: ${String(err)}`));
|
||||
|
||||
@@ -75,10 +75,11 @@ const packageManifestContractTests: PackageManifestContractParams[] = [
|
||||
"@azure/identity",
|
||||
"@microsoft/teams.api",
|
||||
"@microsoft/teams.apps",
|
||||
"express",
|
||||
"jsonwebtoken",
|
||||
"jwks-rsa",
|
||||
],
|
||||
mirroredRootRuntimeDeps: ["typebox", "express"],
|
||||
mirroredRootRuntimeDeps: ["typebox"],
|
||||
minHostVersionBaseline: "2026.3.22",
|
||||
},
|
||||
{ pluginId: "nextcloud-talk", minHostVersionBaseline: "2026.3.22" },
|
||||
|
||||
Reference in New Issue
Block a user