Merge commit '90def724c28a2eacf173f4987e195dc14908f25d' into feature-clean

This commit is contained in:
2025-02-25 22:45:08 +03:00
83 changed files with 3417 additions and 1901 deletions

View File

@@ -20,7 +20,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
// Developer flags
developerMode: false,
showVersionFilesInTable: false,
// showAdsWithPlus: false,
// showAdsWithPlus: false,
alwaysShowChecklistAsPopup: true,
// Feature toggles
projectTypesPrimaryNav: false,

View File

@@ -10,19 +10,111 @@ interface PyroFetchOptions {
url?: string;
token?: string;
};
retry?: boolean;
retry?: number | boolean;
}
async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> {
class PyroServerError extends Error {
public readonly errors: Map<string, Error> = new Map();
public readonly timestamp: number = Date.now();
constructor(message?: string) {
super(message || "Multiple errors occurred");
this.name = "PyroServerError";
}
addError(module: string, error: Error) {
this.errors.set(module, error);
this.message = this.buildErrorMessage();
}
hasErrors() {
return this.errors.size > 0;
}
private buildErrorMessage(): string {
return Array.from(this.errors.entries())
.map(([_module, error]) => error.message)
.join("\n");
}
}
export class PyroServersFetchError extends Error {
constructor(
message: string,
public readonly statusCode?: number,
public readonly originalError?: Error,
public readonly module?: string,
) {
let errorMessage = message;
let method = "GET";
let path = "";
if (originalError instanceof FetchError) {
const matches = message.match(/\[([A-Z]+)\]\s+"([^"]+)":/);
if (matches) {
method = matches[1];
path = matches[2].replace(/https?:\/\/[^/]+\/[^/]+\/v\d+\//, "");
}
const statusMessage = (() => {
if (!statusCode) return "Unknown Error";
switch (statusCode) {
case 400:
return "Bad Request";
case 401:
return "Unauthorized";
case 403:
return "Forbidden";
case 404:
return "Not Found";
case 408:
return "Request Timeout";
case 429:
return "Too Many Requests";
case 500:
return "Internal Server Error";
case 502:
return "Bad Gateway";
case 503:
return "Service Unavailable";
case 504:
return "Gateway Timeout";
default:
return `HTTP ${statusCode}`;
}
})();
errorMessage = `[${method}] ${statusMessage} (${statusCode}) while fetching ${path}${module ? ` in ${module}` : ""}`;
} else {
errorMessage = `${message}${statusCode ? ` (${statusCode})` : ""}${module ? ` in ${module}` : ""}`;
}
super(errorMessage);
this.name = "PyroServersFetchError";
}
}
async function PyroFetch<T>(
path: string,
options: PyroFetchOptions = {},
module?: string,
): Promise<T> {
const config = useRuntimeConfig();
const auth = await useAuth();
const authToken = auth.value?.token;
if (!authToken) {
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
throw new PyroServersFetchError("Missing auth token", 401, undefined, module);
}
const { method = "GET", contentType = "application/json", body, version = 0, override } = options;
const {
method = "GET",
contentType = "application/json",
body,
version = 0,
override,
retry = method === "GET" ? 3 : 0,
} = options;
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
@@ -30,9 +122,11 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
);
if (!base) {
throw new PyroFetchError(
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables",
10001,
throw new PyroServersFetchError(
"Configuration error: Missing PYRO_BASE_URL",
500,
undefined,
module,
);
}
@@ -40,9 +134,7 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
? `https://${override.url}/${path.replace(/^\//, "")}`
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
type HeadersRecord = Record<string, string>;
const headers: HeadersRecord = {
const headers: Record<string, string> = {
Authorization: `Bearer ${override?.token ?? authToken}`,
"Access-Control-Allow-Headers": "Authorization",
"User-Agent": "Pyro/1.0 (https://pyro.host)",
@@ -57,43 +149,47 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
headers.Origin = window.location.origin;
}
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
timeout: 10000,
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0,
});
return response;
} catch (error) {
console.error("[PyroServers/PyroFetch]:", error);
if (error instanceof FetchError) {
const statusCode = error.response?.status;
const statusText = error.response?.statusText || "[no status text available]";
const errorMessages: { [key: number]: string } = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
};
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`;
throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error);
let attempts = 0;
const maxAttempts = (typeof retry === "boolean" ? (retry ? 1 : 0) : retry) + 1;
let lastError: Error | null = null;
while (attempts < maxAttempts) {
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
timeout: 10000,
});
return response;
} catch (error) {
lastError = error as Error;
attempts++;
if (error instanceof FetchError) {
const statusCode = error.response?.status;
const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
if (!isRetryable || attempts >= maxAttempts) {
throw new PyroServersFetchError(error.message, statusCode, error, module);
}
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw new PyroServersFetchError(
"Unexpected error during fetch operation",
undefined,
error as Error,
module,
);
}
throw new PyroFetchError(
"[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.",
undefined,
error as Error,
);
}
throw lastError || new Error("Maximum retry attempts reached");
}
const internalServerRefrence = ref<any>(null);
@@ -271,100 +367,96 @@ const constructServerProperties = (properties: any): string => {
};
const processImage = async (iconUrl: string | undefined) => {
const image = ref<string | null>(null);
const sharedImage = useState<string | undefined>(
`server-icon-${internalServerRefrence.value.serverId}`,
() => undefined,
);
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
try {
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
override: auth,
retry: false,
});
if (fileData instanceof Blob) {
if (import.meta.client) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(fileData);
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
internalServerRefrence.value.general.image = dataURL;
image.value = dataURL;
sharedImage.value = dataURL; // Store in useState
resolve();
};
});
}
}
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) {
sharedImage.value = undefined;
} else {
console.error(error);
}
if (sharedImage.value) {
return sharedImage.value;
}
if (image.value === null && iconUrl) {
console.log("iconUrl", iconUrl);
try {
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
try {
const response = await fetch(iconUrl);
const file = await response.blob();
const originalfile = new File([file], "server-icon-original.png", {
type: "image/png",
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
override: auth,
retry: false,
});
if (import.meta.client) {
const scaledFile = await new Promise<File>((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob((blob) => {
if (blob) {
const data = new File([blob], "server-icon.png", { type: "image/png" });
resolve(data);
} else {
reject(new Error("Canvas toBlob failed"));
}
}, "image/png");
};
img.onerror = reject;
});
if (scaledFile) {
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalfile,
override: auth,
if (fileData instanceof Blob) {
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(fileData);
});
return dataURL;
}
}
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) {
console.log("[PYROSERVERS] No server icon found");
} else {
console.error(error);
if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
try {
const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
const file = await response.blob();
const originalFile = new File([file], "server-icon-original.png", { type: "image/png" });
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" });
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalFile,
override: auth,
});
}
}, "image/png");
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
return dataURL;
}
} catch (error) {
console.error("Failed to process external icon:", error);
}
}
}
} catch (error) {
console.error("Failed to process server icon:", error);
}
return image.value;
sharedImage.value = undefined;
return undefined;
};
// ------------------ GENERAL ------------------ //
@@ -564,10 +656,14 @@ const reinstallContent = async (replace: string, projectId: string, versionId: s
const createBackup = async (backupName: string) => {
try {
const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, {
method: "POST",
body: { name: backupName },
})) as { id: string };
const response = await PyroFetch<{ id: string }>(
`servers/${internalServerRefrence.value.serverId}/backups`,
{
method: "POST",
body: { name: backupName },
},
);
await internalServerRefrence.value.refresh(["backups"]);
return response.id;
} catch (error) {
console.error("Error creating backup:", error);
@@ -581,6 +677,7 @@ const renameBackup = async (backupId: string, newName: string) => {
method: "POST",
body: { name: newName },
});
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error renaming backup:", error);
throw error;
@@ -592,6 +689,7 @@ const deleteBackup = async (backupId: string) => {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
method: "DELETE",
});
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error deleting backup:", error);
throw error;
@@ -606,6 +704,7 @@ const restoreBackup = async (backupId: string) => {
method: "POST",
},
);
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error restoring backup:", error);
throw error;
@@ -644,12 +743,10 @@ const getAutoBackup = async () => {
const lockBackup = async (backupId: string) => {
try {
return await PyroFetch(
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`,
{
method: "POST",
},
);
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
method: "POST",
});
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error locking backup:", error);
throw error;
@@ -658,14 +755,12 @@ const lockBackup = async (backupId: string) => {
const unlockBackup = async (backupId: string) => {
try {
return await PyroFetch(
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`,
{
method: "POST",
},
);
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
method: "POST",
});
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error locking backup:", error);
console.error("Error unlocking backup:", error);
throw error;
}
};
@@ -760,7 +855,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
try {
return await requestFn();
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 401) {
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
await internalServerRefrence.value.refresh(["fs"]);
return await requestFn();
}
@@ -947,17 +1042,18 @@ const modules: any = {
general: {
get: async (serverId: string) => {
try {
const data = await PyroFetch<General>(`servers/${serverId}`);
// TODO: temp hack to fix hydration error
const data = await PyroFetch<General>(`servers/${serverId}`, {}, "general");
if (data.upstream?.project_id) {
const res = await $fetch(
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
);
data.project = res as Project;
}
if (import.meta.client) {
data.image = (await processImage(data.project?.icon_url)) ?? undefined;
}
const motd = await getMotd();
if (motd === "A Minecraft Server") {
await setMotd(
@@ -967,8 +1063,19 @@ const modules: any = {
data.motd = motd;
return data;
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
status: "error",
server_id: serverId,
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
updateName,
@@ -982,16 +1089,23 @@ const modules: any = {
content: {
get: async (serverId: string) => {
try {
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`);
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`, {}, "content");
return {
data:
internalServerRefrence.value.error === undefined
? mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? ""))
: [],
data: mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")),
};
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
data: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
install: installContent,
@@ -1001,10 +1115,22 @@ const modules: any = {
backups: {
get: async (serverId: string) => {
try {
return { data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`) };
return {
data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`, {}, "backups"),
};
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
data: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
create: createBackup,
@@ -1020,10 +1146,26 @@ const modules: any = {
network: {
get: async (serverId: string) => {
try {
return { allocations: await PyroFetch<Allocation[]>(`servers/${serverId}/allocations`) };
return {
allocations: await PyroFetch<Allocation[]>(
`servers/${serverId}/allocations`,
{},
"network",
),
};
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
allocations: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
reserveAllocation,
@@ -1035,10 +1177,19 @@ const modules: any = {
startup: {
get: async (serverId: string) => {
try {
return await PyroFetch<Startup>(`servers/${serverId}/startup`);
return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
update: updateStartupSettings,
@@ -1046,20 +1197,39 @@ const modules: any = {
ws: {
get: async (serverId: string) => {
try {
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`);
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
},
fs: {
get: async (serverId: string) => {
try {
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`) };
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
auth: undefined,
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
listDirContents,
@@ -1367,12 +1537,44 @@ type FSFunctions = {
downloadFile: (path: string, raw?: boolean) => Promise<any>;
};
type GeneralModule = General & GeneralFunctions;
type ContentModule = { data: Mod[] } & ContentFunctions;
type BackupsModule = { data: Backup[] } & BackupFunctions;
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
type StartupModule = Startup & StartupFunctions;
export type FSModule = { auth: JWTAuth } & FSFunctions;
type ModuleError = {
error: PyroServersFetchError;
timestamp: number;
};
type GeneralModule = General &
GeneralFunctions & {
error?: ModuleError;
};
type ContentModule = {
data: Mod[];
error?: ModuleError;
} & ContentFunctions;
type BackupsModule = {
data: Backup[];
error?: ModuleError;
} & BackupFunctions;
type NetworkModule = {
allocations: Allocation[];
error?: ModuleError;
} & NetworkFunctions;
type StartupModule = Startup &
StartupFunctions & {
error?: ModuleError;
};
type WSModule = JWTAuth & {
error?: ModuleError;
};
type FSModule = {
auth: JWTAuth;
error?: ModuleError;
} & FSFunctions;
type ModulesMap = {
general: GeneralModule;
@@ -1380,7 +1582,7 @@ type ModulesMap = {
backups: BackupsModule;
network: NetworkModule;
startup: StartupModule;
ws: JWTAuth;
ws: WSModule;
fs: FSModule;
};
@@ -1401,6 +1603,7 @@ export type Server<T extends avaliableModules> = {
preserveInstallState?: boolean;
},
) => Promise<void>;
loadModules: (modulesToLoad: avaliableModules) => Promise<void>;
setError: (error: Error) => void;
error?: Error;
serverId: string;
@@ -1419,58 +1622,92 @@ export const usePyroServer = async (serverId: string, includedModules: avaliable
return;
}
const modulesToRefresh = refreshModules || includedModules;
const promises: Promise<void>[] = [];
const modulesToRefresh = [...new Set(refreshModules || includedModules)];
const serverError = new PyroServerError();
const uniqueModules = [...new Set(modulesToRefresh)];
const modulePromises = modulesToRefresh.map(async (module) => {
try {
const mods = modules[module];
if (!mods?.get) return;
for (const module of uniqueModules) {
const mods = modules[module];
if (mods.get) {
promises.push(
(async () => {
const data = await mods.get(serverId);
if (data) {
if (module === "general" && options?.preserveConnection) {
const updatedData = {
...server[module],
...data,
};
if (server[module]?.image) {
updatedData.image = server[module].image;
}
if (server[module]?.motd) {
updatedData.motd = server[module].motd;
}
if (options.preserveInstallState && server[module]?.status === "installing") {
updatedData.status = "installing";
}
server[module] = updatedData;
} else {
server[module] = { ...server[module], ...data };
}
}
})(),
);
const data = await mods.get(serverId);
if (!data) return;
if (module === "general" && options?.preserveConnection) {
server[module] = {
...server[module],
...data,
image: server[module]?.image || data.image,
motd: server[module]?.motd || data.motd,
status:
options.preserveInstallState && server[module]?.status === "installing"
? "installing"
: data.status,
};
} else {
server[module] = { ...server[module], ...data };
}
} catch (error) {
console.error(`Failed to refresh module ${module}:`, error);
if (error instanceof Error) {
serverError.addError(module, error);
}
}
});
await Promise.allSettled(modulePromises);
if (serverError.hasErrors()) {
if (server.error && server.error instanceof PyroServerError) {
serverError.errors.forEach((error, module) => {
(server.error as PyroServerError).addError(module, error);
});
} else {
server.setError(serverError);
}
}
},
loadModules: async (modulesToLoad: avaliableModules) => {
const newModules = modulesToLoad.filter((module) => !server[module]);
if (newModules.length === 0) return;
await Promise.all(promises);
newModules.forEach((module) => {
server[module] = modules[module];
});
await server.refresh(newModules);
},
setError: (error: Error) => {
server.error = error;
if (!server.error) {
server.error = error;
} else if (error instanceof PyroServerError) {
if (!(server.error instanceof PyroServerError)) {
const newError = new PyroServerError();
newError.addError("previous", server.error);
server.error = newError;
}
error.errors.forEach((err, module) => {
(server.error as PyroServerError).addError(module, err);
});
}
},
serverId,
});
for (const module of includedModules) {
const mods = modules[module];
server[module] = mods;
}
const initialModules = includedModules.filter((module) => ["general", "ws"].includes(module));
const deferredModules = includedModules.filter((module) => !["general", "ws"].includes(module));
initialModules.forEach((module) => {
server[module] = modules[module];
});
internalServerRefrence.value = server;
await server.refresh(initialModules);
await server.refresh();
if (deferredModules.length > 0) {
await server.loadModules(deferredModules);
}
return server as Server<typeof includedModules>;
};