fix(assets): accept identity-provider characters in image upload namespaces (#12288)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Humans oversee those agents in teams, so each person has a login and
a profile with an avatar
> - Avatars, logos and pasted images all go to one asset upload API,
which files each object under a namespace
> - The avatar namespace embeds the user id, and a deployment can take
user ids from an external identity layer, where a subject often holds
":", "|", "." or "@"
> - But the namespace validator accepted only letters, numbers, "/", "_"
and "-", so those users got a 400 "Invalid image metadata" error and
could not set a profile photo
> - This pull request widens the accepted characters, rejects "." and
".." path segments with a clear message, and cleans the namespace in the
upload client
> - The benefit is that profile photo upload works for every user, and a
namespace the API refuses now returns a message that says what is wrong
## Linked Issues or Issue Description
No existing issue or open pull request covers this. I searched the issue
and pull request lists for "avatar upload", "profile photo", "Invalid
image metadata" and "asset namespace" and found no duplicate. The bug
report follows.
**What happened?**
Profile photo upload fails. `ui/src/pages/ProfileSettings.tsx` sends the
namespace `profiles/${user.id}` to `POST
/api/companies/:companyId/assets/images`. When the user id comes from an
external identity layer it can contain ":", "|", "." or "@" — for
example `oidc:example|jane.example@example.com`.
`createAssetImageMetadataSchema` in
`packages/shared/src/validators/asset.ts` accepted only
`/^[a-zA-Z0-9\/_-]+$/`, so the route returned 400 "Invalid image
metadata" (`server/src/routes/assets.ts`). The image bytes were never
the problem, but the message pointed at the image, so the toast gave the
user nothing to act on.
A second case has the same cause. The agent instructions editor in
`ui/src/pages/AgentDetail.tsx` builds a namespace that ends with a
filename, such as `agents/<id>/instructions/SKILL.md`. The "." in the
filename also failed the check.
**Expected behavior**
A profile photo uploads for any user id the app itself issues, and an
image pasted into the agent instructions editor uploads for any
instruction filename. A namespace the API does refuse returns a message
that names the field and states the rule.
**Steps to reproduce**
1. Run Paperclip with an external identity provider, so `user.id` holds
an OIDC subject such as `oidc:example|jane.example@example.com`.
2. Open Settings, then Profile.
3. Choose an avatar image.
4. The upload fails and the page shows "Invalid image metadata".
Or, with no identity provider:
1. Open an agent, then the instructions editor, and select a file whose
name contains a "." such as `SKILL.md`.
2. Paste an image into the editor.
3. The upload fails with the same error.
**Paperclip version or commit**
`master` at eb86fcd49.
**Deployment mode**
Any deployment whose user ids come from an external identity layer. The
instructions-editor case reproduces on a plain self-hosted install too.
**Agent adapter(s) involved**
Not adapter-specific (core bug).
## What Changed
- `packages/shared/src/validators/asset.ts`: widen the namespace pattern
to `/^[a-zA-Z0-9\/_.:@|-]+$/`, and reject any "/"-separated segment
equal to "." or "..". A traversal attempt now gets a clean 400 from the
validator instead of an error from the storage provider.
- `packages/shared/src/validators/asset.ts`: add
`sanitizeAssetNamespace()`, which maps any string to a namespace the
schema accepts. It works per segment: it keeps the accepted characters,
turns the others into "-", collapses repeated dashes, drops empty and
dot-only segments, and caps the result at 120 characters. It returns
`undefined` when no segment survives, and the caller then sends no
namespace.
- `packages/shared/src/validators/asset.ts`: export
`ASSET_NAMESPACE_MAX_LENGTH` and `ASSET_NAMESPACE_RULE`, so the rule
text and the API error cannot drift apart.
- `ui/src/api/assets.ts`: run the namespace through
`sanitizeAssetNamespace()` in `uploadImage`. This is one choke point for
all callers, so no caller has to know the rule.
- `server/src/routes/assets.ts`: name the field in the 400 message —
`Invalid image metadata: "namespace" must be 1-120 characters of
letters, numbers, or / _ - . : @ |, and cannot contain "." or ".." path
segments`. The zod issue details stay in the response. The UI shows
`body.error`, so the toast is now actionable.
- Tests: a new `packages/shared/src/validators/asset.test.ts`
accept/reject matrix for the schema and the sanitizer; three cases in
`server/src/__tests__/assets.test.ts`; one case in
`ui/src/pages/ProfileSettings.test.tsx`.
## Verification
Targeted runs:
```
npx vitest run packages/shared/src/validators/asset.test.ts # 22 passed
npx vitest run server/src/__tests__/assets.test.ts # 11 passed
npx vitest run ui/src/pages/ProfileSettings.test.tsx # 2 passed
```
New cases:
- Schema: accepts identity-provider ids that hold ":", "|", "." and "@";
accepts `agents/<id>/instructions/SKILL.md`; rejects `profiles/bad
name!`, over-length input, and `.` or `..` segments.
- Sanitizer: passes identity-provider ids through unchanged, replaces
and collapses the other characters, drops the `.` and `..` segments
while keeping a segment of three or more dots, caps at 120 characters
without leaving a dot segment behind at the cut, and returns `undefined`
when nothing survives. One case asserts the sanitizer output always
parses.
- Route: 201 for `profiles/oidc:example|jane.example@example.com`, and
the storage service receives that namespace; 400 naming `namespace` for
`profiles/bad name!`; 400 for `profiles/../secrets`.
- UI: a session user id holding ":" and "|" uploads, and the namespace
reaches the API unchanged.
Typecheck:
```
pnpm --filter @paperclipai/shared typecheck # clean
pnpm --filter @paperclipai/ui typecheck # clean
cd server && npx tsc --noEmit -p tsconfig.json # clean
```
Package suites:
```
npx vitest run --project @paperclipai/shared --exclude "**/dist/**" # 586 passed, 8 pre-existing failures in src/worktree-seed-source.test.ts
npx vitest run --project @paperclipai/ui --exclude "**/dist/**" # 4402 passed
```
CI runs the server suite as ten shards (five general, five serialized),
which is the authoritative full run for this package. All shards pass on
this branch.
The `worktree-seed-source` failures reproduce on an unmodified checkout
of the same base commit and are unrelated to this change. The UI
failures seen in that run were 5-second test timeouts caused by running
two suites at once on one machine; each file passes when it runs alone.
No document states the namespace character rule — I checked `docs/` and
`doc/`, where the asset upload endpoint appears only in an OpenAPI
registry entry and a smoke-lab note, neither of which describes the
metadata fields. The rule now lives in one exported constant that the
API error reuses.
## Risks
Low risk.
- The wider character set does not widen what a caller can write to
disk. `server/src/storage/service.ts` already replaces every character
outside `[a-zA-Z0-9._-]` in each path segment, and
`server/src/storage/local-disk-provider.ts` already rejects "." and ".."
segments and any key that resolves outside the base directory. This
change moves the "." and ".." refusal earlier, to the validator, so the
caller gets a clear 400.
- The API is more permissive than before, so no request that used to
succeed can start failing.
- Namespaces stored before this change keep working. The namespace is
not a key that is looked up; it is a prefix under which new objects are
filed.
- One behavior change worth noting: the UI now cleans a namespace
instead of sending it as typed, so a caller that passes an unusable
namespace gets a cleaned prefix rather than a failed upload.
## Model Used
- Claude (Anthropic), Claude Opus, 1M context window, extended thinking,
agentic tool use through Claude Code.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
parent
d27998a230
commit
325041cb00
|
|
@ -2145,7 +2145,10 @@ export {
|
|||
createCostEventSchema,
|
||||
createFinanceEventSchema,
|
||||
updateBudgetSchema,
|
||||
ASSET_NAMESPACE_MAX_LENGTH,
|
||||
ASSET_NAMESPACE_RULE,
|
||||
createAssetImageMetadataSchema,
|
||||
sanitizeAssetNamespace,
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
acceptInviteSchema,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ASSET_NAMESPACE_MAX_LENGTH,
|
||||
createAssetImageMetadataSchema,
|
||||
sanitizeAssetNamespace,
|
||||
} from "./asset.js";
|
||||
|
||||
function parseNamespace(namespace: string) {
|
||||
return createAssetImageMetadataSchema.safeParse({ namespace });
|
||||
}
|
||||
|
||||
describe("createAssetImageMetadataSchema", () => {
|
||||
it("accepts a plain slug namespace", () => {
|
||||
expect(parseNamespace("goals/drafts").success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts identity-provider user ids", () => {
|
||||
const namespaces = [
|
||||
"profiles/oidc:example|user-1",
|
||||
"profiles/jane.example@example.com",
|
||||
"profiles/auth0|507f1f77bcf86cd799439011",
|
||||
"profiles/https:__id.example.com_users_1",
|
||||
];
|
||||
for (const namespace of namespaces) {
|
||||
expect(parseNamespace(namespace), namespace).toMatchObject({ success: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts filenames that contain a dot", () => {
|
||||
expect(parseNamespace("agents/agent-1/instructions/SKILL.md").success).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the namespace as optional", () => {
|
||||
expect(createAssetImageMetadataSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(createAssetImageMetadataSchema.parse({ namespace: " goals " })).toEqual({
|
||||
namespace: "goals",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects namespaces with characters outside the accepted set", () => {
|
||||
const namespaces = ["profiles/bad name!", "profiles/user#1", "profiles/user\\1", "profiles/user?x=1"];
|
||||
for (const namespace of namespaces) {
|
||||
expect(parseNamespace(namespace).success, namespace).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty and whitespace-only namespaces", () => {
|
||||
expect(parseNamespace("").success).toBe(false);
|
||||
expect(parseNamespace(" ").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects namespaces longer than the maximum length", () => {
|
||||
expect(parseNamespace("a".repeat(ASSET_NAMESPACE_MAX_LENGTH)).success).toBe(true);
|
||||
expect(parseNamespace("a".repeat(ASSET_NAMESPACE_MAX_LENGTH + 1)).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects dot path segments", () => {
|
||||
const namespaces = ["profiles/../secrets", "../secrets", "profiles/./self", "profiles/..", "."];
|
||||
for (const namespace of namespaces) {
|
||||
expect(parseNamespace(namespace).success, namespace).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("names the namespace field in the failure message", () => {
|
||||
const parsed = parseNamespace("profiles/bad name!");
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error?.issues[0]?.message).toContain("namespace");
|
||||
expect(parsed.error?.issues[0]?.path).toEqual(["namespace"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeAssetNamespace", () => {
|
||||
it("keeps identity-provider user ids unchanged", () => {
|
||||
const namespaces = [
|
||||
"profiles/oidc:example|user-1",
|
||||
"profiles/jane.example@example.com",
|
||||
"agents/agent-1/instructions/SKILL.md",
|
||||
];
|
||||
for (const namespace of namespaces) {
|
||||
expect(sanitizeAssetNamespace(namespace), namespace).toBe(namespace);
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces characters outside the accepted set with a dash", () => {
|
||||
expect(sanitizeAssetNamespace("profiles/bad name")).toBe("profiles/bad-name");
|
||||
expect(sanitizeAssetNamespace("profiles/user#1")).toBe("profiles/user-1");
|
||||
});
|
||||
|
||||
it("collapses repeated dashes", () => {
|
||||
expect(sanitizeAssetNamespace("profiles/a b")).toBe("profiles/a-b");
|
||||
expect(sanitizeAssetNamespace("profiles/a???b")).toBe("profiles/a-b");
|
||||
});
|
||||
|
||||
it("removes control characters", () => {
|
||||
expect(sanitizeAssetNamespace("profiles/user\u0000\u0007id")).toBe("profiles/user-id");
|
||||
});
|
||||
|
||||
it("trims whitespace around every segment", () => {
|
||||
expect(sanitizeAssetNamespace(" profiles / user-1 ")).toBe("profiles/user-1");
|
||||
});
|
||||
|
||||
it("drops empty segments", () => {
|
||||
expect(sanitizeAssetNamespace("profiles//user-1/")).toBe("profiles/user-1");
|
||||
expect(sanitizeAssetNamespace("/profiles/user-1")).toBe("profiles/user-1");
|
||||
});
|
||||
|
||||
it("drops the . and .. segments", () => {
|
||||
expect(sanitizeAssetNamespace("profiles/../secrets")).toBe("profiles/secrets");
|
||||
expect(sanitizeAssetNamespace("profiles/./user-1")).toBe("profiles/user-1");
|
||||
expect(sanitizeAssetNamespace("../../etc/passwd")).toBe("etc/passwd");
|
||||
});
|
||||
|
||||
it("keeps a segment of three or more dots, which the schema accepts", () => {
|
||||
expect(sanitizeAssetNamespace("profiles/...")).toBe("profiles/...");
|
||||
expect(sanitizeAssetNamespace("profiles/....")).toBe("profiles/....");
|
||||
expect(parseNamespace("profiles/...").success).toBe(true);
|
||||
});
|
||||
|
||||
it("caps the result at the maximum length", () => {
|
||||
const long = `profiles/${"a".repeat(400)}`;
|
||||
const sanitized = sanitizeAssetNamespace(long);
|
||||
expect(sanitized).toBeDefined();
|
||||
expect(sanitized?.length).toBe(ASSET_NAMESPACE_MAX_LENGTH);
|
||||
});
|
||||
|
||||
it("does not leave a dot segment behind after a cut", () => {
|
||||
const long = `${"a".repeat(ASSET_NAMESPACE_MAX_LENGTH - 3)}/..b`;
|
||||
expect(sanitizeAssetNamespace(long)).toBe("a".repeat(ASSET_NAMESPACE_MAX_LENGTH - 3));
|
||||
});
|
||||
|
||||
it("returns undefined when no segment survives", () => {
|
||||
expect(sanitizeAssetNamespace("")).toBeUndefined();
|
||||
expect(sanitizeAssetNamespace(" ")).toBeUndefined();
|
||||
expect(sanitizeAssetNamespace("///")).toBeUndefined();
|
||||
expect(sanitizeAssetNamespace("../..")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a value that the schema accepts", () => {
|
||||
const inputs = [
|
||||
"profiles/oidc:example|user-1",
|
||||
"profiles/bad name!",
|
||||
"profiles/../secrets",
|
||||
`profiles/${"a".repeat(400)}`,
|
||||
`${"a".repeat(ASSET_NAMESPACE_MAX_LENGTH - 3)}/..b`,
|
||||
"profiles/user\u0000id",
|
||||
];
|
||||
for (const input of inputs) {
|
||||
const sanitized = sanitizeAssetNamespace(input);
|
||||
expect(sanitized, input).toBeDefined();
|
||||
expect(parseNamespace(sanitized as string).success, `${input} -> ${sanitized}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,81 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/** Maximum length of an asset namespace, in characters. */
|
||||
export const ASSET_NAMESPACE_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* Characters that an asset namespace can contain.
|
||||
*
|
||||
* The set is wider than a plain slug because namespaces embed user ids. A
|
||||
* hosted deployment takes user ids from its identity layer without a change,
|
||||
* and an OIDC subject often contains ":", "|", "." or "@".
|
||||
*/
|
||||
const ASSET_NAMESPACE_PATTERN = /^[a-zA-Z0-9/_.:@|-]+$/;
|
||||
|
||||
/** Characters that a single namespace segment cannot contain. */
|
||||
const DISALLOWED_SEGMENT_CHARS = /[^a-zA-Z0-9_.:@|-]+/g;
|
||||
|
||||
/** Human-readable statement of the namespace rule. Reused in API errors. */
|
||||
export const ASSET_NAMESPACE_RULE = `"namespace" must be 1-${ASSET_NAMESPACE_MAX_LENGTH} characters of letters, numbers, or / _ - . : @ |, and cannot contain "." or ".." path segments`;
|
||||
|
||||
/** True when the segment is a relative path step, which is traversal. */
|
||||
function isDotSegment(segment: string): boolean {
|
||||
return segment === "." || segment === "..";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the segment is safe to keep in a namespace.
|
||||
*
|
||||
* The schema and the sanitizer share `isDotSegment`, so the sanitizer never
|
||||
* drops a segment that the schema accepts. A segment of three or more dots is
|
||||
* an ordinary directory name, and it survives.
|
||||
*/
|
||||
function isUsableSegment(segment: string): boolean {
|
||||
return segment.length > 0 && !isDotSegment(segment);
|
||||
}
|
||||
|
||||
function hasNoDotSegments(namespace: string): boolean {
|
||||
return !namespace.split("/").some(isDotSegment);
|
||||
}
|
||||
|
||||
export const createAssetImageMetadataSchema = z.object({
|
||||
namespace: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.regex(/^[a-zA-Z0-9/_-]+$/)
|
||||
.max(ASSET_NAMESPACE_MAX_LENGTH)
|
||||
.regex(ASSET_NAMESPACE_PATTERN, ASSET_NAMESPACE_RULE)
|
||||
.refine(hasNoDotSegments, { message: ASSET_NAMESPACE_RULE })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type CreateAssetImageMetadata = z.infer<typeof createAssetImageMetadataSchema>;
|
||||
|
||||
/**
|
||||
* Make a namespace that `createAssetImageMetadataSchema` accepts.
|
||||
*
|
||||
* Each "/"-separated segment keeps its accepted characters. Other characters
|
||||
* become "-", repeated dashes collapse, and empty, "." and ".." segments go
|
||||
* away. The result is at most `ASSET_NAMESPACE_MAX_LENGTH` characters.
|
||||
*
|
||||
* Returns undefined when no segment survives. Callers then send no namespace
|
||||
* and the server applies its default.
|
||||
*/
|
||||
export function sanitizeAssetNamespace(namespace: string): string | undefined {
|
||||
const cleaned = namespace
|
||||
.split("/")
|
||||
.map((segment) => segment.trim().replace(DISALLOWED_SEGMENT_CHARS, "-").replace(/-{2,}/g, "-"))
|
||||
.filter(isUsableSegment)
|
||||
.join("/");
|
||||
if (cleaned.length <= ASSET_NAMESPACE_MAX_LENGTH) {
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
// A cut can leave an empty, "." or ".." tail segment, so filter again.
|
||||
const truncated = cleaned
|
||||
.slice(0, ASSET_NAMESPACE_MAX_LENGTH)
|
||||
.split("/")
|
||||
.filter(isUsableSegment)
|
||||
.join("/");
|
||||
return truncated.length > 0 ? truncated : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -701,7 +701,10 @@ export {
|
|||
} from "./finance.js";
|
||||
|
||||
export {
|
||||
ASSET_NAMESPACE_MAX_LENGTH,
|
||||
ASSET_NAMESPACE_RULE,
|
||||
createAssetImageMetadataSchema,
|
||||
sanitizeAssetNamespace,
|
||||
type CreateAssetImageMetadata,
|
||||
} from "./asset.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,68 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("accepts namespaces that hold identity-provider user ids", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = await createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const namespace = "profiles/oidc:example|jane.example@example.com";
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", namespace)
|
||||
.attach("file", Buffer.from("png"), "avatar.png"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(png.__calls.putFileInputs[0]).toMatchObject({
|
||||
companyId: "company-1",
|
||||
namespace: `assets/${namespace}`,
|
||||
originalFilename: "avatar.png",
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects namespaces with characters outside the accepted set", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = await createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "profiles/bad name!")
|
||||
.attach("file", Buffer.from("png"), "avatar.png"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("namespace");
|
||||
expect(res.body.details?.[0]?.path).toEqual(["namespace"]);
|
||||
expect(png.__calls.putFileInputs).toHaveLength(0);
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects namespaces that hold a dot path segment", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = await createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "profiles/../secrets")
|
||||
.attach("file", Buffer.from("png"), "avatar.png"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("namespace");
|
||||
expect(png.__calls.putFileInputs).toHaveLength(0);
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows supported non-image attachments outside the company logo flow", async () => {
|
||||
const text = createStorageService("text/plain");
|
||||
const app = await createApp(text);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import multer from "multer";
|
|||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { createAssetImageMetadataSchema } from "@paperclipai/shared";
|
||||
import { ASSET_NAMESPACE_RULE, createAssetImageMetadataSchema } from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { assetService, logActivity } from "../services/index.js";
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
|
|
@ -133,7 +133,10 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
|||
|
||||
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
|
||||
if (!parsedMeta.success) {
|
||||
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
|
||||
res.status(400).json({
|
||||
error: `Invalid image metadata: ${ASSET_NAMESPACE_RULE}`,
|
||||
details: parsedMeta.error.issues,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AssetImage } from "@paperclipai/shared";
|
||||
import { sanitizeAssetNamespace, type AssetImage } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const assetsApi = {
|
||||
|
|
@ -11,8 +11,11 @@ export const assetsApi = {
|
|||
const safeFile = new File([buffer], file.name, { type: file.type });
|
||||
|
||||
const form = new FormData();
|
||||
if (namespace && namespace.trim().length > 0) {
|
||||
form.append("namespace", namespace.trim());
|
||||
// Callers build namespaces from ids and filenames that can hold characters
|
||||
// the API rejects. Clean the namespace here so every caller is covered.
|
||||
const safeNamespace = namespace ? sanitizeAssetNamespace(namespace) : undefined;
|
||||
if (safeNamespace) {
|
||||
form.append("namespace", safeNamespace);
|
||||
}
|
||||
form.append("file", safeFile);
|
||||
return api.postForm<AssetImage>(`/companies/${companyId}/assets/images`, form);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { act } from "react";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sanitizeAssetNamespace } from "@paperclipai/shared";
|
||||
import { ProfileSettings } from "./ProfileSettings";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
|
|
@ -130,4 +131,61 @@ describe("ProfileSettings", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uploads an avatar for a user id that comes from an identity provider", async () => {
|
||||
const userId = "oidc:example|jane.example@example.com";
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId },
|
||||
user: {
|
||||
id: userId,
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: "https://example.com/jane.png",
|
||||
},
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProfileSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const avatarInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(avatarInput).not.toBeNull();
|
||||
|
||||
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
|
||||
Object.defineProperty(avatarInput, "files", {
|
||||
configurable: true,
|
||||
value: [file],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
avatarInput?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const namespace = `profiles/${userId}`;
|
||||
expect(mockAssetsApi.uploadImage).toHaveBeenCalledWith("company-1", file, namespace);
|
||||
// The namespace goes to the API without a change, so the avatar arrives
|
||||
// under the identity of the user.
|
||||
expect(sanitizeAssetNamespace(namespace)).toBe(namespace);
|
||||
expect(mockAuthApi.updateProfile).toHaveBeenCalledWith({
|
||||
name: "Jane Example",
|
||||
image: "/api/assets/asset-1/content",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue