refactor: virtual file extensions for subtree zip + MD conversion
Replace `?zip=1` / `?convert=docx|html|pdf` query forms with path-suffix URLs that look like ordinary files. `<dir>.zip` and `<file>.docx` / `.html` / `.pdf` are virtual files served by the dispatcher when stat fails at the requested path AND the corresponding base resource exists: GET /Project-1/archive.zip ← if archive/ is a real directory GET /Project-1/notes.docx ← if notes.md exists Real on-disk files always win — a genuine archive.zip in the tree serves its bytes normally. The virtual forms only fire when nothing real is there. Why: the URL form lets clients emit plain <a href> without query- string handling; `curl -O` writes a sensible filename; mirror tools pick up the path through normal recursion; the protocol surface becomes "every URL is a file". Bash + filesystem mental model. Server: - New helpers handler.RecognizeVirtualSubtreeZip / RecognizeVirtualConvert (in subtreezip.go and converthandler.go). - Dispatcher's stat-fails branch checks them between IsDefaultMdlSpec and MatchAppHTML. ACL is enforced on the base resource (the source directory for zip, the .md source for convert). - Three legacy query-form branches removed from main.go. Client: - browse/js/download.js: `dir + '.zip'` instead of `dir + '/?zip=1'`. - browse/js/preview-markdown.js: convert anchor hrefs become `<mdUrl-minus-.md>.<fmt>` instead of `<mdUrl>?convert=<fmt>`. - shared/zddc-source.js downloadConverted: same transform. Tests: subtreezip_test.go test URLs cosmetically updated to the new shape (the handler is exercised directly, so the URL is metadata only, but the test reads better). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
050902fa9e
commit
167a56dc07
8 changed files with 174 additions and 59 deletions
|
|
@ -6,11 +6,12 @@
|
|||
// reads bytes through the file handle and blob-downloads.
|
||||
//
|
||||
// downloadFolder: an arbitrary directory node as a .zip. Server
|
||||
// mode points an <a download> at "<node-path>/?zip=1" so zddc-server
|
||||
// streams an ACL-filtered archive without buffering on the client.
|
||||
// FS-API mode walks the picked handle in two passes — metadata
|
||||
// first, then bytes — so we can warn before loading a very large
|
||||
// tree into memory.
|
||||
// mode points an <a download> at the virtual "<node-path>.zip"
|
||||
// URL — zddc-server recognises the suffix and streams an ACL-
|
||||
// filtered archive without buffering on the client. FS-API mode
|
||||
// walks the picked handle in two passes — metadata first, then
|
||||
// bytes — so we can warn before loading a very large tree into
|
||||
// memory.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -142,10 +143,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Download an arbitrary folder node as a .zip — same dispatch as
|
||||
// downloadCurrentSubtree but scoped to the picked node instead of
|
||||
// state.currentPath / state.rootHandle. Server mode hits
|
||||
// "<node-path>/?zip=1"; FS mode walks the directory handle.
|
||||
// Download an arbitrary folder node as a .zip. Server mode points
|
||||
// an <a download> at the virtual "<node-path>.zip" URL (the
|
||||
// dispatcher recognises the suffix and streams the subtree). FS
|
||||
// mode walks the directory handle.
|
||||
async function downloadFolder(node) {
|
||||
if (busy) return;
|
||||
if (!node || !node.isDir) {
|
||||
|
|
@ -158,7 +159,7 @@
|
|||
var tree = window.app.modules.tree;
|
||||
var dir = tree.pathFor(node).replace(/\/$/, '');
|
||||
events().statusInfo('Preparing ' + node.name + '.zip…');
|
||||
downloadUrl(node.name + '.zip', dir + '/?zip=1');
|
||||
downloadUrl(node.name + '.zip', dir + '.zip');
|
||||
setTimeout(function () { events().statusClear(); }, 2500);
|
||||
} else if (state.source === 'fs' && node.handle
|
||||
&& node.handle.kind === 'directory') {
|
||||
|
|
|
|||
|
|
@ -457,10 +457,16 @@
|
|||
node.url && /\.md$/i.test(node.name);
|
||||
var convertBtns = [];
|
||||
if (serverModeMd) {
|
||||
// Virtual-extension URLs: <file>.md → <file>.docx etc.
|
||||
// The dispatcher recognises the sibling-extension pattern
|
||||
// and routes through ServeConverted. Cleaner than the
|
||||
// old `?convert=` query form — right-clicking the link
|
||||
// gives a sensible "Save as <file>.docx" prompt.
|
||||
var mdUrlBase = node.url.replace(/\.md$/i, '');
|
||||
['docx', 'html', 'pdf'].forEach(function (fmt) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'btn btn-sm btn-secondary md-shell__download';
|
||||
a.href = node.url + '?convert=' + encodeURIComponent(fmt);
|
||||
a.href = mdUrlBase + '.' + fmt;
|
||||
// target=_blank: clicks open in a new tab. The server
|
||||
// sends Content-Disposition: inline, so the new tab
|
||||
// either renders (HTML → web page; PDF → browser's
|
||||
|
|
|
|||
|
|
@ -395,9 +395,14 @@
|
|||
// srcUrl points at the .md source on the server. fmt is one of
|
||||
// "docx" | "html" | "pdf". The server response status maps to a
|
||||
// friendly error message for the caller to surface (toast / status).
|
||||
//
|
||||
// URL grammar: srcUrl is the `<file>.md` source; the converted
|
||||
// form lives at `<file>.<fmt>` (virtual file extension recognised
|
||||
// by zddc-server's dispatcher). Replaces the older `?convert=`
|
||||
// query form.
|
||||
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
||||
{ credentials: 'same-origin' });
|
||||
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
var msg;
|
||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||
|
|
|
|||
|
|
@ -1027,12 +1027,44 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
}
|
||||
// File doesn't exist at this path. If the URL matches one of
|
||||
// the canonical app HTML names AND the request directory is
|
||||
// one where that app is available (working/staging/incoming
|
||||
// for classifier, staging for transmittal, anywhere for
|
||||
// archive + browse, root only for landing), resolve via the
|
||||
// apps subsystem.
|
||||
// File doesn't exist at this path. Before falling through to
|
||||
// app-HTML routing or 404, check the two virtual-file-extension
|
||||
// shapes that ZDDC exposes through the listing convention:
|
||||
//
|
||||
// <dir>.zip — subtree download (replaces `<dir>/?zip=1`)
|
||||
// <file>.docx|html|pdf — MD-source conversion of sibling <file>.md
|
||||
// (replaces `<file>.md?convert=<fmt>`)
|
||||
//
|
||||
// Both fire ONLY when stat failed at the requested URL — a
|
||||
// real file always wins. The path-suffix form lets clients
|
||||
// emit a plain <a href> + lets `curl -O` produce the right
|
||||
// filename, no query-string handling required.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if absDir, ok := handler.RecognizeVirtualSubtreeZip(cfg.Root, urlPath); ok {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, absDir)
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
handler.ServeSubtreeZip(cfg, w, r, absDir)
|
||||
return
|
||||
}
|
||||
if mdAbs, format, ok := handler.RecognizeVirtualConvert(cfg.Root, urlPath); ok {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(mdAbs))
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
handler.ServeConverted(cfg, w, r, mdAbs, format, chain)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If the URL matches one of the canonical app HTML names AND
|
||||
// the request directory is one where that app is available
|
||||
// (working/staging/incoming for classifier, staging for
|
||||
// transmittal, anywhere for archive + browse, root only for
|
||||
// landing), resolve via the apps subsystem.
|
||||
if appsSrv != nil {
|
||||
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
|
||||
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
||||
|
|
@ -1098,12 +1130,9 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||
(strings.HasSuffix(urlPath, "/") || filepath.Ext(urlPath) == "") &&
|
||||
zddc.IsDeclaredPath(cfg.Root, absPath) {
|
||||
if r.URL.Query().Has("zip") {
|
||||
// Subtree download of a cascade-declared dir that
|
||||
// doesn't exist on disk yet → an empty zip.
|
||||
handler.ServeSubtreeZip(cfg, w, r, absPath)
|
||||
return
|
||||
}
|
||||
// (Empty-subtree zip for cascade-declared paths is now
|
||||
// handled by RecognizeVirtualSubtreeZip at the top of
|
||||
// this branch — same handler, path-suffix grammar.)
|
||||
if strings.HasSuffix(urlPath, "/") {
|
||||
handler.ServeDirectory(cfg, appsSrv, w, r)
|
||||
return
|
||||
|
|
@ -1135,15 +1164,12 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
}
|
||||
// Subtree download: GET /dir/?zip=1 streams an application/zip of
|
||||
// every readable file under this directory, ACL-filtered. Checked
|
||||
// before the slash/no-slash routing so it works on both /dir and
|
||||
// /dir/. Writes (PUT/DELETE/POST) never reach here — they're
|
||||
// intercepted by the file API earlier — so this is GET/HEAD only.
|
||||
if r.URL.Query().Has("zip") {
|
||||
handler.ServeSubtreeZip(cfg, w, r, absPath)
|
||||
return
|
||||
}
|
||||
// (Subtree download `GET /dir/?zip=1` retired in favour of
|
||||
// `GET /dir.zip` — see RecognizeVirtualSubtreeZip handling at
|
||||
// the top of the stat-fails branch above. Real directories
|
||||
// stat-succeed here, so the virtual zip URL stat-fails at
|
||||
// /dir.zip and matches there.)
|
||||
|
||||
// Slash/no-slash routing convention: trailing slash → the
|
||||
// directory view (handler.ServeDirectory → DirTool, which
|
||||
// resolves to browse by default; JSON requests always get the
|
||||
|
|
@ -1190,15 +1216,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// MD→{docx,html,pdf} on-demand conversion. The endpoint reuses the
|
||||
// source file's read policy (already gated above), so no separate
|
||||
// ACL verb. Only .md sources are convertible; everything else falls
|
||||
// through to the regular file serve.
|
||||
if fmt := r.URL.Query().Get("convert"); fmt != "" &&
|
||||
strings.HasSuffix(strings.ToLower(absPath), ".md") {
|
||||
handler.ServeConverted(cfg, w, r, absPath, fmt, chain)
|
||||
return
|
||||
}
|
||||
// (MD→{docx,html,pdf} on-demand conversion now lives at
|
||||
// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
|
||||
// see RecognizeVirtualConvert). The .md source serves
|
||||
// normally here.)
|
||||
|
||||
handler.ServeFile(w, r, absPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,19 @@ import (
|
|||
|
||||
// On-demand MD→{docx,html,pdf} conversion endpoint.
|
||||
//
|
||||
// GET /<path>/foo.md?convert=docx|html|pdf
|
||||
// GET /<path>/foo.docx (or .html / .pdf)
|
||||
//
|
||||
// The source file's read policy (already enforced by the dispatcher
|
||||
// before this handler runs) gates the response. The converted bytes
|
||||
// are cached at <dir>/.converted/<base>.<ext>, with mtime synced to the
|
||||
// source — so a fast-path GET that finds a fresh cache hit serves the
|
||||
// disk file via http.ServeContent without invoking pandoc at all.
|
||||
// The URL is the rendered form of a sibling `foo.md` source. The
|
||||
// dispatcher recognises the pattern via RecognizeVirtualConvert when
|
||||
// a stat on `foo.docx` (etc.) fails AND `foo.md` exists; only then is
|
||||
// ServeConverted invoked. A real on-disk `foo.docx` wins precedence
|
||||
// and serves its bytes normally.
|
||||
//
|
||||
// The source file's read policy (enforced by the dispatcher before this
|
||||
// handler runs) gates the response. The converted bytes are cached at
|
||||
// <dir>/.converted/<base>.<ext>, with mtime synced to the source — so a
|
||||
// fast-path GET that finds a fresh cache hit serves the disk file via
|
||||
// http.ServeContent without invoking pandoc at all.
|
||||
//
|
||||
// When the cache is stale (or absent) the handler:
|
||||
// 1. Reads source bytes.
|
||||
|
|
@ -42,6 +48,40 @@ var convertSF singleflightGroup
|
|||
// runner itself enforces a finer-grained timeout on the container.
|
||||
const convertTimeout = 90 * time.Second
|
||||
|
||||
// RecognizeVirtualConvert reports whether urlPath names a virtual
|
||||
// "<file>.<format>" — a rendered form of a sibling markdown source.
|
||||
// Returns (mdAbsPath, format, true) when <file>.md exists on disk and
|
||||
// the requested extension is one of docx / html / pdf. The caller
|
||||
// (the dispatcher) only invokes this when a stat on the requested
|
||||
// path itself fails — a real on-disk file always wins.
|
||||
//
|
||||
// The path-suffix grammar replaces the legacy `<file>.md?convert=docx`
|
||||
// query form. A virtual file URL means `<a href="…/foo.docx">` works
|
||||
// without any query-string handling, and a script's `curl -O …/foo.pdf`
|
||||
// writes the expected filename.
|
||||
func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok bool) {
|
||||
lower := strings.ToLower(urlPath)
|
||||
for _, ext := range []string{".docx", ".html", ".pdf"} {
|
||||
if !strings.HasSuffix(lower, ext) {
|
||||
continue
|
||||
}
|
||||
base := urlPath[:len(urlPath)-len(ext)]
|
||||
if base == "" || strings.HasSuffix(base, "/") {
|
||||
continue
|
||||
}
|
||||
rel := strings.Trim(base, "/") + ".md"
|
||||
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
||||
// Path containment.
|
||||
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
|
||||
return abs, ext[1:], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// ServeConverted is the entry point. format is the requested target
|
||||
// extension; chain is the already-resolved ACL chain (re-used here
|
||||
// only to extract the convert: cascade metadata).
|
||||
|
|
|
|||
|
|
@ -35,9 +35,46 @@ func zipMethodFor(name string) uint16 {
|
|||
return zip.Deflate
|
||||
}
|
||||
|
||||
// RecognizeVirtualSubtreeZip reports whether urlPath names a virtual
|
||||
// "<dir>.zip" — a download endpoint that streams a directory's
|
||||
// subtree as a zip. Returns the directory's absolute path when the
|
||||
// URL strips to a real directory under fsRoot, or to a cascade-
|
||||
// declared path that the listing pipeline would render as empty.
|
||||
//
|
||||
// The path-suffix grammar replaces the legacy `<dir>/?zip=1` query
|
||||
// form. A virtual file living next to its source means clients can
|
||||
// emit a plain `<a href>` without query-string handling; mirror
|
||||
// tools pick it up via normal recursion; `curl -O` writes a sensible
|
||||
// filename without a `--remote-header-name` hint. Real `.zip` files
|
||||
// in the tree always win — stat is checked before this helper, so a
|
||||
// genuine archive at `<path>.zip` serves its bytes normally.
|
||||
func RecognizeVirtualSubtreeZip(fsRoot, urlPath string) (absDir string, ok bool) {
|
||||
if !strings.HasSuffix(urlPath, ".zip") {
|
||||
return "", false
|
||||
}
|
||||
base := strings.TrimSuffix(urlPath, ".zip")
|
||||
if base == "" || base == "/" {
|
||||
return "", false
|
||||
}
|
||||
rel := strings.Trim(base, "/")
|
||||
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
||||
// Path containment.
|
||||
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
if info, err := os.Stat(abs); err == nil && info.IsDir() {
|
||||
return abs, true
|
||||
}
|
||||
if zddc.IsDeclaredPath(fsRoot, abs) {
|
||||
return abs, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ServeSubtreeZip streams an application/zip download of every readable
|
||||
// file under absDir (recursively), ACL-filtered against the requester.
|
||||
// It's the handler behind `GET /some/dir/?zip=1`.
|
||||
// Invoked from the dispatcher when RecognizeVirtualSubtreeZip matches
|
||||
// the request URL.
|
||||
//
|
||||
// Permissions: each file is gated by the .zddc chain of its containing
|
||||
// directory (cached per directory), exactly like serveArchiveListing.
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ func TestServeSubtreeZip(t *testing.T) {
|
|||
cfg := config.Config{Root: root}
|
||||
|
||||
t.Run("headers + ACL-filtered contents", func(t *testing.T) {
|
||||
req := subtreeReq(t, http.MethodGet, "/Proj/sub/?zip=1", "bob@x")
|
||||
req := subtreeReq(t, http.MethodGet, "/Proj/sub.zip", "bob@x")
|
||||
rec := httptest.NewRecorder()
|
||||
ServeSubtreeZip(cfg, rec, req, sub)
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ func TestServeSubtreeZip(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("owner sees the locked subdir too", func(t *testing.T) {
|
||||
req := subtreeReq(t, http.MethodGet, "/Proj/sub/?zip=1", "owner@x")
|
||||
req := subtreeReq(t, http.MethodGet, "/Proj/sub.zip", "owner@x")
|
||||
rec := httptest.NewRecorder()
|
||||
ServeSubtreeZip(cfg, rec, req, sub)
|
||||
got := readZipResponse(t, rec)
|
||||
|
|
@ -136,7 +136,7 @@ func TestServeSubtreeZip(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("HEAD sets headers, no body", func(t *testing.T) {
|
||||
req := subtreeReq(t, http.MethodHead, "/Proj/sub/?zip=1", "bob@x")
|
||||
req := subtreeReq(t, http.MethodHead, "/Proj/sub.zip", "bob@x")
|
||||
rec := httptest.NewRecorder()
|
||||
ServeSubtreeZip(cfg, rec, req, sub)
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
@ -151,7 +151,7 @@ func TestServeSubtreeZip(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
req := subtreeReq(t, http.MethodPost, "/Proj/sub/?zip=1", "bob@x")
|
||||
req := subtreeReq(t, http.MethodPost, "/Proj/sub.zip", "bob@x")
|
||||
rec := httptest.NewRecorder()
|
||||
ServeSubtreeZip(cfg, rec, req, sub)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
|
|
@ -169,7 +169,7 @@ func TestServeSubtreeZip_AllDenied(t *testing.T) {
|
|||
mkfile(t, filepath.Join(locked, "deep", "y.txt"), "y")
|
||||
|
||||
cfg := config.Config{Root: root}
|
||||
req := subtreeReq(t, http.MethodGet, "/Proj/locked/?zip=1", "bob@x")
|
||||
req := subtreeReq(t, http.MethodGet, "/Proj/locked.zip", "bob@x")
|
||||
rec := httptest.NewRecorder()
|
||||
ServeSubtreeZip(cfg, rec, req, locked)
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
@ -186,7 +186,7 @@ func TestServeSubtreeZip_Nonexistent(t *testing.T) {
|
|||
mkfile(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": r\n")
|
||||
cfg := config.Config{Root: root}
|
||||
missing := filepath.Join(root, "Proj", "working") // declared by the cascade, not on disk
|
||||
req := subtreeReq(t, http.MethodGet, "/Proj/working/?zip=1", "bob@x")
|
||||
req := subtreeReq(t, http.MethodGet, "/Proj/working.zip", "bob@x")
|
||||
rec := httptest.NewRecorder()
|
||||
ServeSubtreeZip(cfg, rec, req, missing)
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
|
|||
|
|
@ -1375,7 +1375,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-14 17:07:00 · a62960b-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-14 17:23:02 · 050902f-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -2322,9 +2322,14 @@ body.help-open .app-header {
|
|||
// srcUrl points at the .md source on the server. fmt is one of
|
||||
// "docx" | "html" | "pdf". The server response status maps to a
|
||||
// friendly error message for the caller to surface (toast / status).
|
||||
//
|
||||
// URL grammar: srcUrl is the `<file>.md` source; the converted
|
||||
// form lives at `<file>.<fmt>` (virtual file extension recognised
|
||||
// by zddc-server's dispatcher). Replaces the older `?convert=`
|
||||
// query form.
|
||||
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
||||
{ credentials: 'same-origin' });
|
||||
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
var msg;
|
||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||
|
|
|
|||
Loading…
Reference in a new issue