feat: Agregar funcionalidad de gestión de items en el panel de control con encriptación de datos

This commit is contained in:
Shni
2025-10-15 12:27:24 -05:00
parent f85ef74549
commit e0537b5b84
3 changed files with 602 additions and 0 deletions

View File

@@ -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") {

View File

@@ -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>
<% } %>

View 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)=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'":"&#39;' })[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>