diff --git a/internal/server/fsstream.go b/internal/server/fsstream.go
index 319b5ac..0ef0a96 100644
--- a/internal/server/fsstream.go
+++ b/internal/server/fsstream.go
@@ -199,10 +199,13 @@ var inlineTypes = map[string]string{
".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime",
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".m4a": "audio/mp4",
".aac": "audio/aac", ".flac": "audio/flac", ".opus": "audio/ogg",
+ ".pdf": "application/pdf",
}
// fileHeaders sets what the browser needs to download or show the file. A
// file shown in place runs nothing: no scripts in an SVG, whatever it holds.
+// A PDF is the exception, as the browser's viewer will not open in a sandbox;
+// it runs in that viewer, not in the panel's page.
func fileHeaders(w http.ResponseWriter, p string, inline bool) {
name := path.Base(p)
ctype := "application/octet-stream"
@@ -216,7 +219,9 @@ func fileHeaders(w http.ResponseWriter, p string, inline bool) {
w.Header().Set("Content-Type", ctype)
w.Header().Set("Content-Disposition", disp+"; filename=\""+strings.ReplaceAll(name, "\"", "")+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
- w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'")
+ if ctype != "application/pdf" {
+ w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'")
+ }
w.Header().Set("Cache-Control", "no-store")
}
diff --git a/internal/server/web/static/docview.js b/internal/server/web/static/docview.js
new file mode 100644
index 0000000..a6631ff
--- /dev/null
+++ b/internal/server/web/static/docview.js
@@ -0,0 +1,391 @@
+// Light previews of office files for the file manager, with no libraries:
+// docx, xlsx and ods are zip archives of XML, opened with the browser's own
+// DecompressionStream; csv and tsv are parsed here; an old binary .doc only
+// gives up its text. Each turns into plain HTML the page styles itself.
+(function () {
+ 'use strict';
+
+ const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
+
+ // ---------- zip ----------
+ // Reads the central directory; a file is inflated only when asked for.
+ function openZip(buf) {
+ const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
+ let eocd = -1;
+ for (let i = b.length - 22; i >= Math.max(0, b.length - 65557); i--) {
+ if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
+ }
+ if (eocd < 0) throw new Error('не zip-архив');
+ const count = dv.getUint16(eocd + 10, true);
+ let p = dv.getUint32(eocd + 16, true);
+ const files = new Map();
+ const dec = new TextDecoder();
+ for (let n = 0; n < count && dv.getUint32(p, true) === 0x02014b50; n++) {
+ const method = dv.getUint16(p + 10, true), size = dv.getUint32(p + 20, true);
+ const nameLen = dv.getUint16(p + 28, true), extra = dv.getUint16(p + 30, true), comment = dv.getUint16(p + 32, true);
+ const local = dv.getUint32(p + 42, true);
+ files.set(dec.decode(b.subarray(p + 46, p + 46 + nameLen)), {method, size, local});
+ p += 46 + nameLen + extra + comment;
+ }
+ async function bytes(name) {
+ const f = files.get(name);
+ if (!f) return null;
+ const start = f.local + 30 + dv.getUint16(f.local + 26, true) + dv.getUint16(f.local + 28, true);
+ const raw = b.subarray(start, start + f.size);
+ if (f.method === 0) return raw;
+ if (f.method !== 8) throw new Error('сжатие zip, которое браузер не знает');
+ const out = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
+ return new Uint8Array(await new Response(out).arrayBuffer());
+ }
+ async function xml(name) {
+ const raw = await bytes(name);
+ return raw ? new DOMParser().parseFromString(new TextDecoder().decode(raw), 'application/xml') : null;
+ }
+ return {files, bytes, xml};
+ }
+
+ // Children of an element by local name, whatever the namespace prefix.
+ const kids = (el, name) => el ? Array.from(el.children).filter(c => c.localName === name) : [];
+ const kid = (el, name) => kids(el, name)[0] || null;
+ const attr = (el, name) => {
+ if (!el) return null;
+ for (const a of el.attributes) if (a.localName === name) return a.value;
+ return null;
+ };
+
+ // ---------- docx ----------
+ async function docx(buf) {
+ const z = openZip(buf);
+ const doc = await z.xml('word/document.xml');
+ if (!doc) throw new Error('в архиве нет word/document.xml');
+ // Style ids to names, to know headings whatever the language of Word.
+ const styles = {};
+ const st = await z.xml('word/styles.xml');
+ if (st) for (const s of st.getElementsByTagNameNS('*', 'style')) {
+ styles[attr(s, 'styleId')] = (attr(kid(s, 'name'), 'val') || '').toLowerCase();
+ }
+ const body = doc.getElementsByTagNameNS('*', 'body')[0];
+ return '
' + blocks(body, styles) + '
';
+ }
+ function blocks(el, styles) {
+ let html = '', list = false;
+ for (const c of el ? el.children : []) {
+ if (c.localName === 'p') {
+ const pPr = kid(c, 'pPr');
+ const isList = !!kid(pPr, 'numPr');
+ if (isList && !list) { html += '
'; list = true; }
+ if (!isList && list) { html += '
'; list = false; }
+ html += para(c, pPr, styles, isList);
+ } else {
+ if (list) { html += ''; list = false; }
+ if (c.localName === 'tbl') html += table(c, styles);
+ else if (c.localName === 'sdt') html += blocks(kid(c, 'sdtContent'), styles);
+ }
+ }
+ return html + (list ? '' : '');
+ }
+ function para(p, pPr, styles, isList) {
+ const name = styles[attr(kid(pPr, 'pStyle'), 'val')] || '';
+ const h = /^heading (\d)/.exec(name);
+ const tag = isList ? 'li' : name === 'title' ? 'h1' : h ? 'h' + Math.min(6, +h[1] + 1) : 'p';
+ const jc = attr(kid(pPr, 'jc'), 'val');
+ const align = jc === 'center' ? 'center' : (jc === 'right' || jc === 'end') ? 'right' : (jc === 'both' ? 'justify' : '');
+ const inner = runs(p);
+ return '<' + tag + (align ? ' style="text-align:' + align + '"' : '') + '>' + (inner || ' ') + '' + tag + '>';
+ }
+ function runs(el) {
+ let html = '';
+ for (const c of el.children) {
+ if (c.localName === 'r') html += run(c);
+ else if (c.localName === 'hyperlink' || c.localName === 'smartTag' || c.localName === 'ins' || c.localName === 'sdt' || c.localName === 'sdtContent' || c.localName === 'fldSimple') {
+ const inner = runs(c);
+ html += c.localName === 'hyperlink' ? '' + inner + '' : inner;
+ }
+ }
+ return html;
+ }
+ function run(r) {
+ const pr = kid(r, 'rPr');
+ const on = n => { const e = kid(pr, n); return !!e && attr(e, 'val') !== '0' && attr(e, 'val') !== 'false'; };
+ let t = '';
+ for (const c of r.children) {
+ if (c.localName === 't') t += esc(c.textContent);
+ else if (c.localName === 'tab') t += ' ';
+ else if (c.localName === 'br' || c.localName === 'cr') t += ' ';
+ }
+ if (!t) return '';
+ if (on('b')) t = '' + t + '';
+ if (on('i')) t = '' + t + '';
+ if (kid(pr, 'u') && attr(kid(pr, 'u'), 'val') !== 'none') t = '' + t + '';
+ if (on('strike')) t = '' + t + '';
+ // A size set on the run itself, as many documents do instead of styles.
+ const sz = +attr(kid(pr, 'sz'), 'val');
+ if (sz && sz !== 24) t = '' + t + '';
+ const va = attr(kid(pr, 'vertAlign'), 'val');
+ if (va === 'superscript') t = '' + t + '';
+ else if (va === 'subscript') t = '' + t + '';
+ return t;
+ }
+ function table(tbl, styles) {
+ let html = '
';
+ for (const tr of kids(tbl, 'tr')) {
+ html += '
';
+ for (const tc of kids(tr, 'tc')) {
+ const span = attr(kid(kid(tc, 'tcPr'), 'gridSpan'), 'val');
+ html += '