feat(favorites): GET /favorites/:id/icon proxy endpoint
Ownership-scoped (userId, matching update/remove) icon byte proxy. Loads the row's stored iconUrl server-side and streams it through IconDiscoveryService.fetchIconBytes -- never accepts a client-supplied URL, so this can't become an open SSRF proxy. Not-found/not-owned/no-icon -> 404. Upstream fetch failure (unreachable, timeout, non-image, SSRF-blocked) -> 502, never a 200 with a placeholder. Success sets Cache-Control so the browser doesn't refetch every load. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,9 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||||
import { FavoritesService } from './favorites.service';
|
import { FavoritesService } from './favorites.service';
|
||||||
@@ -66,6 +67,32 @@ export class FavoritesController {
|
|||||||
return this.favoritesService.create(userId, tenantId, dto);
|
return this.favoritesService.create(userId, tenantId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /favorites/:id/icon — streams the stored icon bytes for a favorite
|
||||||
|
* owned by the caller, from Tessera's own origin. This avoids the browser
|
||||||
|
* blocking a cross-origin <img> hotlink when the external site sends
|
||||||
|
* Cross-Origin-Resource-Policy: same-origin (e.g. claude.ai).
|
||||||
|
*
|
||||||
|
* Takes only a FavoriteLink id — never a client-supplied URL — so this
|
||||||
|
* cannot be used as an arbitrary-URL SSRF proxy (T-QFIP-01).
|
||||||
|
*/
|
||||||
|
@Get(':id/icon')
|
||||||
|
async getIcon(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { userId } = this.extractContext(req);
|
||||||
|
const { contentType, body } = await this.favoritesService.getIconBytes(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.send(body);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async update(
|
async update(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||||
@@ -94,4 +100,32 @@ export class FavoritesService {
|
|||||||
|
|
||||||
await this.prisma.favoriteLink.delete({ where: { id } });
|
await this.prisma.favoriteLink.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
||||||
|
* requesting user (T-08-06 — same ownership check as update/remove).
|
||||||
|
* Never accepts a client-supplied URL — only the stored iconUrl on a
|
||||||
|
* row the caller owns is fetched (T-QFIP-01).
|
||||||
|
*
|
||||||
|
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||||
|
* by the caller, or has no icon on record. Throws a 502 HttpException
|
||||||
|
* if the upstream fetch fails (unreachable, timeout, non-image, or
|
||||||
|
* SSRF-blocked) -- never returns a placeholder image.
|
||||||
|
*/
|
||||||
|
async getIconBytes(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ contentType: string; body: Buffer }> {
|
||||||
|
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!link || link.userId !== userId || !link.iconUrl) {
|
||||||
|
throw new NotFoundException('FavoriteLink not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
||||||
|
} catch {
|
||||||
|
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user