diff --git a/src/generator/v2/V2GenerateHandler.ts b/src/generator/v2/V2GenerateHandler.ts index e6647000..572d9757 100644 --- a/src/generator/v2/V2GenerateHandler.ts +++ b/src/generator/v2/V2GenerateHandler.ts @@ -6,6 +6,7 @@ import { GenerateV2Request } from "../../routes/v2/types"; import { V2GenerateResponseBody } from "../../typings/v2/V2GenerateResponseBody"; import { V2SkinResponse } from "../../typings/v2/V2SkinResponse"; import { container } from "../../inversify.config"; +import * as Sentry from "@sentry/node"; export const MC_TEXTURE_PREFIX = "https://textures.minecraft.net/texture/"; @@ -62,59 +63,69 @@ export class V2GenerateHandler { static skinToJson(skin: IPopulatedSkin2Document, duplicate: boolean = false): SkinInfo2 { - if (!skin.data) { - throw new Error("Skin data is missing"); - } - return { - uuid: skin.uuid, - shortId: skin.shortId, - name: skin.meta.name, - visibility: skin.meta.visibility, - variant: skin.meta.variant, - texture: { - data: { - value: skin.data.value, - signature: skin.data.signature + return Sentry.startSpan({ + op: 'generate_handler', + name: 'skinToJson' + }, span => { + if (!skin.data) { + throw new Error("Skin data is missing"); + } + return { + uuid: skin.uuid, + shortId: skin.shortId, + name: skin.meta.name, + visibility: skin.meta.visibility, + variant: skin.meta.variant, + texture: { + data: { + value: skin.data.value, + signature: skin.data.signature + }, + hash: { + skin: skin.data.hash?.skin.minecraft, + cape: skin.data.hash?.cape?.minecraft + }, + url: { + skin: MC_TEXTURE_PREFIX + skin.data.hash?.skin.minecraft, + cape: skin.data.hash?.cape?.minecraft ? (MC_TEXTURE_PREFIX + skin.data.hash?.cape?.minecraft) : undefined + } }, - hash: { - skin: skin.data.hash?.skin.minecraft, - cape: skin.data.hash?.cape?.minecraft + generator: { + timestamp: skin.data.createdAt.getTime(), + account: skin.data.generatedBy.account?.substring(0, 16), + server: skin.data.generatedBy.server, + worker: skin.data.generatedBy.worker, + version: 'unknown', //TODO + duration: skin.data.queue?.end?.getTime() - skin.data.queue?.start?.getTime() || 0 }, - url: { - skin: MC_TEXTURE_PREFIX + skin.data.hash?.skin.minecraft, - cape: skin.data.hash?.cape?.minecraft ? (MC_TEXTURE_PREFIX + skin.data.hash?.cape?.minecraft) : undefined - } - }, - generator: { - timestamp: skin.data.createdAt.getTime(), - account: skin.data.generatedBy.account?.substring(0, 16), - server: skin.data.generatedBy.server, - worker: skin.data.generatedBy.worker, - version: 'unknown', //TODO - duration: skin.data.queue?.end?.getTime() - skin.data.queue?.start?.getTime() || 0 - }, - tags: skin.tags?.map(t => ({tag: t.tag})), - views: skin.interaction.views, - duplicate: duplicate - }; + tags: skin.tags?.map(t => ({tag: t.tag})), + views: skin.interaction.views, + duplicate: duplicate + }; + }); } static makeRateLimitInfo(req: GenerateV2Request): RateLimitInfo { - const now = Date.now(); - return { - next: { - absolute: req.nextRequest || now, - relative: Math.max(0, (req.nextRequest || now) - now) - }, - delay: { - millis: req.minDelay || 0, - seconds: req.minDelay ? req.minDelay / 1000 : 0 - }, - limit: { - limit: req.maxPerMinute || 0, - remaining: Math.max(0, (req.maxPerMinute || 0) - (req.requestsThisMinute || 0)) - } - }; + return Sentry.startSpan({ + op: 'generate_handler', + name: 'makeRateLimitInfo' + }, span => { + const now = Date.now(); + return { + next: { + absolute: req.nextRequest || now, + relative: Math.max(0, (req.nextRequest || now) - now) + }, + delay: { + millis: req.minDelay || 0, + seconds: req.minDelay ? req.minDelay / 1000 : 0 + }, + limit: { + limit: req.maxPerMinute || 0, + remaining: Math.max(0, (req.maxPerMinute || 0) - (req.requestsThisMinute || 0)) + } + }; + }); } } diff --git a/src/generator/v2/V2UploadHandler.ts b/src/generator/v2/V2UploadHandler.ts index e463c908..5d0d5e4b 100644 --- a/src/generator/v2/V2UploadHandler.ts +++ b/src/generator/v2/V2UploadHandler.ts @@ -12,6 +12,7 @@ import * as fs from "node:fs"; import { Readable } from "stream"; import { readFile } from "fs/promises"; import { Log } from "../../Log"; +import * as Sentry from "@sentry/node"; export class V2UploadHandler extends V2GenerateHandler { @@ -22,45 +23,50 @@ export class V2UploadHandler extends V2GenerateHandler { } async getImageBuffer(): Promise { - const file: Maybe = this.req.file; - if (!file) { - throw new GeneratorError('missing_file', "No file uploaded", { - httpCode: 400, - source: ErrorSource.CLIENT - }); - } + return await Sentry.startSpan({ + op: 'upload_handler', + name: 'getImageBuffer' + }, async span => { + const file: Maybe = this.req.file; + if (!file) { + throw new GeneratorError('missing_file', "No file uploaded", { + httpCode: 400, + source: ErrorSource.CLIENT + }); + } - Log.l.debug(`${ this.req.breadcrumbC } FILE: "${ file.filename || file.originalname }"`); + Log.l.debug(`${ this.req.breadcrumbC } FILE: "${ file.filename || file.originalname }"`); - this.tempFile = await Temp.file({ - dir: UPL_DIR - }); - - if (file.buffer) { - await new Promise((resolve, reject) => { - Readable.from(file.buffer) - .pipe(new ExifTransformer()) // strip metadata - .pipe(fs.createWriteStream(this.tempFile!.path)) - .on('finish', resolve) - .on('error', reject); - }); - } else if (file.path) { - await new Promise((resolve, reject) => { - fs.createReadStream(file.path) - .pipe(new ExifTransformer()) // strip metadata - .pipe(fs.createWriteStream(this.tempFile!.path)) - .on('finish', resolve) - .on('error', reject); + this.tempFile = await Temp.file({ + dir: UPL_DIR }); - } else { - throw new GeneratorError('missing_file', "No file uploaded", { - httpCode: 400, - source: ErrorSource.CLIENT - }); - } - const buffer = await readFile(this.tempFile.path); - return {buffer}; + if (file.buffer) { + await new Promise((resolve, reject) => { + Readable.from(file.buffer) + .pipe(new ExifTransformer()) // strip metadata + .pipe(fs.createWriteStream(this.tempFile!.path)) + .on('finish', resolve) + .on('error', reject); + }); + } else if (file.path) { + await new Promise((resolve, reject) => { + fs.createReadStream(file.path) + .pipe(new ExifTransformer()) // strip metadata + .pipe(fs.createWriteStream(this.tempFile!.path)) + .on('finish', resolve) + .on('error', reject); + }); + } else { + throw new GeneratorError('missing_file', "No file uploaded", { + httpCode: 400, + source: ErrorSource.CLIENT + }); + } + + const buffer = await readFile(this.tempFile.path); + return {buffer}; + }); } cleanupImage() { diff --git a/src/generator/v2/V2UrlHandler.ts b/src/generator/v2/V2UrlHandler.ts index d43efac0..f7128d4c 100644 --- a/src/generator/v2/V2UrlHandler.ts +++ b/src/generator/v2/V2UrlHandler.ts @@ -14,6 +14,7 @@ import { Log } from "../../Log"; import { UrlChecks } from "./UrlChecks"; import { container } from "../../inversify.config"; import { TYPES as GeneratorTypes } from "@mineskin/generator/dist/ditypes"; +import * as Sentry from "@sentry/node"; export class V2UrlHandler extends V2GenerateHandler { @@ -24,87 +25,92 @@ export class V2UrlHandler extends V2GenerateHandler { } async getImageBuffer(): Promise { - const {url: originalUrl} = GenerateReqUrl.parse(this.req.body); - Log.l.debug(`${ this.req.breadcrumbC } URL: "${ originalUrl }"`); + return await Sentry.startSpan({ + op: 'url_handler', + name: 'getImageBuffer' + }, async span => { + const {url: originalUrl} = GenerateReqUrl.parse(this.req.body); + Log.l.debug(`${ this.req.breadcrumbC } URL: "${ originalUrl }"`); - if (UrlChecks.isBlockedHost(originalUrl)) { - throw new GeneratorError('blocked_url_host', "The url host is not allowed", { - httpCode: 400, - details: originalUrl, - source: ErrorSource.CLIENT - }); - } + if (UrlChecks.isBlockedHost(originalUrl)) { + throw new GeneratorError('blocked_url_host', "The url host is not allowed", { + httpCode: 400, + details: originalUrl, + source: ErrorSource.CLIENT + }); + } - // check for duplicate texture or mineskin url - const originalDuplicateCheck = await this.checkDuplicateUrl(this.req, originalUrl, this.options); - if (originalDuplicateCheck) { - return {existing: originalDuplicateCheck}; - } + // check for duplicate texture or mineskin url + const originalDuplicateCheck = await this.checkDuplicateUrl(this.req, originalUrl, this.options); + if (originalDuplicateCheck) { + return {existing: originalDuplicateCheck}; + } - // fix user errors - const rewrittenUrl = UrlHandler.rewriteUrl(originalUrl, this.req.breadcrumb || "????"); + // fix user errors + const rewrittenUrl = UrlHandler.rewriteUrl(originalUrl, this.req.breadcrumb || "????"); - // try to find the source image - const followResponse = await UrlHandler.followUrl(rewrittenUrl, this.req.breadcrumb || "????"); - if (!followResponse || typeof followResponse === 'string') { - throw new GeneratorError(GenError.INVALID_IMAGE_URL, - "Failed to find image from url" + (typeof followResponse === 'string' ? ": " + followResponse : ""), - {httpCode: 400, details: originalUrl}); - } - // validate response headers - const followedUrl = UrlHandler.getUrlFromResponse(followResponse, originalUrl); - if (!followedUrl) { - throw new GeneratorError(GenError.INVALID_IMAGE_URL, "Failed to follow url", { - httpCode: 400, - details: originalUrl - }); - } - // Check for duplicate from url again, if the followed url is different - if (followedUrl !== originalUrl) { - const followedUrlDuplicate = await this.checkDuplicateUrl(this.req, followedUrl, this.options); - if (followedUrlDuplicate) { - return {existing: followedUrlDuplicate}; + // try to find the source image + const followResponse = await UrlHandler.followUrl(rewrittenUrl, this.req.breadcrumb || "????"); + if (!followResponse || typeof followResponse === 'string') { + throw new GeneratorError(GenError.INVALID_IMAGE_URL, + "Failed to find image from url" + (typeof followResponse === 'string' ? ": " + followResponse : ""), + {httpCode: 400, details: originalUrl}); } - if (UrlChecks.isBlockedHost(followedUrl)) { - throw new GeneratorError('blocked_url_host', "The followed url host is not allowed", { + // validate response headers + const followedUrl = UrlHandler.getUrlFromResponse(followResponse, originalUrl); + if (!followedUrl) { + throw new GeneratorError(GenError.INVALID_IMAGE_URL, "Failed to follow url", { httpCode: 400, details: originalUrl }); } - } + // Check for duplicate from url again, if the followed url is different + if (followedUrl !== originalUrl) { + const followedUrlDuplicate = await this.checkDuplicateUrl(this.req, followedUrl, this.options); + if (followedUrlDuplicate) { + return {existing: followedUrlDuplicate}; + } + if (UrlChecks.isBlockedHost(followedUrl)) { + throw new GeneratorError('blocked_url_host', "The followed url host is not allowed", { + httpCode: 400, + details: originalUrl + }); + } + } - // validate response - Log.l.debug(`${ this.req.breadcrumbC } Followed URL: "${ followedUrl }"`); - const contentType = UrlHandler.getContentTypeFromResponse(followResponse); - Log.l.debug(`${ this.req.breadcrumbC } Content-Type: "${ contentType }"`); - if (!contentType || !contentType.startsWith("image") || !ALLOWED_IMAGE_TYPES.includes(contentType)) { - throw new GeneratorError(GenError.INVALID_IMAGE, "Invalid image content type: " + contentType, { - httpCode: 400, - details: originalUrl - }); - } - const size = UrlHandler.getSizeFromResponse(followResponse); - Log.l.debug(`${ this.req.breadcrumbC } Content-Length: ${ size }`); - if (!size || size < 100 || size > MAX_IMAGE_SIZE) { - throw new GeneratorError(GenError.INVALID_IMAGE, "Invalid image file size: " + size, { - httpCode: 400 - }); - } + // validate response + Log.l.debug(`${ this.req.breadcrumbC } Followed URL: "${ followedUrl }"`); + const contentType = UrlHandler.getContentTypeFromResponse(followResponse); + Log.l.debug(`${ this.req.breadcrumbC } Content-Type: "${ contentType }"`); + if (!contentType || !contentType.startsWith("image") || !ALLOWED_IMAGE_TYPES.includes(contentType)) { + throw new GeneratorError(GenError.INVALID_IMAGE, "Invalid image content type: " + contentType, { + httpCode: 400, + details: originalUrl + }); + } + const size = UrlHandler.getSizeFromResponse(followResponse); + Log.l.debug(`${ this.req.breadcrumbC } Content-Length: ${ size }`); + if (!size || size < 100 || size > MAX_IMAGE_SIZE) { + throw new GeneratorError(GenError.INVALID_IMAGE, "Invalid image file size: " + size, { + httpCode: 400 + }); + } - // Download the image temporarily - this.tempFile = await Temp.file({ - dir: URL_DIR - }); - try { - this.tempFile = await Temp.downloadImage(followedUrl, this.tempFile) - } catch (e) { - throw new GeneratorError(GenError.INVALID_IMAGE, "Failed to download image", {httpCode: 500}); - } + // Download the image temporarily + this.tempFile = await Temp.file({ + dir: URL_DIR + }); + try { + this.tempFile = await Temp.downloadImage(followedUrl, this.tempFile) + } catch (e) { + throw new GeneratorError(GenError.INVALID_IMAGE, "Failed to download image", {httpCode: 500}); + } - // Log.l.debug(tempFile.path); - const buffer = await readFile(this.tempFile.path); - return {buffer}; + // Log.l.debug(tempFile.path); + const buffer = await readFile(this.tempFile.path); + return {buffer}; + }); } cleanupImage() { @@ -114,31 +120,36 @@ export class V2UrlHandler extends V2GenerateHandler { } async checkDuplicateUrl(req: GenerateV2Request, url: string, options: GenerateOptions): Promise { - const duplicateChecker = container.get(GeneratorTypes.DuplicateChecker); - const originalUrlV2Duplicate = await duplicateChecker.findDuplicateV2FromUrl(url, options, req.breadcrumb || "????"); - if (originalUrlV2Duplicate.existing) { - const isMineSkinOrTextureUrl = UrlChecks.isMineSkinUrl(url) || UrlChecks.isMinecraftTextureUrl(url); - // found existing - const result = await duplicateChecker.handleV2DuplicateResult( - { - source: originalUrlV2Duplicate.source, - existing: originalUrlV2Duplicate.existing, - data: originalUrlV2Duplicate.existing.data - }, - options, - req.clientInfo!, - req.breadcrumb || "????", - isMineSkinOrTextureUrl // ignore visibility on mineskin/texture urls to return existing - ); - await duplicateChecker.handleDuplicateResultMetrics(result, GenerateType.URL, options, req.clientInfo!); - if (!!result.existing) { - // full duplicate, return existing skin - // return await V2GenerateHandler.queryAndSendSkin(req, res, result.existing.uuid, true); - return result.existing.uuid; + return await Sentry.startSpan({ + op: 'url_handler', + name: 'checkDuplicateUrl' + }, async span => { + const duplicateChecker = container.get(GeneratorTypes.DuplicateChecker); + const originalUrlV2Duplicate = await duplicateChecker.findDuplicateV2FromUrl(url, options, req.breadcrumb || "????"); + if (originalUrlV2Duplicate.existing) { + const isMineSkinOrTextureUrl = UrlChecks.isMineSkinUrl(url) || UrlChecks.isMinecraftTextureUrl(url); + // found existing + const result = await duplicateChecker.handleV2DuplicateResult( + { + source: originalUrlV2Duplicate.source, + existing: originalUrlV2Duplicate.existing, + data: originalUrlV2Duplicate.existing.data + }, + options, + req.clientInfo!, + req.breadcrumb || "????", + isMineSkinOrTextureUrl // ignore visibility on mineskin/texture urls to return existing + ); + await duplicateChecker.handleDuplicateResultMetrics(result, GenerateType.URL, options, req.clientInfo!); + if (!!result.existing) { + // full duplicate, return existing skin + // return await V2GenerateHandler.queryAndSendSkin(req, res, result.existing.uuid, true); + return result.existing.uuid; + } + // otherwise, continue with generator } - // otherwise, continue with generator - } - return false; + return false; + }); } } \ No newline at end of file diff --git a/src/generator/v2/V2UserHandler.ts b/src/generator/v2/V2UserHandler.ts index d32424cc..8981d1ba 100644 --- a/src/generator/v2/V2UserHandler.ts +++ b/src/generator/v2/V2UserHandler.ts @@ -9,8 +9,9 @@ import { GenerateReqUser } from "../../validation/generate"; import { stripUuid } from "../../util"; import { Caching } from "../Caching"; import { Log } from "../../Log"; +import * as Sentry from "@sentry/node"; -export class V2UserHandler extends V2GenerateHandler{ +export class V2UserHandler extends V2GenerateHandler { constructor(req: GenerateV2Request, res: Response, options: GenerateOptions) { super(req, res, options, GenerateType.USER); @@ -21,14 +22,19 @@ export class V2UserHandler extends V2GenerateHandler{ } async getImageReference(hashes?: ImageHashes): Promise { - let {user} = GenerateReqUser.parse(this.req.body); - user = stripUuid(user); - Log.l.debug(`${ this.req.breadcrumbC } USER: "${ user }"`); - const userValidation = await Caching.getUserByUuid(user); - if (!userValidation || !userValidation.valid) { - throw new GeneratorError('invalid_user', "Invalid user",{httpCode:400, source: ErrorSource.CLIENT}) - } - return user; + return await Sentry.startSpan({ + op: 'user_handler', + name: 'getImageReference' + }, async span => { + let {user} = GenerateReqUser.parse(this.req.body); + user = stripUuid(user); + Log.l.debug(`${ this.req.breadcrumbC } USER: "${ user }"`); + const userValidation = await Caching.getUserByUuid(user); + if (!userValidation || !userValidation.valid) { + throw new GeneratorError('invalid_user', "Invalid user", {httpCode: 400, source: ErrorSource.CLIENT}) + } + return user; + }); } } \ No newline at end of file diff --git a/src/models/v2/generate.ts b/src/models/v2/generate.ts index da6c1518..8d6f2d74 100644 --- a/src/models/v2/generate.ts +++ b/src/models/v2/generate.ts @@ -69,145 +69,187 @@ function getClient() { } export async function v2GenerateAndWait(req: GenerateV2Request, res: Response): Promise { - const {skin, job} = await v2SubmitGeneratorJob(req, res); - if (job) { - req.links.job = `/v2/queue/${ job.id }`; - if (job.request.image) { - req.links.image = `/v2/images/${ job.request.image }`; + return await Sentry.startSpan({ + op: 'generate_v2', + name: 'v2GenerateAndWait' + }, async span => { + const {skin, job} = await v2SubmitGeneratorJob(req, res); + if (job) { + req.links.job = `/v2/queue/${ job.id }`; + if (job.request.image) { + req.links.image = `/v2/images/${ job.request.image }`; + } + } + if (skin) { + req.links.skin = `/v2/skins/${ skin.id }`; + const queried = await querySkinOrThrow(skin.id); + return { + success: true, + skin: V2GenerateHandler.skinToJson(queried, skin.duplicate), + rateLimit: V2GenerateHandler.makeRateLimitInfo(req) + }; } - } - if (skin) { - req.links.skin = `/v2/skins/${ skin.id }`; - const queried = await querySkinOrThrow(skin.id); - return { - success: true, - skin: V2GenerateHandler.skinToJson(queried, skin.duplicate), - rateLimit: V2GenerateHandler.makeRateLimitInfo(req) - }; - } - - //TODO: figure out a better way to handle this - const checkOnly = (!!(req.body as any)["checkOnly"] || !!req.query["checkOnly"]) - if (checkOnly) { - throw new GeneratorError(GenError.NO_DUPLICATE, "No duplicate found", {httpCode: 400}) - } - if (!job) { - throw new GeneratorError('job_not_found', "Job not found", { - httpCode: 404, - source: ErrorSource.CLIENT - }); - } - try { - const timeoutSeconds = GenerateTimeout.parse(req.query.timeout); - const result = await getClient().waitForJob(job.id, timeoutSeconds * 1000) as GenerateResult; //TODO: configure timeout - Log.l.debug(JSON.stringify(result, null, 2)); - await sleep(200); - req.links.skin = `/v2/skins/${ result.skin }`; - const queried = await querySkinOrThrow(result.skin); - return { - success: true, - skin: V2GenerateHandler.skinToJson(queried, !!result.duplicate), - rateLimit: V2GenerateHandler.makeRateLimitInfo(req), - usage: result.usage - }; - } catch (e) { - if (e instanceof MineSkinError) { - throw e; + //TODO: figure out a better way to handle this + const checkOnly = (!!(req.body as any)["checkOnly"] || !!req.query["checkOnly"]) + if (checkOnly) { + throw new GeneratorError(GenError.NO_DUPLICATE, "No duplicate found", {httpCode: 400}) } - if (e.message.includes('timed out before finishing') || e.message.includes('Timeout')) { // this kinda sucks - Log.l.warn(e); - throw new GeneratorError('generator_timeout', "generator request timed out", { - httpCode: 500, - error: e, - source: ErrorSource.SERVER + + if (!job) { + throw new GeneratorError('job_not_found', "Job not found", { + httpCode: 404, + source: ErrorSource.CLIENT }); } - Log.l.error(e); - Sentry.captureException(e); - throw new GeneratorError('unexpected_error', "unexpected error", {httpCode: 500, error: e}); - } + try { + const timeoutSeconds = GenerateTimeout.parse(req.query.timeout); + const result = await getClient().waitForJob(job.id, timeoutSeconds * 1000) as GenerateResult; //TODO: configure timeout + Log.l.debug(JSON.stringify(result, null, 2)); + await sleep(200); + req.links.skin = `/v2/skins/${ result.skin }`; + const queried = await querySkinOrThrow(result.skin); + return { + success: true, + skin: V2GenerateHandler.skinToJson(queried, !!result.duplicate), + rateLimit: V2GenerateHandler.makeRateLimitInfo(req), + usage: result.usage + }; + } catch (e) { + if (e instanceof MineSkinError) { + throw e; + } + if (e.message.includes('timed out before finishing') || e.message.includes('Timeout')) { // this kinda sucks + Log.l.warn(e); + throw new GeneratorError('generator_timeout', "generator request timed out", { + httpCode: 500, + error: e, + source: ErrorSource.SERVER + }); + } + Log.l.error(e); + Sentry.captureException(e); + throw new GeneratorError('unexpected_error', "unexpected error", {httpCode: 500, error: e}); + } + }); } export async function v2GenerateEnqueue(req: GenerateV2Request, res: Response): Promise { - const {skin, job} = await v2SubmitGeneratorJob(req, res); - if (job) { - req.links.job = `/v2/queue/${ job.id }`; - if (job.request.image) { - req.links.image = `/v2/images/${ job.request.image }`; + return await Sentry.startSpan({ + op: 'generate_v2', + name: 'v2GenerateEnqueue' + }, async span => { + const {skin, job} = await v2SubmitGeneratorJob(req, res); + if (job) { + req.links.job = `/v2/queue/${ job.id }`; + if (job.request.image) { + req.links.image = `/v2/images/${ job.request.image }`; + } } - } - if (skin) { - req.links.skin = `/v2/skins/${ skin.id }`; - const queried = await querySkinOrThrow(skin.id); + if (skin) { + req.links.skin = `/v2/skins/${ skin.id }`; + const queried = await querySkinOrThrow(skin.id); + + let jobInfo: JobInfo; + if (job) { + jobInfo = { + id: job?.id || null, + status: job?.status || 'completed', + timestamp: job?.createdAt?.getTime() || 0, + result: skin.id + }; + } else { + jobInfo = await saveFakeJob(skin.id); + } - let jobInfo: JobInfo; - if (job) { - jobInfo = { - id: job?.id || null, - status: job?.status || 'completed', - timestamp: job?.createdAt?.getTime() || 0, - result: skin.id + res.status(200); + return { + success: true, + messages: [{ + code: 'skin_found', + message: "Found existing skin" + }], + job: jobInfo, + skin: V2GenerateHandler.skinToJson(queried, skin.duplicate), + rateLimit: V2GenerateHandler.makeRateLimitInfo(req) }; - } else { - jobInfo = await saveFakeJob(skin.id); } - res.status(200); + let eta = undefined; + try { + const stats = await getCachedV2Stats(); + const durationEta = (stats?.generator?.duration?.pending + stats?.generator?.duration?.generate) || undefined; //TODO: multiply pending by user's pending job count + eta = durationEta && job ? new Date(job?.createdAt?.getTime() + durationEta) : undefined; + } catch (e) { + Sentry.captureException(e); + } + + res.status(202); return { success: true, messages: [{ - code: 'skin_found', - message: "Found existing skin" + code: 'job_queued', + message: "Job queued" }], - job: jobInfo, - skin: V2GenerateHandler.skinToJson(queried, skin.duplicate), + job: { + id: job?.id || null, + status: job?.status || 'unknown', + timestamp: job?.createdAt?.getTime() || 0, + eta: eta?.getTime() || undefined + }, rateLimit: V2GenerateHandler.makeRateLimitInfo(req) }; - } - - let eta = undefined; - try { - const stats = await getCachedV2Stats(); - const durationEta = (stats?.generator?.duration?.pending + stats?.generator?.duration?.generate) || undefined; //TODO: multiply pending by user's pending job count - eta = durationEta && job ? new Date(job?.createdAt?.getTime() + durationEta) : undefined; - } catch (e) { - Sentry.captureException(e); - } - - res.status(202); - return { - success: true, - messages: [{ - code: 'job_queued', - message: "Job queued" - }], - job: { - id: job?.id || null, - status: job?.status || 'unknown', - timestamp: job?.createdAt?.getTime() || 0, - eta: eta?.getTime() || undefined - }, - rateLimit: V2GenerateHandler.makeRateLimitInfo(req) - }; + }); } export async function v2GetJob(req: GenerateV2Request, res: Response): Promise { - const jobId = ZObjectId.parse(req.params.jobId); + return await Sentry.startSpan({ + op: 'route', + name: 'v2GetJob' + }, async span => { + const jobId = ZObjectId.parse(req.params.jobId); + + req.links.job = `/v2/queue/${ jobId }`; + req.links.self = req.links.job; + + if (jobId.startsWith('f4c3')) { + const fakeJob = await getFakeJob(jobId); + // if (!fakeJob) { + // throw new GeneratorError('job_not_found', `Fake job not found: ${ jobId }`, {httpCode: 404}); + // } + + if (fakeJob && fakeJob.status === 'completed') { + const result = fakeJob.result!; + req.links.skin = `/v2/skins/${ result }`; + const queried = await querySkinOrThrow(result); + return { + success: true, + messages: [{ + code: 'job_completed', + message: "Job completed" + }], + job: { + id: fakeJob.id, + status: fakeJob.status || 'completed', + timestamp: fakeJob.timestamp || 0, + result: result + }, + skin: V2GenerateHandler.skinToJson(queried, false) + }; + } + } - req.links.job = `/v2/queue/${ jobId }`; - req.links.self = req.links.job; + const job = await getClient().getJob(jobId); + if (!job) { + throw new GeneratorError('job_not_found', `Job not found: ${ jobId }`, {httpCode: 404}); + } - if (jobId.startsWith('f4c3')) { - const fakeJob = await getFakeJob(jobId); - // if (!fakeJob) { - // throw new GeneratorError('job_not_found', `Fake job not found: ${ jobId }`, {httpCode: 404}); - // } + req.links.image = `/v2/images/${ job.request.image }`; - if (fakeJob && fakeJob.status === 'completed') { - const result = fakeJob.result!; - req.links.skin = `/v2/skins/${ result }`; - const queried = await querySkinOrThrow(result); + if (job.status === 'completed') { + const result = job.result!; + req.links.skin = `/v2/skins/${ result.skin }`; + const queried = await querySkinOrThrow(result.skin); return { success: true, messages: [{ @@ -215,366 +257,349 @@ export async function v2GetJob(req: GenerateV2Request, res: Response): Promise { - let jobs; - if (req.client.hasApiKey()) { - jobs = await getClient().getByApiKey(req.client.apiKeyId!); - } else if (req.client.hasUser()) { - jobs = await getClient().getByUser(req.client.userId!); - } else { - throw new GeneratorError('unauthorized', "no client info", {httpCode: 401}); - } + return await Sentry.startSpan({ + op: 'route', + name: 'v2ListJobs' + }, async span => { + let jobs; + if (req.client.hasApiKey()) { + jobs = await getClient().getByApiKey(req.client.apiKeyId!); + } else if (req.client.hasUser()) { + jobs = await getClient().getByUser(req.client.userId!); + } else { + throw new GeneratorError('unauthorized', "no client info", {httpCode: 401}); + } - const threeHoursAgo = new Date(); - threeHoursAgo.setHours(threeHoursAgo.getHours() - 3); + const threeHoursAgo = new Date(); + threeHoursAgo.setHours(threeHoursAgo.getHours() - 3); - return { - success: true, - jobs: jobs - .filter(j => j.createdAt > threeHoursAgo) - .map(job => { - return { - id: job.id, - status: job.status, - timestamp: job?.createdAt?.getTime() || 0, - result: job.result?.skin - } - }) - }; + return { + success: true, + jobs: jobs + .filter(j => j.createdAt > threeHoursAgo) + .map(job => { + return { + id: job.id, + status: job.status, + timestamp: job?.createdAt?.getTime() || 0, + result: job.result?.skin + } + }) + }; + }); } //TODO: track stats async function v2SubmitGeneratorJob(req: GenerateV2Request, res: Response): Promise { - - // need to call multer stuff first so fields are parsed - if (!(req as any)._uploadProcessed) { //TODO: remove - if (req.is('multipart/form-data')) { - await tryHandleFileUpload(req, res); - } else { - upload.none(); + return await Sentry.startSpan({ + op: 'generate_v2', + name: 'v2SubmitGeneratorJob' + }, async span => { + + // need to call multer stuff first so fields are parsed + if (!(req as any)._uploadProcessed) { //TODO: remove + if (req.is('multipart/form-data')) { + await tryHandleFileUpload(req, res); + } else { + upload.none(); + } } - } - - const options = getAndValidateOptions(req); - //const client = getClientInfo(req); - if (!req.clientInfo) { - throw new GeneratorError('invalid_client', "no client info", {httpCode: 500}); - } - // // check rate limit - const trafficService = container.get(GeneratorTypes.TrafficService); - // req.nextRequest = await trafficService.getNextRequest(req.clientInfo); - // req.minDelay = await trafficService.getMinDelaySeconds(req.clientInfo, req.apiKey) * 1000; - // if (req.nextRequest > req.clientInfo.time) { - // throw new GeneratorError('rate_limit', `request too soon, next request in ${ ((Math.round(req.nextRequest - Date.now()) / 100) * 100) }ms`, {httpCode: 429}); - // } - - // // check credits - // // (always check, even when not enabled, to handle free credits) - // if (req.client.canUseCredits()) { - // const billingService = BillingService.getInstance(); - // const credit = await billingService.getClientCredits(req.clientInfo); - // if (!credit) { - // req.warnings.push({ - // code: 'no_credits', - // message: "no credits" - // }); - // req.clientInfo.credits = false; - // } else { - // if (!credit.isValid()) { - // req.warnings.push({ - // code: 'invalid_credits', - // message: "invalid credits" - // }); - // req.clientInfo.credits = false; - // } else if (credit.balance <= 0) { - // req.warnings.push({ - // code: 'insufficient_credits', - // message: "insufficient credits" - // }); - // req.clientInfo.credits = false; - // } - // res.header('MineSkin-Credits-Type', credit.type); - // res.header('MineSkin-Credits-Balance', `${ credit.balance }`); - // } - // } - - /* - if (!req.apiKey && !req.client.hasUser()) { - throw new GeneratorError('unauthorized', "API key or user required", {httpCode: 401}); - }*/ - - if (req.client.hasUser()) { - const pendingByUser = await getClient().getPendingCountByUser(req.client.userId!) - const limit = req.client.getQueueLimit(); - if (pendingByUser > limit) { - throw new GeneratorError('job_limit', "You have too many jobs in the queue", { - httpCode: 429, - source: ErrorSource.CLIENT - }); + const options = getAndValidateOptions(req); + //const client = getClientInfo(req); + if (!req.clientInfo) { + throw new GeneratorError('invalid_client', "no client info", {httpCode: 500}); } - } else { - const pendingByIp = await getClient().getPendingCountByIp(req.client.ip!) - if (pendingByIp > 4) { - throw new GeneratorError('job_limit', "You have too many jobs in the queue", { - httpCode: 429, - source: ErrorSource.CLIENT - }); - } - await sleep(200 * Math.random()); - } - if (!req.client.hasCredits()) { - await sleep(200 * Math.random()); - } + // // check rate limit + const trafficService = container.get(GeneratorTypes.TrafficService); + // req.nextRequest = await trafficService.getNextRequest(req.clientInfo); + // req.minDelay = await trafficService.getMinDelaySeconds(req.clientInfo, req.apiKey) * 1000; + // if (req.nextRequest > req.clientInfo.time) { + // throw new GeneratorError('rate_limit', `request too soon, next request in ${ ((Math.round(req.nextRequest - Date.now()) / 100) * 100) }ms`, {httpCode: 429}); + // } - if (options.visibility === SkinVisibility2.PRIVATE) { - if (!req.apiKey && !req.client.hasUser()) { - throw new GeneratorError('unauthorized', "private skins require an API key or User", { - httpCode: 401, - source: ErrorSource.CLIENT - }); - } - if (!req.client.grants?.private_skins) { - throw new GeneratorError('insufficient_grants', "you are not allowed to generate private skins", { - httpCode: 403, - source: ErrorSource.CLIENT - }); - } - Log.l.debug(`${ req.breadcrumbC } generating private`); - } + // // check credits + // // (always check, even when not enabled, to handle free credits) + // if (req.client.canUseCredits()) { + // const billingService = BillingService.getInstance(); + // const credit = await billingService.getClientCredits(req.clientInfo); + // if (!credit) { + // req.warnings.push({ + // code: 'no_credits', + // message: "no credits" + // }); + // req.clientInfo.credits = false; + // } else { + // if (!credit.isValid()) { + // req.warnings.push({ + // code: 'invalid_credits', + // message: "invalid credits" + // }); + // req.clientInfo.credits = false; + // } else if (credit.balance <= 0) { + // req.warnings.push({ + // code: 'insufficient_credits', + // message: "insufficient credits" + // }); + // req.clientInfo.credits = false; + // } + // res.header('MineSkin-Credits-Type', credit.type); + // res.header('MineSkin-Credits-Balance', `${ credit.balance }`); + // } + // } - let handler: V2GenerateHandler; - - //TODO: support base64 - if (req.is('multipart/form-data')) { - handler = new V2UploadHandler(req, res, options); - } else if (req.is('application/json')) { - console.debug('application/json') //TODO: remove - if ('url' in req.body) { - handler = new V2UrlHandler(req, res, options); - } else if ('user' in req.body) { - //TODO: validate user - handler = new V2UserHandler(req, res, options); + /* + if (!req.apiKey && !req.client.hasUser()) { + throw new GeneratorError('unauthorized', "API key or user required", {httpCode: 401}); + }*/ + + if (req.client.hasUser()) { + const pendingByUser = await getClient().getPendingCountByUser(req.client.userId!) + const limit = req.client.getQueueLimit(); + if (pendingByUser > limit) { + throw new GeneratorError('job_limit', "You have too many jobs in the queue", { + httpCode: 429, + source: ErrorSource.CLIENT + }); + } } else { - throw new GeneratorError('invalid_request', `invalid request properties (expected url or user)`, { - httpCode: 400, - source: ErrorSource.CLIENT - }); + const pendingByIp = await getClient().getPendingCountByIp(req.client.ip!) + if (pendingByIp > 4) { + throw new GeneratorError('job_limit', "You have too many jobs in the queue", { + httpCode: 429, + source: ErrorSource.CLIENT + }); + } + await sleep(200 * Math.random()); } - } else { - throw new GeneratorError('invalid_content_type', `invalid content type: ${ req.header('content-type') } (expected multipart/form-data or application/json)`, {httpCode: 400}); - } - // preliminary rate limiting - if (req.client.useDelayRateLimit()) { - req.nextRequest = await trafficService.updateLastAndNextRequest(req.clientInfo, 200); - Log.l.debug(`next request at ${ req.nextRequest }`); - } - if (req.client.usePerMinuteRateLimit()) { - req.requestsThisMinute = (req.requestsThisMinute || 0) + 1; - await trafficService.incRequest(req.clientInfo); - } - - let hashes: Maybe = undefined; - if (handler.handlesImage()) { - const imageResult = await handler.getImageBuffer(); - if (imageResult.existing) { - // await V2GenerateHandler.queryAndSendSkin(req, res, imageResult.existing, true); - return { - skin: { - id: imageResult.existing, - duplicate: true - } - }; - } - const imageBuffer = imageResult.buffer; - if (!imageBuffer) { - throw new GeneratorError(GenError.INVALID_IMAGE, "Failed to get image buffer", {httpCode: 500}); + if (!req.client.hasCredits()) { + await sleep(200 * Math.random()); } + if (options.visibility === SkinVisibility2.PRIVATE) { + if (!req.apiKey && !req.client.hasUser()) { + throw new GeneratorError('unauthorized', "private skins require an API key or User", { + httpCode: 401, + source: ErrorSource.CLIENT + }); + } + if (!req.client.grants?.private_skins) { + throw new GeneratorError('insufficient_grants', "you are not allowed to generate private skins", { + httpCode: 403, + source: ErrorSource.CLIENT + }); + } + Log.l.debug(`${ req.breadcrumbC } generating private`); + } - const validation = await ImageValidation.validateImageBuffer(imageBuffer); - Log.l.debug(validation); + let handler: V2GenerateHandler; - //TODO: ideally don't do this here and in the generator - if (options.variant === SkinVariant.UNKNOWN) { - if (validation.variant === SkinVariant.UNKNOWN) { - throw new GeneratorError(GenError.UNKNOWN_VARIANT, "Unknown variant", { - source: ErrorSource.CLIENT, - httpCode: 400 + //TODO: support base64 + if (req.is('multipart/form-data')) { + handler = new V2UploadHandler(req, res, options); + } else if (req.is('application/json')) { + console.debug('application/json') //TODO: remove + if ('url' in req.body) { + handler = new V2UrlHandler(req, res, options); + } else if ('user' in req.body) { + //TODO: validate user + handler = new V2UserHandler(req, res, options); + } else { + throw new GeneratorError('invalid_request', `invalid request properties (expected url or user)`, { + httpCode: 400, + source: ErrorSource.CLIENT }); } - Log.l.info(req.breadcrumb + " Switching unknown skin variant to " + validation.variant + " from detection"); - Sentry.setExtra("generate_detected_variant", validation.variant); - options.variant = validation.variant; + } else { + throw new GeneratorError('invalid_content_type', `invalid content type: ${ req.header('content-type') } (expected multipart/form-data or application/json)`, {httpCode: 400}); } - - try { - const imageService = container.get(GeneratorTypes.ImageService); - hashes = await imageService.getImageHashes(imageBuffer, validation.dimensions.width || 64, validation.dimensions.height || 64); - } catch (e) { - // span?.setStatus({ - // code: 2, - // message: "invalid_argument" - // }); - throw new GeneratorError(GenError.INVALID_IMAGE, `Failed to get image hash: ${ e.message }`, { - httpCode: 400, - error: e - }); + // preliminary rate limiting + if (req.client.useDelayRateLimit()) { + req.nextRequest = await trafficService.updateLastAndNextRequest(req.clientInfo, 200); + Log.l.debug(`next request at ${ req.nextRequest }`); + } + if (req.client.usePerMinuteRateLimit()) { + req.requestsThisMinute = (req.requestsThisMinute || 0) + 1; + await trafficService.incRequest(req.clientInfo); } - Log.l.debug(req.breadcrumbC + " Image hash: ", hashes); - - // duplicate check V2, same as in generator - // just to avoid unnecessary submissions to generator - const duplicateChecker = container.get(GeneratorTypes.DuplicateChecker); - const duplicateV2Data = await duplicateChecker.findDuplicateDataFromImageHash(hashes, options.variant, GenerateType.UPLOAD, req.breadcrumb || "????"); - if (duplicateV2Data.existing) { - // found existing data - const skinForDuplicateData = await duplicateChecker.findV2ForData(duplicateV2Data.existing); - const result = await duplicateChecker.handleV2DuplicateResult({ - source: duplicateV2Data.source, - existing: skinForDuplicateData, - data: duplicateV2Data.existing - }, options, req.clientInfo, req.breadcrumb || "????"); - await duplicateChecker.handleDuplicateResultMetrics(result, GenerateType.UPLOAD, options, req.clientInfo); - if (!!result.existing) { - // full duplicate, return existing skin - //await V2GenerateHandler.queryAndSendSkin(req, res, result.existing.uuid, true); + + let hashes: Maybe = undefined; + if (handler.handlesImage()) { + const imageResult = await handler.getImageBuffer(); + if (imageResult.existing) { + // await V2GenerateHandler.queryAndSendSkin(req, res, imageResult.existing, true); return { skin: { - id: result.existing.uuid, + id: imageResult.existing, duplicate: true } }; } - // otherwise, continue with generator - } + const imageBuffer = imageResult.buffer; + if (!imageBuffer) { + throw new GeneratorError(GenError.INVALID_IMAGE, "Failed to get image buffer", {httpCode: 500}); + } - /* - const duplicateResult = await DuplicateChecker.findDuplicateDataFromImageHash(hashes, options.variant, GenerateType.UPLOAD, req.breadcrumb || "????"); - Log.l.debug(JSON.stringify(duplicateResult, null, 2)); - if (duplicateResult.existing && isV1SkinDocument(duplicateResult.existing)) { - return res.json({ - success: true, - skin: v1SkinToV2Json(duplicateResult.existing, true) - }); - } else if (duplicateResult.existing && isPopulatedSkin2Document(duplicateResult.existing)) { - return res.json({ - success: true, - skin: skinToJson(duplicateResult.existing, true) - }); - } - */ - const imageUploaded = await getClient().insertUploadedImage(hashes.minecraft, imageBuffer); - } else if (handler.type === GenerateType.USER) { - //TODO: check for recent requests on the same user and return duplicate - } + const validation = await ImageValidation.validateImageBuffer(imageBuffer); + Log.l.debug(validation); - handler.cleanupImage(); + //TODO: ideally don't do this here and in the generator + if (options.variant === SkinVariant.UNKNOWN) { + if (validation.variant === SkinVariant.UNKNOWN) { + throw new GeneratorError(GenError.UNKNOWN_VARIANT, "Unknown variant", { + source: ErrorSource.CLIENT, + httpCode: 400 + }); + } + Log.l.info(req.breadcrumb + " Switching unknown skin variant to " + validation.variant + " from detection"); + Sentry.setExtra("generate_detected_variant", validation.variant); + options.variant = validation.variant; + } - if (!req.client.hasUser() || !req.client.hasCredits()) { - await sleep(200 * Math.random()); - } - if (req.client.useConcurrencyLimit()) { - await trafficService.incrementConcurrent(req.clientInfo); - req.concurrentRequests = (req.concurrentRequests || 0) + 1; - } + try { + const imageService = container.get(GeneratorTypes.ImageService); + hashes = await imageService.getImageHashes(imageBuffer, validation.dimensions.width || 64, validation.dimensions.height || 64); + } catch (e) { + // span?.setStatus({ + // code: 2, + // message: "invalid_argument" + // }); + throw new GeneratorError(GenError.INVALID_IMAGE, `Failed to get image hash: ${ e.message }`, { + httpCode: 400, + error: e + }); + } + Log.l.debug(req.breadcrumbC + " Image hash: ", hashes); + + // duplicate check V2, same as in generator + // just to avoid unnecessary submissions to generator + const duplicateChecker = container.get(GeneratorTypes.DuplicateChecker); + const duplicateV2Data = await duplicateChecker.findDuplicateDataFromImageHash(hashes, options.variant, GenerateType.UPLOAD, req.breadcrumb || "????"); + if (duplicateV2Data.existing) { + // found existing data + const skinForDuplicateData = await duplicateChecker.findV2ForData(duplicateV2Data.existing); + const result = await duplicateChecker.handleV2DuplicateResult({ + source: duplicateV2Data.source, + existing: skinForDuplicateData, + data: duplicateV2Data.existing + }, options, req.clientInfo, req.breadcrumb || "????"); + await duplicateChecker.handleDuplicateResultMetrics(result, GenerateType.UPLOAD, options, req.clientInfo); + if (!!result.existing) { + // full duplicate, return existing skin + //await V2GenerateHandler.queryAndSendSkin(req, res, result.existing.uuid, true); + return { + skin: { + id: result.existing.uuid, + duplicate: true + } + }; + } + // otherwise, continue with generator + } - const billingService = container.get(BillingTypes.BillingService); - await billingService.trackGenerateRequest(req.clientInfo); + /* + const duplicateResult = await DuplicateChecker.findDuplicateDataFromImageHash(hashes, options.variant, GenerateType.UPLOAD, req.breadcrumb || "????"); + Log.l.debug(JSON.stringify(duplicateResult, null, 2)); + if (duplicateResult.existing && isV1SkinDocument(duplicateResult.existing)) { + return res.json({ + success: true, + skin: v1SkinToV2Json(duplicateResult.existing, true) + }); + } else if (duplicateResult.existing && isPopulatedSkin2Document(duplicateResult.existing)) { + return res.json({ + success: true, + skin: skinToJson(duplicateResult.existing, true) + }); + } + */ - const request: GenerateRequest = { - breadcrumb: req.breadcrumb || "????", - type: handler.type, - image: await handler.getImageReference(hashes), - options: options, - clientInfo: req.clientInfo - } - const queueOptions: QueueOptions = { - priority: req.client.getPriority() - }; - const job = await getClient().submitRequest(request, queueOptions); - return {job}; + const imageUploaded = await getClient().insertUploadedImage(hashes.minecraft, imageBuffer); + } else if (handler.type === GenerateType.USER) { + //TODO: check for recent requests on the same user and return duplicate + } + + handler.cleanupImage(); + + if (!req.client.hasUser() || !req.client.hasCredits()) { + await sleep(200 * Math.random()); + } + + if (req.client.useConcurrencyLimit()) { + await trafficService.incrementConcurrent(req.clientInfo); + req.concurrentRequests = (req.concurrentRequests || 0) + 1; + } + + const billingService = container.get(BillingTypes.BillingService); + await billingService.trackGenerateRequest(req.clientInfo); + + const request: GenerateRequest = { + breadcrumb: req.breadcrumb || "????", + type: handler.type, + image: await handler.getImageReference(hashes), + options: options, + clientInfo: req.clientInfo + } + const queueOptions: QueueOptions = { + priority: req.client.getPriority() + }; + const job = await getClient().submitRequest(request, queueOptions); + return {job}; + }); } async function saveFakeJob(result: string, status: JobStatus = 'completed'): Promise { @@ -601,7 +626,7 @@ async function getFakeJob(id: string): Promise> { function getAndValidateOptions(req: GenerateV2Request): GenerateOptions { return Sentry.startSpan({ - op: "v2_generate_getAndValidateOptions", + op: "generate_v2", name: "getAndValidateOptions" }, (span) => { console.debug(req.header('content-type')) @@ -680,32 +705,42 @@ function validateName(name?: string): Maybe { async function tryHandleFileUpload(req: GenerateV2Request, res: Response): Promise { - try { - return await new Promise((resolve, reject) => { - upload.single('file')(req, res, function (err) { - if (err) { - reject(err); - } else { - resolve(); - } - }) - }); - } catch (e) { - Sentry.captureException(e); - if (e instanceof MulterError) { - throw new GeneratorError('invalid_file', `invalid file: ${ e.message }`, {httpCode: 400, error: e}); - } else { - throw new GeneratorError('upload_error', `upload error: ${ e.message }`, {httpCode: 500, error: e}); + return await Sentry.startSpan({ + op: 'generate_v2', + name: 'tryHandleFileUpload' + }, async span => { + try { + return await new Promise((resolve, reject) => { + upload.single('file')(req, res, function (err) { + if (err) { + reject(err); + } else { + resolve(); + } + }) + }); + } catch (e) { + Sentry.captureException(e); + if (e instanceof MulterError) { + throw new GeneratorError('invalid_file', `invalid file: ${ e.message }`, {httpCode: 400, error: e}); + } else { + throw new GeneratorError('upload_error', `upload error: ${ e.message }`, {httpCode: 500, error: e}); + } } - } + }); } async function querySkinOrThrow(uuid: UUID): Promise { - const skin = await container.get(GeneratorTypes.SkinService).findForUuid(uuid); - if (!skin || !isPopulatedSkin2Document(skin) || !skin.data) { - throw new GeneratorError('skin_not_found', `Skin not found: ${ uuid }`, {httpCode: 404}); - } - return skin; + return await Sentry.startSpan({ + op: 'generate_v2', + name: 'querySkinOrThrow' + }, async span => { + const skin = await container.get(GeneratorTypes.SkinService).findForUuid(uuid); + if (!skin || !isPopulatedSkin2Document(skin) || !skin.data) { + throw new GeneratorError('skin_not_found', `Skin not found: ${ uuid }`, {httpCode: 404}); + } + return skin; + }); } interface JobWithSkin { diff --git a/src/routes/generate.ts b/src/routes/generate.ts index 123c7dd3..6df210c3 100644 --- a/src/routes/generate.ts +++ b/src/routes/generate.ts @@ -368,32 +368,37 @@ export const register = (app: Application) => { } async function sendV2WrappedSkin(req: GenerateV2Request, res: Response, skin: V2SkinResponse) { - const json: any = MigrationHandler.v2SkinInfoToV1Json(skin.skin); - const delayInfo = await Generator.getDelay(req.apiKey); - json.duplicate = skin.skin.duplicate; - delete json.visibility; - json.usage = skin.usage; - json.rateLimit = skin.rateLimit; - if (delayInfo) { - json.nextRequest = Math.round(delayInfo.seconds); // deprecated - if (req.minDelay) { - json.delay = Math.ceil(req.minDelay / 1000); - json.delayInfo = { - millis: req.minDelay, - seconds: Math.ceil(req.minDelay / 1000) - }; - } else { - json.delay = delayInfo.seconds; - json.delayInfo = { - millis: delayInfo.millis, - seconds: delayInfo.seconds - }; + await Sentry.startSpan({ + op: 'generate_compat', + name: 'sendV2WrappedSkin' + }, async span => { + const json: any = MigrationHandler.v2SkinInfoToV1Json(skin.skin); + const delayInfo = await Generator.getDelay(req.apiKey); + json.duplicate = skin.skin.duplicate; + delete json.visibility; + json.usage = skin.usage; + json.rateLimit = skin.rateLimit; + if (delayInfo) { + json.nextRequest = Math.round(delayInfo.seconds); // deprecated + if (req.minDelay) { + json.delay = Math.ceil(req.minDelay / 1000); + json.delayInfo = { + millis: req.minDelay, + seconds: Math.ceil(req.minDelay / 1000) + }; + } else { + json.delay = delayInfo.seconds; + json.delayInfo = { + millis: delayInfo.millis, + seconds: delayInfo.seconds + }; + } } - } - if (req.warnings) { - json.warnings = req.warnings; - } - res.json(json); + if (req.warnings) { + json.warnings = req.warnings; + } + res.json(json); + }); } function getClientInfo(req: GenerateRequest): ClientInfo {