requests = $requests; $this->files = $files; $this->sanitizer = $sanitizer; $this->signer = $signer; $this->scheduler = $scheduler; $this->product_check = $product_check; $this->provider = $provider; $this->limits = $limits; $this->clock = $clock; } /** * Sanitises and stores a shopper photo. * * @param string $bytes Uploaded file content. * @param string $visitor Visitor hash. * @param string $ip IP hash, '' when unknown. * @return array{photo: string, expires: int} * @throws ApiError When the photo is rejected or cannot be stored. */ public function upload_photo( string $bytes, string $visitor, string $ip ): array { if ( ! $this->limits->allow_upload( $ip ) ) { throw new ApiError( ApiError::LIMIT_REACHED, Limits::UPLOADS ); } try { $jpeg = $this->sanitizer->sanitize( $bytes ); } catch ( InvalidImage $e ) { throw new ApiError( ApiError::INVALID_PHOTO, $e->reason() ); } try { $path = $this->files->put( FileStore::UPLOADS, $jpeg ); } catch ( \Throwable $e ) { throw new ApiError( ApiError::STORAGE_FAILED ); } $uuid = substr( $path, strlen( FileStore::UPLOADS ) + 1, 36 ); $expires = $this->now() + self::PHOTO_LIFETIME; return array( 'photo' => $uuid . '.' . $expires . '.' . $this->signer->token( $uuid, self::PHOTO_KIND, $visitor, $expires ), 'expires' => $expires, ); } /** * Deletes a photo of the visitor. * * @param string $photo Photo reference. * @param string $visitor Visitor hash. * @throws ApiError NOT_FOUND when the reference is not the visitor's. */ public function delete_photo( string $photo, string $visitor ): void { $path = $this->photo_path( $photo, $visitor ); if ( null === $path ) { throw new ApiError( ApiError::NOT_FOUND ); } $this->files->delete( $path ); } /** * Starts a try-on. * * @param string $visitor Visitor hash. * @param string $ip IP hash, '' when unknown. * @param int $user_id Logged-in user, 0 for guests. * @param string $photo Photo reference. * @param int $product_id Product. * @param int $variation_id Variation, 0 for none. * @return array{id: string, status: string} * @throws ApiError When the try-on cannot start. */ public function start( string $visitor, string $ip, int $user_id, string $photo, int $product_id, int $variation_id ): array { $provider = ( $this->provider )(); if ( ! is_string( $provider ) || '' === $provider ) { throw new ApiError( ApiError::UNAVAILABLE ); } $path = $this->photo_path( $photo, $visitor ); if ( null === $path || null === $this->files->file( $path ) ) { throw new ApiError( ApiError::PHOTO_EXPIRED ); } $problem = ( $this->product_check )( $product_id, $variation_id ); if ( null !== $problem ) { throw new ApiError( ApiError::PRODUCT_UNAVAILABLE, (string) $problem ); } if ( $this->requests->active_for_visitor( $visitor ) > 0 ) { throw new ApiError( ApiError::BUSY ); } $limit = $this->limits->tryon_problem( $visitor, $ip ); if ( null !== $limit ) { throw new ApiError( ApiError::LIMIT_REACHED, $limit ); } $id = $this->requests->create( array( 'visitor_hash' => $visitor, 'ip_hash' => $ip, 'user_id' => max( 0, $user_id ), 'product_id' => $product_id, 'variation_id' => max( 0, $variation_id ), 'upload_path' => $path, 'provider' => $provider, 'prompt_version' => PromptBuilder::VERSION, ) ); $this->scheduler->run_now( $id ); return array( 'id' => $id, 'status' => Status::QUEUED, ); } /** * Public state of a request. * * @param string $id Request UUID. * @param string $visitor Visitor hash. * @param int $user_id Logged-in user, 0 for guests. * @return array{id: string, status: string, error: string|null, image: array{expires: int, token: string}|null} * @throws ApiError NOT_FOUND when the caller does not own it. */ public function status( string $id, string $visitor, int $user_id ): array { $row = $this->owned( $id, $visitor, $user_id ); $status = (string) $row['status']; $image = null; if ( Status::DONE === $status && '' !== (string) ( $row['result_path'] ?? '' ) ) { $expires = $this->signer->expires_at(); $image = array( 'expires' => $expires, 'token' => $this->signer->token( $id, self::RESULT_KIND, $visitor, $expires ), ); } $error = $row['error'] ?? null; return array( 'id' => $id, 'status' => $status, 'error' => Status::FAILED === $status && is_string( $error ) && '' !== $error ? $error : null, 'image' => $image, ); } /** * Absolute path of a result image, for a valid signed link. * * @param string $id Request UUID. * @param string $visitor Visitor hash. * @param int $user_id Logged-in user, 0 for guests. * @param int $expires Expiry from the link. * @param string $token Token from the link. * @throws ApiError NOT_FOUND for anything but a valid link to an existing result. */ public function image( string $id, string $visitor, int $user_id, int $expires, string $token ): string { $row = $this->owned( $id, $visitor, $user_id ); if ( ! $this->signer->verify( $id, self::RESULT_KIND, $visitor, $expires, $token ) ) { throw new ApiError( ApiError::NOT_FOUND ); } $file = Status::DONE === $row['status'] ? $this->files->file( (string) ( $row['result_path'] ?? '' ) ) : null; if ( null === $file ) { throw new ApiError( ApiError::NOT_FOUND ); } return $file; } /** * Cancels a request and deletes its result. * * @param string $id Request UUID. * @param string $visitor Visitor hash. * @param int $user_id Logged-in user, 0 for guests. * @throws ApiError NOT_FOUND when the caller does not own it. */ public function discard( string $id, string $visitor, int $user_id ): void { $row = $this->owned( $id, $visitor, $user_id ); $this->requests->discard( $id, gmdate( 'Y-m-d H:i:s', $this->now() ) ); $this->files->delete( (string) ( $row['result_path'] ?? '' ) ); } /** * The row, when the caller owns it. * * @param string $id Request UUID. * @param string $visitor Visitor hash. * @param int $user_id Logged-in user, 0 for guests. * @return array * @throws ApiError NOT_FOUND otherwise. */ private function owned( string $id, string $visitor, int $user_id ): array { $row = $this->requests->find( $id ); if ( null !== $row ) { $by_visitor = '' !== $visitor && hash_equals( (string) $row['visitor_hash'], $visitor ); $by_user = $user_id > 0 && (int) $row['user_id'] === $user_id; if ( $by_visitor || $by_user ) { return $row; } } throw new ApiError( ApiError::NOT_FOUND ); } /** * Stored path of a valid, unexpired photo reference of this visitor, or null. * * @param string $photo Photo reference. * @param string $visitor Visitor hash. */ private function photo_path( string $photo, string $visitor ): ?string { if ( '' === $visitor || 1 !== preg_match( self::PHOTO_REF, $photo, $parts ) ) { return null; } if ( ! $this->signer->verify( $parts[1], self::PHOTO_KIND, $visitor, (int) $parts[2], $parts[3] ) ) { return null; } return FileStore::UPLOADS . '/' . $parts[1] . '.jpg'; } /** * Current Unix time. */ private function now(): int { return (int) ( $this->clock )(); } }