Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/api/src/routes/me.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,37 @@ function memberEnv(opts: {
} as unknown as Env;
}

function metadataDb(
rows: Array<{ workspace: string; key: string; meta: Record<string, string> }>,
): SQLiteD1 {
const db = new DatabaseSync(":memory:");
db.exec(
readFileSync(
fileURLToPath(
new NodeURL("../../migrations/20260713210559_file_metadata.sql", import.meta.url),
),
"utf8",
),
);
const insert = db.prepare(
"INSERT INTO file_metadata (workspace, object_key, meta_key, meta_value, updated_at) VALUES (?, ?, ?, ?, ?)",
);
for (const row of rows) {
for (const [k, v] of Object.entries(row.meta)) {
insert.run(row.workspace, row.key, k, v, "2026-07-13T00:00:00.000Z");
}
}
return new SQLiteD1(db);
}

const R2_RECORD = {
provider: "r2",
bucket: "shared",
binding: "UPLOADS_DEFAULT",
prefix: "acme/",
publicBaseUrl: "https://storage.uploads.sh",
};

describe("GET /me/workspaces/:name/galleries", () => {
it("404s for a workspace the caller is not a member of", async () => {
const env = stubEnv(USER, (path) => {
Expand Down Expand Up @@ -839,3 +870,74 @@ describe("POST /me/workspaces/:name/invites", () => {
expect(res.status).toBe(400);
});
});

describe("GET /me/workspaces/:name/files/search", () => {
it("returns files matching an ANDed metadata filter", async () => {
const db = metadataDb([
{
workspace: "acme",
key: "f/x/shot.png",
meta: { "gh.repo": "buildinternet/uploads", app: "web" },
},
{ workspace: "acme", key: "f/y/other.png", meta: { "gh.repo": "buildinternet/uploads" } },
]);
const env = memberEnv({ workspace: "acme", db, bucket: new FakeR2Bucket(), record: R2_RECORD });
const res = await app().request(
"/me/workspaces/acme/files/search?meta.gh.repo=buildinternet/uploads&meta.app=web",
{},
env,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { key: string; url: string; metadata: Record<string, string> }[];
truncated: boolean;
};
expect(body.truncated).toBe(false);
expect(body.items).toHaveLength(1);
expect(body.items[0]).toMatchObject({
key: "f/x/shot.png",
url: "https://storage.uploads.sh/acme/f/x/shot.png",
});
});

it("rejects a repeated filter key with file_metadata_duplicate_filter", async () => {
const env = memberEnv({ workspace: "acme", db: metadataDb([]), record: R2_RECORD });
const res = await app().request(
"/me/workspaces/acme/files/search?meta.app=web&meta.app=api",
{},
env,
);
expect(res.status).toBe(400);
expect((await res.json()) as { error: { code: string } }).toMatchObject({
error: { code: "file_metadata_duplicate_filter" },
});
});

it("rejects a malformed filter key with file_metadata_invalid_key", async () => {
const env = memberEnv({ workspace: "acme", db: metadataDb([]), record: R2_RECORD });
const res = await app().request("/me/workspaces/acme/files/search?meta.BadKey=x", {}, env);
expect(res.status).toBe(400);
expect((await res.json()) as { error: { code: string } }).toMatchObject({
error: { code: "file_metadata_invalid_key" },
});
});

it("requires at least one meta.* filter", async () => {
const env = memberEnv({ workspace: "acme", db: metadataDb([]), record: R2_RECORD });
const res = await app().request("/me/workspaces/acme/files/search", {}, env);
expect(res.status).toBe(400);
});

it("short-circuits the communal workspace with an empty result", async () => {
const env = memberEnv({ workspace: "default", db: metadataDb([]) });
const res = await app().request("/me/workspaces/default/files/search?meta.app=web", {}, env);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ items: [], truncated: false });
});

it("404s for a workspace the caller is not a member of", async () => {
const env = memberEnv({ workspace: "acme", db: metadataDb([]), record: R2_RECORD });
const res = await app().request("/me/workspaces/other/files/search?meta.app=web", {}, env);
expect(res.status).toBe(404);
});
});
57 changes: 56 additions & 1 deletion apps/api/src/routes/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import { ForbiddenError, NotFoundError, RateLimitedError, ValidationError } from
import { createFilesRouter, signedDownloadUrl } from "@uploads/storage";
import { Hono, type Context } from "hono";
import { usageWithLimits } from "../budget";
import { findObjectsByMetadata, validateMetadataFilters } from "../file-metadata";
import { badKey, listObjects, setObjectVisibility } from "../files-core";
import { listGalleries } from "../galleries";
import { gallerySummary } from "../gallery-service";
import { allowWrite } from "../guards";
import { membershipsForUser, orgForWorkspace, workspacesForOrg } from "../org-workspaces";
import { requireSessionUser, sessionAuth, type SessionVars } from "../session-auth";
import { publicUrl, storage, storageConfig } from "../storage";
import { objectPublicUrls, publicUrl, storage, storageConfig } from "../storage";
import { getWorkspaceUsage } from "../usage";
import { sanitizeVisibility, VISIBILITY_VALUES } from "../visibility";
import { loadWorkspaceRecord } from "../workspace";
Expand Down Expand Up @@ -175,6 +176,60 @@ export const me = new Hono<SessionVars>()
return c.json({ communal: false, files: items });
})

