add build profiles
This commit is contained in:
@@ -17,6 +17,14 @@ const rawServerListCheckbox = document.getElementById("raw-server-list");
|
||||
const serverUrlInput = document.getElementById("server-url");
|
||||
const solMemoCheckbox = document.getElementById("sol-memo");
|
||||
const solSettings = document.getElementById("sol-settings");
|
||||
const profileSelect = document.getElementById("build-profile-select");
|
||||
const profileNameInput = document.getElementById("build-profile-name");
|
||||
const profileSaveBtn = document.getElementById("profile-save-btn");
|
||||
const profileLoadBtn = document.getElementById("profile-load-btn");
|
||||
const profileDeleteBtn = document.getElementById("profile-delete-btn");
|
||||
const profileExportBtn = document.getElementById("profile-export-btn");
|
||||
const profileImportBtn = document.getElementById("profile-import-btn");
|
||||
const profileImportFile = document.getElementById("profile-import-file");
|
||||
|
||||
let currentServerVersion = null;
|
||||
let currentUserRole = null;
|
||||
@@ -87,16 +95,20 @@ function updateWindowsSectionVisibility() {
|
||||
}
|
||||
|
||||
const BUILD_SETTINGS_KEY = "overlord_build_settings";
|
||||
const PROFILE_EXPORT_MAGIC = "overlord-build-profile";
|
||||
|
||||
function saveFormSettings() {
|
||||
try {
|
||||
const settings = {
|
||||
let buildProfiles = [];
|
||||
|
||||
function collectFormSettings() {
|
||||
return {
|
||||
platforms: Array.from(document.querySelectorAll('input[name="platform"]')).map((el) => ({ value: el.value, checked: el.checked })),
|
||||
serverUrl: document.getElementById("server-url")?.value ?? "",
|
||||
rawServerList: document.getElementById("raw-server-list")?.checked ?? false,
|
||||
solMemo: document.getElementById("sol-memo")?.checked ?? false,
|
||||
solAddress: document.getElementById("sol-address")?.value ?? "",
|
||||
solRpcEndpoints: document.getElementById("sol-rpc-endpoints")?.value ?? "",
|
||||
outputName: document.getElementById("output-name")?.value ?? "",
|
||||
iosBundleId: document.getElementById("ios-bundle-id")?.value ?? "",
|
||||
mutex: document.getElementById("mutex")?.value ?? "",
|
||||
disableMutex: document.querySelector('input[name="disable-mutex"]')?.checked ?? false,
|
||||
stripDebug: document.querySelector('input[name="strip-debug"]')?.checked ?? true,
|
||||
@@ -123,61 +135,62 @@ function saveFormSettings() {
|
||||
outputExtension: document.getElementById("output-extension")?.value ?? ".exe",
|
||||
cryptableMode: document.getElementById("cryptable-mode")?.checked ?? false,
|
||||
};
|
||||
localStorage.setItem(BUILD_SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch (err) {
|
||||
console.error("Failed to save form settings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFormSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(BUILD_SETTINGS_KEY);
|
||||
if (!raw) return;
|
||||
const s = JSON.parse(raw);
|
||||
function applyFormSettings(settings) {
|
||||
if (!settings || typeof settings !== "object") return;
|
||||
|
||||
const setVal = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; };
|
||||
const setCb = (sel, val) => { const el = document.querySelector(sel); if (el && val !== undefined) el.checked = val; };
|
||||
const setVal = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && val !== undefined && val !== null) el.value = String(val);
|
||||
};
|
||||
const setCb = (sel, val) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && val !== undefined) el.checked = !!val;
|
||||
};
|
||||
|
||||
if (Array.isArray(s.platforms)) {
|
||||
s.platforms.forEach(({ value, checked }) => {
|
||||
if (Array.isArray(settings.platforms)) {
|
||||
settings.platforms.forEach(({ value, checked }) => {
|
||||
const el = document.querySelector(`input[name="platform"][value="${value}"]`);
|
||||
if (el) el.checked = checked;
|
||||
if (el) el.checked = !!checked;
|
||||
});
|
||||
}
|
||||
if (s.serverUrl !== undefined) setVal("server-url", s.serverUrl);
|
||||
if (s.rawServerList !== undefined) setCb("#raw-server-list", s.rawServerList);
|
||||
if (s.solMemo !== undefined) setCb("#sol-memo", s.solMemo);
|
||||
if (s.solAddress !== undefined) setVal("sol-address", s.solAddress);
|
||||
if (s.solRpcEndpoints !== undefined) setVal("sol-rpc-endpoints", s.solRpcEndpoints);
|
||||
if (s.mutex !== undefined) setVal("mutex", s.mutex);
|
||||
if (s.disableMutex !== undefined) setCb('input[name="disable-mutex"]', s.disableMutex);
|
||||
if (s.stripDebug !== undefined) setCb('input[name="strip-debug"]', s.stripDebug);
|
||||
if (s.disableCgo !== undefined) setCb('input[name="disable-cgo"]', s.disableCgo);
|
||||
if (s.noPrinting !== undefined) setCb('input[name="no-printing"]', s.noPrinting);
|
||||
if (s.obfuscate !== undefined) setCb('input[name="obfuscate"]', s.obfuscate);
|
||||
if (s.garbleLiterals !== undefined) setCb('input[name="garble-literals"]', s.garbleLiterals);
|
||||
if (s.garbleTiny !== undefined) setCb('input[name="garble-tiny"]', s.garbleTiny);
|
||||
if (s.garbleSeed !== undefined) setVal("garble-seed", s.garbleSeed);
|
||||
if (s.enableUpx !== undefined) setCb('input[name="enable-upx"]', s.enableUpx);
|
||||
if (s.upxStripHeaders !== undefined) setCb('input[name="upx-strip-headers"]', s.upxStripHeaders);
|
||||
if (s.sleepSeconds !== undefined) setVal("sleep-seconds", s.sleepSeconds);
|
||||
if (s.enablePersistence !== undefined) setCb('input[name="enable-persistence"]', s.enablePersistence);
|
||||
if (Array.isArray(s.persistenceMethods)) {
|
||||
if (settings.serverUrl !== undefined) setVal("server-url", settings.serverUrl);
|
||||
if (settings.rawServerList !== undefined) setCb("#raw-server-list", settings.rawServerList);
|
||||
if (settings.solMemo !== undefined) setCb("#sol-memo", settings.solMemo);
|
||||
if (settings.solAddress !== undefined) setVal("sol-address", settings.solAddress);
|
||||
if (settings.solRpcEndpoints !== undefined) setVal("sol-rpc-endpoints", settings.solRpcEndpoints);
|
||||
if (settings.outputName !== undefined) setVal("output-name", settings.outputName);
|
||||
if (settings.iosBundleId !== undefined) setVal("ios-bundle-id", settings.iosBundleId);
|
||||
if (settings.mutex !== undefined) setVal("mutex", settings.mutex);
|
||||
if (settings.disableMutex !== undefined) setCb('input[name="disable-mutex"]', settings.disableMutex);
|
||||
if (settings.stripDebug !== undefined) setCb('input[name="strip-debug"]', settings.stripDebug);
|
||||
if (settings.disableCgo !== undefined) setCb('input[name="disable-cgo"]', settings.disableCgo);
|
||||
if (settings.noPrinting !== undefined) setCb('input[name="no-printing"]', settings.noPrinting);
|
||||
if (settings.obfuscate !== undefined) setCb('input[name="obfuscate"]', settings.obfuscate);
|
||||
if (settings.garbleLiterals !== undefined) setCb('input[name="garble-literals"]', settings.garbleLiterals);
|
||||
if (settings.garbleTiny !== undefined) setCb('input[name="garble-tiny"]', settings.garbleTiny);
|
||||
if (settings.garbleSeed !== undefined) setVal("garble-seed", settings.garbleSeed);
|
||||
if (settings.enableUpx !== undefined) setCb('input[name="enable-upx"]', settings.enableUpx);
|
||||
if (settings.upxStripHeaders !== undefined) setCb('input[name="upx-strip-headers"]', settings.upxStripHeaders);
|
||||
if (settings.sleepSeconds !== undefined) setVal("sleep-seconds", settings.sleepSeconds);
|
||||
if (settings.enablePersistence !== undefined) setCb('input[name="enable-persistence"]', settings.enablePersistence);
|
||||
if (Array.isArray(settings.persistenceMethods)) {
|
||||
document.querySelectorAll('input[name="persistence-method"]').forEach((el) => {
|
||||
el.checked = s.persistenceMethods.includes(el.value);
|
||||
el.checked = settings.persistenceMethods.includes(el.value);
|
||||
});
|
||||
}
|
||||
if (s.startupName !== undefined) setVal("startup-name", s.startupName);
|
||||
if (s.hideConsole !== undefined) setCb('input[name="hide-console"]', s.hideConsole);
|
||||
if (s.requireAdmin !== undefined) setCb('input[name="require-admin"]', s.requireAdmin);
|
||||
if (s.criticalProcess !== undefined) setCb('input[name="critical-process"]', s.criticalProcess);
|
||||
if (s.assemblyTitle !== undefined) setVal("assembly-title", s.assemblyTitle);
|
||||
if (s.assemblyProduct !== undefined) setVal("assembly-product", s.assemblyProduct);
|
||||
if (s.assemblyCompany !== undefined) setVal("assembly-company", s.assemblyCompany);
|
||||
if (s.assemblyVersion !== undefined) setVal("assembly-version", s.assemblyVersion);
|
||||
if (s.assemblyCopyright !== undefined) setVal("assembly-copyright", s.assemblyCopyright);
|
||||
if (s.outputExtension !== undefined) setVal("output-extension", s.outputExtension);
|
||||
if (s.cryptableMode !== undefined) setCb("#cryptable-mode", s.cryptableMode);
|
||||
if (settings.startupName !== undefined) setVal("startup-name", settings.startupName);
|
||||
if (settings.hideConsole !== undefined) setCb('input[name="hide-console"]', settings.hideConsole);
|
||||
if (settings.requireAdmin !== undefined) setCb('input[name="require-admin"]', settings.requireAdmin);
|
||||
if (settings.criticalProcess !== undefined) setCb('input[name="critical-process"]', settings.criticalProcess);
|
||||
if (settings.assemblyTitle !== undefined) setVal("assembly-title", settings.assemblyTitle);
|
||||
if (settings.assemblyProduct !== undefined) setVal("assembly-product", settings.assemblyProduct);
|
||||
if (settings.assemblyCompany !== undefined) setVal("assembly-company", settings.assemblyCompany);
|
||||
if (settings.assemblyVersion !== undefined) setVal("assembly-version", settings.assemblyVersion);
|
||||
if (settings.assemblyCopyright !== undefined) setVal("assembly-copyright", settings.assemblyCopyright);
|
||||
if (settings.outputExtension !== undefined) setVal("output-extension", settings.outputExtension);
|
||||
if (settings.cryptableMode !== undefined) setCb("#cryptable-mode", settings.cryptableMode);
|
||||
|
||||
const restoredObfuscate = document.querySelector('input[name="obfuscate"]');
|
||||
const garbleContainer = document.getElementById("garble-settings-container");
|
||||
@@ -189,11 +202,164 @@ function restoreFormSettings() {
|
||||
if (restoredUpx && upxContainer) {
|
||||
upxContainer.classList.toggle("hidden", !restoredUpx.checked);
|
||||
}
|
||||
|
||||
updateWindowsSectionVisibility();
|
||||
updateIosSectionVisibility();
|
||||
updatePersistenceSettingsVisibility();
|
||||
if (solMemoCheckbox && solSettings) {
|
||||
solSettings.classList.toggle("hidden", !solMemoCheckbox.checked);
|
||||
}
|
||||
if (serverUrlInput && rawServerListCheckbox) {
|
||||
serverUrlInput.placeholder = getDefaultServerUrlPlaceholder(rawServerListCheckbox.checked);
|
||||
}
|
||||
applyCryptableMode(document.getElementById("cryptable-mode")?.checked || false);
|
||||
}
|
||||
|
||||
function saveFormSettings() {
|
||||
try {
|
||||
const settings = collectFormSettings();
|
||||
localStorage.setItem(BUILD_SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch (err) {
|
||||
console.error("Failed to save form settings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFormSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(BUILD_SETTINGS_KEY);
|
||||
if (!raw) return;
|
||||
const settings = JSON.parse(raw);
|
||||
applyFormSettings(settings);
|
||||
} catch (err) {
|
||||
console.error("Failed to restore form settings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeProfileName(name) {
|
||||
if (typeof name !== "string") return "";
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed.length > 64) return "";
|
||||
if (!/^[A-Za-z0-9 _.-]+$/.test(trimmed)) return "";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function renderProfileOptions(selectedName = "") {
|
||||
if (!profileSelect) return;
|
||||
profileSelect.innerHTML = "";
|
||||
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Select a saved profile...";
|
||||
profileSelect.appendChild(placeholder);
|
||||
|
||||
buildProfiles.forEach((profile) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.name;
|
||||
option.textContent = profile.name;
|
||||
if (selectedName && selectedName === profile.name) option.selected = true;
|
||||
profileSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedProfile() {
|
||||
if (!profileSelect || !profileSelect.value) return null;
|
||||
return buildProfiles.find((profile) => profile.name === profileSelect.value) || null;
|
||||
}
|
||||
|
||||
async function loadBuildProfiles(selectedName = "") {
|
||||
try {
|
||||
const res = await fetch("/api/build/profiles", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
buildProfiles = [];
|
||||
renderProfileOptions();
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
buildProfiles = Array.isArray(data?.profiles) ? data.profiles : [];
|
||||
renderProfileOptions(selectedName || profileSelect?.value || "");
|
||||
} catch (err) {
|
||||
console.error("Failed to load build profiles:", err);
|
||||
buildProfiles = [];
|
||||
renderProfileOptions();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentProfile() {
|
||||
const inputName = profileNameInput?.value || "";
|
||||
const selectedName = profileSelect?.value || "";
|
||||
const profileName = sanitizeProfileName(inputName || selectedName);
|
||||
if (!profileName) {
|
||||
alert("Enter a valid profile name (1-64 chars; letters, numbers, spaces, . _ -)");
|
||||
profileNameInput?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = collectFormSettings();
|
||||
|
||||
const res = await fetch("/api/build/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name: profileName, config }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to save profile");
|
||||
}
|
||||
|
||||
if (profileNameInput) profileNameInput.value = profileName;
|
||||
await loadBuildProfiles(profileName);
|
||||
}
|
||||
|
||||
function exportProfileToFile(name, config) {
|
||||
const payload = {
|
||||
type: PROFILE_EXPORT_MAGIC,
|
||||
version: 1,
|
||||
name,
|
||||
exportedAt: Date.now(),
|
||||
config,
|
||||
};
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const safeName = (name || "build-profile").replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
a.href = url;
|
||||
a.download = `${safeName}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function importProfileFromFile(file) {
|
||||
const text = await file.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Invalid JSON file");
|
||||
}
|
||||
|
||||
const fallbackName = file.name.replace(/\.json$/i, "");
|
||||
const importedName = sanitizeProfileName(parsed?.name || fallbackName);
|
||||
if (!importedName) {
|
||||
throw new Error("Invalid profile name in imported file");
|
||||
}
|
||||
|
||||
const importedConfig = parsed?.config;
|
||||
if (!importedConfig || typeof importedConfig !== "object" || Array.isArray(importedConfig)) {
|
||||
throw new Error("Imported profile is missing a valid config object");
|
||||
}
|
||||
|
||||
applyFormSettings(importedConfig);
|
||||
saveFormSettings();
|
||||
|
||||
if (profileNameInput) profileNameInput.value = importedName;
|
||||
await saveCurrentProfile();
|
||||
}
|
||||
|
||||
const CRYPTABLE_DISABLE_TARGETS = [
|
||||
'input[name="enable-persistence"]',
|
||||
'input[name="enable-upx"]',
|
||||
@@ -999,10 +1165,16 @@ async function init() {
|
||||
buildBtn.disabled = true;
|
||||
buildBtn.innerHTML =
|
||||
'<i class="fa-solid fa-lock"></i> <span>Build requires permission</span>';
|
||||
if (profileSaveBtn) profileSaveBtn.disabled = true;
|
||||
if (profileLoadBtn) profileLoadBtn.disabled = true;
|
||||
if (profileDeleteBtn) profileDeleteBtn.disabled = true;
|
||||
if (profileExportBtn) profileExportBtn.disabled = true;
|
||||
if (profileImportBtn) profileImportBtn.disabled = true;
|
||||
}
|
||||
|
||||
await loadServerVersion();
|
||||
await loadSavedBuilds();
|
||||
await loadBuildProfiles();
|
||||
|
||||
const toggleAllBuildsBtn = document.getElementById("toggle-all-builds-btn");
|
||||
const toggleAllBuildsLabel = document.getElementById("toggle-all-builds-label");
|
||||
@@ -1038,6 +1210,95 @@ if (logoutBtn && !logoutBtn.dataset.boundLogout) {
|
||||
});
|
||||
}
|
||||
|
||||
if (profileSelect) {
|
||||
profileSelect.addEventListener("change", () => {
|
||||
const profile = getSelectedProfile();
|
||||
if (profileNameInput) {
|
||||
profileNameInput.value = profile?.name || "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (profileSaveBtn) {
|
||||
profileSaveBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await saveCurrentProfile();
|
||||
alert("Profile saved.");
|
||||
} catch (err) {
|
||||
alert(err?.message || "Failed to save profile");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (profileLoadBtn) {
|
||||
profileLoadBtn.addEventListener("click", () => {
|
||||
const profile = getSelectedProfile();
|
||||
if (!profile) {
|
||||
alert("Select a profile to load.");
|
||||
return;
|
||||
}
|
||||
applyFormSettings(profile.config || {});
|
||||
saveFormSettings();
|
||||
if (profileNameInput) profileNameInput.value = profile.name;
|
||||
alert(`Loaded profile: ${profile.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (profileDeleteBtn) {
|
||||
profileDeleteBtn.addEventListener("click", async () => {
|
||||
const profile = getSelectedProfile();
|
||||
if (!profile) {
|
||||
alert("Select a profile to delete.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete profile \"${profile.name}\"?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/build/profiles/${encodeURIComponent(profile.name)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to delete profile");
|
||||
}
|
||||
await loadBuildProfiles();
|
||||
if (profileNameInput) profileNameInput.value = "";
|
||||
alert("Profile deleted.");
|
||||
} catch (err) {
|
||||
alert(err?.message || "Failed to delete profile");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (profileExportBtn) {
|
||||
profileExportBtn.addEventListener("click", () => {
|
||||
const profile = getSelectedProfile();
|
||||
if (profile) {
|
||||
exportProfileToFile(profile.name, profile.config || {});
|
||||
return;
|
||||
}
|
||||
const fallbackName = sanitizeProfileName(profileNameInput?.value || "") || "current-build-profile";
|
||||
exportProfileToFile(fallbackName, collectFormSettings());
|
||||
});
|
||||
}
|
||||
|
||||
if (profileImportBtn && profileImportFile) {
|
||||
profileImportBtn.addEventListener("click", () => profileImportFile.click());
|
||||
profileImportFile.addEventListener("change", async () => {
|
||||
const file = profileImportFile.files?.[0];
|
||||
profileImportFile.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
await importProfileFromFile(file);
|
||||
alert("Profile imported and saved.");
|
||||
} catch (err) {
|
||||
alert(err?.message || "Failed to import profile");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
@@ -57,6 +57,45 @@
|
||||
|
||||
<form id="build-form" class="flex flex-col gap-3">
|
||||
|
||||
<div class="border border-slate-800 rounded-lg p-3 bg-slate-800/40 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-slate-200">
|
||||
<i class="fa-solid fa-bookmark text-blue-400"></i>
|
||||
Build Profiles
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<select id="build-profile-select"
|
||||
class="w-full px-3 py-2 bg-slate-800/70 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors">
|
||||
<option value="">Select a saved profile...</option>
|
||||
</select>
|
||||
<input type="text" id="build-profile-name" maxlength="64" placeholder="Profile name"
|
||||
class="w-full px-3 py-2 bg-slate-800/70 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
<button type="button" id="profile-save-btn"
|
||||
class="px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors">
|
||||
<i class="fa-solid fa-floppy-disk mr-1"></i> Save
|
||||
</button>
|
||||
<button type="button" id="profile-load-btn"
|
||||
class="px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium transition-colors">
|
||||
<i class="fa-solid fa-folder-open mr-1"></i> Load
|
||||
</button>
|
||||
<button type="button" id="profile-delete-btn"
|
||||
class="px-3 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors">
|
||||
<i class="fa-solid fa-trash mr-1"></i> Delete
|
||||
</button>
|
||||
<button type="button" id="profile-export-btn"
|
||||
class="px-3 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-700 text-white text-sm font-medium transition-colors">
|
||||
<i class="fa-solid fa-file-export mr-1"></i> Export
|
||||
</button>
|
||||
<button type="button" id="profile-import-btn"
|
||||
class="px-3 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium transition-colors">
|
||||
<i class="fa-solid fa-file-import mr-1"></i> Import
|
||||
</button>
|
||||
</div>
|
||||
<input type="file" id="profile-import-file" accept="application/json,.json" class="hidden" />
|
||||
<p class="text-xs text-slate-500">Profiles are saved per user on the server and can be exported/imported as JSON files.</p>
|
||||
</div>
|
||||
|
||||
<!-- == Section 1: Target Platforms (open) == -->
|
||||
<div class="accordion-section border border-slate-800 rounded-lg overflow-hidden" data-open="true">
|
||||
<button type="button" class="accordion-header w-full flex items-center justify-between px-4 py-3 bg-slate-800/50 hover:bg-slate-800/80 transition-colors text-left">
|
||||
|
||||
@@ -193,6 +193,18 @@ try { db.run(`ALTER TABLE builds ADD COLUMN build_tag TEXT`); } catch {}
|
||||
try { db.run(`ALTER TABLE builds ADD COLUMN built_by_user_id INTEGER`); } catch {}
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_builds_build_tag ON builds(build_tag);`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS build_profiles (
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
profile_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, name)
|
||||
);
|
||||
`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_build_profiles_user_updated ON build_profiles(user_id, updated_at DESC);`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS notification_screenshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1460,6 +1472,57 @@ export function getClientBookmark(id: string): boolean {
|
||||
return row?.bookmarked === 1;
|
||||
}
|
||||
|
||||
export interface BuildProfileRecord {
|
||||
userId: number;
|
||||
name: string;
|
||||
profileJson: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export function listBuildProfilesForUser(userId: number): BuildProfileRecord[] {
|
||||
return db
|
||||
.query<any>(
|
||||
`SELECT user_id, name, profile_json, created_at, updated_at
|
||||
FROM build_profiles
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC, name ASC`,
|
||||
)
|
||||
.all(userId)
|
||||
.map((row: any) => ({
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
profileJson: row.profile_json,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export function saveBuildProfileForUser(userId: number, name: string, profileJson: string): void {
|
||||
const now = Date.now();
|
||||
db.run(
|
||||
`INSERT INTO build_profiles (user_id, name, profile_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, name) DO UPDATE SET
|
||||
profile_json = excluded.profile_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
userId,
|
||||
name,
|
||||
profileJson,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteBuildProfileForUser(userId: number, name: string): boolean {
|
||||
const result = db.run(
|
||||
`DELETE FROM build_profiles WHERE user_id = ? AND name = ?`,
|
||||
userId,
|
||||
name,
|
||||
);
|
||||
return ((result as any)?.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
status: string;
|
||||
|
||||
@@ -4,7 +4,16 @@ import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import * as buildManager from "../../build/buildManager";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { deleteBuild, getAllBuilds, getBuild, countBuildsForUser, getOldestBuildForUser } from "../../db";
|
||||
import {
|
||||
deleteBuild,
|
||||
getAllBuilds,
|
||||
getBuild,
|
||||
countBuildsForUser,
|
||||
getOldestBuildForUser,
|
||||
listBuildProfilesForUser,
|
||||
saveBuildProfileForUser,
|
||||
deleteBuildProfileForUser,
|
||||
} from "../../db";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
@@ -380,6 +389,90 @@ export async function handleBuildRoutes(
|
||||
return Response.json({ builds });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/build/profiles") {
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const profiles = listBuildProfilesForUser(user.userId).map((p) => {
|
||||
let parsedConfig: any = {};
|
||||
try {
|
||||
parsedConfig = JSON.parse(p.profileJson);
|
||||
} catch {
|
||||
parsedConfig = {};
|
||||
}
|
||||
return {
|
||||
name: p.name,
|
||||
config: parsedConfig,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
};
|
||||
});
|
||||
return Response.json({ profiles });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/build/profiles") {
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const body = await req.json();
|
||||
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||
const config = body?.config;
|
||||
|
||||
if (!name || name.length > 64 || !/^[A-Za-z0-9 _.-]+$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid profile name (1-64 chars, letters/numbers/space/._-)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return Response.json({ error: "Invalid profile config" }, { status: 400 });
|
||||
}
|
||||
|
||||
const MAX_PROFILES_PER_USER = 50;
|
||||
const existingProfiles = listBuildProfilesForUser(user.userId);
|
||||
const hasExistingWithSameName = existingProfiles.some((p) => p.name === name);
|
||||
if (!hasExistingWithSameName && existingProfiles.length >= MAX_PROFILES_PER_USER) {
|
||||
return Response.json(
|
||||
{ error: `Profile limit reached (${MAX_PROFILES_PER_USER} max)` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let profileJson = "{}";
|
||||
try {
|
||||
profileJson = JSON.stringify(config);
|
||||
} catch {
|
||||
return Response.json({ error: "Profile config must be JSON serializable" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (profileJson.length > 512 * 1024) {
|
||||
return Response.json({ error: "Profile config too large (max 512KB)" }, { status: 400 });
|
||||
}
|
||||
|
||||
saveBuildProfileForUser(user.userId, name, profileJson);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && url.pathname.match(/^\/api\/build\/profiles\/.+$/)) {
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const encodedName = url.pathname.split("/api/build/profiles/")[1] || "";
|
||||
let name = "";
|
||||
try {
|
||||
name = decodeURIComponent(encodedName).trim();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid profile name" }, { status: 400 });
|
||||
}
|
||||
if (!name) {
|
||||
return Response.json({ error: "Invalid profile name" }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = deleteBuildProfileForUser(user.userId, name);
|
||||
if (!deleted) {
|
||||
return Response.json({ error: "Profile not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && url.pathname.match(/^\/api\/build\/(.+)\/delete$/)) {
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user