feat: added image uploads to /now/post
c7ef8689
2 file(s) · +248 −11
| 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` |
|
| 79 | + | : ``, |
|
| 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 | > |
|
| 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 | }, |
|