feat: added image uploads to /now/post c7ef8689
Steve · 2026-02-12 21:41 2 file(s) · +248 −11
packages/client/src/components/now/PostComposer.tsx +98 −2
1 -
import { useState } from "react";
1 +
import { useState, useRef } from "react";
2 2
import { useAuth } from "../auth/AuthStatus";
3 3
4 4
const API_URL = import.meta.env.PUBLIC_API_URL || "https://api.stevedylan.dev";
9 9
	onPostCreated?: () => void;
10 10
}
11 11
12 +
interface UploadedImage {
13 +
	blob: {
14 +
		$type: string;
15 +
		ref: { $link: string };
16 +
		mimeType: string;
17 +
		size: number;
18 +
	};
19 +
	blobUrl: string;
20 +
}
21 +
12 22
export function PostComposer({ onPostCreated }: PostComposerProps) {
13 23
	const { authenticated } = useAuth();
14 24
	const [title, setTitle] = useState("");
17 27
	const [isSubmitting, setIsSubmitting] = useState(false);
18 28
	const [error, setError] = useState<string | null>(null);
19 29
	const [success, setSuccess] = useState(false);
30 +
	const [isUploading, setIsUploading] = useState(false);
31 +
	const [uploadedImage, setUploadedImage] = useState<UploadedImage | null>(
32 +
		null,
33 +
	);
34 +
	const fileInputRef = useRef<HTMLInputElement>(null);
20 35
21 36
	if (!authenticated) {
22 37
		return null;
23 38
	}
24 39
40 +
	const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
41 +
		const file = e.target.files?.[0];
42 +
		if (!file) return;
43 +
44 +
		const allowedTypes = ["image/png", "image/jpeg", "image/webp", "image/gif"];
45 +
		if (!allowedTypes.includes(file.type)) {
46 +
			setError("Invalid file type. Allowed: png, jpeg, webp, gif");
47 +
			return;
48 +
		}
49 +
50 +
		const maxSize = 100 * 1024 * 1024;
51 +
		if (file.size > maxSize) {
52 +
			setError("File too large. Maximum size is 100MB");
53 +
			return;
54 +
		}
55 +
56 +
		setIsUploading(true);
57 +
		setError(null);
58 +
59 +
		try {
60 +
			const formData = new FormData();
61 +
			formData.append("file", file);
62 +
63 +
			const response = await fetch(`${API_URL}/now/upload`, {
64 +
				method: "POST",
65 +
				credentials: "include",
66 +
				body: formData,
67 +
			});
68 +
69 +
			const data = await response.json();
70 +
71 +
			if (!response.ok) {
72 +
				throw new Error(data.error || "Failed to upload image");
73 +
			}
74 +
75 +
			setUploadedImage({ blob: data.blob, blobUrl: data.blobUrl });
76 +
			setContent((prev) =>
77 +
				prev
78 +
					? `${prev}\n\n![image](${data.blobUrl})`
79 +
					: `![image](${data.blobUrl})`,
80 +
			);
81 +
		} catch (err) {
82 +
			setError(
83 +
				err instanceof Error ? err.message : "Failed to upload image",
84 +
			);
85 +
		} finally {
86 +
			setIsUploading(false);
87 +
			if (fileInputRef.current) {
88 +
				fileInputRef.current.value = "";
89 +
			}
90 +
		}
91 +
	};
92 +
25 93
	const handleSubmit = async (e: React.FormEvent) => {
26 94
		e.preventDefault();
27 95
59 127
							: `/${path.trim()}`
60 128
						: undefined,
61 129
					content: content.trim(),
130 +
					coverImage: uploadedImage?.blob,
62 131
				}),
63 132
			});
64 133
71 140
			setTitle("");
72 141
			setPath("");
73 142
			setContent("");
143 +
			setUploadedImage(null);
74 144
			setSuccess(true);
75 145
			setTimeout(() => setSuccess(false), 3000);
76 146
152 222
				/>
153 223
			</div>
154 224
225 +
			{/* Image Upload */}
226 +
			<div className="mb-4 flex items-center gap-3">
227 +
				<input
228 +
					ref={fileInputRef}
229 +
					type="file"
