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 { promises as fs } from "node:fs";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import { createCipheriv, randomBytes, createDecipheriv } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
gzipSync,
|
gzipSync,
|
||||||
brotliCompressSync,
|
brotliCompressSync,
|
||||||
@@ -230,6 +231,49 @@ function sanitizeString(v: unknown, opts?: { max?: number }) {
|
|||||||
return s.trim();
|
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) {
|
function validateDiscordId(id: unknown) {
|
||||||
if (!id) return false;
|
if (!id) return false;
|
||||||
const s = String(id);
|
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)
|
// Dev-only helper: fetch roles for a guild (requires COLLAB_TEST=1 and DISCORD_BOT_TOKEN)
|
||||||
if (url.pathname.startsWith("/__dev/fetch-roles")) {
|
if (url.pathname.startsWith("/__dev/fetch-roles")) {
|
||||||
if (process.env.COLLAB_TEST !== "1") {
|
if (process.env.COLLAB_TEST !== "1") {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
<%- await include('../partials/dashboard/dashboard_overview') %>
|
<%- await include('../partials/dashboard/dashboard_overview') %>
|
||||||
<% } else if (typeof page !== 'undefined' && page === 'settings') { %>
|
<% } else if (typeof page !== 'undefined' && page === 'settings') { %>
|
||||||
<div id="dashMain"><%- await include('../partials/dashboard/dashboard_settings') %></div>
|
<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 { %>
|
<% } else { %>
|
||||||
<div id="dashMain"><!-- empty main placeholder --></div>
|
<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