diff --git a/packages/happy-app/sources/app/(app)/settings/account.tsx b/packages/happy-app/sources/app/(app)/settings/account.tsx index 4744f28f7..813df7e67 100644 --- a/packages/happy-app/sources/app/(app)/settings/account.tsx +++ b/packages/happy-app/sources/app/(app)/settings/account.tsx @@ -3,6 +3,7 @@ import { View, Text, Pressable, Platform } from 'react-native'; import { useAuth } from '@/auth/AuthContext'; import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; +import * as ImagePicker from 'expo-image-picker'; import { useFocusEffect } from '@react-navigation/native'; import { Typography } from '@/constants/Typography'; import { formatSecretKeyForBackup } from '@/auth/secretKeyBackup'; @@ -23,6 +24,7 @@ import { useHappyAction } from '@/hooks/useHappyAction'; import { disconnectGitHub } from '@/sync/apiGithub'; import { disconnectService } from '@/sync/apiServices'; import { fetchPushTokens, type PushToken } from '@/sync/apiPush'; +import { uploadAvatar } from '@/sync/apiUpload'; import { getCurrentExpoPushToken, getCurrentPushDeviceMetadata, @@ -163,6 +165,49 @@ export default React.memo(() => { }, [loadPushSettings]) ); + // Avatar upload + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const handleUploadAvatar = useCallback(async () => { + if (!auth.credentials) { + return; + } + + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permissionResult.granted) { + Modal.alert( + t('common.error'), + 'Photo library access is required to upload an avatar. Please enable it in your device settings.' + ); + return; + } + + const pickerResult = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + aspect: [1, 1], + quality: 0.85, + }); + + if (pickerResult.canceled || !pickerResult.assets[0]) { + return; + } + + const asset = pickerResult.assets[0]; + setUploadingAvatar(true); + try { + await uploadAvatar(auth.credentials, asset); + await sync.refreshProfile(); + } catch (error) { + console.error('Avatar upload failed:', error); + Modal.alert( + t('common.error'), + error instanceof Error ? error.message : 'Failed to upload avatar. Please try again.' + ); + } finally { + setUploadingAvatar(false); + } + }, [auth.credentials]); + // GitHub disconnection const [disconnecting, handleDisconnectGitHub] = useHappyAction(async () => { const confirmed = await Modal.confirm( @@ -343,39 +388,46 @@ export default React.memo(() => { {/* Profile Section */} - {(displayName || githubUsername || profile.avatar) && ( - - {displayName && ( - - )} - {githubUsername && ( - - ) : ( - - )} + + {displayName && ( + + )} + + ) : ( + )} - - )} + /> + {githubUsername && ( + } + /> + )} + {/* Connected Services Section */} {profile.connectedServices && profile.connectedServices.length > 0 && (() => { diff --git a/packages/happy-app/sources/sync/apiUpload.ts b/packages/happy-app/sources/sync/apiUpload.ts new file mode 100644 index 000000000..0a99281b1 --- /dev/null +++ b/packages/happy-app/sources/sync/apiUpload.ts @@ -0,0 +1,111 @@ +import { getServerUrl } from "@/sync/serverConfig"; +import type { AuthCredentials } from "@/auth/tokenStorage"; +import type { ImagePickerAsset } from "expo-image-picker"; + +const UPLOAD_ROUTE = "avatarUpload"; + +/** + * Uploads a user avatar directly to Cloudflare R2 using a presigned URL + * issued by the server. + * + * Flow: + * 1. Request a presigned upload URL from the server (POST /v1/upload?action=presign) + * 2. PUT the image bytes straight to R2 — the file never transits the server + * 3. Notify the server that the upload finished (POST /v1/upload?action=complete) + * which triggers thumbhash generation and the account avatar update + */ +export async function uploadAvatar( + credentials: AuthCredentials, + asset: ImagePickerAsset +): Promise { + const serverUrl = getServerUrl(); + const endpoint = `${serverUrl}/v1/upload`; + const authHeader = `Bearer ${credentials.token}`; + + const fileName = asset.fileName ?? `avatar.${asset.mimeType === "image/png" ? "png" : "jpg"}`; + const mimeType = asset.mimeType ?? "image/jpeg"; + const fileSize = asset.fileSize ?? 0; + + // ── Step 1: Request a presigned URL ────────────────────────────────────── + const presignResponse = await fetch( + `${endpoint}?route=${UPLOAD_ROUTE}&action=presign`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": authHeader, + }, + body: JSON.stringify({ + files: [{ name: fileName, size: fileSize, type: mimeType }], + }), + } + ); + + if (!presignResponse.ok) { + const err = await presignResponse.json().catch(() => ({})) as Record; + throw new Error((err as { error?: string }).error ?? `Presign failed: ${presignResponse.status}`); + } + + const presignData = await presignResponse.json() as { + success: boolean; + error?: string; + results: Array<{ + success: boolean; + presignedUrl: string; + key: string; + error?: string; + }>; + }; + + if (!presignData.success || !presignData.results?.[0]?.success) { + throw new Error(presignData.error ?? presignData.results?.[0]?.error ?? "Failed to get presigned URL"); + } + + const { presignedUrl, key } = presignData.results[0]; + + // ── Step 2: Upload the image bytes directly to R2 ──────────────────────── + // React Native's fetch supports reading local file URIs as blobs. + const fileResponse = await fetch(asset.uri); + const blob = await fileResponse.blob(); + + const uploadResponse = await fetch(presignedUrl, { + method: "PUT", + headers: { "Content-Type": mimeType }, + body: blob, + }); + + if (!uploadResponse.ok) { + throw new Error(`R2 upload failed: ${uploadResponse.status}`); + } + + // ── Step 3: Notify the server to finalise the avatar ───────────────────── + // The server fetches the image from R2, generates the thumbhash with Sharp, + // persists the file record, updates the account avatar, and emits a + // real-time socket update to all connected clients. + const completeResponse = await fetch( + `${endpoint}?route=${UPLOAD_ROUTE}&action=complete`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": authHeader, + }, + body: JSON.stringify({ + completions: [ + { + key, + file: { name: fileName, size: fileSize, type: mimeType }, + }, + ], + }), + } + ); + + if (!completeResponse.ok) { + // The upload itself succeeded — only the DB record and socket update failed. + // Log the error but don't surface it as a hard failure. + const err = await completeResponse.json().catch(() => ({})) as Record; + console.error("Avatar finalisation failed:", err); + throw new Error((err as { error?: string }).error ?? `Finalise failed: ${completeResponse.status}`); + } +} diff --git a/packages/happy-server/.env.dev b/packages/happy-server/.env.dev index 87e9b33e9..0bd6a67d8 100644 --- a/packages/happy-server/.env.dev +++ b/packages/happy-server/.env.dev @@ -28,7 +28,7 @@ DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING=true # Redis (cross-process pub/sub) # REDIS_URL=redis://localhost:6379 -# S3/MinIO — file storage (start with `yarn s3`) +# S3/MinIO — legacy file storage for GitHub OAuth avatars (start with `yarn s3`) # S3_HOST=localhost # S3_PORT=9000 # S3_USE_SSL=false @@ -37,5 +37,14 @@ DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING=true # S3_BUCKET=happy # S3_PUBLIC_URL=http://localhost:9000/happy +# Cloudflare R2 — user-uploaded files (avatar photos, etc.) +# Create an R2 bucket and an API token with Object Read & Write permissions. +# See: https://developers.cloudflare.com/r2/api/s3/tokens/ +# CLOUDFLARE_ACCOUNT_ID=your-account-id +# CLOUDFLARE_R2_BUCKET=happy-uploads +# CLOUDFLARE_R2_ACCESS_KEY_ID=your-r2-access-key-id +# CLOUDFLARE_R2_SECRET_ACCESS_KEY=your-r2-secret-access-key +# CLOUDFLARE_R2_PUBLIC_URL=https://pub-your-bucket-hash.r2.dev + # Voice — 11Labs API key (secret, not checked in) # ELEVENLABS_API_KEY= diff --git a/packages/happy-server/sources/app/api/api.ts b/packages/happy-server/sources/app/api/api.ts index c24e28ef0..8cde2dc48 100644 --- a/packages/happy-server/sources/app/api/api.ts +++ b/packages/happy-server/sources/app/api/api.ts @@ -22,6 +22,7 @@ import { userRoutes } from "./routes/userRoutes"; import { feedRoutes } from "./routes/feedRoutes"; import { kvRoutes } from "./routes/kvRoutes"; import { v3SessionRoutes } from "./routes/v3SessionRoutes"; +import { uploadRoutes } from "./routes/uploadRoutes"; import { isLocalStorage, getLocalFilesDir } from "@/storage/files"; import * as path from "path"; import * as fs from "fs"; @@ -90,8 +91,9 @@ export async function startApi() { feedRoutes(typed); kvRoutes(typed); v3SessionRoutes(typed); + uploadRoutes(typed); - // Start HTTP + // Start HTTP const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005; await app.listen({ port, host: '0.0.0.0' }); onShutdown('api', async () => { diff --git a/packages/happy-server/sources/app/api/routes/uploadRoutes.ts b/packages/happy-server/sources/app/api/routes/uploadRoutes.ts new file mode 100644 index 000000000..b35696d6d --- /dev/null +++ b/packages/happy-server/sources/app/api/routes/uploadRoutes.ts @@ -0,0 +1,178 @@ +import { createUploadConfig } from "pushduck/dist/server"; +import { toFastifyHandler } from "pushduck/dist/adapters/index"; +import { Fastify } from "../types"; +import { auth } from "@/app/auth/auth"; +import { processImage } from "@/storage/processImage"; +import { db } from "@/storage/db"; +import { allocateUserSeq } from "@/storage/seq"; +import { eventRouter } from "@/app/events/eventRouter"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { log } from "@/utils/log"; +import type { UpdatePayload } from "@/app/events/eventRouter"; + +// +// R2 configuration +// +// Required environment variables: +// CLOUDFLARE_R2_ACCESS_KEY_ID — R2 API token access key ID +// CLOUDFLARE_R2_SECRET_ACCESS_KEY — R2 API token secret access key +// CLOUDFLARE_ACCOUNT_ID — Cloudflare account ID +// CLOUDFLARE_R2_BUCKET — R2 bucket name +// CLOUDFLARE_R2_PUBLIC_URL — Public base URL for the bucket +// (e.g. https://pub-abc123.r2.dev or custom domain) +// + +const r2Configured = + !!process.env.CLOUDFLARE_R2_ACCESS_KEY_ID && + !!process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY && + !!process.env.CLOUDFLARE_ACCOUNT_ID && + !!process.env.CLOUDFLARE_R2_BUCKET; + +if (!r2Configured) { + log( + { module: "upload", level: "warn" }, + "Cloudflare R2 is not fully configured — upload routes will return 503 until all CLOUDFLARE_R2_* env vars are set" + ); +} + +const { s3 } = createUploadConfig() + .provider("cloudflareR2", { + accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID ?? "", + secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY ?? "", + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "", + bucket: process.env.CLOUDFLARE_R2_BUCKET ?? "", + customDomain: process.env.CLOUDFLARE_R2_PUBLIC_URL, + }) + .build(); + +const uploadRouter = s3.createRouter({ + avatarUpload: s3 + .image() + .maxFileSize("5MB") + .middleware(async ({ req }) => { + if (!r2Configured) { + throw new Error("Upload service is not configured"); + } + + const authHeader = req.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + throw new Error("Unauthorized"); + } + + const token = authHeader.substring(7); + const verified = await auth.verifyToken(token); + if (!verified) { + throw new Error("Unauthorized"); + } + + return { userId: verified.userId }; + }) + .paths({ + generateKey: ({ file, metadata }) => { + const ext = file.name.toLowerCase().endsWith(".png") ? "png" : "jpg"; + const key = randomKeyNaked(12); + return `public/users/${metadata.userId}/avatars/${key}.${ext}`; + }, + }) + .onUploadComplete(async ({ file: _file, url, key, metadata }) => { + try { + if (!url) { + throw new Error("Upload completed but no public URL was returned"); + } + + // Fetch the uploaded image from R2 to generate the thumbhash + // and extract dimensions — this runs server-side via Sharp. + const imageResponse = await fetch(url); + if (!imageResponse.ok) { + throw new Error( + `Failed to fetch uploaded avatar from R2: ${imageResponse.status}` + ); + } + const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); + const processed = await processImage(imageBuffer); + + // Update the database atomically: remove any old user-uploaded avatar + // record, insert the new one, and point the account to the new avatar. + await db.$transaction(async (tx: Parameters[0] extends (arg: infer T) => unknown ? T : never) => { + await tx.uploadedFile.deleteMany({ + where: { + accountId: metadata.userId, + path: { + startsWith: `public/users/${metadata.userId}/avatars/`, + }, + }, + }); + + await tx.uploadedFile.create({ + data: { + accountId: metadata.userId, + path: key, + width: processed.width, + height: processed.height, + thumbhash: processed.thumbhash, + }, + }); + + await tx.account.update({ + where: { id: metadata.userId }, + data: { + avatar: { + path: key, + width: processed.width, + height: processed.height, + thumbhash: processed.thumbhash, + }, + }, + }); + }); + + // Push a real-time update to all connected clients so the avatar + // refreshes immediately without a manual profile reload. + const updSeq = await allocateUserSeq(metadata.userId); + const updatePayload: UpdatePayload = { + id: randomKeyNaked(12), + seq: updSeq, + body: { + t: "update-account", + id: metadata.userId, + avatar: { + path: key, + width: processed.width, + height: processed.height, + thumbhash: processed.thumbhash, + url, + }, + }, + createdAt: Date.now(), + }; + eventRouter.emitUpdate({ + userId: metadata.userId, + payload: updatePayload, + recipientFilter: { type: "user-scoped-only" }, + }); + + log( + { module: "upload" }, + `Avatar upload complete for user ${metadata.userId}: ${key}` + ); + } catch (error) { + log( + { module: "upload", level: "error" }, + `Failed to process avatar upload for user ${metadata.userId}: ${error}` + ); + throw error; + } + }), +}); + +const { GET, POST } = uploadRouter.handlers; +const uploadHandler = toFastifyHandler({ GET, POST }); + +export function uploadRoutes(app: Fastify) { + // The pushduck handler uses GET for presigned-URL info and POST for + // the presign + complete actions. Auth is validated inside the route + // middleware above, not via Fastify's preHandler, so that pushduck + // can return a structured JSON error response. + app.get("/v1/upload", uploadHandler); + app.post("/v1/upload", uploadHandler); +}