230 +
					accept="image/png,image/jpeg,image/webp,image/gif"
231 +
					onChange={handleImageUpload}
232 +
					className="hidden"
233 +
				/>
234 +
				<button
235 +
					type="button"
236 +
					onClick={() => fileInputRef.current?.click()}
237 +
					disabled={isSubmitting || isUploading}
238 +
					className="px-3 py-1.5 border border-white text-white text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
239 +
				>
240 +
					{isUploading ? "Uploading..." : "Add Image"}
241 +
				</button>
242 +
				{uploadedImage && (
243 +
					<span className="text-xs text-green-400">Image uploaded</span>
244 +
				)}
245 +
			</div>
246 +
155 247
			<div className="flex items-center justify-between mt-3">
156 248
				<div className="flex items-center gap-3">
157 249
					{error && <span className="text-sm text-red-500">{error}</span>}
164 256
				<button
165 257
					type="submit"
166 258
					disabled={
167 -
						isSubmitting || isTitleOverLimit || !title.trim() || !content.trim()
259 +
						isSubmitting ||
260 +
						isUploading ||
261 +
						isTitleOverLimit ||
262 +
						!title.trim() ||
263 +
						!content.trim()
168 264
					}
169 265
					className="px-4 py-2 border-white border text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
170 266
				>
