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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions cms-extract/src/lib/api-response 2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";

export function apiError(
status: number,
code: string,
message: string,
details?: unknown
) {
return NextResponse.json(
{
ok: false,
error: {
code,
message,
details,
},
},
{ status }
);
}

export function apiSuccess<T extends Record<string, unknown>>(
data: T,
status = 200
) {
return NextResponse.json(
{
ok: true,
...data,
},
{ status }
);
}
83 changes: 83 additions & 0 deletions cms-extract/src/lib/github-api 2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const DEFAULT_RETRY_ATTEMPTS = 3;
const DEFAULT_RETRY_BASE_DELAY_MS = 300;
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;

const RETRYABLE_STATUS_CODES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);

export interface GitHubRetryOptions {
attempts?: number;
baseDelayMs?: number;
timeoutMs?: number;
}

export async function fetchGitHubWithRetry(
url: string,
init: RequestInit,
options: GitHubRetryOptions = {}
): Promise<Response> {
const attempts = Math.max(1, options.attempts ?? DEFAULT_RETRY_ATTEMPTS);
const baseDelayMs = Math.max(0, options.baseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS);
const timeoutMs = Math.max(1, options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);

let lastError: unknown;

for (let attempt = 1; attempt <= attempts; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);

try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
clearTimeout(timeout);

if (!shouldRetryStatus(response.status) || attempt === attempts) {
return response;
}

await wait(getRetryDelayMs(response, baseDelayMs, attempt));
} catch (error) {
clearTimeout(timeout);
lastError = error;

if (attempt === attempts) {
break;
}

await wait(baseDelayMs * 2 ** (attempt - 1));
}
}

if (lastError instanceof Error) {
throw lastError;
}

throw new Error("GitHub request failed after retries");
}

function shouldRetryStatus(status: number): boolean {
return RETRYABLE_STATUS_CODES.has(status);
}

function getRetryDelayMs(
response: Response,
baseDelayMs: number,
attempt: number
): number {
const retryAfter = response.headers.get("retry-after");
if (!retryAfter) {
return baseDelayMs * 2 ** (attempt - 1);
}

const seconds = Number.parseInt(retryAfter, 10);
if (Number.isFinite(seconds) && seconds >= 0) {
return seconds * 1000;
}

return baseDelayMs * 2 ** (attempt - 1);
}

function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
133 changes: 133 additions & 0 deletions cms-extract/src/lib/svg-upload 2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { optimize, type Config } from "svgo";

export const GITHUB_FILE_WARNING_BYTES = 50 * 1024 * 1024; // 50 MiB
export const PLATFORM_MAX_FILE_BYTES = 100 * 1024 * 1024; // GitHub/Vercel hard cap
export const DEFAULT_SVG_TARGET_BYTES = 2 * 1024 * 1024; // CMS budget

const DANGEROUS_SVG_PATTERN =
/<\s*script\b|<\s*foreignObject\b|\son[a-z]+\s*=|(?:xlink:)?href\s*=\s*["'][^"']*javascript:|<!DOCTYPE|<!ENTITY/i;

export class SvgUploadError extends Error {
status: number;

constructor(message: string, status = 422) {
super(message);
this.name = "SvgUploadError";
this.status = status;
}
}

export interface SvgOptimizationResult {
content: string;
originalBytes: number;
optimizedBytes: number;
usedAggressivePass: boolean;
}

export function parseByteLimit(
value: string | undefined,
fallback: number
): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return parsed;
}

export function isSvgUpload(file: File): boolean {
const name = file.name.toLowerCase();
return file.type === "image/svg+xml" || name.endsWith(".svg");
}

export function optimizeSvgForUpload(
svgInput: string,
targetBytes: number
): SvgOptimizationResult {
const originalBytes = Buffer.byteLength(svgInput, "utf-8");
if (originalBytes > PLATFORM_MAX_FILE_BYTES) {
throw new SvgUploadError(
`SVG is too large (${formatBytes(originalBytes)}). Max allowed is ${formatBytes(PLATFORM_MAX_FILE_BYTES)}.`,
413
);
}

assertSafeSvg(svgInput);

let optimized = runSvgo(svgInput, buildBaseConfig());
let usedAggressivePass = false;

if (Buffer.byteLength(optimized, "utf-8") > targetBytes) {
optimized = runSvgo(optimized, buildAggressiveConfig());
usedAggressivePass = true;
}

assertSafeSvg(optimized);

const optimizedBytes = Buffer.byteLength(optimized, "utf-8");
if (optimizedBytes > PLATFORM_MAX_FILE_BYTES) {
throw new SvgUploadError(
`Optimized SVG is too large (${formatBytes(optimizedBytes)}). Max allowed is ${formatBytes(PLATFORM_MAX_FILE_BYTES)}.`,
413
);
}

return {
content: optimized,
originalBytes,
optimizedBytes,
usedAggressivePass,
};
}

function assertSafeSvg(svgContent: string): void {
if (!/<\s*svg\b/i.test(svgContent)) {
throw new SvgUploadError("Invalid SVG: root <svg> tag is missing.");
}

if (DANGEROUS_SVG_PATTERN.test(svgContent)) {
throw new SvgUploadError(
"Unsafe SVG detected. Remove scripts, foreignObject, inline handlers, javascript: URLs, and DTD entities."
);
}
}

function runSvgo(input: string, config: Config): string {
const result = optimize(input, config);
return result.data;
}

function buildBaseConfig(): Config {
return {
multipass: true,
js2svg: { indent: 0, pretty: false },
plugins: [
{
name: "preset-default",
},
],
};
}

function buildAggressiveConfig(): Config {
return {
multipass: true,
js2svg: { indent: 0, pretty: false },
plugins: [
{
name: "preset-default",
params: {
overrides: {
cleanupNumericValues: { floatPrecision: 2 },
convertPathData: { floatPrecision: 2 },
},
},
},
"removeDimensions",
"sortAttrs",
],
};
}

function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
}
2 changes: 2 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const customJestConfig = {
moduleNameMapper: {
// Handle module aliases (this will be automatically configured for you based on your tsconfig.json paths)
'^@/(.*)$': '<rootDir>/src/$1',
// Stub out heavy icon bundle that exceeds jest-runtime buffer limits
'^@gravity-ui/icons$': '<rootDir>/src/__mocks__/@gravity-ui/icons.js',
},
testEnvironment: 'jest-environment-jsdom',
}
Expand Down
3 changes: 3 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
turbopack: {
root: process.cwd(),
},
// Add caching headers for better performance
async headers() {
return [
Expand Down
82 changes: 82 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"@gravity-ui/uikit": "^7.35.2",
"@keystatic/core": "^0.5.50",
"@keystatic/next": "^5.0.4",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4",
Expand Down
Binary file added public/cases/rzd/screen-05 2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions src/__mocks__/@gravity-ui/icons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Lightweight stub – the real bundle is too large for jest-runtime's readFileBuffer.
// Returns a Proxy so any named icon import resolves to a no-op React component.
const handler = {
get(_target, prop) {
if (prop === '__esModule') return true;
// Return a minimal functional component for every icon
const Icon = () => null;
Icon.displayName = prop;
return Icon;
},
};

module.exports = new Proxy({}, handler);
Loading
Loading