Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/public-dir-not-only-public-val.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@valbuild/core": patch
"@valbuild/shared": patch
"@valbuild/server": patch
"@valbuild/cli": patch
"@valbuild/ui": patch
---

Allow storing files anywhere under `/public`, not only `/public/val`. Config validation, remote refs, and the studio file/image fields now accept any `/public/...` directory (the default remains `/public/val`). Also removed the `files` property from `SharedValConfig` — the per-schema `s.files`/`s.images` directory is now the source of truth.
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function main(): Promise<void> {

Command: list-unused-files
Description: EXPERIMENTAL.
List files that are in public/val but not in use by any Val module.
List files that are in the configured files directory (files.directory, default public/val) but not in use by any Val module.
This is useful for cleaning up unused files.
Options:
--root [root], -r [root] Set project root directory (default process.cwd())
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/listUnusedFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ import {
import { createService } from "@valbuild/server";
import { glob } from "fast-glob";
import path from "path";
import { evalValConfigFile } from "./utils/evalValConfigFile";

export async function listUnusedFiles({ root }: { root?: string }) {
const managedDir = "public/val";
const projectRoot = root ? path.resolve(root) : process.cwd();

const valConfigFile =
(await evalValConfigFile(projectRoot, "val.config.ts")) ||
(await evalValConfigFile(projectRoot, "val.config.js"));
// Strip the leading "/" so it is relative to the project root (e.g. "public/val").
const managedDir = (valConfigFile?.files?.directory ?? "/public/val").replace(
/^\//,
"",
);

const service = await createService(projectRoot, {});

const valFiles: string[] = await glob("**/*.val.{js,ts}", {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/runValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,12 @@ export async function handleRemoteFileUpload(
const relativeFilePath = path
.relative(ctx.projectRoot, filePath)
.split(path.sep)
.join("/") as `public/val/${string}`;
.join("/") as `public/${string}`;

if (!relativeFilePath.startsWith("public/val/")) {
if (!relativeFilePath.startsWith("public/")) {
return {
success: false,
errorMessage: `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`,
errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`,
};
}

Expand Down
18 changes: 13 additions & 5 deletions packages/cli/src/utils/evalValConfigFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@ const ValConfigSchema = z.object({
root: z.string().optional(),
files: z
.object({
directory: z
.string()
.refine((val): val is `/public/val` => val.startsWith("/public/val"), {
message: "files.directory must start with '/public/val'",
}),
directory: z.string().refine(
(val): val is `/public` | `/public/${string}` =>
(val === "/public" ||
(val.startsWith("/public/") && !val.endsWith("/"))) &&
// Reject path traversal so the directory cannot escape /public
!val
.split("/")
.some((segment) => segment === "." || segment === ".."),
{
message:
"files.directory must start with '/public', must not end with '/' and must not contain '.' or '..' segments",
},
),
})
.optional(),
gitCommit: z.string().optional(),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/initVal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type ValConstructor = {
unstable_getPath: typeof getPath;
};

export type ConfigDirectory = `/public/val`;
export type ConfigDirectory = `/public` | `/public/${string}`;

export type ValConfig = {
project?: string;
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/remote/splitRemoteRef.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,77 @@ describe("splitRemoteRef", () => {
});
});

it("should handle a file path directly under public", () => {
const ref =
"https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/images/test.png";
const result = splitRemoteRef(ref);

expect(result).toEqual({
status: "success",
remoteHost: "https://remote.val.build",
projectId: "project123",
bucket: "01",
version: "1.0.0",
validationHash: "abc123",
fileHash: "def456",
filePath: "public/images/test.png",
});
});

it("should return an error if the file path is not within public", () => {
const ref =
"https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/secret/test.png";
const result = splitRemoteRef(ref);

expect(result).toEqual({
status: "error",
error: "Invalid remote ref: " + ref,
});
});

it("should return an error if the file path traverses out of public", () => {
const ref =
"https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/../secret/test.png";
const result = splitRemoteRef(ref);

expect(result).toEqual({
status: "error",
error: "Invalid remote ref: " + ref,
});
});

it("should return an error if the file path contains a current dir segment", () => {
const ref =
"https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/./test.png";
const result = splitRemoteRef(ref);

expect(result).toEqual({
status: "error",
error: "Invalid remote ref: " + ref,
});
});

it("should not confuse a file name containing dots with traversal", () => {
const ref =
"https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/..hidden..test.png";
const result = splitRemoteRef(ref);

expect(result).toMatchObject({
status: "success",
filePath: "public/val/..hidden..test.png",
});
});

it("should return an error for a local ref", () => {
const ref = "/public/val/test.png";
const result = splitRemoteRef(ref);

expect(result).toEqual({
status: "error",
error: "Not a remote ref: " + ref,
});
});

it("should handle a remote ref with an HTTP host", () => {
const ref =
"http://example.com/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/test.png";
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/remote/splitRemoteRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function splitRemoteRef(ref: string):
version: string;
validationHash: string;
fileHash: string;
filePath: `public/val/${string}`;
filePath: `public/${string}`;
}
| {
status: "error";
Expand All @@ -29,7 +29,15 @@ export function splitRemoteRef(ref: string):
error: "Invalid remote ref: " + ref,
};
}
if (match[7].indexOf("public/val/") !== 0) {
if (match[7].indexOf("public/") !== 0) {
return {
status: "error",
error: "Invalid remote ref: " + ref,
};
}
if (
match[7].split("/").some((segment) => segment === "." || segment === "..")
) {
return {
status: "error",
error: "Invalid remote ref: " + ref,
Expand All @@ -44,6 +52,6 @@ export function splitRemoteRef(ref: string):
version: match[4],
validationHash: match[5],
fileHash: match[6],
filePath: match[7] as `public/val/${string}`,
filePath: match[7] as `public/${string}`,
};
}
107 changes: 107 additions & 0 deletions packages/core/src/schema/images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,113 @@ describe("ImagesSchema", () => {
});
});

describe("defaults", () => {
test("should default accept to image/* when options are omitted", () => {
const schema = images();
const serialized = schema["executeSerialize"]();
expect((serialized as SerializedImagesSchema).mediaType).toBe("images");
expect(serialized.accept).toBe("image/*");
expect(serialized.directory).toBe("/public/val");
expect(serialized.remote).toBe(false);
expect(serialized.opt).toBe(false);
});

test("should default accept to image/* when options are empty", () => {
const serialized = images({})["executeSerialize"]();
expect(serialized.accept).toBe("image/*");
expect(serialized.directory).toBe("/public/val");
});

test("should default accept to image/* when only directory is given", () => {
const serialized = images({ directory: "/public/images" })[
"executeSerialize"
]();
expect(serialized.accept).toBe("image/*");
expect(serialized.directory).toBe("/public/images");
});

test("should default alt to a nullable string when options are omitted", () => {
const serialized = images()["executeSerialize"]();
expect(serialized.alt).toMatchObject({ type: "string", opt: true });
});

test("should accept any image mime type when options are omitted", () => {
const schema = images();
const src: Record<string, ImagesEntryMetadata> = {
"/public/val/test.png": {
width: 800,
height: 600,
mimeType: "image/png",
alt: null,
},
};
expect(
filterCheckErrors(schema["executeValidate"]("path" as SourcePath, src)),
).toBeFalsy();
});

test("should reject non-image mime types when options are omitted", () => {
const schema = images();
const src = {
"/public/val/test.pdf": {
width: 800,
height: 600,
mimeType: "application/pdf",
alt: null,
},
};
const result = schema["executeValidate"](
"path" as SourcePath,
src as unknown as Record<string, ImagesEntryMetadata>,
);
expect(result).toBeTruthy();
const errors = Object.values(result as object).flat();
const hasMimeError = errors.some((e: { message: string }) =>
e.message.includes("Mime type mismatch"),
);
expect(hasMimeError).toBe(true);
});

test("should use the default /public/val directory when options are omitted", () => {
const schema = images();
const src: Record<string, ImagesEntryMetadata> = {
"/public/other/test.png": {
width: 800,
height: 600,
mimeType: "image/png",
alt: null,
},
};
const result = schema["executeValidate"]("path" as SourcePath, src);
expect(result).toBeTruthy();
const errors = Object.values(result as object).flat();
const hasDirError = errors.some((e: { message: string }) =>
e.message.includes("must be within the /public/val/ directory"),
);
expect(hasDirError).toBe(true);
});

test("should not allow remote refs when options are omitted", () => {
const schema = images();
const src: Record<string, ImagesEntryMetadata> = {
"https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/image.webp":
{
width: 800,
height: 600,
mimeType: "image/webp",
alt: null,
},
};
const result = schema["executeValidate"]("path" as SourcePath, src);
expect(result).toBeTruthy();
const errors = Object.values(result as object).flat();
const hasRemoteError = errors.some((e: { message: string }) =>
e.message.includes("Remote URLs are not allowed"),
);
expect(hasRemoteError).toBe(true);
});
});

describe("custom validation", () => {
test("should support custom validation function", () => {
const schema = images({ accept: "image/webp" }).validate((src) => {
Expand Down
16 changes: 10 additions & 6 deletions packages/core/src/schema/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ export type AltSchema =
export type ImagesOptions<Accept extends `image/${string}`> = {
/**
* The accepted mime type pattern. Must be an image type (e.g., "image/png", "image/webp", "image/*")
* @default "image/*"
*/
accept: Accept;
accept?: Accept;
/**
Comment thread
freekh marked this conversation as resolved.
* The directory where images should be stored.
* Must start with "/public" (e.g., "/public/val/images")
Expand Down Expand Up @@ -74,6 +75,9 @@ type ImagesItemSrc = {
/**
* Define a collection of images.
*
* All options are optional: `s.images()` accepts any image type (`"image/*"`) in
* the default `/public/val` directory, with nullable alt text and remote disabled.
*
* @example
* ```typescript
* const schema = s.images({
Expand All @@ -92,14 +96,14 @@ type ImagesItemSrc = {
* ```
*/
export const images = <Accept extends `image/${string}`>(
options: ImagesOptions<Accept>,
options?: ImagesOptions<Accept>,
): RecordSchema<
ObjectSchema<ImagesItemProps, ImagesItemSrc>,
Schema<string>,
Record<string, ImagesEntryMetadata>
> => {
const directory = options.directory ?? "/public/val";
const altSchema = options.alt ?? string().nullable();
const directory = options?.directory ?? "/public/val";
const altSchema = options?.alt ?? string().nullable();
const itemSchema = new ObjectSchema(
{
width: new NumberSchema<number>(undefined, false),
Expand All @@ -111,9 +115,9 @@ export const images = <Accept extends `image/${string}`>(
) as ObjectSchema<ImagesItemProps, ImagesItemSrc>;
return new RecordSchema(itemSchema, false, [], null, null, {
type: "images",
accept: options.accept,
accept: options?.accept ?? "image/*",
directory,
remote: options.remote ?? false,
remote: options?.remote ?? false,
altSchema,
});
Comment thread
freekh marked this conversation as resolved.
};
5 changes: 3 additions & 2 deletions packages/core/src/source/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ export const initRemote = (config?: ValConfig) => {
return remote;
};

// NOTE: the segments must match the ref built by createRemoteRef below and the RegEx in splitRemoteRef.
export type RemoteRef =
`${string}/file/p/${string}/v/${string}/h/${string}/f/${string}/p/public/val/${string}`;
`${string}/file/p/${string}/b/${string}/v/${string}/h/${string}/f/${string}/p/public/${string}`;

Comment thread
freekh marked this conversation as resolved.
export function createRemoteRef(
remoteHost: string,
Expand All @@ -47,7 +48,7 @@ export function createRemoteRef(
coreVersion: string;
validationHash: string;
fileHash: string;
filePath: `public/val/${string}`;
filePath: `public/${string}`;
bucket: string;
},
): RemoteRef {
Expand Down
Loading
Loading