// Metadata search — the session-authed twin of the token route's
// `GET /v1/:workspace/files?meta.*` (files.ts). Same AND-of-equality
// semantics and shared validators; scoped to one workspace, member-gated.
// Results carry no `visibility` (it isn't in the D1 index — accepted caveat).
.get("/workspaces/:name/files/search", async (c) => {
const name = c.req.param("name");
const ws = await memberWorkspaceOr404(c.env, requireUserId(c), name);
if (ws.communal) return c.json({ items: [], truncated: false });

const record = await loadWorkspaceRecord(c.env, name);
if (!record) {
throw new NotFoundError("workspace not found", { code: "workspace_not_found" });
}

const query = c.req.query();
const metaParamKeys = Object.keys(query).filter((k) => k.startsWith("meta."));
if (metaParamKeys.length === 0) {
throw new ValidationError("at least one meta.* filter is required", {
code: "file_metadata_invalid_key",
});
}
const filters: Record<string, string> = {};
for (const param of metaParamKeys) {
const key = param.slice("meta.".length);
const values = c.req.queries(param) ?? [];
if (values.length > 1) {
throw new ValidationError(`repeated metadata filter for key: ${key}`, {
code: "file_metadata_duplicate_filter",
details: { key },
});
}
filters[key] = values[0] ?? query[param];
}
validateMetadataFilters(filters);

const SEARCH_LIMIT = 100;
const [cfg, matches] = await Promise.all([
storageConfig(c.env, record),
findObjectsByMetadata(c.env.DB, name, filters, {
prefix: query.prefix,
limit: SEARCH_LIMIT + 1,
}),
]);
const truncated = matches.length > SEARCH_LIMIT;
const page = truncated ? matches.slice(0, SEARCH_LIMIT) : matches;
return c.json({
items: page.map((match) => {
const urls = objectPublicUrls(c.env, cfg, match.key);
return { key: match.key, url: urls.url, embedUrl: urls.embedUrl, metadata: match.metadata };
}),
truncated,
});
})

// Resolve a selected browser item to a usable URL, by storage capability
// (issue #123): the stable public URL when `publicBaseUrl` is configured;
// otherwise a short-lived signed download URL when the provider can sign;
Expand Down
137 changes: 137 additions & 0 deletions apps/web/src/components/MetadataSearchResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Badge, Button, Callout } from "@uploads/ui";
import { useEffect, useState } from "react";
import { searchWorkspaceFiles, type SearchFileItem } from "../lib/api-client";
import type { MetaFilter } from "../lib/workspace-search-url";

