Skip to content

Commit 5df0ce5

Browse files
fix: image upload via /image slash command not working (#184) (#195)
* fix: image upload via /image slash command not working (#184) - Add page-images storage bucket migration with RLS policies - Return structured errors from uploadImage instead of silent null - Show toast notifications on upload failure (type, size, storage errors) - Add regression tests for uploadImage validation and error paths Co-authored-by: Ona <no-reply@ona.com> * fix: [ci-fix] add missing SQL to page-images bucket migration The migration file was empty — no SQL to create the storage bucket or RLS policies. Added bucket creation with size/type constraints and policies for authenticated upload, public read, and owner delete. Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
1 parent 3da9337 commit 5df0ce5

3 files changed

Lines changed: 190 additions & 17 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
// Mock Supabase client
4+
const mockUpload = vi.fn();
5+
const mockGetPublicUrl = vi.fn();
6+
7+
vi.mock("@/lib/supabase/client", () => ({
8+
createClient: () => ({
9+
storage: {
10+
from: () => ({
11+
upload: mockUpload,
12+
getPublicUrl: mockGetPublicUrl,
13+
}),
14+
},
15+
}),
16+
}));
17+
18+
vi.mock("@/lib/sentry", () => ({
19+
captureSupabaseError: vi.fn(),
20+
}));
21+
22+
vi.mock("@sentry/nextjs", () => ({
23+
captureException: vi.fn(),
24+
}));
25+
26+
vi.mock("sonner", () => ({
27+
toast: { error: vi.fn() },
28+
}));
29+
30+
import { uploadImage } from "./image-plugin";
31+
32+
function makeFile(
33+
name: string,
34+
type: string,
35+
sizeBytes: number = 1024,
36+
): File {
37+
const buffer = new ArrayBuffer(sizeBytes);
38+
return new File([buffer], name, { type });
39+
}
40+
41+
beforeEach(() => {
42+
vi.restoreAllMocks();
43+
});
44+
45+
describe("uploadImage", () => {
46+
it("rejects unsupported image types with an error message", async () => {
47+
const file = makeFile("doc.pdf", "application/pdf");
48+
const result = await uploadImage(file);
49+
50+
expect(result.url).toBeNull();
51+
expect(result.error).toBe(
52+
"Unsupported image type. Use PNG, JPEG, GIF, WebP, or SVG.",
53+
);
54+
expect(mockUpload).not.toHaveBeenCalled();
55+
});
56+
57+
it("rejects files exceeding 5 MB", async () => {
58+
const file = makeFile("big.png", "image/png", 6 * 1024 * 1024);
59+
const result = await uploadImage(file);
60+
61+
expect(result.url).toBeNull();
62+
expect(result.error).toBe("Image is too large. Maximum size is 5 MB.");
63+
expect(mockUpload).not.toHaveBeenCalled();
64+
});
65+
66+
it("returns an error when Supabase upload fails", async () => {
67+
mockUpload.mockResolvedValue({
68+
error: { message: "Bucket not found", statusCode: "404" },
69+
});
70+
71+
const file = makeFile("photo.png", "image/png");
72+
const result = await uploadImage(file);
73+
74+
expect(result.url).toBeNull();
75+
expect(result.error).toBe("Failed to upload image");
76+
expect(mockUpload).toHaveBeenCalledTimes(1);
77+
});
78+
79+
it("returns the public URL on successful upload", async () => {
80+
mockUpload.mockResolvedValue({ error: null });
81+
mockGetPublicUrl.mockReturnValue({
82+
data: { publicUrl: "https://storage.example.com/uploads/test.png" },
83+
});
84+
85+
const file = makeFile("photo.png", "image/png");
86+
const result = await uploadImage(file);
87+
88+
expect(result.error).toBeNull();
89+
expect(result.url).toBe(
90+
"https://storage.example.com/uploads/test.png",
91+
);
92+
});
93+
94+
it("accepts all supported image MIME types", async () => {
95+
mockUpload.mockResolvedValue({ error: null });
96+
mockGetPublicUrl.mockReturnValue({
97+
data: { publicUrl: "https://storage.example.com/uploads/img.png" },
98+
});
99+
100+
const types = [
101+
"image/png",
102+
"image/jpeg",
103+
"image/gif",
104+
"image/webp",
105+
"image/svg+xml",
106+
];
107+
108+
for (const type of types) {
109+
const ext = type.split("/")[1].split("+")[0];
110+
const file = makeFile(`test.${ext}`, type);
111+
const result = await uploadImage(file);
112+
expect(result.error).toBeNull();
113+
expect(result.url).toBeTruthy();
114+
}
115+
});
116+
117+
it("accepts files exactly at the 5 MB limit", async () => {
118+
mockUpload.mockResolvedValue({ error: null });
119+
mockGetPublicUrl.mockReturnValue({
120+
data: { publicUrl: "https://storage.example.com/uploads/exact.png" },
121+
});
122+
123+
const file = makeFile("exact.png", "image/png", 5 * 1024 * 1024);
124+
const result = await uploadImage(file);
125+
126+
expect(result.error).toBeNull();
127+
expect(result.url).toBeTruthy();
128+
});
129+
});

src/components/editor/image-plugin.tsx

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type LexicalCommand,
1414
} from "lexical";
1515
import * as Sentry from "@sentry/nextjs";
16+
import { toast } from "sonner";
1617
import { $createImageNode, type ImagePayload } from "@/components/editor/image-node";
1718
import { createClient } from "@/lib/supabase/client";
1819
import { captureSupabaseError } from "@/lib/sentry";
@@ -30,9 +31,17 @@ const ACCEPTED_IMAGE_TYPES = new Set([
3031

3132
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
3233

33-
async function uploadImage(file: File): Promise<string | null> {
34-
if (!ACCEPTED_IMAGE_TYPES.has(file.type)) return null;
35-
if (file.size > MAX_FILE_SIZE) return null;
34+
type UploadResult =
35+
| { url: string; error: null }
36+
| { url: null; error: string };
37+
38+
export async function uploadImage(file: File): Promise<UploadResult> {
39+
if (!ACCEPTED_IMAGE_TYPES.has(file.type)) {
40+
return { url: null, error: "Unsupported image type. Use PNG, JPEG, GIF, WebP, or SVG." };
41+
}
42+
if (file.size > MAX_FILE_SIZE) {
43+
return { url: null, error: "Image is too large. Maximum size is 5 MB." };
44+
}
3645

3746
const supabase = createClient();
3847
const ext = file.name.split(".").pop() ?? "png";
@@ -48,14 +57,14 @@ async function uploadImage(file: File): Promise<string | null> {
4857

4958
if (error) {
5059
captureSupabaseError(error, "image-plugin:upload");
51-
return null;
60+
return { url: null, error: "Failed to upload image" };
5261
}
5362

5463
const { data: urlData } = supabase.storage
5564
.from("page-images")
5665
.getPublicUrl(filePath);
5766

58-
return urlData.publicUrl;
67+
return { url: urlData.publicUrl, error: null };
5968
}
6069

6170
export function ImagePlugin(): null {
@@ -98,16 +107,19 @@ export function ImagePlugin(): null {
98107

99108
for (const file of imageFiles) {
100109
void uploadImage(file)
101-
.then((url) => {
102-
if (url) {
103-
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
104-
src: url,
105-
altText: file.name,
106-
});
110+
.then((result) => {
111+
if (result.error !== null) {
112+
toast.error(result.error, { duration: 8000 });
113+
return;
107114
}
115+
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
116+
src: result.url,
117+
altText: file.name,
118+
});
108119
})
109120
.catch((error) => {
110121
Sentry.captureException(error);
122+
toast.error("Failed to upload image", { duration: 8000 });
111123
});
112124
}
113125

@@ -146,15 +158,18 @@ export function openImagePicker(editor: ReturnType<typeof useLexicalComposerCont
146158
if (!file) return;
147159

148160
try {
149-
const url = await uploadImage(file);
150-
if (url) {
151-
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
152-
src: url,
153-
altText: file.name,
154-
});
161+
const result = await uploadImage(file);
162+
if (result.error !== null) {
163+
toast.error(result.error, { duration: 8000 });
164+
return;
155165
}
166+
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
167+
src: result.url,
168+
altText: file.name,
169+
});
156170
} catch (error) {
157171
Sentry.captureException(error);
172+
toast.error("Failed to upload image", { duration: 8000 });
158173
}
159174
};
160175
input.click();
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- Create the page-images storage bucket for editor image uploads.
2+
-- Public bucket so images can be served without auth tokens.
3+
4+
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
5+
VALUES (
6+
'page-images',
7+
'page-images',
8+
true,
9+
5242880, -- 5 MB
10+
ARRAY['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
11+
);
12+
13+
-- Authenticated users can upload images
14+
CREATE POLICY "Authenticated users can upload page images"
15+
ON storage.objects FOR INSERT
16+
TO authenticated
17+
WITH CHECK (bucket_id = 'page-images');
18+
19+
-- Anyone can view uploaded images (public bucket)
20+
CREATE POLICY "Public read access for page images"
21+
ON storage.objects FOR SELECT
22+
TO public
23+
USING (bucket_id = 'page-images');
24+
25+
-- Image owners can delete their uploads
26+
CREATE POLICY "Users can delete own page images"
27+
ON storage.objects FOR DELETE
28+
TO authenticated
29+
USING (bucket_id = 'page-images' AND (storage.foldername(name))[1] = 'uploads' AND auth.uid() = owner);

0 commit comments

Comments
 (0)