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);
+}