diff --git a/apps/api/.env.example b/apps/api/.env.example index 0443fa0..e9b389e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -8,7 +8,7 @@ API_BASE_URL=http://localhost:3000 MOBILE_APP_BASE_URL=plannting:// # Enforce a minimum required mobile app version -MOBILE_APP_MINIMUM_REQUIRED_VERSION=0.12.0 +MOBILE_APP_MINIMUM_REQUIRED_VERSION=0.13.0 # Debug logging # See readme for "Verbose debug logging" diff --git a/apps/api/package.json b/apps/api/package.json index 4451888..decf348 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@plannting/api", - "version": "0.8.0", + "version": "0.9.0", "private": true, "main": "dist/index.js", "scripts": { diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index adca7f8..3bb2444 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -16,7 +16,7 @@ export const config: ApiConfig = { baseUrl: process.env.MOBILE_APP_BASE_URL, // Used by the mobile app to determine whether it must hard-block the UI until the user updates. // Default is 0.0.0 so we never block unless explicitly configured. - minimumRequiredVersion: (process.env.MOBILE_APP_MINIMUM_REQUIRED_VERSION ?? '0.12.0') as SemVerString, + minimumRequiredVersion: (process.env.MOBILE_APP_MINIMUM_REQUIRED_VERSION ?? '0.13.0') as SemVerString, storeUrls: { android: process.env.MOBILE_APP_STORE_URL_ANDROID ?? 'https://play.google.com/store/apps/details?id=com.completecodesolutions.***', ios: process.env.MOBILE_APP_STORE_URL_IOS ?? 'https://apps.apple.com/us/app/***/***', diff --git a/apps/api/src/endpoints/media/plantPhotos/delete.ts b/apps/api/src/endpoints/media/plantPhotos/delete.ts new file mode 100644 index 0000000..b230f76 --- /dev/null +++ b/apps/api/src/endpoints/media/plantPhotos/delete.ts @@ -0,0 +1,39 @@ +import { Request, Response } from 'express' + +import * as blobService from '../../../services/blob' + +import { getAuthenticatedUser } from '../../../utils/getAuthenticatedUser' + +/** + * Delete only the blob URLs provided in the body (e.g. orphan uploads from a cancelled add/edit). + * Body: { urls: string[] }. At least one URL is required; each must be under plant-photos/{userId}/. + */ +export async function deletePlantPhotosByUrl(req: Request, res: Response): Promise { + let userId: string + try { + const auth = await getAuthenticatedUser(req) + userId = auth.userId + } catch { + res.status(401).json({ message: 'Unauthorized' }) + + return + } + + const body = req.body + if (!Array.isArray(body?.urls) || body.urls.some((u: string) => typeof u !== 'string')) { + res.status(400).json({ message: 'Body must be { urls: string[] }' }) + + return + } + + // Never allow this endpoint to delete all blobs for a user! + if (body.urls.length === 0) { + res.status(400).json({ message: 'urls must contain at least one URL' }) + + return + } + + await blobService.deleteUserPlantPhotoBlobsByUrl(body.urls, userId) + + res.status(204).send() +} diff --git a/apps/api/src/endpoints/media/plantPhotos/get.ts b/apps/api/src/endpoints/media/plantPhotos/get.ts index 567261b..8abdbf8 100644 --- a/apps/api/src/endpoints/media/plantPhotos/get.ts +++ b/apps/api/src/endpoints/media/plantPhotos/get.ts @@ -1,11 +1,11 @@ import { Request, Response } from 'express' -import jwt from 'jsonwebtoken' import { Readable } from 'stream' import { get as getBlob } from '@vercel/blob' import { config } from '../../../config' import { Plant } from '../../../models' +import { getAuthenticatedUser } from '../../../utils/getAuthenticatedUser' /** * Stream a private plant photo blob. Requires exactly one of: @@ -26,21 +26,12 @@ export async function getPlantPhoto(req: Request, res: Response): Promise return } - const authHeader = req.headers.authorization - const token = authHeader?.replace(/^Bearer\s+/i, '') - - if (!token) { - res.status(401).json({ message: 'Unauthorized' }) - - return - } - let userId: string try { - const decoded = jwt.verify(token, config.jwt.secret) as { userId: string } - userId = decoded.userId + const auth = await getAuthenticatedUser(req) + userId = auth.userId } catch { - res.status(401).json({ message: 'Invalid token' }) + res.status(401).json({ message: 'Unauthorized' }) return } diff --git a/apps/api/src/endpoints/media/plantPhotos/getUploadPathname.ts b/apps/api/src/endpoints/media/plantPhotos/getUploadPathname.ts new file mode 100644 index 0000000..8a92ff9 --- /dev/null +++ b/apps/api/src/endpoints/media/plantPhotos/getUploadPathname.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express' + +import { getAuthenticatedUser } from '../../../utils/getAuthenticatedUser' + +/** + * Returns a pathname for the authenticated user to use when uploading a plant photo to Blob. + * Client should call this first, then POST to plant-photo-upload with that pathname in the body. + */ +export async function getPlantPhotoUploadPathname(req: Request, res: Response): Promise { + try { + const { userId } = await getAuthenticatedUser(req) + const pathname = `plant-photos/${userId}/${Date.now()}.jpg` + + res.json({ pathname }) + } catch { + res.status(401).json({ message: 'Unauthorized' }) + } +} diff --git a/apps/api/src/endpoints/media/plantPhotos/upload.ts b/apps/api/src/endpoints/media/plantPhotos/upload.ts new file mode 100644 index 0000000..c5ad02f --- /dev/null +++ b/apps/api/src/endpoints/media/plantPhotos/upload.ts @@ -0,0 +1,57 @@ +import { Request, Response } from 'express' +import { handleUpload, type HandleUploadBody } from '@vercel/blob/client' + +import { config } from '../../../config' +import { getAuthenticatedUser } from '../../../utils/getAuthenticatedUser' + +const ALLOWED_CONTENT_TYPES = ['image/jpeg', 'image/png', 'image/webp'] + +/** + * Handles client upload token generation and upload-completed callbacks for Vercel Blob. + * Client POSTs HandleUploadBody (generate-client-token or upload-completed). + * For generate-client-token we validate pathname is under plant-photos/${userId}/. + */ +export async function plantPhotoUpload(req: Request, res: Response): Promise { + let userId: string + try { + const auth = await getAuthenticatedUser(req) + userId = auth.userId + } catch { + res.status(401).json({ message: 'Unauthorized' }) + + return + } + + const blobToken = config.blob.readWriteToken + if (!blobToken) { + res.status(503).json({ message: 'Blob storage not configured' }) + + return + } + + try { + const jsonResponse = await handleUpload({ + body: req.body, + request: req, + token: blobToken, + onBeforeGenerateToken: async (pathname, _clientPayload, _multipart) => { + const prefix = `plant-photos/${userId}/` + if (!pathname.startsWith(prefix)) { + throw new Error('Forbidden: pathname must be under plant-photos for this user') + } + return { + allowedContentTypes: ALLOWED_CONTENT_TYPES, + addRandomSuffix: true, + tokenPayload: JSON.stringify({ userId }), + } + }, + }) + + res.json(jsonResponse) + } catch (error) { + const message = error instanceof Error ? error.message : 'Bad request' + const status = message.startsWith('Forbidden') ? 403 : 400 + + res.status(status).json({ error: message }) + } +} diff --git a/apps/api/src/endpoints/trpc/me/delete.ts b/apps/api/src/endpoints/trpc/me/delete.ts index 39718d5..7ff6fff 100644 --- a/apps/api/src/endpoints/trpc/me/delete.ts +++ b/apps/api/src/endpoints/trpc/me/delete.ts @@ -5,7 +5,7 @@ import { Chore, ChoreLog, Fertilizer, PasswordResetToken, Plant, PlantLifecycleE import { authProcedure } from '../../../procedures/authProcedure' -import * as blobService from '../../../services/blob/deletePlantPhoto' +import * as blobService from '../../../services/blob' export const deleteMe = authProcedure .mutation(async ({ ctx }) => { diff --git a/apps/api/src/endpoints/trpc/plants/deletePlant.ts b/apps/api/src/endpoints/trpc/plants/deletePlant.ts index 4a08378..07110f5 100644 --- a/apps/api/src/endpoints/trpc/plants/deletePlant.ts +++ b/apps/api/src/endpoints/trpc/plants/deletePlant.ts @@ -4,7 +4,7 @@ import { Chore, ChoreLog, PlantLifecycleEvent } from '../../../models' import { plantProcedure } from '../../../procedures/plantProcedure' -import * as blobService from '../../../services/blob/deletePlantPhoto' +import * as blobService from '../../../services/blob' export const deletePlant = plantProcedure .input(z.object({ diff --git a/apps/api/src/endpoints/trpc/plants/identifyByImages.ts b/apps/api/src/endpoints/trpc/plants/identifyByImages.ts index 4487f9b..7d969c5 100644 --- a/apps/api/src/endpoints/trpc/plants/identifyByImages.ts +++ b/apps/api/src/endpoints/trpc/plants/identifyByImages.ts @@ -1,38 +1,87 @@ +import { TRPCError } from '@trpc/server' +import { get as getBlob } from '@vercel/blob' import { z } from 'zod' -import { authProcedure } from '../../../procedures/authProcedure' +import { config } from '../../../config' +import { authProcedure } from '../../../procedures/authProcedure' +import * as blobService from '../../../services/blob' import * as debugService from '../../../services/debug' - import * as plantIdentificationService from '../../../services/plantIdentification' -const debugPlantIdentification = debugService.init('app:plantIdentification') +import { streamToBuffer } from '../../../utils/streamToBuffer' -const base64ImageSchema = z.string().refine( - (val) => { - const base64 = val.includes(',') ? val.split(',')[1] : val - return /^[A-Za-z0-9+/]*={0,2}$/.test(base64) - }, - { message: 'Invalid base64 image data' } -) +const debugPlantIdentification = debugService.init('app:plantIdentification') export const identifyByImages = authProcedure .input(z.object({ - images: z.array(base64ImageSchema).min(1, 'At least one image is required'), + imageUrls: z.array(z.url().refine( + (url) => { + try { + const u = new URL(url) + + return u.hostname.includes(blobService.BLOB_HOST) + } catch { + return false + } + }, + { message: 'Invalid blob URL' } + )).min(1, 'At least one image URL is required'), })) .mutation(async ({ ctx, input }) => { - debugPlantIdentification(`Request made to plants.identifyByImages by ${ctx.user?._id ? `user ${ctx.user._id}` : 'unauthenticated user'}. Payload contains ${input.images.length} images.`) + const userId = ctx.userId + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' }) + } + + const prefix = `plant-photos/${userId}/` + for (const url of input.imageUrls) { + try { + const parsed = new URL(url) + if (!parsed.pathname.includes(prefix)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Image URL must be a plant photo belonging to you', + }) + } + } catch (error) { + if (error instanceof TRPCError) throw error + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image URL' }) + } + } + + debugPlantIdentification(`Request made to plants.identifyByImages by user ${userId}. Payload contains ${input.imageUrls.length} image URLs.`) + + const blobToken = config.blob.readWriteToken + if (!blobToken) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Blob storage not configured', + }) + } + + const imageBuffers: Buffer[] = [] + for (const url of input.imageUrls) { + const result = await getBlob(url, { + access: 'private', + token: blobToken, + }) - const imageBuffers = input.images.map((base64Image) => { - const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image + if (!result || result.statusCode !== 200 || !result.stream) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Could not fetch image from storage', + }) + } - return Buffer.from(base64Data, 'base64') - }) + const buffer = await streamToBuffer(result.stream) + imageBuffers.push(buffer) + } const results = await plantIdentificationService.identifyPlantByImages(imageBuffers) - debugPlantIdentification(`Response from plants.identifyByImages for ${ctx.user?._id ? `user ${ctx.user._id}` : 'unauthenticated user'}: ${JSON.stringify(results, null, 2)}`) + debugPlantIdentification(`Response from plants.identifyByImages for user ${userId}: ${JSON.stringify(results, null, 2)}`) return results }) diff --git a/apps/api/src/endpoints/trpc/plants/updatePlant.ts b/apps/api/src/endpoints/trpc/plants/updatePlant.ts index 7a4a0b9..0671adb 100644 --- a/apps/api/src/endpoints/trpc/plants/updatePlant.ts +++ b/apps/api/src/endpoints/trpc/plants/updatePlant.ts @@ -5,7 +5,7 @@ import { PlantLifecycleEvent, Species, plantLifecycleEnum } from '../../../model import { plantProcedure } from '../../../procedures/plantProcedure' -import * as blobService from '../../../services/blob/deletePlantPhoto' +import * as blobService from '../../../services/blob' export const updatePlant = plantProcedure .input(z.object({ diff --git a/apps/api/src/endpoints/trpc/plants/uploadPlantPhoto.ts b/apps/api/src/endpoints/trpc/plants/uploadPlantPhoto.ts deleted file mode 100644 index d12f5fa..0000000 --- a/apps/api/src/endpoints/trpc/plants/uploadPlantPhoto.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TRPCError } from '@trpc/server' -import { put as putBlob } from '@vercel/blob' -import { z } from 'zod' - -import { config } from '../../../config' - -import { authProcedure } from '../../../procedures/authProcedure' - -export const uploadPlantPhoto = authProcedure - .input(z.object({ - /** Base64-encoded image data (e.g. from ImagePicker with base64: true) */ - imageBase64: z.string(), - contentType: z.enum(['image/jpeg', 'image/png', 'image/webp']).optional().default('image/jpeg'), - })) - .mutation(async ({ ctx, input }) => { - const token = config.blob.readWriteToken - if (!token) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Blob storage is not configured (BLOB_READ_WRITE_TOKEN missing)', - }) - } - - const buffer = Buffer.from(input.imageBase64, 'base64') - if (buffer.length === 0) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Invalid or empty image data', - }) - } - - const pathname = `plant-photos/${ctx.userId}/${Date.now()}` - - const blob = await putBlob(pathname, buffer, { - access: 'private', - addRandomSuffix: true, - contentType: input.contentType, - token, - }) - - return { url: blob.url } - }) diff --git a/apps/api/src/middlewares/auth.ts b/apps/api/src/middlewares/auth.ts index 0f06803..ff3cef2 100644 --- a/apps/api/src/middlewares/auth.ts +++ b/apps/api/src/middlewares/auth.ts @@ -1,46 +1,23 @@ import { TRPCError } from '@trpc/server' -import jwt from 'jsonwebtoken' - -import { config } from '../config' - -import { User } from '../models' +import { getAuthenticatedUser } from '../utils/getAuthenticatedUser' import { middleware } from '../trpc' -// Middleware to extract user from token export const authMiddleware = middleware(async ({ ctx, next }) => { - const authHeader = ctx.req?.headers.authorization - const token = authHeader?.replace('Bearer ', '') - - if (!token) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'No token provided', - }) - } - try { - const decoded = jwt.verify(token, config.jwt.secret) as { userId: string } - const user = await User.findById(decoded.userId) - - if (!user) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User not found', - }) - } + const { userId, user } = await getAuthenticatedUser(ctx.req ?? { headers: {} }) return next({ ctx: { ...ctx, - userId: user._id.toString(), + userId, user, }, }) } catch (error) { throw new TRPCError({ code: 'UNAUTHORIZED', - message: 'Invalid token', + message: error instanceof Error ? error.message : 'Invalid token', }) } }) diff --git a/apps/api/src/routers/media/index.ts b/apps/api/src/routers/media/index.ts index dc3f0e0..278293b 100644 --- a/apps/api/src/routers/media/index.ts +++ b/apps/api/src/routers/media/index.ts @@ -1,10 +1,16 @@ import { Router } from 'express' +import { deletePlantPhotosByUrl } from '../../endpoints/media/plantPhotos/delete' import { getPlantPhoto } from '../../endpoints/media/plantPhotos/get' +import { getPlantPhotoUploadPathname } from '../../endpoints/media/plantPhotos/getUploadPathname' +import { plantPhotoUpload } from '../../endpoints/media/plantPhotos/upload' const mediaRouter = Router() mediaRouter - .get('/plant-photos', getPlantPhoto) + .get('/plant-photos', getPlantPhoto) // This endpoint must be non-tRPC because it streams binary image data - tRPC does not support streaming. + .get('/plant-photos/upload-pathname', getPlantPhotoUploadPathname) // This endpoint is non-tRPC only to group it with the pre-existing GET /plant-photos endpoint. It could be moved to tRPC. + .post('/plant-photos/upload', plantPhotoUpload) // This endpoint is non-tRPC to group it with the pre-existing GET /plant-photos endpoint, but also to make it better match the API of @vercel/blog.handleUpload which this is a port of. It could be moved to tRPC. + .post('/plant-photos/delete-by-url', deletePlantPhotosByUrl) // This endpoint is non-tRPC only to group it with the pre-existing GET /plant-photos endpoint. It could be moved to tRPC. It is a POST request instead of DELETE, because the user must send a BODY containing urls to be deleted. export { mediaRouter } diff --git a/apps/api/src/routers/trpc/plants.ts b/apps/api/src/routers/trpc/plants.ts index 064c49a..c2fa53f 100644 --- a/apps/api/src/routers/trpc/plants.ts +++ b/apps/api/src/routers/trpc/plants.ts @@ -9,7 +9,6 @@ import { listPlantLifecycleEvents } from '../../endpoints/trpc/plants/listPlantL import { listPlants } from '../../endpoints/trpc/plants/listPlants' import { unarchivePlant } from '../../endpoints/trpc/plants/unarchivePlant' import { updatePlant } from '../../endpoints/trpc/plants/updatePlant' -import { uploadPlantPhoto } from '../../endpoints/trpc/plants/uploadPlantPhoto' export const plantsRouter = router({ archive: archivePlant, @@ -21,7 +20,6 @@ export const plantsRouter = router({ listLifecycleEvents: listPlantLifecycleEvents, unarchive: unarchivePlant, update: updatePlant, - uploadPhoto: uploadPlantPhoto, }) export type PlantsRouter = typeof plantsRouter diff --git a/apps/api/src/services/blob/deletePlantPhoto.ts b/apps/api/src/services/blob/deletePlantPhoto.ts deleted file mode 100644 index 1653aaf..0000000 --- a/apps/api/src/services/blob/deletePlantPhoto.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { del as delBlob } from '@vercel/blob' - -import { config } from '../../config' - -const BLOB_HOST = 'blob.vercel-storage.com' - -/** - * Deletes a plant photo from blob storage if the URL is from our store. - * Swallows errors so callers (e.g. plant delete) can continue if blob delete fails. - */ -export async function deletePlantPhotoFromBlob(url: string | null | undefined): Promise { - if (!isOurBlobUrl(url)) return - - const token = config.blob.readWriteToken - if (!token) return - - try { - await delBlob(url!, { token }) - } catch (err) { - console.error('[blob] Failed to delete plant photo:', url, err) - } -} - -/** - * Returns true if the URL is from our Vercel Blob store (so we are allowed to delete it). - */ -function isOurBlobUrl(url: string | null | undefined): boolean { - if (!url || typeof url !== 'string') return false - try { - const u = new URL(url) - - return u.hostname.endsWith(BLOB_HOST) - } catch { - - return false - } -} diff --git a/apps/api/src/services/blob/index.ts b/apps/api/src/services/blob/index.ts new file mode 100644 index 0000000..587b7bc --- /dev/null +++ b/apps/api/src/services/blob/index.ts @@ -0,0 +1,69 @@ +import { del as delBlob } from '@vercel/blob' + +import { config } from '../../config' + +export const BLOB_HOST = 'blob.vercel-storage.com' + +/** + * Deletes a plant photo from blob storage if the URL is from our store. + * Swallows errors so callers (e.g. plant delete) can continue if blob delete fails. + */ +export async function deletePlantPhotoFromBlob(url: string | null | undefined): Promise { + if (!isOurBlobUrl(url)) return + + const token = config.blob.readWriteToken + if (!token) return + + try { + await delBlob(url!, { token }) + } catch (err) { + console.error('[blob] Failed to delete plant photo:', url, err) + } +} + +const PLANT_PHOTOS_PREFIX = 'plant-photos/' + +/** + * Deletes only the given blob URLs (e.g. orphans from a cancelled add/edit). + * Validates each URL is under plant-photos/{userId}/ so callers cannot delete other users' blobs. + * Does not list or delete any other blobs. + */ +export async function deleteUserPlantPhotoBlobsByUrl(urls: string[], userId: string): Promise { + const token = config.blob.readWriteToken + if (!token) return + + const prefix = `${PLANT_PHOTOS_PREFIX}${userId}/` + const toDelete = urls.filter((url) => { + if (!url || !isOurBlobUrl(url)) return false + try { + const u = new URL(url) + + return u.pathname.includes(prefix) + } catch { + return false + } + }) + + for (const url of toDelete) { + try { + await delBlob(url, { token }) + } catch (err) { + console.error('[blob] Failed to delete plant photo:', url, err) + } + } +} + +/** + * Returns true if the URL is from our Vercel Blob store (so we are allowed to delete it). + */ +function isOurBlobUrl(url: string | null | undefined): boolean { + if (!url || typeof url !== 'string') return false + try { + const u = new URL(url) + + return u.hostname.endsWith(BLOB_HOST) + } catch { + + return false + } +} diff --git a/apps/api/src/utils/getAuthenticatedUser.ts b/apps/api/src/utils/getAuthenticatedUser.ts new file mode 100644 index 0000000..cf4b471 --- /dev/null +++ b/apps/api/src/utils/getAuthenticatedUser.ts @@ -0,0 +1,39 @@ +import jwt from 'jsonwebtoken' + +import { config } from '../config' +import { User } from '../models' + +type UserDoc = Awaited> + +export type AuthenticatedUser = { + userId: string + user: NonNullable +} + +/** + * Verify JWT from Authorization header and load the user. + * Use in Express routes and tRPC middleware. + * @throws Error with message 'No token provided' | 'User not found' | 'Invalid token' + */ +export async function getAuthenticatedUser( + req: { headers: { authorization?: string } } +): Promise { + const authHeader = req.headers.authorization + const token = authHeader?.replace(/^Bearer\s+/i, '') + + if (!token) { + throw new Error('No token provided') + } + + const decoded = jwt.verify(token, config.jwt.secret) as { userId: string } + const user = await User.findById(decoded.userId) + + if (!user) { + throw new Error('User not found') + } + + return { + userId: user._id.toString(), + user, + } +} diff --git a/apps/api/src/utils/streamToBuffer.ts b/apps/api/src/utils/streamToBuffer.ts new file mode 100644 index 0000000..cd72346 --- /dev/null +++ b/apps/api/src/utils/streamToBuffer.ts @@ -0,0 +1,14 @@ +import { Readable } from 'stream' + +/** + * Consume a web ReadableStream into a Buffer. + */ +export async function streamToBuffer(stream: ReadableStream): Promise { + const nodeStream = Readable.fromWeb(stream as import('stream/web').ReadableStream) + const chunks: Buffer[] = [] + for await (const chunk of nodeStream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + + return Buffer.concat(chunks) +} diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 5d10f3a..19fb1d0 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -5,7 +5,7 @@ export default { "name": "Plannting", "slug": "plannting", "version": version.replace(/^([0-9]*\.[0-9]*\.[0-9]*).*/, '$1'), - runtimeVersion: '9', + runtimeVersion: '10', scheme: 'plannting', "orientation": "portrait", "icon": "./assets/icon-light.png", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 70d1af4..5e50c6a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@plannting/mobile", - "version": "0.12.0", + "version": "0.13.0", "private": true, "main": "index.ts", "scripts": { @@ -31,6 +31,7 @@ "expo-constants": "~18.0.10", "expo-dev-client": "~6.0.20", "expo-device": "~8.0.10", + "expo-image-manipulator": "~14.0.7", "expo-image-picker": "~17.0.10", "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", diff --git a/apps/mobile/src/app/plants/add-edit.tsx b/apps/mobile/src/app/plants/add-edit.tsx index e24e871..d185f08 100644 --- a/apps/mobile/src/app/plants/add-edit.tsx +++ b/apps/mobile/src/app/plants/add-edit.tsx @@ -27,7 +27,7 @@ import { useRefreshOnFocus } from '../../hooks/useRefetchOnFocus' import { trpc } from '../../trpc' import { LIFECYCLE_ICONS, type PlantLifecycle } from '../../utils/lifecycle' -import { getPlantPhotoImageSource } from '../../utils/plantPhoto' +import { deleteOrphanedPlantPhotoBlobs, getPlantPhotoImageSource, uploadPlantPhoto, type PlantPhotoUploadPhase } from '../../utils/plantPhoto' import { palette, styles } from '../../styles' @@ -67,16 +67,34 @@ export function AddEditPlantScreen() { const [showSpeciesSuggestions, setShowSpeciesSuggestions] = useState(false) const [selectedSpecies, setSelectedSpecies] = useState(null) const [identificationPhotoUri, setIdentificationPhotoUri] = useState(null) - /** Base64 of the current photo when taken/picked locally (for upload). Not set when showing existing plant.photoUrl. */ - const [identificationPhotoBase64, setIdentificationPhotoBase64] = useState(null) + /** Blob URL after optimize+upload. Used for identification and as photoUrl on submit. */ + const [identificationPhotoBlobUrl, setIdentificationPhotoBlobUrl] = useState(null) /** When true, user tapped "Remove photo" – show no photo and send photoUrl: null on save. */ const [userRemovedPhoto, setUserRemovedPhoto] = useState(false) const [showPlantedAtDatePicker, setShowPlantedAtDatePicker] = useState(false) const [showLifecycleChangeModal, setShowLifecycleChangeModal] = useState(false) const [showLifecycleChangeDatePicker, setShowLifecycleChangeDatePicker] = useState(false) const [showIdentificationModal, setShowIdentificationModal] = useState(false) - /** Shown while photo is processing/uploading before the identification modal appears. */ + /** Shown while photo is optimizing/uploading/identifying before the identification modal appears. */ const [showIdentificationLoadingOverlay, setShowIdentificationLoadingOverlay] = useState(false) + /** Current phase for loading overlay: optimizing | uploading | identifying */ + const [photoLoadingPhase, setPhotoLoadingPhase] = useState('optimizing') + /** Upload progress 0–1 for progress bar during uploading phase. */ + const [uploadProgress, setUploadProgress] = useState(0) + + /** Blob URLs uploaded this session that were never saved; deleted on unmount if user cancels. */ + const pendingOrphanBlobUrlsRef = React.useRef([]) + const tokenRef = React.useRef(token) + tokenRef.current = token + + useEffect(() => { + return () => { + const urls = pendingOrphanBlobUrlsRef.current + if (urls.length > 0 && tokenRef.current) { + deleteOrphanedPlantPhotoBlobs(urls, tokenRef.current) + } + } + }, []) useEffect(() => { if (mode !== 'add') { @@ -121,26 +139,27 @@ export function AddEditPlantScreen() { /** Saved photo URL from API (list response includes photoUrl at runtime). */ const savedPhotoUrl = plant ? plant.photoUrl ?? null : null - /** User has selected a new photo this session (local file or just-uploaded). */ - const hasNewPhotoThisSession = !!( - identificationPhotoBase64 || - (identificationPhotoUri && (identificationPhotoUri.startsWith('file://') || identificationPhotoUri.startsWith('content://'))) - ) + /** User has selected a new photo this session (optimizing/uploading or already have blob URL). */ + const hasNewPhotoThisSession = !!(identificationPhotoBlobUrl || identificationPhotoUri) - /** URI to display: none if user removed, else new selection, else saved photo, else state. */ + /** URI/URL to display: none if user removed, else new local uri or blob (prefer local for instant preview), else saved photo. */ const displayPhotoUri = userRemovedPhoto ? null - : (hasNewPhotoThisSession ? identificationPhotoUri : (savedPhotoUrl ?? identificationPhotoUri ?? null)) + : (hasNewPhotoThisSession ? (identificationPhotoUri ?? identificationPhotoBlobUrl) : (savedPhotoUrl ?? identificationPhotoUri ?? null)) const getPlantPhotoSrc = (displayPhotoUri && hasNewPhotoThisSession && { photoUrl: displayPhotoUri }) || (displayPhotoUri && { plant }) || null - // When plant data is loaded, set the form data + // When plant data is loaded, set the form data. When in add mode (!plant), only reset when first entering add mode (not when hasNewPhotoThisSession flips), so we don't clear a just-uploaded photo. + const prevPlantRef = React.useRef(undefined) useEffect(() => { if (!plant) { - resetForm() - + if (prevPlantRef.current !== null) { + resetForm() + } + prevPlantRef.current = null return } + prevPlantRef.current = plant setFormData({ name: plant.name, @@ -166,7 +185,7 @@ export function AddEditPlantScreen() { if (!hasNewPhotoThisSession) { setUserRemovedPhoto(false) setIdentificationPhotoUri(savedUrl) - setIdentificationPhotoBase64(null) + setIdentificationPhotoBlobUrl(null) } }, [ plant, @@ -180,18 +199,18 @@ export function AddEditPlantScreen() { const createMutation = trpc.plants.create.useMutation({ onSuccess: () => { + pendingOrphanBlobUrlsRef.current = [] router.back() }, }) const updateMutation = trpc.plants.update.useMutation({ onSuccess: () => { + pendingOrphanBlobUrlsRef.current = [] router.back() }, }) - const uploadPhotoMutation = trpc.plants.uploadPhoto.useMutation() - const mutation = mode === 'add' ? createMutation : updateMutation const resetForm = () => { @@ -203,7 +222,7 @@ export function AddEditPlantScreen() { setShowSpeciesInput(false) setSpeciesSearchQuery('') setIdentificationPhotoUri(null) - setIdentificationPhotoBase64(null) + setIdentificationPhotoBlobUrl(null) setUserRemovedPhoto(false) } @@ -212,14 +231,6 @@ export function AddEditPlantScreen() { ...identifyByImagesMutation } = trpc.plants.identifyByImages.useMutation() - // When identification request finishes, hide loading overlay and show identification modal (one modal at a time so the identification modal is not blocked) - useEffect(() => { - if (showIdentificationLoadingOverlay && !isLoadingIdentification) { - setShowIdentificationLoadingOverlay(false) - setShowIdentificationModal(true) - } - }, [showIdentificationLoadingOverlay, isLoadingIdentification]) - const handleSpeciesSelect = (species: NonNullable['species'][0]) => { // Update speciesId and set name to commonName if name is empty const newName = formData.name.trim() === '' ? species.commonName : formData.name @@ -298,23 +309,36 @@ export function AddEditPlantScreen() { const imagePickerOptions: ImagePicker.ImagePickerOptions = { mediaTypes: [ 'images' ], allowsEditing: false, - base64: true, - quality: 0.8, } const result = mode === 'camera' ? await ImagePicker.launchCameraAsync(imagePickerOptions) : await ImagePicker.launchImageLibraryAsync(imagePickerOptions) - if (!result.canceled && result.assets[0]) { + if (!result.canceled && result.assets[0] && token) { const asset = result.assets[0] - setUserRemovedPhoto(false) - setIdentificationPhotoUri(asset.uri) - setIdentificationPhotoBase64(asset.base64 ?? null) - if (asset.base64 && !selectedSpecies) { - setShowIdentificationLoadingOverlay(true) - identifyByImagesMutation.mutate({ images: [asset.base64] }) + setShowIdentificationLoadingOverlay(true) + setPhotoLoadingPhase('optimizing') + setUploadProgress(0) + + try { + const { blobUrl, imageUri } = await uploadPlantPhoto(asset.uri, token, { + compressionQuality: 0.8, + onPhase: (phase) => setPhotoLoadingPhase(phase), + onUploadProgress: (loaded, total) => setUploadProgress(total > 0 ? loaded / total : 0), + }) + pendingOrphanBlobUrlsRef.current.push(blobUrl) + setIdentificationPhotoUri(imageUri) + setIdentificationPhotoBlobUrl(blobUrl) + setShowIdentificationLoadingOverlay(false) + if (!selectedSpecies) { + setShowIdentificationModal(true) + identifyByImagesMutation.mutate({ imageUrls: [blobUrl] }) + } + } catch (err) { + setShowIdentificationLoadingOverlay(false) + alert('Upload failed', err instanceof Error ? err.message : 'Could not upload photo.') } } } @@ -323,19 +347,8 @@ export function AddEditPlantScreen() { let photoUrl: string | null | undefined if (userRemovedPhoto && mode === 'edit') { photoUrl = null - } else if (identificationPhotoBase64) { - try { - const result = await uploadPhotoMutation.mutateAsync({ - imageBase64: identificationPhotoBase64, - contentType: 'image/jpeg', - }) - photoUrl = result.url - setIdentificationPhotoUri(result.url) - setIdentificationPhotoBase64(null) - } catch (err) { - alert('Upload failed', err instanceof Error ? err.message : 'Could not upload photo.') - return - } + } else if (identificationPhotoBlobUrl) { + photoUrl = identificationPhotoBlobUrl } else if (identificationPhotoUri && (identificationPhotoUri.startsWith('http://') || identificationPhotoUri.startsWith('https://'))) { photoUrl = identificationPhotoUri } else if (mode === 'edit') { @@ -499,7 +512,7 @@ export function AddEditPlantScreen() { onPress={() => { setUserRemovedPhoto(true) setIdentificationPhotoUri(null) - setIdentificationPhotoBase64(null) + setIdentificationPhotoBlobUrl(null) }} style={{ marginTop: 12, alignSelf: 'center' }} > @@ -717,19 +730,36 @@ export function AddEditPlantScreen() { isPending={updateMutation.isPending} /> - - - + {/* View overlay (not Modal) so only one Modal is used at a time — avoids PlantIdentificationModal failing to show on RN */} + {showIdentificationLoadingOverlay && ( + + - Processing photo… + + {photoLoadingPhase === 'optimizing' && 'Preparing image…'} + {photoLoadingPhase === 'uploading' && 'Uploading…'} + {photoLoadingPhase === 'identifying' && 'Identifying plant…'} + + {photoLoadingPhase === 'uploading' && ( + + + + )} - + )} { + return new Promise((resolve, reject) => { + Image.getSize(uri, (width, height) => resolve({ width, height }), reject) + }) +} + /** * Private Vercel Blob URLs require auth; we proxy them through our API. */ const PRIVATE_BLOB_HOST = 'blob.vercel-storage.com' +const DEFAULT_MAX_IMAGE_DIMENSION = 2560 +const DEFAULT_COMPRESSION_QUALITY = 0.8 + +const VERCEL_BLOB_API_BASE = 'https://vercel.com/api/blob' +const VERCEL_BLOB_API_VERSION = '12' + +export type PlantPhotoUploadPhase = 'optimizing' | 'uploading' + +export type OptimizeAndUploadResult = { + blobUrl: string + imageUri: string +} + +/** + * Optimize an image (resize to max 2560, compress to JPEG) and upload it directly to Vercel Blob. + * Uses API for pathname and client token; uploads file with progress support. + */ +export async function uploadPlantPhoto( + imageUri: string, + token: string, + options: { + allowOptimization?: boolean, + compressionQuality?: number, + maxImageDimension?: number, + onPhase?: (phase: PlantPhotoUploadPhase) => void + onUploadProgress?: (loaded: number, total: number) => void + }, +): Promise { + const { + allowOptimization = true, + compressionQuality = DEFAULT_COMPRESSION_QUALITY, + maxImageDimension = DEFAULT_MAX_IMAGE_DIMENSION, + } = options + + const apiBase = config.api.baseUrl?.replace(/\/$/, '') ?? '' + if (!apiBase) { + throw new Error('API base URL is required') + } + + let imageUriToUpload = imageUri + + if (allowOptimization) { + options.onPhase?.('optimizing') + const { width, height } = await getImageDimensions(imageUri) + const needsResize = width > maxImageDimension || height > maxImageDimension + const resizeAction = + needsResize && width >= height + ? { resize: { width: maxImageDimension } as const } + : needsResize && height > width + ? { resize: { height: maxImageDimension } as const } + : null + const actions = resizeAction ? [resizeAction] : [] + const manipulated = await ImageManipulator.manipulateAsync( + imageUri, + actions, + { compress: compressionQuality, format: ImageManipulator.SaveFormat.JPEG }, + ) + imageUriToUpload = manipulated.uri + } + + options.onPhase?.('uploading') + + const pathnameRes = await fetch(`${apiBase}/media/plant-photos/upload-pathname`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!pathnameRes.ok) { + throw new Error(pathnameRes.status === 401 ? 'Unauthorized' : 'Failed to get upload pathname') + } + const { pathname } = (await pathnameRes.json()) as { pathname: string } + + const tokenRes = await fetch(`${apiBase}/media/plant-photos/upload`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + type: 'blob.generate-client-token', + payload: { pathname, multipart: false, clientPayload: null }, + }), + }) + if (!tokenRes.ok) { + const err = await tokenRes.json().catch(() => ({})) + throw new Error((err as { error?: string })?.error ?? 'Failed to get upload token') + } + const tokenData = (await tokenRes.json()) as { type: string; clientToken: string } + const clientToken = tokenData.clientToken + if (!clientToken) { + throw new Error('No client token in response') + } + + const fileResponse = await fetch(imageUriToUpload) + const arrayBuffer = await fileResponse.arrayBuffer() + const bytes = new Uint8Array(arrayBuffer) + + // Vercel Blob API expects pathname as query param: /?pathname=plant-photos/... + const uploadUrl = `${VERCEL_BLOB_API_BASE}/?${new URLSearchParams({ pathname }).toString()}` + + const blobUrl = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('PUT', uploadUrl) + xhr.setRequestHeader('Authorization', `Bearer ${clientToken}`) + xhr.setRequestHeader('Content-Type', 'image/jpeg') + xhr.setRequestHeader('x-api-version', VERCEL_BLOB_API_VERSION) + xhr.setRequestHeader('x-vercel-blob-access', 'private') + + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + options.onUploadProgress?.(event.loaded, event.total) + } + }) + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const json = JSON.parse(xhr.responseText) as { url?: string } + if (json.url) { + options.onUploadProgress?.(1, 1) + + resolve(json.url) + } else { + reject(new Error('No URL in upload response')) + } + } catch { + reject(new Error('Invalid upload response')) + } + } else { + reject(new Error(`Upload failed: ${xhr.status}`)) + } + } + xhr.onerror = () => reject(new Error('Upload failed')) + xhr.send(bytes.buffer) + }) + + if (options.onUploadProgress) { + // Add a slight delay to ensure that any upload progress bar has time to show 100% + await new Promise(resolve => setTimeout(resolve, 250)) + } + + return { blobUrl, imageUri: imageUriToUpload } +} + /** * Get image source for a plant photo. * Pass { plant } for a plant with an existing image. @@ -73,3 +224,21 @@ function isPrivateBlobUrl(url: string | null | undefined): boolean { return false } } + +/** + * Ask the API to delete blob URLs that were uploaded but never attached to a plant (e.g. user cancelled add/edit). + * Fire-and-forget; use from unmount cleanup. + */ +export function deleteOrphanedPlantPhotoBlobs(urls: string[], token: string | null): void { + if (!token || urls.length === 0) return + const apiBase = config.api.baseUrl?.replace(/\/$/, '') ?? '' + if (!apiBase) return + fetch(`${apiBase}/media/plant-photos/delete-by-url`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ urls }), + }).catch(() => {}) +} diff --git a/package-lock.json b/package-lock.json index af27ef2..4c12702 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ }, "apps/api": { "name": "@plannting/api", - "version": "0.8.0", + "version": "0.9.0", "dependencies": { "@trpc/server": "^11.5.1", "@types/debug": "^4.1.12", @@ -139,7 +139,7 @@ }, "apps/mobile": { "name": "@plannting/mobile", - "version": "0.12.0", + "version": "0.13.0", "dependencies": { "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "8.4.4", @@ -154,6 +154,7 @@ "expo-constants": "~18.0.10", "expo-dev-client": "~6.0.20", "expo-device": "~8.0.10", + "expo-image-manipulator": "~14.0.7", "expo-image-picker": "~17.0.10", "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", @@ -10613,6 +10614,18 @@ "expo": "*" } }, + "node_modules/expo-image-manipulator": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz", + "integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image-picker": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",