fluxer/fluxer_docs/scripts/generate_media_proxy.mjs
2026-02-17 12:22:36 +00:00

1061 lines
36 KiB
JavaScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {createFrontmatter, escapeTableText, wrapCode, writeFile} from './shared.mjs';
const MEDIA_PROXY_BASE_URL = 'https://fluxerusercontent.com';
/**
* Media proxy API endpoint URL helper.
* Mintlify generates URLs from summary (slugified), not operationId.
*/
const MEDIA_PROXY_URLS = {
getAvatar: '/media-proxy-api/images/get-user-or-guild-avatar',
getIcon: '/media-proxy-api/images/get-guild-icon',
getBanner: '/media-proxy-api/images/get-user-or-guild-banner',
getEmoji: '/media-proxy-api/content/get-custom-emoji',
getSticker: '/media-proxy-api/content/get-sticker',
getAttachment: '/media-proxy-api/content/get-message-attachment',
getExternalMedia: '/media-proxy-api/external/proxy-external-media',
extractMetadata: '/media-proxy-api/internal/extract-media-metadata',
generateThumbnail: '/media-proxy-api/internal/generate-video-thumbnail',
extractFrames: '/media-proxy-api/internal/extract-video-frames',
};
const ENUMS = {
ImageSizeEnum: [
'16',
'20',
'22',
'24',
'28',
'32',
'40',
'44',
'48',
'56',
'60',
'64',
'80',
'96',
'100',
'128',
'160',
'240',
'256',
'300',
'320',
'480',
'512',
'600',
'640',
'1024',
'1280',
'1536',
'2048',
'3072',
'4096',
],
ImageFormatEnum: ['png', 'jpg', 'jpeg', 'webp', 'gif'],
ImageQualityEnum: ['high', 'low', 'lossless'],
};
const SCHEMAS = {
ImageQueryParams: {
description: 'Standard query parameters for image routes.',
fields: [
{
name: 'size?',
type: '[ImageSizeEnum](#imagesizeenum)',
description: 'Target image size in pixels. Default: `128`',
},
{
name: 'format?',
type: '[ImageFormatEnum](#imageformatenum)',
description: 'Output image format. Default: `webp`',
},
{
name: 'quality?',
type: '[ImageQualityEnum](#imagequalityenum)',
description: 'Image quality level. Default: `high`',
},
{
name: 'animated?',
type: 'boolean',
description: 'Whether to return animated images (GIF, APNG) if available. Default: `false`',
},
],
},
ExternalMediaQueryParams: {
description: 'Query parameters for the external media proxy.',
fields: [
{
name: 'width?',
type: 'integer',
description:
'Target width in pixels (1-4096). If only width is specified, height is calculated to maintain aspect ratio.',
},
{
name: 'height?',
type: 'integer',
description:
'Target height in pixels (1-4096). If only height is specified, width is calculated to maintain aspect ratio.',
},
{
name: 'format?',
type: '[ImageFormatEnum](#imageformatenum)',
description: 'Output image format. If not specified, the original format is preserved.',
},
{
name: 'quality?',
type: '[ImageQualityEnum](#imagequalityenum)',
description: 'Image quality level. Default: `lossless`',
},
{
name: 'animated?',
type: 'boolean',
description: 'Whether to preserve animation for GIF images. Default: `false`',
},
],
},
MetadataResponse: {
description: 'Response from metadata extraction.',
fields: [
{name: 'format', type: 'string', description: 'Detected media format (e.g., `png`, `jpeg`, `gif`, `mp4`)'},
{name: 'content_type', type: 'string', description: 'MIME content type'},
{name: 'content_hash', type: 'string', description: 'SHA-256 hash of the content'},
{name: 'size', type: 'integer', description: 'File size in bytes'},
{name: 'width?', type: 'integer', description: 'Image or video width in pixels'},
{name: 'height?', type: 'integer', description: 'Image or video height in pixels'},
{name: 'duration?', type: 'number', description: 'Video or audio duration in seconds'},
{name: 'placeholder?', type: 'string', description: 'BlurHash placeholder string for progressive loading'},
{name: 'base64?', type: 'string', description: 'Base64-encoded content (if `with_base64` was requested)'},
{name: 'animated?', type: 'boolean', description: 'Whether the image is animated (GIF, APNG)'},
{name: 'nsfw', type: 'boolean', description: 'Whether the content was flagged as NSFW'},
{name: 'nsfw_probability?', type: 'number', description: 'NSFW probability score (0-1)'},
{name: 'nsfw_predictions?', type: 'map<string, number>', description: 'Per-category NSFW predictions'},
],
},
MetadataRequestExternal: {
description: 'Metadata request for external URLs.',
fields: [
{name: 'type', type: 'string', description: 'Must be `"external"`'},
{name: 'url', type: 'string', description: 'External URL to fetch'},
{name: 'with_base64?', type: 'boolean', description: 'Include base64-encoded content in response'},
{name: 'isNSFWAllowed', type: 'boolean', description: 'Whether NSFW content is permitted'},
],
},
MetadataRequestUpload: {
description: 'Metadata request for uploaded files.',
fields: [
{name: 'type', type: 'string', description: 'Must be `"upload"`'},
{name: 'upload_filename', type: 'string', description: 'Filename in the uploads bucket'},
{name: 'isNSFWAllowed', type: 'boolean', description: 'Whether NSFW content is permitted'},
],
},
MetadataRequestBase64: {
description: 'Metadata request for base64-encoded data.',
fields: [
{name: 'type', type: 'string', description: 'Must be `"base64"`'},
{name: 'base64', type: 'string', description: 'Base64-encoded media data'},
{name: 'isNSFWAllowed', type: 'boolean', description: 'Whether NSFW content is permitted'},
],
},
MetadataRequestS3: {
description: 'Metadata request for S3 objects.',
fields: [
{name: 'type', type: 'string', description: 'Must be `"s3"`'},
{name: 'bucket', type: 'string', description: 'S3 bucket name'},
{name: 'key', type: 'string', description: 'S3 object key'},
{name: 'with_base64?', type: 'boolean', description: 'Include base64-encoded content in response'},
{name: 'isNSFWAllowed', type: 'boolean', description: 'Whether NSFW content is permitted'},
],
},
ThumbnailRequestBody: {
description: 'Request body for thumbnail generation.',
fields: [
{name: 'upload_filename', type: 'string', description: 'Filename of the uploaded video in the uploads bucket'},
],
},
ThumbnailResponse: {
description: 'Response from thumbnail generation.',
fields: [
{name: 'thumbnail', type: 'string', description: 'Base64-encoded thumbnail image (JPEG)'},
{name: 'mime_type', type: 'string', description: 'MIME type of the thumbnail (always `image/jpeg`)'},
],
},
FrameRequestUpload: {
description: 'Frame extraction request for uploaded files.',
fields: [
{name: 'type', type: 'string', description: 'Must be `"upload"`'},
{name: 'upload_filename', type: 'string', description: 'Filename in the uploads bucket'},
],
},
FrameRequestS3: {
description: 'Frame extraction request for S3 objects.',
fields: [
{name: 'type', type: 'string', description: 'Must be `"s3"`'},
{name: 'bucket', type: 'string', description: 'S3 bucket name'},
{name: 'key', type: 'string', description: 'S3 object key'},
],
},
ExtractedFrame: {
description: 'Single extracted video frame.',
fields: [
{name: 'timestamp', type: 'number', description: 'Frame timestamp in seconds'},
{name: 'mime_type', type: 'string', description: 'MIME type of the frame image'},
{name: 'base64', type: 'string', description: 'Base64-encoded frame image'},
],
},
FrameResponse: {
description: 'Response from frame extraction.',
fields: [{name: 'frames', type: '[ExtractedFrame](#extractedframe)[]', description: 'Extracted video frames'}],
},
};
function renderSchemaSection(name, schema) {
let out = '';
out += `<a id="${name.toLowerCase()}"></a>\n\n`;
out += `## ${name}\n\n`;
if (schema.description) {
out += `${schema.description}\n\n`;
}
if (schema.relatedEndpoints) {
out += '**Related endpoints**\n\n';
for (const ep of schema.relatedEndpoints) {
out += `- [\`${ep.method} ${ep.path}\`](${ep.href})\n`;
}
out += '\n';
}
if (schema.fields && schema.fields.length > 0) {
out += '| Field | Type | Description |\n';
out += '|-------|------|-------------|\n';
for (const field of schema.fields) {
out += `| ${escapeTableText(field.name)} | ${escapeTableText(field.type)} | ${escapeTableText(field.description)} |\n`;
}
out += '\n';
}
return out;
}
function renderEndpointsMdx() {
let out = '';
out += createFrontmatter({
title: 'Media proxy',
description: 'Object schemas used by the Fluxer media proxy.',
});
out += '\n\n';
out += `The media proxy serves all user-generated content for Fluxer, including avatars, icons, attachments, emojis, stickers, and external media. All public routes are served from \`${MEDIA_PROXY_BASE_URL}\`.\n\n`;
out += 'See the [Media Proxy API reference](/media-proxy-api) for the full list of available endpoints.\n\n';
out += '## Field notation\n\n';
out += 'Resource tables use a compact notation:\n\n';
out += '| Notation | Meaning |\n';
out += '|----------|----------|\n';
out += '| `field` | Required field |\n';
out += '| `field?` | Optional field (may be omitted) |\n';
out += '| `?type` | Nullable (value can be `null`) |\n';
out += '\n';
out += '## Enums\n\n';
out += '<a id="imagesizeenum"></a>\n\n';
out += '### ImageSizeEnum\n\n';
out += 'Allowed image sizes in pixels.\n\n';
out += '**Related endpoints**\n\n';
out += `- [\`GET /avatars/{id}/{filename}\`](${MEDIA_PROXY_URLS.getAvatar})\n`;
out += `- [\`GET /icons/{id}/{filename}\`](${MEDIA_PROXY_URLS.getIcon})\n`;
out += `- [\`GET /banners/{id}/{filename}\`](${MEDIA_PROXY_URLS.getBanner})\n`;
out += `- [\`GET /emojis/{id}\`](${MEDIA_PROXY_URLS.getEmoji})\n`;
out += `- [\`GET /stickers/{id}\`](${MEDIA_PROXY_URLS.getSticker})\n`;
out += `- [\`GET /attachments/{channel_id}/{attachment_id}/{filename}\`](${MEDIA_PROXY_URLS.getAttachment})\n\n`;
out += `| Value | Description |\n`;
out += `|-------|-------------|\n`;
for (const size of ENUMS.ImageSizeEnum) {
out += `| ${wrapCode(size)} | ${size} pixels |\n`;
}
out += '\n';
out += '<a id="imageformatenum"></a>\n\n';
out += '### ImageFormatEnum\n\n';
out += 'Allowed image output formats.\n\n';
out += '**Related endpoints**\n\n';
out += `- [\`GET /avatars/{id}/{filename}\`](${MEDIA_PROXY_URLS.getAvatar})\n`;
out += `- [\`GET /external/{signature}/{timestamp}/{url}\`](${MEDIA_PROXY_URLS.getExternalMedia})\n\n`;
out += `| Value | Description |\n`;
out += `|-------|-------------|\n`;
out += `| ${wrapCode('png')} | PNG format |\n`;
out += `| ${wrapCode('jpg')} | JPEG format |\n`;
out += `| ${wrapCode('jpeg')} | JPEG format (alias) |\n`;
out += `| ${wrapCode('webp')} | WebP format (default, recommended) |\n`;
out += `| ${wrapCode('gif')} | GIF format (for animated images) |\n`;
out += '\n';
out += '<a id="imagequalityenum"></a>\n\n';
out += '### ImageQualityEnum\n\n';
out += 'Image quality levels.\n\n';
out += '**Related endpoints**\n\n';
out += `- [\`GET /avatars/{id}/{filename}\`](${MEDIA_PROXY_URLS.getAvatar})\n`;
out += `- [\`GET /external/{signature}/{timestamp}/{url}\`](${MEDIA_PROXY_URLS.getExternalMedia})\n\n`;
out += '| Value | Description |\n';
out += '|-------|-------------|\n';
out += `| ${wrapCode('high')} | High quality compression, good balance of size and fidelity (default for most images) |\n`;
out += `| ${wrapCode('low')} | Lower quality for smaller file sizes, suitable for thumbnails |\n`;
out += `| ${wrapCode('lossless')} | No quality loss, largest file sizes (default for external media) |\n`;
out += '\n';
out += '---\n\n';
out += '## Query parameter schemas\n\n';
const querySchemas = ['ImageQueryParams', 'ExternalMediaQueryParams'];
for (const name of querySchemas) {
const schema = {...SCHEMAS[name]};
schema.relatedEndpoints =
name === 'ImageQueryParams'
? [
{method: 'GET', path: '/avatars/{id}/{filename}', href: MEDIA_PROXY_URLS.getAvatar},
{method: 'GET', path: '/icons/{id}/{filename}', href: MEDIA_PROXY_URLS.getIcon},
{method: 'GET', path: '/emojis/{id}', href: MEDIA_PROXY_URLS.getEmoji},
]
: [
{
method: 'GET',
path: '/external/{signature}/{timestamp}/{url}',
href: MEDIA_PROXY_URLS.getExternalMedia,
},
];
out += renderSchemaSection(name, schema);
}
out += '---\n\n';
out += '## Response schemas\n\n';
const responseSchemas = ['MetadataResponse', 'ThumbnailResponse', 'FrameResponse', 'ExtractedFrame'];
for (const name of responseSchemas) {
const schema = {...SCHEMAS[name]};
if (name === 'MetadataResponse') {
schema.relatedEndpoints = [{method: 'POST', path: '/_metadata', href: MEDIA_PROXY_URLS.extractMetadata}];
} else if (name === 'ThumbnailResponse') {
schema.relatedEndpoints = [{method: 'POST', path: '/_thumbnail', href: MEDIA_PROXY_URLS.generateThumbnail}];
} else if (name === 'FrameResponse') {
schema.relatedEndpoints = [{method: 'POST', path: '/_frames', href: MEDIA_PROXY_URLS.extractFrames}];
}
out += renderSchemaSection(name, schema);
}
out += '---\n\n';
out += '## Request schemas\n\n';
out += '<a id="metadatarequest"></a>\n\n';
out += '### MetadataRequest\n\n';
out += 'Discriminated union for metadata extraction requests. The `type` field determines which variant is used.\n\n';
out += '**Related endpoints**\n\n';
out += `- [\`POST /_metadata\`](${MEDIA_PROXY_URLS.extractMetadata})\n\n`;
out += '| Type | Description |\n';
out += '|------|-------------|\n';
out += '| [MetadataRequestExternal](#metadatarequestexternal) | Fetch metadata from an external URL |\n';
out += '| [MetadataRequestUpload](#metadatarequestupload) | Get metadata from an uploaded file |\n';
out += '| [MetadataRequestBase64](#metadatarequestbase64) | Get metadata from base64-encoded data |\n';
out += '| [MetadataRequestS3](#metadatarequests3) | Get metadata from an S3 object |\n';
out += '\n';
const metadataRequestSchemas = [
'MetadataRequestExternal',
'MetadataRequestUpload',
'MetadataRequestBase64',
'MetadataRequestS3',
];
for (const name of metadataRequestSchemas) {
out += renderSchemaSection(name, SCHEMAS[name]);
}
out += '<a id="framerequest"></a>\n\n';
out += '### FrameRequest\n\n';
out += 'Discriminated union for frame extraction requests. The `type` field determines which variant is used.\n\n';
out += '**Related endpoints**\n\n';
out += `- [\`POST /_frames\`](${MEDIA_PROXY_URLS.extractFrames})\n\n`;
out += '| Type | Description |\n';
out += '|------|-------------|\n';
out += '| [FrameRequestUpload](#framerequestupload) | Extract frames from an uploaded file |\n';
out += '| [FrameRequestS3](#framerequests3) | Extract frames from an S3 object |\n';
out += '\n';
const frameRequestSchemas = ['FrameRequestUpload', 'FrameRequestS3'];
for (const name of frameRequestSchemas) {
out += renderSchemaSection(name, SCHEMAS[name]);
}
out += renderSchemaSection('ThumbnailRequestBody', {
...SCHEMAS.ThumbnailRequestBody,
relatedEndpoints: [{method: 'POST', path: '/_thumbnail', href: MEDIA_PROXY_URLS.generateThumbnail}],
});
return out;
}
function generateOpenApiSpec() {
const spec = {
openapi: '3.1.0',
info: {
title: 'Fluxer Media Proxy API',
version: '1.0.0',
description:
'Media proxy API for Fluxer. Serves avatars, icons, banners, attachments, emojis, stickers, and proxied external media.',
contact: {
name: 'Fluxer Developers',
email: 'developers@fluxer.app',
},
license: {
name: 'AGPL-3.0',
url: 'https://www.gnu.org/licenses/agpl-3.0.html',
},
},
servers: [
{
url: MEDIA_PROXY_BASE_URL,
description: 'Production media proxy',
},
],
tags: [
{name: 'Images', description: 'User and guild images (avatars, icons, banners, splashes)'},
{name: 'Content', description: 'Emojis, stickers, and attachments'},
{name: 'External', description: 'Proxied external media'},
{name: 'Themes', description: 'Theme CSS files'},
{name: 'Internal', description: 'Internal endpoints for service-to-service communication'},
],
paths: {},
components: {
schemas: {
ImageSizeEnum: {
type: 'string',
enum: ENUMS.ImageSizeEnum,
description: 'Allowed image sizes in pixels',
example: '128',
},
ImageFormatEnum: {
type: 'string',
enum: ENUMS.ImageFormatEnum,
description: 'Allowed image output formats',
example: 'webp',
},
ImageQualityEnum: {
type: 'string',
enum: ENUMS.ImageQualityEnum,
description: 'Image quality levels: high (default), low (smaller files), lossless (no compression)',
example: 'high',
},
MetadataRequestExternal: {
type: 'object',
description: 'Metadata request for external URLs',
properties: {
type: {type: 'string', const: 'external'},
url: {type: 'string', format: 'uri', description: 'External URL to fetch'},
with_base64: {type: 'boolean', description: 'Include base64-encoded content in response'},
isNSFWAllowed: {type: 'boolean', description: 'Whether NSFW content is permitted'},
},
required: ['type', 'url', 'isNSFWAllowed'],
},
MetadataRequestUpload: {
type: 'object',
description: 'Metadata request for uploaded files',
properties: {
type: {type: 'string', const: 'upload'},
upload_filename: {type: 'string', description: 'Filename in the uploads bucket'},
isNSFWAllowed: {type: 'boolean', description: 'Whether NSFW content is permitted'},
},
required: ['type', 'upload_filename', 'isNSFWAllowed'],
},
MetadataRequestBase64: {
type: 'object',
description: 'Metadata request for base64-encoded data',
properties: {
type: {type: 'string', const: 'base64'},
base64: {type: 'string', description: 'Base64-encoded media data'},
isNSFWAllowed: {type: 'boolean', description: 'Whether NSFW content is permitted'},
},
required: ['type', 'base64', 'isNSFWAllowed'],
},
MetadataRequestS3: {
type: 'object',
description: 'Metadata request for S3 objects',
properties: {
type: {type: 'string', const: 's3'},
bucket: {type: 'string', description: 'S3 bucket name'},
key: {type: 'string', description: 'S3 object key'},
with_base64: {type: 'boolean', description: 'Include base64-encoded content in response'},
isNSFWAllowed: {type: 'boolean', description: 'Whether NSFW content is permitted'},
},
required: ['type', 'bucket', 'key', 'isNSFWAllowed'],
},
MetadataRequest: {
oneOf: [
{$ref: '#/components/schemas/MetadataRequestExternal'},
{$ref: '#/components/schemas/MetadataRequestUpload'},
{$ref: '#/components/schemas/MetadataRequestBase64'},
{$ref: '#/components/schemas/MetadataRequestS3'},
],
discriminator: {
propertyName: 'type',
mapping: {
external: '#/components/schemas/MetadataRequestExternal',
upload: '#/components/schemas/MetadataRequestUpload',
base64: '#/components/schemas/MetadataRequestBase64',
s3: '#/components/schemas/MetadataRequestS3',
},
},
},
MetadataResponse: {
type: 'object',
description: 'Response from metadata extraction',
properties: {
format: {type: 'string', description: 'Detected media format (e.g., png, jpeg, gif, mp4)'},
content_type: {type: 'string', description: 'MIME content type'},
content_hash: {type: 'string', description: 'SHA-256 hash of content'},
size: {type: 'integer', description: 'File size in bytes'},
width: {type: 'integer', description: 'Image/video width in pixels'},
height: {type: 'integer', description: 'Image/video height in pixels'},
duration: {type: 'number', description: 'Video/audio duration in seconds'},
placeholder: {type: 'string', description: 'BlurHash placeholder string'},
base64: {type: 'string', description: 'Base64-encoded content (if requested)'},
animated: {type: 'boolean', description: 'Whether the media is animated'},
nsfw: {type: 'boolean', description: 'NSFW detection result'},
nsfw_probability: {type: 'number', description: 'NSFW probability score (0-1)'},
nsfw_predictions: {
type: 'object',
additionalProperties: {type: 'number'},
description: 'Per-category NSFW predictions',
},
},
required: ['format', 'content_type', 'content_hash', 'size', 'nsfw'],
},
ThumbnailRequestBody: {
type: 'object',
description: 'Request body for thumbnail generation',
properties: {
upload_filename: {type: 'string', description: 'Filename of the uploaded video'},
},
required: ['upload_filename'],
},
ThumbnailResponse: {
type: 'object',
description: 'Response from thumbnail generation',
properties: {
thumbnail: {type: 'string', description: 'Base64-encoded thumbnail image'},
mime_type: {type: 'string', description: 'MIME type of thumbnail'},
},
required: ['thumbnail', 'mime_type'],
},
FrameRequestUpload: {
type: 'object',
description: 'Frame extraction request for uploaded files',
properties: {
type: {type: 'string', const: 'upload'},
upload_filename: {type: 'string', description: 'Filename in the uploads bucket'},
},
required: ['type', 'upload_filename'],
},
FrameRequestS3: {
type: 'object',
description: 'Frame extraction request for S3 objects',
properties: {
type: {type: 'string', const: 's3'},
bucket: {type: 'string', description: 'S3 bucket name'},
key: {type: 'string', description: 'S3 object key'},
},
required: ['type', 'bucket', 'key'],
},
FrameRequest: {
oneOf: [{$ref: '#/components/schemas/FrameRequestUpload'}, {$ref: '#/components/schemas/FrameRequestS3'}],
discriminator: {
propertyName: 'type',
mapping: {
upload: '#/components/schemas/FrameRequestUpload',
s3: '#/components/schemas/FrameRequestS3',
},
},
},
ExtractedFrame: {
type: 'object',
description: 'Single extracted video frame',
properties: {
timestamp: {type: 'number', description: 'Frame timestamp in seconds'},
mime_type: {type: 'string', description: 'MIME type of frame image'},
base64: {type: 'string', description: 'Base64-encoded frame image'},
},
required: ['timestamp', 'mime_type', 'base64'],
},
FrameResponse: {
type: 'object',
description: 'Response from frame extraction',
properties: {
frames: {
type: 'array',
items: {$ref: '#/components/schemas/ExtractedFrame'},
description: 'Extracted video frames',
},
},
required: ['frames'],
},
Error: {
type: 'object',
description: 'Error response',
properties: {
error: {type: 'string', description: 'Error message'},
},
},
},
parameters: {
sizeParam: {
name: 'size',
in: 'query',
required: false,
schema: {$ref: '#/components/schemas/ImageSizeEnum'},
description: 'Target image size in pixels. Default: 128',
},
formatParam: {
name: 'format',
in: 'query',
required: false,
schema: {$ref: '#/components/schemas/ImageFormatEnum'},
description: 'Output image format. Default: webp',
},
qualityParam: {
name: 'quality',
in: 'query',
required: false,
schema: {$ref: '#/components/schemas/ImageQualityEnum'},
description: 'Image quality level. Default: high',
},
animatedParam: {
name: 'animated',
in: 'query',
required: false,
schema: {type: 'string', enum: ['true', 'false']},
description: 'Whether to return animated images (GIF, APNG). Default: false',
},
widthParam: {
name: 'width',
in: 'query',
required: false,
schema: {type: 'integer', minimum: 1, maximum: 4096},
description: 'Target width in pixels (1-4096)',
},
heightParam: {
name: 'height',
in: 'query',
required: false,
schema: {type: 'integer', minimum: 1, maximum: 4096},
description: 'Target height in pixels (1-4096)',
},
},
securitySchemes: {
internalKey: {
type: 'http',
scheme: 'bearer',
description: 'Internal service authentication using Bearer token',
},
},
},
};
const binaryImageResponse = {
200: {
description: 'Image data',
content: {
'image/png': {schema: {type: 'string', format: 'binary'}},
'image/jpeg': {schema: {type: 'string', format: 'binary'}},
'image/webp': {schema: {type: 'string', format: 'binary'}},
'image/gif': {schema: {type: 'string', format: 'binary'}},
},
},
400: {description: 'Bad request - invalid parameters'},
404: {description: 'Resource not found'},
};
const imageQueryParams = [
{$ref: '#/components/parameters/sizeParam'},
{$ref: '#/components/parameters/formatParam'},
{$ref: '#/components/parameters/qualityParam'},
{$ref: '#/components/parameters/animatedParam'},
];
const externalQueryParams = [
{$ref: '#/components/parameters/widthParam'},
{$ref: '#/components/parameters/heightParam'},
{$ref: '#/components/parameters/formatParam'},
{$ref: '#/components/parameters/qualityParam'},
{$ref: '#/components/parameters/animatedParam'},
];
spec.paths['/avatars/{id}/{filename}'] = {
get: {
operationId: 'getAvatar',
summary: 'Get user or guild avatar',
description:
'Retrieve a user or guild avatar image. Supports animated avatars (GIF) when the filename starts with `a_`.',
tags: ['Images'],
parameters: [
{name: 'id', in: 'path', required: true, schema: {type: 'string'}, description: 'User or guild ID'},
{
name: 'filename',
in: 'path',
required: true,
schema: {type: 'string'},
description: 'Avatar filename (hash.ext or a_hash.ext for animated)',
},
...imageQueryParams,
],
responses: binaryImageResponse,
},
};
spec.paths['/icons/{id}/{filename}'] = {
get: {
operationId: 'getIcon',
summary: 'Get guild icon',
description: 'Retrieve a guild icon image.',
tags: ['Images'],
parameters: [
{name: 'id', in: 'path', required: true, schema: {type: 'string'}, description: 'Guild ID'},
{name: 'filename', in: 'path', required: true, schema: {type: 'string'}, description: 'Icon filename'},
...imageQueryParams,
],
responses: binaryImageResponse,
},
};
spec.paths['/banners/{id}/{filename}'] = {
get: {
operationId: 'getBanner',
summary: 'Get user or guild banner',
description: 'Retrieve a user or guild banner image.',
tags: ['Images'],
parameters: [
{name: 'id', in: 'path', required: true, schema: {type: 'string'}, description: 'User or guild ID'},
{name: 'filename', in: 'path', required: true, schema: {type: 'string'}, description: 'Banner filename'},
...imageQueryParams,
],
responses: binaryImageResponse,
},
};
spec.paths['/splashes/{id}/{filename}'] = {
get: {
operationId: 'getSplash',
summary: 'Get guild invite splash',
description: 'Retrieve a guild invite splash image.',
tags: ['Images'],
parameters: [
{name: 'id', in: 'path', required: true, schema: {type: 'string'}, description: 'Guild ID'},
{name: 'filename', in: 'path', required: true, schema: {type: 'string'}, description: 'Splash filename'},
...imageQueryParams,
],
responses: binaryImageResponse,
},
};
spec.paths['/embed-splashes/{id}/{filename}'] = {
get: {
operationId: 'getEmbedSplash',
summary: 'Get guild embed splash',
description: 'Retrieve a guild embed splash image (used for server previews).',
tags: ['Images'],
parameters: [
{name: 'id', in: 'path', required: true, schema: {type: 'string'}, description: 'Guild ID'},
{
name: 'filename',
in: 'path',
required: true,
schema: {type: 'string'},
description: 'Embed splash filename',
},
...imageQueryParams,
],
responses: binaryImageResponse,
},
};
spec.paths['/emojis/{id}'] = {
get: {
operationId: 'getEmoji',
summary: 'Get custom emoji',
description: 'Retrieve a custom emoji image. May be PNG, GIF, or WebP.',
tags: ['Content'],
parameters: [
{name: 'id', in: 'path', required: true, schema: {type: 'string'}, description: 'Emoji ID'},
...imageQueryParams,
],
responses: binaryImageResponse,
},
};
spec.paths['/stickers/{id}'] = {
get: {
operationId: 'getSticker',
summary: 'Get sticker',
description: 'Retrieve a sticker image or Lottie animation. May return PNG, APNG, GIF, or Lottie JSON.',
tags: ['Content'],
parameters: [
{name: 'id', in: 'path', required: true, schema: {type: 'string'}, description: 'Sticker ID'},
...imageQueryParams,
],
responses: {
200: {
description: 'Sticker data',
content: {
'image/png': {schema: {type: 'string', format: 'binary'}},
'image/apng': {schema: {type: 'string', format: 'binary'}},
'image/gif': {schema: {type: 'string', format: 'binary'}},
'application/json': {schema: {type: 'object', description: 'Lottie animation JSON'}},
},
},
404: {description: 'Sticker not found'},
},
},
};
spec.paths['/guilds/{guild_id}/users/{user_id}/avatars/{filename}'] = {
get: {
operationId: 'getGuildMemberAvatar',
summary: 'Get guild member avatar',
description: 'Retrieve a guild-specific member avatar.',
tags: ['Images'],
parameters: [
{name: 'guild_id', in: 'path', required: true, schema: {type: 'string'}, description: 'Guild ID'},
{name: 'user_id', in: 'path', required: true, schema: {type: 'string'}, description: 'User ID'},
{name: 'filename', in: 'path', required: true, schema: {type: 'string'}, description: 'Avatar filename'},
...imageQueryParams,
],
responses: binaryImageResponse,
},
};
spec.paths['/guilds/{guild_id}/users/{user_id}/banners/{filename}'] = {
get: {
operationId: 'getGuildMemberBanner',
summary: 'Get guild member banner',
description: 'Retrieve a guild-specific member banner.',
tags: ['Images'],
parameters: [
{name: 'guild_id', in: 'path', required: true, schema: {type: 'string'}, description: 'Guild ID'},
{name: 'user_id', in: 'path', required: true, schema: {type: 'string'}, description: 'User ID'},
{name: 'filename', in: 'path', required: true, schema: {type: 'string'}, description: 'Banner filename'},
...imageQueryParams,
],
responses: binaryImageResponse,
},
};
spec.paths['/attachments/{channel_id}/{attachment_id}/{filename}'] = {
get: {
operationId: 'getAttachment',
summary: 'Get message attachment',
description:
'Retrieve a message attachment. Supports images, videos, and other files. Image query parameters only apply to image attachments.',
tags: ['Content'],
parameters: [
{name: 'channel_id', in: 'path', required: true, schema: {type: 'string'}, description: 'Channel ID'},
{name: 'attachment_id', in: 'path', required: true, schema: {type: 'string'}, description: 'Attachment ID'},
{name: 'filename', in: 'path', required: true, schema: {type: 'string'}, description: 'Original filename'},
...imageQueryParams,
],
responses: {
200: {
description: 'File data with appropriate Content-Type',
content: {'*/*': {schema: {type: 'string', format: 'binary'}}},
},
404: {description: 'Attachment not found'},
},
},
};
spec.paths['/themes/{id}.css'] = {
get: {
operationId: 'getTheme',
summary: 'Get theme CSS',
description: 'Retrieve a theme CSS file.',
tags: ['Themes'],
parameters: [{name: 'id', in: 'path', required: true, schema: {type: 'string'}, description: 'Theme ID'}],
responses: {
200: {
description: 'Theme CSS',
content: {'text/css': {schema: {type: 'string'}}},
},
404: {description: 'Theme not found'},
},
},
};
spec.paths['/external/{signature}/{timestamp}/{url}'] = {
get: {
operationId: 'getExternalMedia',
summary: 'Proxy external media',
description:
'Proxy external media through the Fluxer media proxy. External URLs are signed with HMAC-SHA256 to prevent abuse. The signature and timestamp parameters ensure URLs cannot be forged and expire after a configured period.',
tags: ['External'],
parameters: [
{
name: 'signature',
in: 'path',
required: true,
schema: {type: 'string'},
description: 'HMAC-SHA256 signature of the URL and timestamp, hex-encoded',
},
{
name: 'timestamp',
in: 'path',
required: true,
schema: {type: 'string'},
description: 'Unix timestamp (seconds) when the signature was generated, hex-encoded',
},
{
name: 'url',
in: 'path',
required: true,
schema: {type: 'string'},
description: 'External URL with protocol prefix (e.g., https/example.com/image.jpg)',
},
...externalQueryParams,
],
responses: {
...binaryImageResponse,
403: {description: 'Invalid signature or expired URL'},
502: {description: 'Failed to fetch external resource'},
},
},
};
spec.paths['/_metadata'] = {
post: {
operationId: 'extractMetadata',
summary: 'Extract media metadata',
description:
'Extract metadata from media files including dimensions, format, duration, NSFW detection, and optional base64 encoding.',
tags: ['Internal'],
security: [{internalKey: []}],
requestBody: {
required: true,
content: {
'application/json': {
schema: {$ref: '#/components/schemas/MetadataRequest'},
},
},
},
responses: {
200: {
description: 'Metadata extracted successfully',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/MetadataResponse'},
},
},
},
400: {description: 'Invalid request'},
401: {description: 'Unauthorized'},
},
},
};
spec.paths['/_thumbnail'] = {
post: {
operationId: 'generateThumbnail',
summary: 'Generate video thumbnail',
description: 'Generate a thumbnail from a video file.',
tags: ['Internal'],
security: [{internalKey: []}],
requestBody: {
required: true,
content: {
'application/json': {
schema: {$ref: '#/components/schemas/ThumbnailRequestBody'},
},
},
},
responses: {
200: {
description: 'Thumbnail generated successfully',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/ThumbnailResponse'},
},
},
},
400: {description: 'Invalid request or unsupported video format'},
401: {description: 'Unauthorized'},
},
},
};
spec.paths['/_frames'] = {
post: {
operationId: 'extractFrames',
summary: 'Extract video frames',
description: 'Extract multiple frames from a video file at evenly distributed timestamps.',
tags: ['Internal'],
security: [{internalKey: []}],
requestBody: {
required: true,
content: {
'application/json': {
schema: {$ref: '#/components/schemas/FrameRequest'},
},
},
},
responses: {
200: {
description: 'Frames extracted successfully',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/FrameResponse'},
},
},
},
400: {description: 'Invalid request or unsupported video format'},
401: {description: 'Unauthorized'},
},
},
};
return spec;
}
async function main() {
const dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(dirname, '../..');
const resourcesMdx = renderEndpointsMdx();
await writeFile(path.join(repoRoot, 'fluxer_docs/resources/media_proxy.mdx'), resourcesMdx);
const openApiSpec = generateOpenApiSpec();
await writeFile(
path.join(repoRoot, 'fluxer_docs/media-proxy-api/openapi.json'),
JSON.stringify(openApiSpec, null, '\t'),
);
console.log('Generated media proxy documentation');
console.log(' - fluxer_docs/resources/media_proxy.mdx');
console.log(' - fluxer_docs/media-proxy-api/openapi.json');
}
await main();