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