feat: Agregar funcionalidad de gestión de items en el panel de control con encriptación de datos
This commit is contained in:
@@ -2,6 +2,7 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { createCipheriv, randomBytes, createDecipheriv } from "node:crypto";
|
||||
import {
|
||||
gzipSync,
|
||||
brotliCompressSync,
|
||||
@@ -230,6 +231,49 @@ function sanitizeString(v: unknown, opts?: { max?: number }) {
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
// --- Optional item encryption utilities (AES-256-GCM)
|
||||
function getItemEncryptionKey(): Buffer | null {
|
||||
const k = process.env.ITEM_ENCRYPTION_KEY || "";
|
||||
if (!k) return null;
|
||||
// derive 32-byte key from provided secret
|
||||
return createHash("sha256").update(k).digest();
|
||||
}
|
||||
|
||||
function encryptJsonForDb(obj: any): any {
|
||||
const key = getItemEncryptionKey();
|
||||
if (!key) return obj;
|
||||
try {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const plain = JSON.stringify(obj ?? {});
|
||||
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
const payload = Buffer.concat([iv, tag, enc]).toString("base64");
|
||||
return { __enc: true, v: payload };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decryptJsonFromDb(maybe: any): any {
|
||||
const key = getItemEncryptionKey();
|
||||
if (!key) return maybe;
|
||||
if (!maybe || typeof maybe !== "object") return maybe;
|
||||
if (!maybe.__enc || typeof maybe.v !== "string") return maybe;
|
||||
try {
|
||||
const buf = Buffer.from(maybe.v, "base64");
|
||||
const iv = buf.slice(0, 12);
|
||||
const tag = buf.slice(12, 28);
|
||||
const enc = buf.slice(28);
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const dec = Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
|
||||
return JSON.parse(dec);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function validateDiscordId(id: unknown) {
|
||||
if (!id) return false;
|
||||
const s = String(id);
|
||||
@@ -1152,6 +1196,337 @@ export const server = createServer(
|
||||
}
|
||||
}
|
||||
}
|
||||
// API: CRUD for EconomyItem within dashboard
|
||||
// GET /api/dashboard/:guildId/items -> list items (guild + global)
|
||||
// POST /api/dashboard/:guildId/items -> create item
|
||||
// PUT /api/dashboard/:guildId/items/:id -> update item
|
||||
// DELETE /api/dashboard/:guildId/items/:id -> delete item
|
||||
if (
|
||||
url.pathname.startsWith("/api/dashboard/") &&
|
||||
url.pathname.includes("/items")
|
||||
) {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
// parts: ['api','dashboard', guildId, 'items', [id]]
|
||||
if (
|
||||
parts.length >= 4 &&
|
||||
parts[0] === "api" &&
|
||||
parts[1] === "dashboard" &&
|
||||
parts[3] === "items"
|
||||
) {
|
||||
const guildId = parts[2];
|
||||
const itemId = parts[4] || null;
|
||||
|
||||
// session guard (same as settings)
|
||||
const cookiesApi = parseCookies(req);
|
||||
const signedApi = cookiesApi["amayo_sid"];
|
||||
const sidApi = unsignSid(signedApi);
|
||||
const sessionApi = sidApi ? SESSIONS.get(sidApi) : null;
|
||||
if (!sessionApi) {
|
||||
res.writeHead(
|
||||
401,
|
||||
applySecurityHeadersForRequest(req, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
);
|
||||
res.end(JSON.stringify({ error: "not_authenticated" }));
|
||||
return;
|
||||
}
|
||||
const userGuildsApi = sessionApi?.guilds || [];
|
||||
if (
|
||||
!userGuildsApi.find((g: any) => String(g.id) === String(guildId))
|
||||
) {
|
||||
res.writeHead(
|
||||
403,
|
||||
applySecurityHeadersForRequest(req, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
);
|
||||
res.end(JSON.stringify({ error: "forbidden" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// GET list
|
||||
if (req.method === "GET" && !itemId) {
|
||||
try {
|
||||
const items = await prisma.economyItem.findMany({
|
||||
where: {
|
||||
OR: [{ guildId: String(guildId) }, { guildId: null }],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
// Hide potentially sensitive JSON fields from API responses
|
||||
const safe = items.map((it: any) => ({
|
||||
id: it.id,
|
||||
key: it.key,
|
||||
name: it.name,
|
||||
description: it.description,
|
||||
category: it.category,
|
||||
icon: it.icon,
|
||||
stackable: it.stackable,
|
||||
maxPerInventory: it.maxPerInventory,
|
||||
tags: it.tags || [],
|
||||
guildId: it.guildId || null,
|
||||
createdAt: it.createdAt,
|
||||
updatedAt: it.updatedAt,
|
||||
}));
|
||||
res.writeHead(
|
||||
200,
|
||||
applySecurityHeadersForRequest(req, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
);
|
||||
res.end(JSON.stringify({ ok: true, items: safe }));
|
||||
return;
|
||||
} catch (err) {
|
||||
res.writeHead(
|
||||
500,
|
||||
applySecurityHeadersForRequest(req, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
);
|
||||
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// GET single item raw (admin) -> /api/dashboard/:guildId/items/:id?raw=1
|
||||
if (req.method === "GET" && itemId) {
|
||||
const wantRaw = url.searchParams.get('raw') === '1';
|
||||
if (wantRaw) {
|
||||
if (process.env.ALLOW_ITEM_RAW !== '1') {
|
||||
res.writeHead(403, applySecurityHeadersForRequest(req, { 'Content-Type':'application/json' }));
|
||||
res.end(JSON.stringify({ ok:false, error: 'raw_disabled' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const it = await prisma.economyItem.findUnique({ where: { id: String(itemId) } });
|
||||
if (!it) {
|
||||
res.writeHead(404, applySecurityHeadersForRequest(req, { 'Content-Type':'application/json' }));
|
||||
res.end(JSON.stringify({ ok:false, error:'not_found' }));
|
||||
return;
|
||||
}
|
||||
const props = decryptJsonFromDb(it.props);
|
||||
const metadata = decryptJsonFromDb(it.metadata);
|
||||
res.writeHead(200, applySecurityHeadersForRequest(req, { 'Content-Type':'application/json' }));
|
||||
res.end(JSON.stringify({ ok:true, item: { id: it.id, key: it.key, name: it.name, props, metadata } }));
|
||||
return;
|
||||
} catch (err) {
|
||||
res.writeHead(500, applySecurityHeadersForRequest(req, { 'Content-Type':'application/json' }));
|
||||
res.end(JSON.stringify({ ok:false, error: String(err) }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// otherwise fall through to allow POST/PUT/DELETE handling below
|
||||
}
|
||||
|
||||
// Read body helper
|
||||
const raw = await new Promise<string>((resolve) => {
|
||||
let data = "";
|
||||
req.on("data", (c: any) => (data += String(c)));
|
||||
req.on("end", () => resolve(data));
|
||||
req.on("error", () => resolve(""));
|
||||
}).catch(() => "");
|
||||
let payload: any = {};
|
||||
try {
|
||||
payload = raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
// POST create
|
||||
if (req.method === "POST" && !itemId) {
|
||||
const key = sanitizeString(payload.key || "", { max: 200 });
|
||||
const name = sanitizeString(payload.name || "", { max: 200 });
|
||||
if (!key || !name) {
|
||||
res.writeHead(
|
||||
400,
|
||||
applySecurityHeadersForRequest(req, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
);
|
||||
res.end(
|
||||
JSON.stringify({ ok: false, error: "missing_key_or_name" })
|
||||
);
|
||||
return;
|
||||
}
|
||||
const createData: any = {
|
||||
key,
|
||||
name,
|
||||
description:
|
||||
sanitizeString(payload.description || "", { max: 1000 }) ||
|
||||
null,
|
||||
category:
|
||||
sanitizeString(payload.category || "", { max: 200 }) || null,
|
||||
icon: sanitizeString(payload.icon || "", { max: 200 }) || null,
|
||||
stackable: payload.stackable === false ? false : true,
|
||||
maxPerInventory:
|
||||
typeof payload.maxPerInventory === "number"
|
||||
? payload.maxPerInventory
|
||||
: null,
|
||||
guildId: String(guildId),
|
||||
tags: Array.isArray(payload.tags)
|
||||
? payload.tags.map(String)
|
||||
: typeof payload.tags === "string"
|
||||
? payload.tags
|
||||
.split(",")
|
||||
.map((s: any) => String(s).trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
// parse JSON fields if provided as string and encrypt if key present
|
||||
try {
|
||||
const rawProps = payload.props ? (typeof payload.props === 'string' ? JSON.parse(payload.props) : payload.props) : null;
|
||||
const rawMeta = payload.metadata ? (typeof payload.metadata === 'string' ? JSON.parse(payload.metadata) : payload.metadata) : null;
|
||||
createData.props = getItemEncryptionKey() ? encryptJsonForDb(rawProps) : rawProps;
|
||||
createData.metadata = getItemEncryptionKey() ? encryptJsonForDb(rawMeta) : rawMeta;
|
||||
} catch {
|
||||
createData.props = null;
|
||||
createData.metadata = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await prisma.economyItem.create({ data: createData });
|
||||
// Return safe summary only (do not include props/metadata)
|
||||
const safeCreated = {
|
||||
id: created.id,
|
||||
key: created.key,
|
||||
name: created.name,
|
||||
description: created.description,
|
||||
category: created.category,
|
||||
icon: created.icon,
|
||||
stackable: created.stackable,
|
||||
maxPerInventory: created.maxPerInventory,
|
||||
tags: created.tags || [],
|
||||
guildId: created.guildId || null,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
};
|
||||
res.writeHead(
|
||||
200,
|
||||
applySecurityHeadersForRequest(req, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
);
|
||||
res.end(JSON.stringify({ ok: true, item: safeCreated }));
|
||||
return;
|
||||
} catch (err: any) {
|
||||
// Prisma unique constraint error code P2002 -> duplicate key
|
||||
if (err && err.code === 'P2002') {
|
||||
res.writeHead(400, applySecurityHeadersForRequest(req, { 'Content-Type':'application/json' }));
|
||||
res.end(JSON.stringify({ ok:false, error:'duplicate_key' }));
|
||||
return;
|
||||
}
|
||||
const errMsg = String(err || 'unknown');
|
||||
res.writeHead(500, applySecurityHeadersForRequest(req, { 'Content-Type':'application/json' }));
|
||||
res.end(JSON.stringify({ ok:false, error: errMsg }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PUT update
|
||||
if (req.method === "PUT" && itemId) {
|
||||
try {
|
||||
const id = String(itemId);
|
||||
const updateData: any = {};
|
||||
if (payload.key)
|
||||
updateData.key = sanitizeString(payload.key, { max: 200 });
|
||||
if (payload.name)
|
||||
updateData.name = sanitizeString(payload.name, { max: 200 });
|
||||
if (typeof payload.description !== "undefined")
|
||||
updateData.description =
|
||||
sanitizeString(payload.description || "", { max: 1000 }) ||
|
||||
null;
|
||||
if (typeof payload.category !== "undefined")
|
||||
updateData.category =
|
||||
sanitizeString(payload.category || "", { max: 200 }) || null;
|
||||
if (typeof payload.icon !== "undefined")
|
||||
updateData.icon =
|
||||
sanitizeString(payload.icon || "", { max: 200 }) || null;
|
||||
if (typeof payload.stackable !== "undefined")
|
||||
updateData.stackable =
|
||||
payload.stackable === false ? false : true;
|
||||
updateData.maxPerInventory =
|
||||
typeof payload.maxPerInventory === "number"
|
||||
? payload.maxPerInventory
|
||||
: null;
|
||||
if (typeof payload.tags !== "undefined")
|
||||
updateData.tags = Array.isArray(payload.tags)
|
||||
? payload.tags.map(String)
|
||||
: typeof payload.tags === "string"
|
||||
? payload.tags
|
||||
.split(",")
|
||||
.map((s: any) => String(s).trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
try {
|
||||
const rawProps = typeof payload.props === 'string' ? JSON.parse(payload.props) : payload.props;
|
||||
const rawMeta = typeof payload.metadata === 'string' ? JSON.parse(payload.metadata) : payload.metadata;
|
||||
updateData.props = getItemEncryptionKey() ? encryptJsonForDb(rawProps) : rawProps;
|
||||
updateData.metadata = getItemEncryptionKey() ? encryptJsonForDb(rawMeta) : rawMeta;
|
||||
} catch {
|
||||
updateData.props = null;
|
||||
updateData.metadata = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await prisma.economyItem.update({ where: { id }, data: updateData });
|
||||
// Return safe summary only (do not include props/metadata)
|
||||
const safeUpdated = {
|
||||
id: updated.id,
|
||||
key: updated.key,
|
||||
name: updated.name,
|
||||
description: updated.description,
|
||||
category: updated.category,
|
||||
icon: updated.icon,
|
||||
stackable: updated.stackable,
|
||||
maxPerInventory: updated.maxPerInventory,
|
||||
tags: updated.tags || [],
|
||||
guildId: updated.guildId || null,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
res.writeHead(200, applySecurityHeadersForRequest(req, { 'Content-Type': 'application/json' }));
|
||||
res.end(JSON.stringify({ ok: true, item: safeUpdated }));
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (err && err.code === 'P2002') {
|
||||
res.writeHead(400, applySecurityHeadersForRequest(req, { 'Content-Type':'application/json' }));
|
||||
res.end(JSON.stringify({ ok:false, error:'duplicate_key' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(500, applySecurityHeadersForRequest(req, { 'Content-Type': 'application/json' }));
|
||||
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE
|
||||
if (req.method === "DELETE" && itemId) {
|
||||
try {
|
||||
const id = String(itemId);
|
||||
await prisma.economyItem.delete({ where: { id } });
|
||||
res.writeHead(
|
||||
200,
|
||||
applySecurityHeadersForRequest(req, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
);
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
} catch (err) {
|
||||
res.writeHead(
|
||||
500,
|
||||
applySecurityHeadersForRequest(req, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
);
|
||||
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dev-only helper: fetch roles for a guild (requires COLLAB_TEST=1 and DISCORD_BOT_TOKEN)
|
||||
if (url.pathname.startsWith("/__dev/fetch-roles")) {
|
||||
if (process.env.COLLAB_TEST !== "1") {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<%- await include('../partials/dashboard/dashboard_overview') %>
|
||||
<% } else if (typeof page !== 'undefined' && page === 'settings') { %>
|
||||
<div id="dashMain"><%- await include('../partials/dashboard/dashboard_settings') %></div>
|
||||
<% } else if (typeof page !== 'undefined' && page === 'items') { %>
|
||||
<div id="dashMain"><%- await include('../partials/dashboard/dashboard_items') %></div>
|
||||
<% } else { %>
|
||||
<div id="dashMain"><!-- empty main placeholder --></div>
|
||||
<% } %>
|
||||
|
||||
225
src/server/views/partials/dashboard/dashboard_items.ejs
Normal file
225
src/server/views/partials/dashboard/dashboard_items.ejs
Normal file
@@ -0,0 +1,225 @@
|
||||
<div class="p-4 bg-[#071323] rounded">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white text-lg font-semibold">Items</h2>
|
||||
<div>
|
||||
<button id="createItemBtn" class="inline-flex items-center gap-2 px-3 py-1 bg-indigo-600 rounded text-white">Crear item</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="itemsList" class="space-y-3">
|
||||
<% if (typeof guildRoles !== 'undefined') { %>
|
||||
<!-- placeholder: server will not inject items; client will fetch -->
|
||||
<% } %>
|
||||
<div class="text-white/60">Cargando items...</div>
|
||||
</div>
|
||||
|
||||
<!-- Create / Edit modal -->
|
||||
<div id="itemModal" class="fixed inset-0 hidden items-center justify-center z-50">
|
||||
<div class="absolute inset-0 bg-black/70"></div>
|
||||
<div class="relative w-full max-w-3xl bg-[#0b1320] rounded shadow p-6 z-10">
|
||||
<h3 id="modalTitle" class="text-white text-lg mb-2">Crear item</h3>
|
||||
<form id="itemForm">
|
||||
<input type="hidden" name="id" id="itemId">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-white/80">Key</label>
|
||||
<input id="fieldKey" name="key" class="w-full mt-1 p-2 rounded bg-[#0f1720] text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/80">Name</label>
|
||||
<input id="fieldName" name="name" class="w-full mt-1 p-2 rounded bg-[#0f1720] text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/80">Category</label>
|
||||
<input id="fieldCategory" name="category" class="w-full mt-1 p-2 rounded bg-[#0f1720] text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/80">Icon</label>
|
||||
<input id="fieldIcon" name="icon" class="w-full mt-1 p-2 rounded bg-[#0f1720] text-white" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="text-white/80">Description</label>
|
||||
<textarea id="fieldDescription" name="description" class="w-full mt-1 p-2 rounded bg-[#0f1720] text-white" rows="3"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/80">Tags (comma separated)</label>
|
||||
<input id="fieldTags" name="tags" class="w-full mt-1 p-2 rounded bg-[#0f1720] text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/80">Max Per Inventory</label>
|
||||
<input id="fieldMaxPer" name="maxPerInventory" type="number" class="w-full mt-1 p-2 rounded bg-[#0f1720] text-white" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="text-white/80">Props (JSON)</label>
|
||||
<textarea id="fieldProps" name="props" class="w-full mt-1 p-2 rounded bg-[#0f1720] text-white font-mono" rows="6">{}</textarea>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="text-white/80">Metadata (JSON)</label>
|
||||
<textarea id="fieldMetadata" name="metadata" class="w-full mt-1 p-2 rounded bg-[#0f1720] text-white font-mono" rows="4">{}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<button type="button" id="cancelItemBtn" class="px-3 py-1 rounded bg-white/6 text-white">Cancelar</button>
|
||||
<button type="submit" id="saveItemBtn" class="px-3 py-1 rounded bg-green-600 text-white">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const guildId = '<%= selectedGuildId ? selectedGuildId : '' %>';
|
||||
const list = document.getElementById('itemsList');
|
||||
const modal = document.getElementById('itemModal');
|
||||
const form = document.getElementById('itemForm');
|
||||
const createBtn = document.getElementById('createItemBtn');
|
||||
const cancelBtn = document.getElementById('cancelItemBtn');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
|
||||
function showModal(edit=false) {
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
}
|
||||
function hideModal() {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
form.reset();
|
||||
document.getElementById('fieldProps').value = '{}';
|
||||
document.getElementById('fieldMetadata').value = '{}';
|
||||
document.getElementById('itemId').value = '';
|
||||
}
|
||||
|
||||
async function fetchItems() {
|
||||
if (!guildId) return;
|
||||
list.innerHTML = '<div class="text-white/60">Cargando items...</div>';
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/${encodeURIComponent(guildId)}/items`, { headers: { 'Accept':'application/json' } });
|
||||
if (!res.ok) throw new Error('fetch-failed');
|
||||
const j = await res.json();
|
||||
if (!j.ok) throw new Error('bad');
|
||||
renderList(j.items || []);
|
||||
} catch (err) {
|
||||
list.innerHTML = '<div class="text-red-400">Error cargando items</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(items) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
list.innerHTML = '<div class="text-white/60">No hay items definidos.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
for (const it of items) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'p-3 bg-[#071a2a] rounded flex items-start justify-between';
|
||||
el.innerHTML = `<div>
|
||||
<div class="text-white font-medium">${escapeHtml(it.name)} <span class="text-white/50">(${escapeHtml(it.key)})</span></div>
|
||||
<div class="text-white/60 text-sm mt-1">${escapeHtml(it.description || '')}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button data-id="${it.id}" class="editBtn px-2 py-1 bg-indigo-600 rounded text-white text-sm">Editar</button>
|
||||
<button data-id="${it.id}" class="delBtn px-2 py-1 bg-red-600 rounded text-white text-sm">Eliminar</button>
|
||||
</div>`;
|
||||
list.appendChild(el);
|
||||
}
|
||||
// attach handlers
|
||||
Array.from(list.querySelectorAll('.editBtn')).forEach(b=>b.addEventListener('click', onEdit));
|
||||
Array.from(list.querySelectorAll('.delBtn')).forEach(b=>b.addEventListener('click', onDelete));
|
||||
}
|
||||
|
||||
function escapeHtml(s){ if (!s) return ''; return String(s).replace(/[&<>"']/g, (m)=>({ '&':'&','<':'<','>':'>','"':'"','\'":"'' })[m] || m); }
|
||||
|
||||
function onEdit(e) {
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
openEdit(id);
|
||||
}
|
||||
async function openEdit(id) {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/${encodeURIComponent(guildId)}/items`);
|
||||
if (!res.ok) throw new Error('fetch-failed');
|
||||
const j = await res.json();
|
||||
const it = (j.items || []).find(x=>x.id===id);
|
||||
if (!it) return alert('Item no encontrado');
|
||||
document.getElementById('itemId').value = it.id;
|
||||
document.getElementById('fieldKey').value = it.key || '';
|
||||
document.getElementById('fieldName').value = it.name || '';
|
||||
document.getElementById('fieldCategory').value = it.category || '';
|
||||
document.getElementById('fieldIcon').value = it.icon || '';
|
||||
document.getElementById('fieldDescription').value = it.description || '';
|
||||
document.getElementById('fieldTags').value = (Array.isArray(it.tags) ? it.tags.join(',') : '');
|
||||
document.getElementById('fieldMaxPer').value = it.maxPerInventory || '';
|
||||
// For security we do NOT expose stored props/metadata via the API. User must paste JSON manually if needed.
|
||||
document.getElementById('fieldProps').value = '{ /* props are hidden for security; paste JSON to replace */ }';
|
||||
document.getElementById('fieldMetadata').value = '{ /* metadata hidden for security */ }';
|
||||
modalTitle.textContent = 'Editar item';
|
||||
showModal(true);
|
||||
} catch (err) {
|
||||
alert('Error al abrir item');
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(e) {
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
if (!confirm('Eliminar item?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/${encodeURIComponent(guildId)}/items/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('delete-failed');
|
||||
await fetchItems();
|
||||
} catch (err) {
|
||||
alert('Error al eliminar');
|
||||
}
|
||||
}
|
||||
|
||||
createBtn.addEventListener('click', ()=>{ modalTitle.textContent = 'Crear item'; showModal(false); });
|
||||
cancelBtn.addEventListener('click', hideModal);
|
||||
|
||||
form.addEventListener('submit', async (ev)=>{
|
||||
ev.preventDefault();
|
||||
const id = document.getElementById('itemId').value;
|
||||
// validate JSON fields before sending
|
||||
const propsText = document.getElementById('fieldProps').value.trim();
|
||||
const metaText = document.getElementById('fieldMetadata').value.trim();
|
||||
let parsedProps = null;
|
||||
let parsedMeta = null;
|
||||
try {
|
||||
parsedProps = propsText ? JSON.parse(propsText) : null;
|
||||
} catch (e) {
|
||||
return alert('JSON inválido en Props: ' + (e && e.message ? e.message : String(e)));
|
||||
}
|
||||
try {
|
||||
parsedMeta = metaText ? JSON.parse(metaText) : null;
|
||||
} catch (e) {
|
||||
return alert('JSON inválido en Metadata: ' + (e && e.message ? e.message : String(e)));
|
||||
}
|
||||
|
||||
const payload = {
|
||||
key: document.getElementById('fieldKey').value.trim(),
|
||||
name: document.getElementById('fieldName').value.trim(),
|
||||
category: document.getElementById('fieldCategory').value.trim(),
|
||||
icon: document.getElementById('fieldIcon').value.trim(),
|
||||
description: document.getElementById('fieldDescription').value.trim(),
|
||||
tags: document.getElementById('fieldTags').value.split(',').map(s=>s.trim()).filter(Boolean),
|
||||
maxPerInventory: Number(document.getElementById('fieldMaxPer').value) || null,
|
||||
props: parsedProps,
|
||||
metadata: parsedMeta,
|
||||
};
|
||||
try {
|
||||
if (id) {
|
||||
const res = await fetch(`/api/dashboard/${encodeURIComponent(guildId)}/items/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error('save-failed');
|
||||
} else {
|
||||
const res = await fetch(`/api/dashboard/${encodeURIComponent(guildId)}/items`, { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error('create-failed');
|
||||
}
|
||||
hideModal();
|
||||
await fetchItems();
|
||||
} catch (err) {
|
||||
alert('Error al guardar item. ¿JSON válido en Props/Metadata?');
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fetchItems();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
Reference in New Issue
Block a user