interface MetadataSearchResultsProps {
apiOrigin: string;
workspace: string;
filters: MetaFilter[];
onRemoveFilter: (key: string) => void;
}

type State =
| { status: "loading" }
| { status: "error" }
| { status: "ok"; items: SearchFileItem[]; truncated: boolean };

const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i;
const filename = (key: string) => key.split("/").filter(Boolean).pop() ?? key;

Check warning on line 19 in apps/web/src/components/MetadataSearchResults.tsx

View workflow job for this annotation

GitHub Actions / Lint & Format

unicorn(prefer-array-find)

Prefer `find` over filtering and accessing the first result.

export function MetadataSearchResults({

Check warning on line 21 in apps/web/src/components/MetadataSearchResults.tsx

View workflow job for this annotation

GitHub Actions / Lint & Format

unicorn(consistent-function-scoping)

Function `copyLink` does not capture any variables from its parent scope
apiOrigin,
workspace,
filters,
onRemoveFilter,
}: MetadataSearchResultsProps) {
const [state, setState] = useState<State>({ status: "loading" });
// Re-fetch whenever the filter set changes. Serialize the filters into the
// dependency so add/remove re-runs the search.
const key = filters.map((f) => `${f.key}=${f.value}`).join("&");

useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
void searchWorkspaceFiles(apiOrigin, workspace, filters).then((result) => {
if (cancelled) return;
setState(
result.kind === "ok"
? { status: "ok", items: result.items, truncated: result.truncated }
: { status: "error" },
);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiOrigin, workspace, key]);

const copyLink = async (url: string, button: HTMLButtonElement) => {
try {
await navigator.clipboard.writeText(url);
const previous = button.textContent;
button.textContent = "copied ✓";
setTimeout(() => (button.textContent = previous), 1500);
} catch {
/* clipboard blocked — leave the label */
}
};

return (
<div className="ws-search-results">
<div className="ws-search-chips">
{filters.map((f) => (
<Badge key={f.key}>
{f.key}={f.value}
<button
type="button"
className="ws-chip-remove"
aria-label={`Remove filter ${f.key}`}
onClick={() => onRemoveFilter(f.key)}
>
×
</button>
</Badge>
))}
</div>

{state.status === "loading" && <p className="ws-search-status">Searching…</p>}
{state.status === "error" && (
<Callout tone="error">Search is temporarily unavailable. Try again.</Callout>
)}
{state.status === "ok" && state.items.length === 0 && (
<p className="ws-search-status">No files match these filters.</p>
)}
{state.status === "ok" && state.items.length > 0 && (
<>
{state.truncated && (
<p className="ws-search-truncated">
Showing the first 100 matches — add a filter to narrow.
</p>
)}
<ul className="ws-search-list">
{state.items.map((item) => (
<li key={item.key} className="ws-search-row">
<div className="ws-search-thumb">
{item.url && IMAGE_EXT.test(item.key) ? (
<img src={item.url} alt="" loading="lazy" />
) : (
<span className="ws-search-glyph" aria-hidden="true">
</span>
)}
</div>
<div className="ws-search-body">
<span className="ws-search-name">{filename(item.key)}</span>
<span className="ws-search-meta">
{Object.entries(item.metadata).map(([k, v]) => (
<Badge key={k}>
{k}={v}
</Badge>
))}
</span>
</div>
<div className="ws-search-actions">
{item.url && (
<>
<a href={item.url} target="_blank" rel="noopener noreferrer">
Open ↗
</a>
<Button
type="button"
variant="ghost"
onClick={(e) => void copyLink(item.url as string, e.currentTarget)}
>
Copy link
</Button>
</>
)}
</div>
</li>
))}
</ul>
</>
)}
</div>
);
}
Loading
Loading