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 ?? '')); +}