From eebf1f42db347024b3b44f7eb2dc79f04e643d25 Mon Sep 17 00:00:00 2001 From: ErickDevv <111036821+ErickDevv@users.noreply.gçithub.com> Date: Sun, 7 Jun 2026 10:37:22 -0600 Subject: [PATCH 1/2] Add audit workflows and enhance Editor component with Markdown and CSV support --- .github/workflows/audit.yml | 58 ++++++++++++++++++++ audit.sh | 25 +++++++++ web/package.json | 4 ++ web/pnpm-lock.yaml | 82 +++++++++++++++++++++++----- web/src/index.css | 51 ++++++++++++++++++ web/src/pages/Editor.tsx | 104 ++++++++++++++++++++++++++++++++++-- 6 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/audit.yml create mode 100755 audit.sh diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..4098d74 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,58 @@ +name: Audit + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + +jobs: + api-audit: + name: API audit + runs-on: ubuntu-latest + defaults: + run: + working-directory: api + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: api/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - run: pnpm audit + + web-audit: + name: Web audit + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - run: pnpm audit diff --git a/audit.sh b/audit.sh new file mode 100755 index 0000000..1c3b661 --- /dev/null +++ b/audit.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -e + +ROOT="$(cd "$(dirname "$0")" && pwd)" +FAILED=0 + +run() { + echo "" + echo "=== $1 ===" + shift + if ! "$@"; then + FAILED=1 + fi +} + +run "API audit" bash -c "cd '$ROOT/api' && pnpm install --frozen-lockfile && pnpm audit" +run "Web audit" bash -c "cd '$ROOT/web' && pnpm install --frozen-lockfile && pnpm audit" + +echo "" +if [[ $FAILED -eq 0 ]]; then + echo "No vulnerabilities found." +else + echo "One or more audits found vulnerabilities." + exit 1 +fi diff --git a/web/package.json b/web/package.json index 612c0fe..7d91e62 100644 --- a/web/package.json +++ b/web/package.json @@ -18,8 +18,11 @@ "@monaco-editor/react": "^4.6.0", "dictionary-en": "^4.0.0", "dictionary-es": "^4.0.0", + "dompurify": "^3.4.8", "jszip": "^3.10.1", + "marked": "^18.0.5", "monaco-editor": "^0.52.0", + "papaparse": "^5.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0", @@ -35,6 +38,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/papaparse": "^5.5.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 498756e..b469fc7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -17,12 +17,21 @@ importers: dictionary-es: specifier: ^4.0.0 version: 4.0.0 + dompurify: + specifier: ^3.4.8 + version: 3.4.8 jszip: specifier: ^3.10.1 version: 3.10.1 + marked: + specifier: ^18.0.5 + version: 18.0.5 monaco-editor: specifier: ^0.52.0 version: 0.52.2 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -48,6 +57,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/react': specifier: ^18.3.3 version: 18.3.29 @@ -56,7 +68,7 @@ importers: version: 18.3.7(@types/react@18.3.29) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@6.4.3(jiti@1.21.7)) + version: 4.7.0(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)) '@vitest/coverage-v8': specifier: ^4.1.0 version: 4.1.8(vitest@4.1.8) @@ -77,10 +89,10 @@ importers: version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.3(jiti@1.21.7) + version: 6.4.3(@types/node@25.9.2)(jiti@1.21.7) vitest: specifier: ^4.1.0 - version: 4.1.8(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)) packages: @@ -633,6 +645,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -644,6 +662,9 @@ packages: '@types/react@18.3.29': resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -826,6 +847,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.4.8: + resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==} + electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} @@ -1034,6 +1058,11 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -1086,6 +1115,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -1367,6 +1399,9 @@ packages: typo-js@1.3.2: resolution: {integrity: sha512-Z1YkJ7IIYNrFeOxAlHUercY4Q2I+PhYD/3VkWpJGy/Oqudy3bFpNcQxnv6Oa9fTSXCHPGz1eDoX1bZYm2Z891A==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.27.0: resolution: {integrity: sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==} engines: {node: '>=20.18.1'} @@ -1935,6 +1970,14 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@25.9.2': + dependencies: + undici-types: 7.24.6 + + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 25.9.2 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.29)': @@ -1946,7 +1989,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 - '@vitejs/plugin-react@4.7.0(vite@6.4.3(jiti@1.21.7))': + '@types/trusted-types@2.0.7': + optional: true + + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -1954,7 +2000,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(jiti@1.21.7) + vite: 6.4.3(@types/node@25.9.2)(jiti@1.21.7) transitivePeerDependencies: - supports-color @@ -1970,7 +2016,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)) + vitest: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)) '@vitest/expect@4.1.8': dependencies: @@ -1981,13 +2027,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@6.4.3(jiti@1.21.7))': + '@vitest/mocker@4.1.8(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(jiti@1.21.7) + vite: 6.4.3(@types/node@25.9.2)(jiti@1.21.7) '@vitest/pretty-format@4.1.8': dependencies: @@ -2131,6 +2177,10 @@ snapshots: dom-accessibility-api@0.6.3: {} + dompurify@3.4.8: + optionalDependencies: + '@types/trusted-types': 2.0.7 + electron-to-chromium@1.5.364: {} entities@8.0.0: {} @@ -2346,6 +2396,8 @@ snapshots: dependencies: semver: 7.8.1 + marked@18.0.5: {} + mdn-data@2.27.1: {} merge2@1.4.1: {} @@ -2381,6 +2433,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.5.3: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -2674,6 +2728,8 @@ snapshots: typo-js@1.3.2: {} + undici-types@7.24.6: {} + undici@7.27.0: {} update-browserslist-db@1.2.3(browserslist@4.28.2): @@ -2684,7 +2740,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@6.4.3(jiti@1.21.7): + vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -2693,13 +2749,14 @@ snapshots: rollup: 4.60.4 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 25.9.2 fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.1.8(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)): + vitest@4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@6.4.3(jiti@1.21.7)) + '@vitest/mocker': 4.1.8(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -2716,9 +2773,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.3(jiti@1.21.7) + vite: 6.4.3(@types/node@25.9.2)(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 25.9.2 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) jsdom: 29.1.1 transitivePeerDependencies: diff --git a/web/src/index.css b/web/src/index.css index 2c5afdc..ae5d60c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -50,3 +50,54 @@ body { select { color-scheme: dark; } + +/* Markdown preview */ +.markdown-preview h1, +.markdown-preview h2, +.markdown-preview h3, +.markdown-preview h4 { + font-weight: 600; + margin: 1.2em 0 0.5em; + line-height: 1.3; +} +.markdown-preview h1 { font-size: 1.6em; } +.markdown-preview h2 { font-size: 1.35em; } +.markdown-preview h3 { font-size: 1.15em; } +.markdown-preview p, +.markdown-preview ul, +.markdown-preview ol, +.markdown-preview blockquote, +.markdown-preview pre, +.markdown-preview table { + margin: 0.6em 0; +} +.markdown-preview ul { list-style: disc; padding-left: 1.5em; } +.markdown-preview ol { list-style: decimal; padding-left: 1.5em; } +.markdown-preview a { color: #60a5fa; text-decoration: underline; } +.markdown-preview code { + background: rgba(255, 255, 255, 0.08); + border-radius: 4px; + padding: 0.1em 0.35em; + font-family: "Geist Mono", ui-monospace, monospace; + font-size: 0.9em; +} +.markdown-preview pre { + background: rgba(255, 255, 255, 0.05); + border-radius: 6px; + padding: 0.8em 1em; + overflow: auto; +} +.markdown-preview pre code { background: none; padding: 0; } +.markdown-preview blockquote { + border-left: 3px solid rgba(255, 255, 255, 0.2); + padding-left: 1em; + color: #a3a3a3; +} +.markdown-preview table { border-collapse: collapse; } +.markdown-preview th, +.markdown-preview td { + border: 1px solid rgba(255, 255, 255, 0.15); + padding: 0.4em 0.7em; +} +.markdown-preview hr { border-color: rgba(255, 255, 255, 0.15); margin: 1.2em 0; } +.markdown-preview img { max-width: 100%; } diff --git a/web/src/pages/Editor.tsx b/web/src/pages/Editor.tsx index 8810d2f..fd1c98e 100644 --- a/web/src/pages/Editor.tsx +++ b/web/src/pages/Editor.tsx @@ -1,8 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { flushSync } from "react-dom"; import MonacoEditor from "@monaco-editor/react"; import type * as MonacoType from "monaco-editor"; +import { marked } from "marked"; +import DOMPurify from "dompurify"; +import Papa from "papaparse"; import { api, Project, ProjectFile } from "../api"; import { itemsFromDrop, itemsFromFileList, UploadItem } from "../upload"; import { setupLatexValidation, revalidateSpell } from "../monacoSetup"; @@ -13,6 +16,7 @@ function langForPath(path: string): string { return "latex"; if (path.endsWith(".bib")) return "bibtex"; if (path.endsWith(".md")) return "markdown"; + if (path.endsWith(".csv")) return "plaintext"; return "plaintext"; } @@ -32,12 +36,55 @@ export default function Editor() { const [dragOver, setDragOver] = useState(false); const [uploading, setUploading] = useState(false); const [spellLang, setSpellLangState] = useState(getSpellLang); + const [mdPreview, setMdPreview] = useState(true); const saveTimer = useRef>(); const fileInput = useRef(null); const folderInput = useRef(null); const editorRef = useRef(null); const activeFile = files.find((f) => f.path === active); + const isMarkdown = active.toLowerCase().endsWith(".md"); + const renderedMarkdown = useMemo( + () => + isMarkdown + ? DOMPurify.sanitize(marked.parse(content, { async: false }) as string) + : "", + [isMarkdown, content] + ); + const isCsv = active.toLowerCase().endsWith(".csv"); + const csvRows = useMemo( + () => + isCsv + ? (Papa.parse(content, { skipEmptyLines: false }) + .data as string[][]) + : [], + [isCsv, content] + ); + + function addCsvRow() { + const cols = csvRows[0]?.length || 1; + const rows = [...csvRows, new Array(cols).fill("")]; + const next = Papa.unparse(rows); + setContent(next); + scheduleSave(next, active); + } + + function deleteCsvRow(row: number) { + const rows = csvRows.filter((_, i) => i !== row); + const next = Papa.unparse(rows); + setContent(next); + scheduleSave(next, active); + } + + function editCsvCell(row: number, col: number, value: string) { + const rows = csvRows.map((r) => r.slice()); + while (rows.length <= row) rows.push([]); + while (rows[row].length <= col) rows[row].push(""); + rows[row][col] = value; + const next = Papa.unparse(rows); + setContent(next); + scheduleSave(next, active); + } async function refresh(selectPath?: string) { if (!id) return; @@ -71,6 +118,7 @@ export default function Editor() { const f = files.find((x) => x.path === path); setActive(path); setContent(f && !f.is_binary ? f.content ?? "" : ""); + setMdPreview(true); } function flushSave() { @@ -389,7 +437,7 @@ export default function Editor() { {/* Editor pane */} -
+
{loaded && activeFile && !activeFile.is_binary && ( - {pdfBust ? ( + {!activeFile?.is_binary && isMarkdown && ( + + )} + {isMarkdown && mdPreview ? ( +
+ ) : isCsv ? ( +
+ + + {csvRows.map((row, r) => ( + + {row.map((cell, c) => ( + + ))} + + + ))} + +
+ editCsvCell(r, c, e.target.value)} + className={`w-full min-w-[6rem] bg-transparent px-2 py-1 outline-none focus:bg-white/10 ${ + r === 0 ? "font-medium text-white" : "text-neutral-300" + }`} + /> + + +
+ +
+ ) : pdfBust ? (