From 3d220bcc20710f458b8c60568a0de540e073d001 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 7 Oct 2026 17:17:03 -0500 Subject: [PATCH] feat: version 2.9.0 -- ver documentos .md/.txt directo, sin descargar Agrega un boton "Ver" en Documentos (adjuntos de proyectos/actividades) para archivos de texto plano y Markdown: se leen via blob (mismo mecanismo ya usado para descargar, que ya maneja el Bearer token) y se muestran en un popup -- el .md se renderiza con formato (marked) y se sanitiza con DOMPurify antes de insertarse en el DOM, para no abrir una via de XSS con HTML/JS embebido en un documento subido por otro usuario. De paso corrige que subir un .md fallaba con "tipo de archivo no permitido": el navegador no manda un mimetype confiable para esa extension (varia segun SO), asi que ahora se normaliza por extension en el fileFilter de multer en vez de depender solo del mimetype del navegador. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 11 ++++++ VERSION | 2 +- backend/package.json | 2 +- backend/src/middleware/upload.js | 10 +++++ frontend/package-lock.json | 35 ++++++++++++++--- frontend/package.json | 4 +- frontend/src/components/AttachmentsPanel.jsx | 40 ++++++++++++++++++-- frontend/src/i18n/messages.js | 4 ++ frontend/src/services/attachments.js | 16 ++++++-- frontend/src/styles/app.css | 21 ++++++++++ frontend/src/utils/renderMarkdown.js | 9 +++++ 11 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 frontend/src/utils/renderMarkdown.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb0347..75ff740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog - project.linkedcorp.net +## [2.9.0] - 2026-10-07 +### Added +- Documentos (adjuntos de proyectos/actividades): los archivos de texto plano (.txt) y Markdown + (.md) ahora se pueden ver directo con el botón "Ver" (ícono de ojo), sin necesidad de + descargarlos — el .md se renderiza con formato (encabezados, negrita, listas, enlaces, bloques + de código), sanitizado para evitar HTML/JS embebido. +### Fixed +- Subir un archivo `.md` fallaba con "tipo de archivo no permitido": el navegador no manda un + mimetype confiable para esa extensión (varía según sistema operativo). Ahora se normaliza por + extensión en vez de depender solo del mimetype que manda el navegador. + ## [2.8.13] - 2026-10-02 ### Changed - El logo de LinkedCorp del login se mueve debajo de "¿Olvidaste tu contraseña? / Crear cuenta", diff --git a/VERSION b/VERSION index 01b0f09..c8e38b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.13 +2.9.0 diff --git a/backend/package.json b/backend/package.json index a74d370..5a52ca0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.8.13", + "version": "2.9.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/middleware/upload.js b/backend/src/middleware/upload.js index bc0a4bd..c994127 100644 --- a/backend/src/middleware/upload.js +++ b/backend/src/middleware/upload.js @@ -14,9 +14,15 @@ const ALLOWED_MIME = new Set([ 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', + 'text/markdown', 'application/zip', ]); +// El navegador no manda un mimetype confiable para .md (varia segun SO: text/markdown, +// application/octet-stream, vacio...) -- se normaliza por extension para no rechazar +// documentos validos ni depender de esa adivinanza. +const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']); + const uploadsDir = path.join(env.storageRoot, 'attachments'); fs.mkdirSync(uploadsDir, { recursive: true }); @@ -33,6 +39,10 @@ export const upload = multer({ storage, limits: { fileSize: 15 * 1024 * 1024 }, fileFilter: (req, file, cb) => { + if (MARKDOWN_EXTENSIONS.has(path.extname(file.originalname).toLowerCase())) { + file.mimetype = 'text/markdown'; + return cb(null, true); + } if (!ALLOWED_MIME.has(file.mimetype)) { return cb(new Error('Tipo de archivo no permitido')); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef6416a..8d7b0d5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,14 +1,16 @@ { "name": "project-linkedcorp-frontend", - "version": "2.8.0", + "version": "2.8.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-frontend", - "version": "2.8.0", + "version": "2.8.13", "dependencies": { + "dompurify": "^3.4.16", "lucide-react": "^0.462.0", + "marked": "^18.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.18.3", @@ -2740,7 +2742,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@vitejs/plugin-react": { @@ -3577,6 +3579,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5046,6 +5057,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.0.tgz", + "integrity": "sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6059,9 +6082,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/frontend/package.json b/frontend/package.json index a3455d7..e698506 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.8.13", + "version": "2.9.0", "type": "module", "scripts": { "dev": "vite", @@ -9,7 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "dompurify": "^3.4.16", "lucide-react": "^0.462.0", + "marked": "^18.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.18.3", diff --git a/frontend/src/components/AttachmentsPanel.jsx b/frontend/src/components/AttachmentsPanel.jsx index fab3cb1..1efd7e9 100644 --- a/frontend/src/components/AttachmentsPanel.jsx +++ b/frontend/src/components/AttachmentsPanel.jsx @@ -1,15 +1,35 @@ -import { useRef } from 'react'; -import { Upload, Download, Trash2, Paperclip } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { Upload, Download, Trash2, Paperclip, Eye } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; import { useAsync, useAction } from '../hooks/useAsync.js'; -import { listAttachments, uploadAttachment, deleteAttachment, downloadAttachment } from '../services/attachments.js'; +import { listAttachments, uploadAttachment, deleteAttachment, downloadAttachment, readAttachmentText } from '../services/attachments.js'; +import { renderMarkdown } from '../utils/renderMarkdown.js'; import { Button } from './Button.jsx'; +import { DetailPopup } from './DetailPopup.jsx'; + +const VIEWABLE_MIME = new Set(['text/plain', 'text/markdown']); export function AttachmentsPanel({ entityType, entityId }) { const { t, openDialog } = useUi(); const runAction = useAction(); const { data: files, reload } = useAsync(() => listAttachments(entityType, entityId), [entityType, entityId]); const inputRef = useRef(null); + const [viewing, setViewing] = useState(null); // adjunto abierto en el popup de lectura + const [viewContent, setViewContent] = useState(''); + const [viewLoading, setViewLoading] = useState(false); + + async function openView(file) { + setViewing(file); + setViewContent(''); + setViewLoading(true); + try { + setViewContent(await readAttachmentText(file.id)); + } catch { + setViewContent(t('document_view_error')); + } finally { + setViewLoading(false); + } + } async function handleFile(e) { const file = e.target.files?.[0]; @@ -51,10 +71,24 @@ export function AttachmentsPanel({ entityType, entityId }) { {f.uploaded_by_name} · {(f.size / 1024).toFixed(0)} KB · {String(f.created_at).slice(0, 10)} + {VIEWABLE_MIME.has(f.mime_type) && ( + + )} ))} + + setViewing(null)} width={720} height={600}> + {viewLoading ? ( +
{t('common_loading')}
+ ) : viewing?.mime_type === 'text/markdown' ? ( + // eslint-disable-next-line react/no-danger -- sanitizado con DOMPurify en renderMarkdown() +
+ ) : ( +
{viewContent}
+ )} +
); } diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 4e451d1..2b415f9 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -326,6 +326,8 @@ export const messages = { documents_title: 'Documentos adjuntos', document_upload: 'Subir archivo', + document_view: 'Ver', + document_view_error: 'No se pudo cargar el contenido del archivo.', document_download: 'Descargar', notifications_title: 'Notificaciones', @@ -659,6 +661,8 @@ export const messages = { documents_title: 'Attachments', document_upload: 'Upload file', + document_view: 'View', + document_view_error: 'Could not load the file content.', document_download: 'Download', notifications_title: 'Notifications', diff --git a/frontend/src/services/attachments.js b/frontend/src/services/attachments.js index fdccbb2..242f177 100644 --- a/frontend/src/services/attachments.js +++ b/frontend/src/services/attachments.js @@ -12,13 +12,17 @@ export async function uploadAttachment(entityType, entityId, file) { export const deleteAttachment = (id) => api.delete(`/attachments/${id}`); -export async function downloadAttachment(id, filename) { +async function fetchAttachmentBlob(id) { const token = localStorage.getItem('plc_token'); const res = await fetch(api.downloadUrl(`/attachments/${id}/download`), { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); - if (!res.ok) throw new Error('No se pudo descargar el archivo'); - const blob = await res.blob(); + if (!res.ok) throw new Error('No se pudo obtener el archivo'); + return res.blob(); +} + +export async function downloadAttachment(id, filename) { + const blob = await fetchAttachmentBlob(id); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -28,3 +32,9 @@ export async function downloadAttachment(id, filename) { a.remove(); URL.revokeObjectURL(url); } + +// Para ver el contenido en pantalla sin descargar (documentos de texto/markdown). +export async function readAttachmentText(id) { + const blob = await fetchAttachmentBlob(id); + return blob.text(); +} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 5d0f3d0..a07a54a 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -390,6 +390,27 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; } Chrome/Edge/Safari; en Firefox se ve el numero en texto plano, degradacion aceptable). */ .pin-input { -webkit-text-security: disc; letter-spacing: 4px; } +/* --- Visor de adjuntos de texto/markdown (ver AttachmentsPanel.jsx) --- */ +.text-view { + white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; + font-size: .82rem; line-height: 1.5; color: rgb(var(--body)); margin: 0; +} +.markdown-view { font-size: .86rem; line-height: 1.55; color: rgb(var(--body)); } +.markdown-view h1, .markdown-view h2, .markdown-view h3, .markdown-view h4 { margin: 1.2em 0 .5em; line-height: 1.3; } +.markdown-view h1 { font-size: 1.4rem; } +.markdown-view h2 { font-size: 1.2rem; } +.markdown-view h3 { font-size: 1.05rem; } +.markdown-view p, .markdown-view ul, .markdown-view ol, .markdown-view blockquote { margin: 0 0 1em; } +.markdown-view ul, .markdown-view ol { padding-left: 1.4em; } +.markdown-view a { color: rgb(var(--accent)); } +.markdown-view code { background: rgb(var(--field)); border-radius: 4px; padding: .1em .35em; font-size: .85em; } +.markdown-view pre { background: rgb(var(--field)); border-radius: var(--r-sm); padding: 10px 12px; overflow-x: auto; } +.markdown-view pre code { background: none; padding: 0; } +.markdown-view blockquote { border-left: 3px solid rgb(var(--line)); padding-left: 12px; color: rgb(var(--muted)); } +.markdown-view img { max-width: 100%; } +.markdown-view table { border-collapse: collapse; margin-bottom: 1em; } +.markdown-view th, .markdown-view td { border: 1px solid rgb(var(--line)); padding: 4px 8px; font-size: .82rem; } + .vault-icon-picker { display: flex; flex-wrap: wrap; gap: 8px; } .vault-icon-brands-header { display: flex; align-items: center; justify-content: space-between; padding-top: 8px; margin-top: 4px; border-top: 1px dashed rgb(var(--line)); } .vault-icon-collapse-btn { color: rgb(var(--danger)); } diff --git a/frontend/src/utils/renderMarkdown.js b/frontend/src/utils/renderMarkdown.js new file mode 100644 index 0000000..f5e1d9c --- /dev/null +++ b/frontend/src/utils/renderMarkdown.js @@ -0,0 +1,9 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +// Un documento .md subido por cualquier miembro del proyecto puede ser visto por otros +// miembros -- sin sanitizar, HTML/JS embebido en el markdown se ejecutaria al renderizarlo +// (marked no sanitiza desde la v5). DOMPurify corre despues de marked, nunca se salta. +export function renderMarkdown(text) { + return DOMPurify.sanitize(marked.parse(text ?? '')); +}