feats: (Un)archiving & dir size (#1)
* feat: file archiving (#1) * feat: file archiving * resource: use name query for archive name * feat: file unarchiving * resource: Return bad param error on unarchive fail * fix: adjust style according to lint * feat: directory size calculation (#2)pull/3756/head
							parent
							
								
									46ee595389
								
							
						
					
					
						commit
						11092eed3c
					
				| 
						 | 
				
			
			@ -39,6 +39,8 @@ type FileInfo struct {
 | 
			
		|||
	Content   string            `json:"content,omitempty"`
 | 
			
		||||
	Checksums map[string]string `json:"checksums,omitempty"`
 | 
			
		||||
	Token     string            `json:"token,omitempty"`
 | 
			
		||||
	DiskUsage int64             `json:"diskUsage,omitempty"`
 | 
			
		||||
	Inodes    int64             `json:"inodes,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FileOptions are the options when getting a file info.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,7 @@ package fileutils
 | 
			
		|||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
)
 | 
			
		||||
| 
						 | 
				
			
			@ -60,3 +61,46 @@ func CopyDir(fs afero.Fs, source, dest string) error {
 | 
			
		|||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func DiskUsage(fs afero.Fs, path string, maxDepth int) (size, inodes int64, err error) {
 | 
			
		||||
	info, err := fs.Stat(path)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, 0, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	size = info.Size()
 | 
			
		||||
	inodes = int64(1)
 | 
			
		||||
 | 
			
		||||
	if !info.IsDir() {
 | 
			
		||||
		return size, inodes, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if maxDepth < 1 {
 | 
			
		||||
		return size, inodes, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	dir, err := fs.Open(path)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return size, inodes, err
 | 
			
		||||
	}
 | 
			
		||||
	defer dir.Close()
 | 
			
		||||
 | 
			
		||||
	fis, err := dir.Readdir(-1)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return size, inodes, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, fi := range fis {
 | 
			
		||||
		if fi.Name() == "." || fi.Name() == ".." {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		s, i, e := DiskUsage(fs, filepath.Join(path, fi.Name()), maxDepth-1)
 | 
			
		||||
		if e != nil {
 | 
			
		||||
			return size, inodes, e
 | 
			
		||||
		}
 | 
			
		||||
		size += s
 | 
			
		||||
		inodes += i
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return size, inodes, err
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,120 @@
 | 
			
		|||
package fileutils
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"io/ioutil"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
	"github.com/stretchr/testify/require"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const RootTestDir string = "testing_dir_func"
 | 
			
		||||
 | 
			
		||||
func createFileStructure() error {
 | 
			
		||||
	childDir := filepath.Join(RootTestDir, "child_dir")
 | 
			
		||||
 | 
			
		||||
	err := os.MkdirAll(childDir, 0755)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	data := []byte("test_data")
 | 
			
		||||
 | 
			
		||||
	err = ioutil.WriteFile(filepath.Join(childDir, "test_file"), data, 0600)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = ioutil.WriteFile(filepath.Join(RootTestDir, "test_file"), data, 0600)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func cleanupFileStructure() {
 | 
			
		||||
	os.RemoveAll(RootTestDir)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDiskUsageOnFile(t *testing.T) {
 | 
			
		||||
	err := createFileStructure()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("createFileStructure() failed: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer cleanupFileStructure()
 | 
			
		||||
 | 
			
		||||
	cwd, err := os.Getwd()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("Getwd() failed: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fs := afero.NewBasePathFs(afero.NewOsFs(), cwd)
 | 
			
		||||
	size, inodes, err := DiskUsage(fs, filepath.Join(RootTestDir, "test_file"), 100)
 | 
			
		||||
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	require.Equal(t, int64(9), size)
 | 
			
		||||
	require.Equal(t, int64(1), inodes)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDiskUsageOnNestedDir(t *testing.T) {
 | 
			
		||||
	err := createFileStructure()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("createFileStructure() failed: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer cleanupFileStructure()
 | 
			
		||||
 | 
			
		||||
	cwd, err := os.Getwd()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("Getwd() failed: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fs := afero.NewBasePathFs(afero.NewOsFs(), cwd)
 | 
			
		||||
	size, inodes, err := DiskUsage(fs, filepath.Join(RootTestDir, "child_dir"), 100)
 | 
			
		||||
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	require.Equal(t, int64(105), size)
 | 
			
		||||
	require.Equal(t, int64(2), inodes)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDiskUsageOnRootDir(t *testing.T) {
 | 
			
		||||
	err := createFileStructure()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("createFileStructure() failed: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer cleanupFileStructure()
 | 
			
		||||
 | 
			
		||||
	cwd, err := os.Getwd()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("Getwd() failed: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fs := afero.NewBasePathFs(afero.NewOsFs(), cwd)
 | 
			
		||||
	size, inodes, err := DiskUsage(fs, RootTestDir, 100)
 | 
			
		||||
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	require.Equal(t, int64(242), size)
 | 
			
		||||
	require.Equal(t, int64(4), inodes)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDiskUsageOnRootDirStopsAtDepthLimit(t *testing.T) {
 | 
			
		||||
	err := createFileStructure()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("createFileStructure() failed: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer cleanupFileStructure()
 | 
			
		||||
 | 
			
		||||
	cwd, err := os.Getwd()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("Getwd() failed: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fs := afero.NewBasePathFs(afero.NewOsFs(), cwd)
 | 
			
		||||
	size, inodes, err := DiskUsage(fs, RootTestDir, 1)
 | 
			
		||||
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	require.Equal(t, int64(233), size)
 | 
			
		||||
	require.Equal(t, int64(3), inodes)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -154,3 +154,34 @@ export async function checksum(url, algo) {
 | 
			
		|||
  const data = await resourceAction(`${url}?checksum=${algo}`, "GET");
 | 
			
		||||
  return (await data.json()).checksums[algo];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function diskUsage(url) {
 | 
			
		||||
  const data = await resourceAction(`${url}?disk_usage=true`, "GET");
 | 
			
		||||
  return await data.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function archive(url, name, format, ...files) {
 | 
			
		||||
  let arg = "";
 | 
			
		||||
 | 
			
		||||
  for (let file of files) {
 | 
			
		||||
    arg += file + ",";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  arg = arg.substring(0, arg.length - 1);
 | 
			
		||||
  arg = encodeURIComponent(arg);
 | 
			
		||||
  url += `?files=${arg}&`;
 | 
			
		||||
  url += `name=${encodeURIComponent(name)}&`;
 | 
			
		||||
 | 
			
		||||
  if (format) {
 | 
			
		||||
    url += `algo=${format}&`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return post(url);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function unarchive(path, name, override) {
 | 
			
		||||
  const to = encodeURIComponent(removePrefix(name));
 | 
			
		||||
  const action = `unarchive`;
 | 
			
		||||
  const url = `${path}?action=${action}&destination=${to}&override=${override}`;
 | 
			
		||||
  return resourceAction(url, "PATCH");
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,88 @@
 | 
			
		|||
<template>
 | 
			
		||||
  <div class="card floating">
 | 
			
		||||
    <div class="card-title">
 | 
			
		||||
      <h2>{{ $t("prompts.archive") }}</h2>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="card-content">
 | 
			
		||||
      <p>{{ $t("prompts.archiveMessage") }}</p>
 | 
			
		||||
      <input
 | 
			
		||||
        class="input input--block"
 | 
			
		||||
        v-focus
 | 
			
		||||
        type="text"
 | 
			
		||||
        @keyup.enter="submit"
 | 
			
		||||
        v-model.trim="name"
 | 
			
		||||
        required
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <button
 | 
			
		||||
        v-for="(ext, format) in formats"
 | 
			
		||||
        :key="format"
 | 
			
		||||
        class="button button--block"
 | 
			
		||||
        @click="archive(format)"
 | 
			
		||||
        v-focus
 | 
			
		||||
      >
 | 
			
		||||
        {{ ext }}
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import { mapState, mapGetters } from "vuex";
 | 
			
		||||
import { files as api } from "@/api";
 | 
			
		||||
import url from "@/utils/url";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: "archive",
 | 
			
		||||
  data: function () {
 | 
			
		||||
    return {
 | 
			
		||||
      name: "",
 | 
			
		||||
      formats: {
 | 
			
		||||
        zip: "zip",
 | 
			
		||||
        tar: "tar",
 | 
			
		||||
        targz: "tar.gz",
 | 
			
		||||
        tarbz2: "tar.bz2",
 | 
			
		||||
        tarxz: "tar.xz",
 | 
			
		||||
        tarlz4: "tar.lz4",
 | 
			
		||||
        tarsz: "tar.sz",
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    ...mapState(["req", "selected"]),
 | 
			
		||||
    ...mapGetters(["isFiles", "isListing"]),
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    cancel: function () {
 | 
			
		||||
      this.$store.commit("closeHovers");
 | 
			
		||||
    },
 | 
			
		||||
    archive: async function (format) {
 | 
			
		||||
      let items = [];
 | 
			
		||||
 | 
			
		||||
      for (let i of this.selected) {
 | 
			
		||||
        items.push(this.req.items[i].name);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      let uri = this.isFiles ? this.$route.path : "/";
 | 
			
		||||
 | 
			
		||||
      if (!this.isListing) {
 | 
			
		||||
        uri = url.removeLastDir(uri);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      uri += "/archive";
 | 
			
		||||
      uri = uri.replace("//", "/");
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        await api.archive(uri, this.name, format, ...items);
 | 
			
		||||
 | 
			
		||||
        this.$store.commit("setReload", true);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        this.$showError(e);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.$store.commit("closeHovers");
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
| 
						 | 
				
			
			@ -12,10 +12,16 @@
 | 
			
		|||
      <p class="break-word" v-if="selected.length < 2">
 | 
			
		||||
        <strong>{{ $t("prompts.displayName") }}</strong> {{ name }}
 | 
			
		||||
      </p>
 | 
			
		||||
      <p v-if="!dir || selected.length > 1">
 | 
			
		||||
      <p v-if="!dir">
 | 
			
		||||
        <strong>{{ $t("prompts.size") }}:</strong>
 | 
			
		||||
        <span id="content_length"></span> {{ humanSize }}
 | 
			
		||||
      </p>
 | 
			
		||||
      <p v-if="dir">
 | 
			
		||||
        <strong>{{ $t("prompts.size") }}: </strong>
 | 
			
		||||
        <code>
 | 
			
		||||
          <a @click="diskUsage($event)">{{ $t("prompts.show") }}</a>
 | 
			
		||||
        </code>
 | 
			
		||||
      </p>
 | 
			
		||||
      <p v-if="selected.length < 2" :title="modTime">
 | 
			
		||||
        <strong>{{ $t("prompts.lastModified") }}:</strong> {{ humanTime }}
 | 
			
		||||
      </p>
 | 
			
		||||
| 
						 | 
				
			
			@ -147,6 +153,38 @@ export default {
 | 
			
		|||
        this.$showError(e);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    diskUsage: async function (event) {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
 | 
			
		||||
      // eslint-disable-next-line
 | 
			
		||||
      event.target.innerHTML = this.$t('files.loading');
 | 
			
		||||
 | 
			
		||||
      let links = [];
 | 
			
		||||
 | 
			
		||||
      if (this.selectedCount === 0 || !this.isListing) {
 | 
			
		||||
        links.push(this.$route.path);
 | 
			
		||||
      } else {
 | 
			
		||||
        for (let selected of this.selected) {
 | 
			
		||||
          links.push(this.req.items[selected].url);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      let size = 0;
 | 
			
		||||
      let inodes = 0;
 | 
			
		||||
 | 
			
		||||
      for (let link of links) {
 | 
			
		||||
        try {
 | 
			
		||||
          let data = await api.diskUsage(link);
 | 
			
		||||
          size += data.diskUsage;
 | 
			
		||||
          inodes += data.inodes;
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          this.$showError(e);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // eslint-disable-next-line
 | 
			
		||||
      event.target.innerHTML = filesize(size) + " " + this.$t("prompts.inodeCount", { count: inodes })
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,6 +12,8 @@ import Delete from "./Delete";
 | 
			
		|||
import Rename from "./Rename";
 | 
			
		||||
import Download from "./Download";
 | 
			
		||||
import Move from "./Move";
 | 
			
		||||
import Archive from "./Archive";
 | 
			
		||||
import Unarchive from "./Unarchive";
 | 
			
		||||
import Copy from "./Copy";
 | 
			
		||||
import NewFile from "./NewFile";
 | 
			
		||||
import NewDir from "./NewDir";
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +33,8 @@ export default {
 | 
			
		|||
    Rename,
 | 
			
		||||
    Download,
 | 
			
		||||
    Move,
 | 
			
		||||
    Archive,
 | 
			
		||||
    Unarchive,
 | 
			
		||||
    Copy,
 | 
			
		||||
    Share,
 | 
			
		||||
    NewFile,
 | 
			
		||||
| 
						 | 
				
			
			@ -91,6 +95,8 @@ export default {
 | 
			
		|||
          "delete",
 | 
			
		||||
          "rename",
 | 
			
		||||
          "move",
 | 
			
		||||
          "archive",
 | 
			
		||||
          "unarchive",
 | 
			
		||||
          "copy",
 | 
			
		||||
          "newFile",
 | 
			
		||||
          "newDir",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,77 @@
 | 
			
		|||
<template>
 | 
			
		||||
  <div class="card floating">
 | 
			
		||||
    <div class="card-title">
 | 
			
		||||
      <h2>{{ $t("prompts.unarchive") }}</h2>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="card-content">
 | 
			
		||||
      <p>{{ $t("prompts.unarchiveMessage") }}</p>
 | 
			
		||||
      <input
 | 
			
		||||
        class="input input--block"
 | 
			
		||||
        v-focus
 | 
			
		||||
        type="text"
 | 
			
		||||
        @keyup.enter="submit"
 | 
			
		||||
        v-model.trim="name"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="card-action">
 | 
			
		||||
      <button
 | 
			
		||||
        class="button button--flat button--grey"
 | 
			
		||||
        @click="$store.commit('closeHovers')"
 | 
			
		||||
        :aria-label="$t('buttons.cancel')"
 | 
			
		||||
        :title="$t('buttons.cancel')"
 | 
			
		||||
      >
 | 
			
		||||
        {{ $t("buttons.cancel") }}
 | 
			
		||||
      </button>
 | 
			
		||||
      <button
 | 
			
		||||
        @click="submit"
 | 
			
		||||
        class="button button--flat"
 | 
			
		||||
        type="submit"
 | 
			
		||||
        :aria-label="$t('buttons.unarchive')"
 | 
			
		||||
        :title="$t('buttons.unarchive')"
 | 
			
		||||
      >
 | 
			
		||||
        {{ $t("buttons.unarchive") }}
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import { mapState, mapGetters } from "vuex";
 | 
			
		||||
import { files as api } from "@/api";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: "rename",
 | 
			
		||||
  data: function () {
 | 
			
		||||
    return {
 | 
			
		||||
      name: "",
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    ...mapState(["req", "selected", "selectedCount"]),
 | 
			
		||||
    ...mapGetters(["isListing", "isFiles"]),
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    cancel: function () {
 | 
			
		||||
      this.$store.commit("closeHovers");
 | 
			
		||||
    },
 | 
			
		||||
    submit: async function () {
 | 
			
		||||
      let item = this.req.items[this.selected[0]];
 | 
			
		||||
      let uri = this.isFiles ? this.$route.path + "/" : "/";
 | 
			
		||||
      let dst = uri + this.name;
 | 
			
		||||
      dst = dst.replace("//", "/");
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        await api.unarchive(item.url, dst, false);
 | 
			
		||||
 | 
			
		||||
        this.$store.commit("setReload", true);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        this.$showError(e);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.$store.commit("closeHovers");
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,7 @@
 | 
			
		|||
{
 | 
			
		||||
  "buttons": {
 | 
			
		||||
    "archive": "Archive",
 | 
			
		||||
    "unarchive": "Unarchive",
 | 
			
		||||
    "cancel": "Cancel",
 | 
			
		||||
    "close": "Close",
 | 
			
		||||
    "copy": "Copy",
 | 
			
		||||
| 
						 | 
				
			
			@ -113,6 +115,10 @@
 | 
			
		|||
  },
 | 
			
		||||
  "permanent": "Permanent",
 | 
			
		||||
  "prompts": {
 | 
			
		||||
    "archive": "Archive",
 | 
			
		||||
    "archiveMessage": "Choose archive name and format:",
 | 
			
		||||
    "unarchive": "Unarchive",
 | 
			
		||||
    "unarchiveMessage": "Choose the destination folder name:",
 | 
			
		||||
    "copy": "Copy",
 | 
			
		||||
    "copyMessage": "Choose the place to copy your files:",
 | 
			
		||||
    "currentlyNavigating": "Currently navigating on:",
 | 
			
		||||
| 
						 | 
				
			
			@ -144,6 +150,7 @@
 | 
			
		|||
    "scheduleMessage": "Pick a date and time to schedule the publication of this post.",
 | 
			
		||||
    "show": "Show",
 | 
			
		||||
    "size": "Size",
 | 
			
		||||
    "inodeCount": "({count} inodes)",
 | 
			
		||||
    "upload": "Upload",
 | 
			
		||||
    "uploadMessage": "Select an option to upload.",
 | 
			
		||||
    "optionalPassword": "Optional password"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -37,6 +37,20 @@
 | 
			
		|||
            :label="$t('buttons.moveFile')"
 | 
			
		||||
            show="move"
 | 
			
		||||
          />
 | 
			
		||||
          <action
 | 
			
		||||
            v-if="headerButtons.archive"
 | 
			
		||||
            id="archive-button"
 | 
			
		||||
            icon="archive"
 | 
			
		||||
            :label="$t('buttons.archive')"
 | 
			
		||||
            show="archive"
 | 
			
		||||
          />
 | 
			
		||||
          <action
 | 
			
		||||
            v-if="headerButtons.unarchive"
 | 
			
		||||
            id="unarchive-button"
 | 
			
		||||
            icon="unarchive"
 | 
			
		||||
            :label="$t('buttons.unarchive')"
 | 
			
		||||
            show="unarchive"
 | 
			
		||||
          />
 | 
			
		||||
          <action
 | 
			
		||||
            v-if="headerButtons.delete"
 | 
			
		||||
            id="delete-button"
 | 
			
		||||
| 
						 | 
				
			
			@ -366,11 +380,29 @@ export default {
 | 
			
		|||
        share: this.selectedCount === 1 && this.user.perm.share,
 | 
			
		||||
        move: this.selectedCount > 0 && this.user.perm.rename,
 | 
			
		||||
        copy: this.selectedCount > 0 && this.user.perm.create,
 | 
			
		||||
        archive: this.selectedCount > 0 && this.user.perm.create,
 | 
			
		||||
        unarchive: this.selectedCount === 1 && this.onlyArchivesSelected,
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
    isMobile() {
 | 
			
		||||
      return this.width <= 736;
 | 
			
		||||
    },
 | 
			
		||||
    onlyArchivesSelected() {
 | 
			
		||||
      let extensions = [".zip", ".tar", ".gz", ".bz2", ".xz", ".lz4", ".sz"];
 | 
			
		||||
 | 
			
		||||
      if (this.selectedCount < 1) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      for (const i of this.selected) {
 | 
			
		||||
        let item = this.req.items[i];
 | 
			
		||||
        if (item.isDir || !extensions.includes(item.extension)) {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return true;
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    req: function () {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										129
									
								
								http/resource.go
								
								
								
								
							
							
						
						
									
										129
									
								
								http/resource.go
								
								
								
								
							| 
						 | 
				
			
			@ -12,6 +12,7 @@ import (
 | 
			
		|||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/mholt/archiver"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
 | 
			
		||||
	"github.com/filebrowser/filebrowser/v2/errors"
 | 
			
		||||
| 
						 | 
				
			
			@ -33,6 +34,17 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
 | 
			
		|||
		return errToStatus(err), err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if r.URL.Query().Get("disk_usage") == "true" {
 | 
			
		||||
		du, inodes, err := fileutils.DiskUsage(file.Fs, file.Path, 100)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return http.StatusInternalServerError, err
 | 
			
		||||
		}
 | 
			
		||||
		file.DiskUsage = du
 | 
			
		||||
		file.Inodes = inodes
 | 
			
		||||
		file.Content = ""
 | 
			
		||||
		return renderJSON(w, r, file)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if file.IsDir {
 | 
			
		||||
		file.Listing.Sorting = d.user.Sorting
 | 
			
		||||
		file.Listing.ApplySort()
 | 
			
		||||
| 
						 | 
				
			
			@ -106,6 +118,15 @@ func resourcePostHandler(fileCache FileCache) handleFunc {
 | 
			
		|||
			return errToStatus(err), err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Archive creation on POST.
 | 
			
		||||
		if strings.HasSuffix(r.URL.Path, "/archive") {
 | 
			
		||||
			if !d.user.Perm.Create {
 | 
			
		||||
				return http.StatusForbidden, nil
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return archiveHandler(r, d)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		file, err := files.NewFileInfo(files.FileOptions{
 | 
			
		||||
			Fs:         d.user.Fs,
 | 
			
		||||
			Path:       r.URL.Path,
 | 
			
		||||
| 
						 | 
				
			
			@ -305,6 +326,20 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach
 | 
			
		|||
		}
 | 
			
		||||
 | 
			
		||||
		return fileutils.Copy(d.user.Fs, src, dst)
 | 
			
		||||
	case "unarchive":
 | 
			
		||||
		if !d.user.Perm.Create {
 | 
			
		||||
			return errors.ErrPermissionDenied
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		src = d.user.FullPath(path.Clean("/" + src))
 | 
			
		||||
		dst = d.user.FullPath(path.Clean("/" + dst))
 | 
			
		||||
 | 
			
		||||
		// THIS COULD BE VUNERABLE TO https://github.com/snyk/zip-slip-vulnerability
 | 
			
		||||
		err := archiver.Unarchive(src, dst)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return errors.ErrInvalidRequestParams
 | 
			
		||||
		}
 | 
			
		||||
		return nil
 | 
			
		||||
	case "rename":
 | 
			
		||||
		if !d.user.Perm.Rename {
 | 
			
		||||
			return errors.ErrPermissionDenied
 | 
			
		||||
| 
						 | 
				
			
			@ -335,3 +370,97 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach
 | 
			
		|||
		return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func archiveHandler(r *http.Request, d *data) (int, error) {
 | 
			
		||||
	dir := strings.TrimSuffix(r.URL.Path, "/archive")
 | 
			
		||||
 | 
			
		||||
	destDir, err := files.NewFileInfo(files.FileOptions{
 | 
			
		||||
		Fs:         d.user.Fs,
 | 
			
		||||
		Path:       dir,
 | 
			
		||||
		Modify:     d.user.Perm.Modify,
 | 
			
		||||
		Expand:     false,
 | 
			
		||||
		ReadHeader: false,
 | 
			
		||||
		Checker:    d,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return errToStatus(err), err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	filenames, err := parseQueryFiles(r, destDir, d.user)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return http.StatusInternalServerError, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	archFile, err := parseQueryFilename(r, destDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return http.StatusBadRequest, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	extension, ar, err := parseArchiver(r.URL.Query().Get("algo"))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return http.StatusInternalServerError, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	archFile += extension
 | 
			
		||||
 | 
			
		||||
	_, err = d.user.Fs.Stat(archFile)
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		return http.StatusConflict, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	dir, _ = path.Split(archFile)
 | 
			
		||||
	err = d.user.Fs.MkdirAll(dir, 0775)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return errToStatus(err), err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for i, path := range filenames {
 | 
			
		||||
		_, err = d.user.Fs.Stat(path)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return errToStatus(err), err
 | 
			
		||||
		}
 | 
			
		||||
		filenames[i] = d.user.FullPath(path)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	dst := d.user.FullPath(archFile)
 | 
			
		||||
	err = ar.Archive(filenames, dst)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return http.StatusInternalServerError, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return errToStatus(err), err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func parseQueryFilename(r *http.Request, f *files.FileInfo) (string, error) {
 | 
			
		||||
	name := r.URL.Query().Get("name")
 | 
			
		||||
	name, err := url.QueryUnescape(strings.Replace(name, "+", "%2B", -1))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
	name = strings.TrimSpace(name)
 | 
			
		||||
	if name == "" {
 | 
			
		||||
		return "", fmt.Errorf("empty name provided")
 | 
			
		||||
	}
 | 
			
		||||
	return filepath.Join(f.Path, slashClean(name)), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func parseArchiver(algo string) (string, archiver.Archiver, error) {
 | 
			
		||||
	switch algo {
 | 
			
		||||
	case "zip", "true", "":
 | 
			
		||||
		return ".zip", archiver.NewZip(), nil
 | 
			
		||||
	case "tar":
 | 
			
		||||
		return ".tar", archiver.NewTar(), nil
 | 
			
		||||
	case "targz":
 | 
			
		||||
		return ".tar.gz", archiver.NewTarGz(), nil
 | 
			
		||||
	case "tarbz2":
 | 
			
		||||
		return ".tar.bz2", archiver.NewTarBz2(), nil
 | 
			
		||||
	case "tarxz":
 | 
			
		||||
		return ".tar.xz", archiver.NewTarXz(), nil
 | 
			
		||||
	case "tarlz4":
 | 
			
		||||
		return ".tar.lz4", archiver.NewTarLz4(), nil
 | 
			
		||||
	case "tarsz":
 | 
			
		||||
		return ".tar.sz", archiver.NewTarSz(), nil
 | 
			
		||||
	default:
 | 
			
		||||
		return "", nil, fmt.Errorf("format not implemented")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue