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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-07 17:17:03 -05:00
parent 27cf2712fb
commit 3d220bcc20
11 changed files with 139 additions and 15 deletions
+11
View File
@@ -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",
+1 -1
View File
@@ -1 +1 @@
2.8.13
2.9.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "2.8.13",
"version": "2.9.0",
"private": true,
"type": "module",
"main": "src/server.js",
+10
View File
@@ -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'));
}
+29 -6
View File
@@ -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": {
+3 -1
View File
@@ -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",
+37 -3
View File
@@ -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)}
</div>
</div>
{VIEWABLE_MIME.has(f.mime_type) && (
<button className="icon-btn" title={t('document_view')} onClick={() => openView(f)}><Eye size={16} /></button>
)}
<button className="icon-btn" onClick={() => downloadAttachment(f.id, f.original_name)}><Download size={16} /></button>
<button className="icon-btn" onClick={() => confirmDelete(f)}><Trash2 size={16} /></button>
</div>
))}
<DetailPopup open={!!viewing} title={viewing?.original_name || ''} onClose={() => setViewing(null)} width={720} height={600}>
{viewLoading ? (
<div className="text-muted">{t('common_loading')}</div>
) : viewing?.mime_type === 'text/markdown' ? (
// eslint-disable-next-line react/no-danger -- sanitizado con DOMPurify en renderMarkdown()
<div className="markdown-view" dangerouslySetInnerHTML={{ __html: renderMarkdown(viewContent) }} />
) : (
<pre className="text-view">{viewContent}</pre>
)}
</DetailPopup>
</div>
);
}
+4
View File
@@ -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',
+13 -3
View File
@@ -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();
}
+21
View File
@@ -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)); }
+9
View File
@@ -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 ?? ''));
}