]> OzVa Git service - ozva-cloud/commitdiff
feat: support edit files (#179)
authorsigoden <sigoden@gmail.com>
Mon, 20 Feb 2023 14:50:24 +0000 (22:50 +0800)
committerGitHub <noreply@github.com>
Mon, 20 Feb 2023 14:50:24 +0000 (22:50 +0800)
close #172

Cargo.lock
Cargo.toml
assets/index.css
assets/index.html
assets/index.js
src/server.rs
tests/fixtures.rs
tests/http.rs
tests/render.rs
tests/utils.rs

index 4f8ca0d9ed6290a5949df64b8b17d14c8074e5d3..738d918d21b3985ddefba83a5bf60b6566e82533 100644 (file)
@@ -277,6 +277,15 @@ dependencies = [
  "unicode-width",
 ]
 
+[[package]]
+name = "content_inspector"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "core-foundation"
 version = "0.9.3"
@@ -442,6 +451,7 @@ dependencies = [
  "chrono",
  "clap",
  "clap_complete",
+ "content_inspector",
  "diqwest",
  "form_urlencoded",
  "futures",
index cbe27008ae9eb63e5a5b89d9beeae8bcd54f88e4..3d8b0bbfff4ab3e44feaa3e50bb7755877eb705c 100644 (file)
@@ -40,6 +40,7 @@ async-stream = "0.3"
 walkdir = "2.3"
 form_urlencoded = "1.0"
 alphanumeric-sort = "1.4"
+content_inspector = "0.2.4"
 
 [features]
 default = ["tls"]
index a1a4908dacb8f1838364c5a35b69e5bf835c66f6..3b2174f62c79d9e22bbb0342274bafc24da7e544 100644 (file)
@@ -108,11 +108,10 @@ body {
 }
 
 .main {
-  padding: 3em 1em 0;
+  padding: 3.3em 1em 0;
 }
 
 .empty-folder {
-  padding-top: 1rem;
   font-style: italic;
 }
 
@@ -202,6 +201,25 @@ body {
   padding-right: 1em;
 }
 
+.editor {
+  width: 100%;
+  height: calc(100vh - 5rem);
+  border: 1px solid #ced4da;
+  outline: none;
+}
+
+.save-btn {
+  margin-left: auto;
+  margin-right: 2em;
+  cursor: pointer;
+  user-select: none;
+}
+
+.not-editable {
+  font-style: italic;
+}
+
+
 @media (min-width: 768px) {
   .path a {
     min-width: 400px;
index a882fde22e01b96fe76c49aaa09d38a49a6dfbbe..0f35410711ca0415689ee3135900a7068bad5283 100644 (file)
@@ -7,59 +7,97 @@
   <link rel="icon" type="image/x-icon" href="__ASSERTS_PREFIX__favicon.ico">
   <link rel="stylesheet" href="__ASSERTS_PREFIX__index.css">
   <script>
-  DATA = __INDEX_DATA__
+    DATA = __INDEX_DATA__
   </script>
   <script src="__ASSERTS_PREFIX__index.js"></script>
 </head>
+
 <body>
   <div class="head">
     <div class="breadcrumb"></div>
     <div class="toolbox">
       <div>
         <a href="?zip" class="zip-root hidden" title="Download folder as a .zip file">
-          <svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
+          <svg width="16" height="16" viewBox="0 0 16 16">
+            <path
+              d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
+            <path
+              d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z" />
+          </svg>
+        </a>
+        <a href="" class="download hidden" title="Download file" download="">
+          <svg width="16" height="16" viewBox="0 0 16 16">
+            <path
+              d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
+            <path
+              d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z" />
+          </svg>
         </a>
       </div>
       <div class="control upload-file hidden" title="Upload files">
         <label for="file">
-          <svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/></svg>
+          <svg width="16" height="16" viewBox="0 0 16 16">
+            <path
+              d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
+            <path
+              d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
+          </svg>
         </label>
         <input type="file" id="file" name="file" multiple>
       </div>
       <div class="control new-folder hidden" title="New folder">
         <svg width="16" height="16" viewBox="0 0 16 16">
-          <path d="m.5 3 .04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.683.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z"/>
-          <path d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 1 1 0 1H14v1.5a.5.5 0 1 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z"/>
+          <path
+            d="m.5 3 .04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.683.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z" />
+          <path
+            d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 1 1 0 1H14v1.5a.5.5 0 1 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z" />
         </svg>
       </div>
     </div>
     <form class="searchbar hidden">
       <div class="icon">
-        <svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/></svg>
+        <svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
+          <path
+            d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
+        </svg>
       </div>
       <input id="search" name="q" type="text" maxlength="128" autocomplete="off" tabindex="1">
       <input type="submit" hidden />
     </form>
+    <div class="save-btn hidden" title="Save file">
+      <svg viewBox="0 0 1024 1024" width="24" height="24">
+        <path
+          d="M426.666667 682.666667v42.666666h170.666666v-42.666666h-170.666666z m-42.666667-85.333334h298.666667v128h42.666666V418.133333L605.866667 298.666667H298.666667v426.666666h42.666666v-128h42.666667z m260.266667-384L810.666667 379.733333V810.666667H213.333333V213.333333h430.933334zM341.333333 341.333333h85.333334v170.666667H341.333333V341.333333z"
+          fill="#444444" p-id="8311"></path>
+      </svg>
+    </div>
   </div>
   <div class="main">
-    <div class="empty-folder hidden"></div>
-    <table class="uploaders-table hidden">
-      <thead>
-        <tr>
-          <th class="cell-name" colspan="2">Name</th>
-          <th class="cell-status">Progress</th>
-        </tr>
-      </thead>
-    </table>
-    <table class="paths-table hidden">
-      <thead>
-      </thead>
-      <tbody>
-      </tbody>
-    </table>
+    <div class="index-page hidden">
+      <div class="empty-folder hidden"></div>
+      <table class="uploaders-table hidden">
+        <thead>
+          <tr>
+            <th class="cell-name" colspan="2">Name</th>
+            <th class="cell-status">Progress</th>
+          </tr>
+        </thead>
+      </table>
+      <table class="paths-table hidden">
+        <thead>
+        </thead>
+        <tbody>
+        </tbody>
+      </table>
+    </div>
+    <div class="editor-page hidden">
+      <div class="not-editable hidden"></div>
+      <textarea class="editor hidden" cols="10"></textarea>
+    </div>
   </div>
   <script>
     window.addEventListener("DOMContentLoaded", ready);
   </script>
 </body>
+
 </html>
\ No newline at end of file
index dfaa1597ffe3eb281e5eb2f5904b2d7113815649..90d01c61e5272733319ae5a4106b42bdb8085491 100644 (file)
@@ -7,9 +7,14 @@
  */
 
 /**
- * @typedef {object} DATA
+ * @typedef {IndexDATA|EditDATA} DATA
+ */
+
+/**
+ * @typedef {object} IndexDATA
  * @property {string} href
  * @property {string} uri_prefix
+ * @property {"Index"} kind
  * @property {PathItem[]} paths
  * @property {boolean} allow_upload
  * @property {boolean} allow_delete
  * @property {boolean} dir_exists
  */
 
+/**
+ * @typedef {object} EditDATA
+ * @property {string} href
+ * @property {string} uri_prefix
+ * @property {"Edit"} kind
+ * @property {string} editable
+ */
+
 /**
  * @type {DATA} DATA
  */
@@ -57,11 +70,43 @@ let $emptyFolder;
 /**
  * @type Element
  */
-let $newFolder;
-/**
- * @type Element
- */
-let $searchbar;
+let $editor;
+
+function ready() {
+  document.title = `Index of ${DATA.href} - Dufs`;
+  $pathsTable = document.querySelector(".paths-table")
+  $pathsTableHead = document.querySelector(".paths-table thead");
+  $pathsTableBody = document.querySelector(".paths-table tbody");
+  $uploadersTable = document.querySelector(".uploaders-table");
+  $emptyFolder = document.querySelector(".empty-folder");
+  $editor = document.querySelector(".editor");
+
+  addBreadcrumb(DATA.href, DATA.uri_prefix);
+
+  if (DATA.kind == "Index") {
+
+    document.querySelector(".index-page").classList.remove("hidden");
+
+    if (DATA.allow_search) {
+      setupSearch()
+    }
+
+    if (DATA.allow_archive) {
+      document.querySelector(".zip-root").classList.remove("hidden");
+    }
+
+    renderPathsTableHead();
+    renderPathsTableBody();
+
+    if (DATA.allow_upload) {
+      dropzone();
+      setupUpload();
+    }
+  } else if (DATA.kind == "Edit") {
+    setupEditor();
+  }
+}
+
 
 class Uploader {
   /**
@@ -83,12 +128,12 @@ class Uploader {
 
   upload() {
     const { idx, name } = this;
-    const url = getUrl(name);
+    const url = newUrl(name);
     const encodedName = encodedStr(name);
     $uploadersTable.insertAdjacentHTML("beforeend", `
   <tr id="upload${idx}" class="uploader">
     <td class="path cell-icon">
-      ${getSvg()}
+      ${getPathSvg()}
     </td>
     <td class="path cell-name">
       <a href="${url}">${encodedName}</a>
@@ -105,7 +150,7 @@ class Uploader {
 
   ajax() {
     Uploader.runnings += 1;
-    const url = getUrl(this.name);
+    const url = newUrl(this.name);
     this.lastUptime = Date.now();
     const ajax = new XMLHttpRequest();
     ajax.upload.addEventListener("progress", e => this.progress(e), false);
@@ -272,7 +317,7 @@ function renderPathsTableBody() {
  */
 function addPath(file, index) {
   const encodedName = encodedStr(file.name);
-  let url = getUrl(file.name)
+  let url = newUrl(file.name)
   let actionDelete = "";
   let actionDownload = "";
   let actionMove = "";
@@ -316,10 +361,10 @@ function addPath(file, index) {
   $pathsTableBody.insertAdjacentHTML("beforeend", `
 <tr id="addPath${index}">
   <td class="path cell-icon">
-    ${getSvg(file.path_type)}
+    ${getPathSvg(file.path_type)}
   </td>
   <td class="path cell-name">
-    <a href="${url}">${encodedName}</a>
+    <a href="${url}?edit" target="_blank">${encodedName}</a>
   </td>
   <td class="cell-mtime">${formatMtime(file.mtime)}</td>
   <td class="cell-size">${formatSize(file.size).join(" ")}</td>
@@ -339,19 +384,16 @@ async function deletePath(index) {
   if (!confirm(`Delete \`${file.name}\`?`)) return;
 
   try {
-    const res = await fetch(getUrl(file.name), {
+    const res = await fetch(newUrl(file.name), {
       method: "DELETE",
     });
-    if (res.status >= 200 && res.status < 300) {
-      document.getElementById(`addPath${index}`).remove();
-      DATA.paths[index] = null;
-      if (!DATA.paths.find(v => !!v)) {
-        $pathsTable.classList.add("hidden");
-        $emptyFolder.textContent = dirEmptyNote;
-        $emptyFolder.classList.remove("hidden");
-      }
-    } else {
-      throw new Error(await res.text())
+    await assertFetch(res);
+    document.getElementById(`addPath${index}`).remove();
+    DATA.paths[index] = null;
+    if (!DATA.paths.find(v => !!v)) {
+      $pathsTable.classList.add("hidden");
+      $emptyFolder.textContent = dirEmptyNote;
+      $emptyFolder.classList.remove("hidden");
     }
   } catch (err) {
     alert(`Cannot delete \`${file.name}\`, ${err.message}`);
@@ -368,7 +410,7 @@ async function movePath(index) {
   const file = DATA.paths[index];
   if (!file) return;
 
-  const fileUrl = getUrl(file.name);
+  const fileUrl = newUrl(file.name);
   const fileUrlObj = new URL(fileUrl)
 
   const prefix = DATA.uri_prefix.slice(0, -1);
@@ -388,11 +430,8 @@ async function movePath(index) {
         "Destination": newFileUrl,
       }
     });
-    if (res.status >= 200 && res.status < 300) {
-      location.href = newFileUrl.split("/").slice(0, -1).join("/")
-    } else {
-      throw new Error(await res.text())
-    }
+    await assertFetch(res);
+    location.href = newFileUrl.split("/").slice(0, -1).join("/")
   } catch (err) {
     alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`);
   }
@@ -426,12 +465,13 @@ function dropzone() {
  * Setup searchbar
  */
 function setupSearch() {
+  const $searchbar = document.querySelector(".searchbar");
   $searchbar.classList.remove("hidden");
   $searchbar.addEventListener("submit", event => {
     event.preventDefault();
     const formData = new FormData($searchbar);
     const q = formData.get("q");
-    let href = getUrl();
+    let href = baseUrl();
     if (q) {
       href += "?q=" + q;
     }
@@ -442,10 +482,8 @@ function setupSearch() {
   }
 }
 
-/**
- * Setup upload
- */
 function setupUpload() {
+  const $newFolder = document.querySelector(".new-folder");
   $newFolder.classList.remove("hidden");
   $newFolder.addEventListener("click", () => {
     const name = prompt("Enter folder name");
@@ -460,19 +498,61 @@ function setupUpload() {
   });
 }
 
+async function setupEditor() {
+  document.querySelector(".editor-page").classList.remove("hidden");;
+
+  const $download = document.querySelector(".download")
+  $download.classList.remove("hidden");
+  $download.href = baseUrl()
+
+  if (!DATA.editable) {
+    const $notEditable = document.querySelector(".not-editable");
+    $notEditable.classList.remove("hidden");
+    $notEditable.textContent = "File is binary or too large.";
+    return;
+  }
+
+  const $saveBtn = document.querySelector(".save-btn");
+  $saveBtn.classList.remove("hidden");
+  $saveBtn.addEventListener("click", saveChange);
+
+  $editor.classList.remove("hidden");
+  try {
+    const res = await fetch(baseUrl());
+    await assertFetch(res);
+    const text = await res.text();
+    $editor.value = text;
+  } catch (err) {
+    alert(`Failed get file, ${err.message}`);
+  }
+}
+
+/**
+ * Save editor change
+ */
+async function saveChange() {
+  try {
+    await fetch(baseUrl(), {
+      method: "PUT",
+      body: $editor.value,
+    });
+  } catch (err) {
+    alert(`Failed to save file, ${err.message}`);
+  }
+}
+
 /**
  * Create a folder
  * @param {string} name 
  */
 async function createFolder(name) {
-  const url = getUrl(name);
+  const url = newUrl(name);
   try {
     const res = await fetch(url, {
       method: "MKCOL",
     });
-    if (res.status >= 200 && res.status < 300) {
-      location.href = url;
-    }
+    await assertFetch(res);
+    location.href = url;
   } catch (err) {
     alert(`Cannot create folder \`${name}\`, ${err.message}`);
   }
@@ -492,15 +572,18 @@ async function addFileEntries(entries, dirs) {
 }
 
 
-function getUrl(name) {
-  let url = location.href.split('?')[0];
+function newUrl(name) {
+  let url = baseUrl();
   if (!url.endsWith("/")) url += "/";
-  if (!name) return url;
   url += name.split("/").map(encodeURIComponent).join("/");
   return url;
 }
 
-function getSvg(path_type) {
+function baseUrl() {
+  return location.href.split('?')[0];
+}
+
+function getPathSvg(path_type) {
   switch (path_type) {
     case "Dir":
       return `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`;
@@ -558,30 +641,8 @@ function encodedStr(rawStr) {
   });
 }
 
-function ready() {
-  document.title = `Index of ${DATA.href} - Dufs`;
-  $pathsTable = document.querySelector(".paths-table")
-  $pathsTableHead = document.querySelector(".paths-table thead");
-  $pathsTableBody = document.querySelector(".paths-table tbody");
-  $uploadersTable = document.querySelector(".uploaders-table");
-  $emptyFolder = document.querySelector(".empty-folder");
-  $newFolder = document.querySelector(".new-folder");
-  $searchbar = document.querySelector(".searchbar");
-
-  if (DATA.allow_search) {
-    setupSearch()
-  }
-
-  if (DATA.allow_archive) {
-    document.querySelector(".zip-root").classList.remove("hidden");
-  }
-
-  addBreadcrumb(DATA.href, DATA.uri_prefix);
-  renderPathsTableHead();
-  renderPathsTableBody();
-
-  if (DATA.allow_upload) {
-    dropzone();
-    setupUpload();
+async function assertFetch(res) {
+  if (!(res.status >= 200 && res.status < 300)) {
+    throw new Error(await res.text())
   }
 }
index 07b570fb2a806fc515e7112805659c2fc58aec5a..d5e7b09ee2aa79a23dab39cb93c42976e97e5496 100644 (file)
@@ -29,7 +29,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
 use std::time::SystemTime;
 use tokio::fs::File;
-use tokio::io::{AsyncSeekExt, AsyncWrite};
+use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite};
 use tokio::{fs, io};
 use tokio_util::io::StreamReader;
 use uuid::Uuid;
@@ -43,6 +43,7 @@ const INDEX_JS: &str = include_str!("../assets/index.js");
 const FAVICON_ICO: &[u8] = include_bytes!("../assets/favicon.ico");
 const INDEX_NAME: &str = "index.html";
 const BUF_SIZE: usize = 65536;
+const TEXT_MAX_SIZE: u64 = 4194304; // 4M
 
 pub struct Server {
     args: Arc<Args>,
@@ -232,8 +233,12 @@ impl Server {
                             .await?;
                     }
                 } else if is_file {
-                    self.handle_send_file(path, headers, head_only, &mut res)
-                        .await?;
+                    if query_params.contains_key("edit") {
+                        self.handle_edit_file(path, head_only, &mut res).await?;
+                    } else {
+                        self.handle_send_file(path, headers, head_only, &mut res)
+                            .await?;
+                    }
                 } else if render_spa {
                     self.handle_render_spa(path, headers, head_only, &mut res)
                         .await?;
@@ -673,6 +678,41 @@ impl Server {
         Ok(())
     }
 
+    async fn handle_edit_file(
+        &self,
+        path: &Path,
+        head_only: bool,
+        res: &mut Response,
+    ) -> BoxResult<()> {
+        let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),);
+        let (file, meta) = (file?, meta?);
+        let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?));
+        let mut buffer: Vec<u8> = vec![];
+        file.take(1024).read_to_end(&mut buffer).await?;
+        let editable = meta.len() <= TEXT_MAX_SIZE && content_inspector::inspect(&buffer).is_text();
+        let data = EditData {
+            href,
+            kind: DataKind::Edit,
+            uri_prefix: self.args.uri_prefix.clone(),
+            allow_upload: self.args.allow_upload,
+            allow_delete: self.args.allow_delete,
+            editable,
+        };
+        res.headers_mut()
+            .typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
+        let output = self
+            .html
+            .replace("__ASSERTS_PREFIX__", &self.assets_prefix)
+            .replace("__INDEX_DATA__", &serde_json::to_string(&data).unwrap());
+        res.headers_mut()
+            .typed_insert(ContentLength(output.as_bytes().len() as u64));
+        if head_only {
+            return Ok(());
+        }
+        *res.body_mut() = output.into();
+        Ok(())
+    }
+
     async fn handle_propfind_dir(
         &self,
         path: &Path,
@@ -855,6 +895,7 @@ impl Server {
         }
         let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?));
         let data = IndexData {
+            kind: DataKind::Index,
             href,
             uri_prefix: self.args.uri_prefix.clone(),
             allow_upload: self.args.allow_upload,
@@ -1018,9 +1059,16 @@ impl Server {
     }
 }
 
+#[derive(Debug, Serialize)]
+enum DataKind {
+    Index,
+    Edit,
+}
+
 #[derive(Debug, Serialize)]
 struct IndexData {
     href: String,
+    kind: DataKind,
     uri_prefix: String,
     allow_upload: bool,
     allow_delete: bool,
@@ -1030,6 +1078,16 @@ struct IndexData {
     paths: Vec<PathItem>,
 }
 
+#[derive(Debug, Serialize)]
+struct EditData {
+    href: String,
+    kind: DataKind,
+    uri_prefix: String,
+    allow_upload: bool,
+    allow_delete: bool,
+    editable: bool,
+}
+
 #[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
 struct PathItem {
     path_type: PathType,
index 78c3c968156f170c08894d428c107421b24fd208..b78567015c0f4eaec8a0f9959968209c4a13ef3b 100644 (file)
@@ -11,9 +11,12 @@ use std::time::{Duration, Instant};
 #[allow(dead_code)]
 pub type Error = Box<dyn std::error::Error>;
 
+#[allow(dead_code)]
+pub const BIN_FILE: &str = "😀.bin";
+
 /// File names for testing purpose
 #[allow(dead_code)]
-pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", "😀.bin"];
+pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", BIN_FILE];
 
 /// Directory names for testing directory don't exist
 #[allow(dead_code)]
@@ -42,10 +45,17 @@ pub static DIRECTORIES: &[&str] = &["dir1/", "dir2/", "dir3/", DIR_NO_INDEX, DIR
 pub fn tmpdir() -> TempDir {
     let tmpdir = assert_fs::TempDir::new().expect("Couldn't create a temp dir for tests");
     for file in FILES {
-        tmpdir
-            .child(file)
-            .write_str(&format!("This is {file}"))
-            .expect("Couldn't write to file");
+        if *file == BIN_FILE {
+            tmpdir
+                .child(file)
+                .write_binary(b"bin\0\0123")
+                .expect("Couldn't write to file");
+        } else {
+            tmpdir
+                .child(file)
+                .write_str(&format!("This is {file}"))
+                .expect("Couldn't write to file");
+        }
     }
     for directory in DIRECTORIES {
         if *directory == DIR_ASSETS {
@@ -58,10 +68,17 @@ pub fn tmpdir() -> TempDir {
                 if *directory == DIR_NO_INDEX && *file == "index.html" {
                     continue;
                 }
-                tmpdir
-                    .child(format!("{directory}{file}"))
-                    .write_str(&format!("This is {directory}{file}"))
-                    .expect("Couldn't write to file");
+                if *file == BIN_FILE {
+                    tmpdir
+                        .child(format!("{directory}{file}"))
+                        .write_binary(b"bin\0\0123")
+                        .expect("Couldn't write to file");
+                } else {
+                    tmpdir
+                        .child(format!("{directory}{file}"))
+                        .write_str(&format!("This is {directory}{file}"))
+                        .expect("Couldn't write to file");
+                }
             }
         }
     }
index a7c297974f1696bf48fbb8925b0fc4b37977d494..ee9ff6beafd1604dc76cf8c8a08a0bdc37b1d470 100644 (file)
@@ -1,9 +1,10 @@
 mod fixtures;
 mod utils;
 
-use fixtures::{server, Error, TestServer};
+use fixtures::{server, Error, TestServer, BIN_FILE};
 use rstest::rstest;
 use serde_json::Value;
+use utils::retrive_edit_file;
 
 #[rstest]
 fn get_dir(server: TestServer) -> Result<(), Error> {
@@ -103,12 +104,12 @@ fn get_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
 
 #[rstest]
 fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
-    let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "😀.bin"))?;
+    let resp = reqwest::blocking::get(format!("{}?q={BIN_FILE}", server.url()))?;
     assert_eq!(resp.status(), 200);
     let paths = utils::retrieve_index_paths(&resp.text()?);
     assert!(!paths.is_empty());
     for p in paths {
-        assert!(p.contains("😀.bin"));
+        assert!(p.contains(BIN_FILE));
     }
     Ok(())
 }
@@ -177,6 +178,24 @@ fn get_file_404(server: TestServer) -> Result<(), Error> {
     Ok(())
 }
 
+#[rstest]
+fn get_file_edit(server: TestServer) -> Result<(), Error> {
+    let resp = fetch!(b"GET", format!("{}index.html?edit", server.url())).send()?;
+    assert_eq!(resp.status(), 200);
+    let editable = retrive_edit_file(&resp.text().unwrap()).unwrap();
+    assert!(editable);
+    Ok(())
+}
+
+#[rstest]
+fn get_file_edit_bin(server: TestServer) -> Result<(), Error> {
+    let resp = fetch!(b"GET", format!("{}{BIN_FILE}?edit", server.url())).send()?;
+    assert_eq!(resp.status(), 200);
+    let editable = retrive_edit_file(&resp.text().unwrap()).unwrap();
+    assert!(!editable);
+    Ok(())
+}
+
 #[rstest]
 fn head_file_404(server: TestServer) -> Result<(), Error> {
     let resp = fetch!(b"HEAD", format!("{}404", server.url())).send()?;
index 3a8e9f2df1a960751603567e8837204e7b1752e1..0621fab3a8ad260c674e53c2b9f467e266169d09 100644 (file)
@@ -1,7 +1,7 @@
 mod fixtures;
 mod utils;
 
-use fixtures::{server, Error, TestServer, DIR_NO_FOUND, DIR_NO_INDEX, FILES};
+use fixtures::{server, Error, TestServer, BIN_FILE, DIR_NO_FOUND, DIR_NO_INDEX, FILES};
 use rstest::rstest;
 
 #[rstest]
@@ -56,11 +56,10 @@ fn render_try_index3(
 #[case(server(&["--render-try-index"] as &[&str]), false)]
 #[case(server(&["--render-try-index", "--allow-search"] as &[&str]), true)]
 fn render_try_index4(#[case] server: TestServer, #[case] searched: bool) -> Result<(), Error> {
-    let resp = reqwest::blocking::get(format!("{}{}?q={}", server.url(), DIR_NO_INDEX, "😀.bin"))?;
+    let resp = reqwest::blocking::get(format!("{}{}?q={}", server.url(), DIR_NO_INDEX, BIN_FILE))?;
     assert_eq!(resp.status(), 200);
     let paths = utils::retrieve_index_paths(&resp.text()?);
-    assert!(!paths.is_empty());
-    assert_eq!(paths.iter().all(|v| v.contains("😀.bin")), searched);
+    assert_eq!(paths.iter().all(|v| v.contains(BIN_FILE)), searched);
     Ok(())
 }
 
index f8d4b5a629cb45a5e4c6614f13ae83714228f8f7..c40be5e1b09dc9a978c4a943a8b65dd9e58e2c56 100644 (file)
@@ -25,24 +25,13 @@ macro_rules! fetch {
 }
 
 #[allow(dead_code)]
-pub fn retrieve_index_paths(index: &str) -> IndexSet<String> {
-    retrieve_index_paths_impl(index).unwrap_or_default()
-}
-
-#[allow(dead_code)]
-pub fn encode_uri(v: &str) -> String {
-    let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
-    parts.join("/")
-}
-
-fn retrieve_index_paths_impl(index: &str) -> Option<IndexSet<String>> {
-    let lines: Vec<&str> = index.lines().collect();
-    let line = lines.iter().find(|v| v.contains("DATA ="))?;
-    let line_col = line.find("DATA =").unwrap() + 6;
-    let value: Value = line[line_col..].parse().ok()?;
+pub fn retrieve_index_paths(content: &str) -> IndexSet<String> {
+    let value = retrive_json(content).unwrap();
     let paths = value
-        .get("paths")?
-        .as_array()?
+        .get("paths")
+        .unwrap()
+        .as_array()
+        .unwrap()
         .iter()
         .flat_map(|v| {
             let name = v.get("name")?.as_str()?;
@@ -54,5 +43,26 @@ fn retrieve_index_paths_impl(index: &str) -> Option<IndexSet<String>> {
             }
         })
         .collect();
-    Some(paths)
+    paths
+}
+
+#[allow(dead_code)]
+pub fn retrive_edit_file(content: &str) -> Option<bool> {
+    let value = retrive_json(content)?;
+    let value = value.get("editable").unwrap();
+    Some(value.as_bool().unwrap())
+}
+
+#[allow(dead_code)]
+pub fn encode_uri(v: &str) -> String {
+    let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
+    parts.join("/")
+}
+
+fn retrive_json(content: &str) -> Option<Value> {
+    let lines: Vec<&str> = content.lines().collect();
+    let line = lines.iter().find(|v| v.contains("DATA ="))?;
+    let line_col = line.find("DATA =").unwrap() + 6;
+    let value: Value = line[line_col..].parse().unwrap();
+    Some(value)
 }