Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@plannting/api",
"version": "0.8.0",
"version": "0.9.0",
"private": true,
"main": "dist/index.js",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/***/***',
Expand Down
39 changes: 39 additions & 0 deletions apps/api/src/endpoints/media/plantPhotos/delete.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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()
}
17 changes: 4 additions & 13 deletions apps/api/src/endpoints/media/plantPhotos/get.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -26,21 +26,12 @@ export async function getPlantPhoto(req: Request, res: Response): Promise<void>
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
}
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/endpoints/media/plantPhotos/getUploadPathname.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
try {
const { userId } = await getAuthenticatedUser(req)
const pathname = `plant-photos/${userId}/${Date.now()}.jpg`

res.json({ pathname })
} catch {
res.status(401).json({ message: 'Unauthorized' })
}
}
57 changes: 57 additions & 0 deletions apps/api/src/endpoints/media/plantPhotos/upload.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 })
}
}
2 changes: 1 addition & 1 deletion apps/api/src/endpoints/trpc/me/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/endpoints/trpc/plants/deletePlant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
83 changes: 66 additions & 17 deletions apps/api/src/endpoints/trpc/plants/identifyByImages.ts
Original file line number Diff line number Diff line change
@@ -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
})
2 changes: 1 addition & 1 deletion apps/api/src/endpoints/trpc/plants/updatePlant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
42 changes: 0 additions & 42 deletions apps/api/src/endpoints/trpc/plants/uploadPlantPhoto.ts

This file was deleted.

Loading
Loading