packages/server/src/routes/now.ts +150 −9
38 38
// 	}
39 39
// }
40 40
41 +
// Upload an image blob to the PDS
42 +
now.post("/upload", async (c) => {
43 +
	try {
44 +
		const sessionId = getSessionIdFromCookie(c);
45 +
		if (!sessionId) {
46 +
			return c.json({ error: "Not authenticated" }, 401);
47 +
		}
48 +
49 +
		const sessionData = await getSession(c.env.SESSIONS, sessionId);
50 +
		if (!sessionData) {
51 +
			return c.json({ error: "Session not found" }, 401);
52 +
		}
53 +
54 +
		let { session, dpopKeyPair } = sessionData;
55 +
56 +
		// Refresh token if expired
57 +
		if (isTokenExpired(session.expiresAt) && session.refreshToken) {
58 +
			const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
59 +
			const clientId = `${c.env.API_URL}/auth/client-metadata.json`;
60 +
61 +
			const { tokenResponse, dpopNonce } = await refreshAccessToken(
62 +
				metadata,
63 +
				session.refreshToken,
64 +
				clientId,
65 +
				dpopKeyPair,
66 +
				session.dpopNonce,
67 +
			);
68 +
69 +
			await updateSession(
70 +
				c.env.SESSIONS,
71 +
				sessionId,
72 +
				tokenResponse.access_token,
73 +
				tokenResponse.refresh_token || session.refreshToken,
74 +
				dpopNonce,
75 +
				tokenResponse.expires_in,
76 +
			);
77 +
78 +
			session.accessToken = tokenResponse.access_token;
79 +
			session.dpopNonce = dpopNonce;
80 +
		}
81 +
82 +
		// Parse multipart form data
83 +
		const body = await c.req.parseBody();
84 +
		const file = body["file"];
85 +
86 +
		if (!file || !(file instanceof File)) {
87 +
			return c.json({ error: "No file provided" }, 400);
88 +
		}
89 +
90 +
		// Validate file type
91 +
		const allowedTypes = ["image/png", "image/jpeg", "image/webp", "image/gif"];
92 +
		if (!allowedTypes.includes(file.type)) {
93 +
			return c.json(
94 +
				{ error: "Invalid file type. Allowed: png, jpeg, webp, gif" },
95 +
				400,
96 +
			);
97 +
		}
98 +
99 +
		// Validate file size (max 100MB - matches PDS_BLOB_UPLOAD_LIMIT)
100 +
		const maxSize = 100 * 1024 * 1024;
101 +
		if (file.size > maxSize) {
102 +
			return c.json({ error: "File too large. Maximum size is 100MB" }, 400);
103 +
		}
104 +
105 +
		const fileBytes = new Uint8Array(await file.arrayBuffer());
106 +
		const uploadUrl = `${c.env.PDS_URL}/xrpc/com.atproto.repo.uploadBlob`;
107 +
108 +
		const makeUploadRequest = async (nonce?: string): Promise<Response> => {
109 +
			const dpopProof = await createDPoPProof(dpopKeyPair, {
110 +
				method: "POST",
111 +
				url: uploadUrl,
112 +
				nonce: nonce || session.dpopNonce,
113 +
				accessToken: session.accessToken,
114 +
			});
115 +
116 +
			return fetch(uploadUrl, {
117 +
				method: "POST",
118 +
				headers: {
119 +
					"Content-Type": file.type,
120 +
					Authorization: `DPoP ${session.accessToken}`,
121 +
					DPoP: dpopProof,
122 +
				},
123 +
				body: fileBytes,
124 +
			});
125 +
		};
126 +
127 +
		let response = await makeUploadRequest();
128 +
129 +
		// Handle DPoP nonce requirement
130 +
		if (response.status === 401) {
131 +
			const newNonce = extractDPoPNonce(response);
132 +
			if (newNonce) {
133 +
				response = await makeUploadRequest(newNonce);
134 +
135 +
				await updateSession(
136 +
					c.env.SESSIONS,
137 +
					sessionId,
138 +
					session.accessToken,
139 +
					session.refreshToken,
140 +
					newNonce,
141 +
					Math.floor((session.expiresAt - Date.now()) / 1000),
142 +
				);
143 +
			}
144 +
		}
145 +
146 +
		if (!response.ok) {
147 +
			const errorData = await response.json();
148 +
			console.error("Failed to upload blob:", errorData);
149 +
			return c.json(
150 +
				{ error: "Failed to upload image", details: errorData },
151 +
				response.status as 400 | 401 | 403 | 500,
152 +
			);
153 +
		}
154 +
155 +
		const result = (await response.json()) as {
156 +
			blob: {
157 +
				$type: string;
158 +
				ref: { $link: string };
159 +
				mimeType: string;
160 +
				size: number;
161 +
			};
162 +
		};
163 +
164 +
		// Build public blob URL
165 +
		const blobUrl = `https://andromeda.social/xrpc/com.atproto.sync.getBlob?did=${session.did}&cid=${result.blob.ref.$link}`;
166 +
167 +
		return c.json({ blob: result.blob, blobUrl });
168 +
	} catch (error) {
169 +
		console.error("Error uploading blob:", error);
170 +
		return c.json({ error: "Internal server error" }, 500);
171 +
	}
172 +
});
173 +
41 174
// Create a new post
42 175
now.post("/post", async (c) => {
43 176
	try {
87 220
			title: string;
88 221
			path?: string;
89 222
			content: string;
223 +
			coverImage?: {
224 +
				$type: string;
225 +
				ref: { $link: string };
226 +
				mimeType: string;
227 +
				size: number;
228 +
			};
90 229
		}>();
91 230
92 231
		if (!body.title || body.title.trim().length === 0) {
143 282
			.replace(/`([^`]+)`/g, "$1") // Remove code formatting
144 283
			.trim();
145 284
285 +
		const defaultCoverImage = {
286 +
			$type: "blob",
287 +
			ref: {
288 +
				$link:
289 +
					"bafkreibuxyp2gth3igqik7fxu4cm4nducetgp67hhlx36bwahgnuw4xmoa",
290 +
			},
291 +
			mimeType: "image/png",
292 +
			size: 2522,
293 +
		};
294 +
146 295
		const documentRecord = {
147 296
			repo: session.did,
148 297
			collection: "site.standard.document",
152 301
				site: "at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.publication/3mbykzswhqc2x",
153 302
				...(normalizedPath && { path: normalizedPath.trim() }),
154 303
				content: markdownContent,
155 -
				coverImage: {
156 -
					$type: "blob",
157 -
					ref: {
158 -
						$link:
159 -
							"bafkreibuxyp2gth3igqik7fxu4cm4nducetgp67hhlx36bwahgnuw4xmoa",
160 -
					},
161 -
					mimeType: "image/png",
162 -
					size: 2522,
163 -
				},
304 +
				coverImage: body.coverImage || defaultCoverImage,
164 305
				textContent: textContent,
165 306
				publishedAt: new Date().toISOString(),
166 307
			},