Improve code quality (#67)

* Migrate to biomejs

* Cleanup and add workers-ci method

* Started refactoring worker into hono + chanfana

* Finish api worker refactor

* Lint project

* Lint project
This commit is contained in:
Gabriel Massadas
2024-12-05 23:11:01 +00:00
committed by GitHub
parent 1e7c97e367
commit 40615ca5c2
104 changed files with 8017 additions and 8303 deletions
-6
View File
@@ -1,6 +0,0 @@
dist
node_modules
packages/github-action/*
packages/worker/bin/*
packages/worker/config/*
packages/dashboard/quasar.config.js
-23
View File
@@ -1,23 +0,0 @@
{
"root": true,
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"env": {
"browser": true,
"es6": true,
"node": true
},
"extends": [
"typescript",
"prettier"
],
"ignorePatterns": [],
"rules": {
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-unused-vars": "warn"
}
}
-3
View File
@@ -1,3 +0,0 @@
git-checks=false
shamefully-hoist=true
auto-install-peers=true
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off",
"noImplicitAnyLet": "off"
},
"style": {
"noParameterAssign": "off"
},
"complexity": {
"noForEach": "off",
"useLiteralKeys": "off"
},
"performance": {
"noAccumulatingSpread": "off",
"noDelete": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}
+19 -23
View File
@@ -1,25 +1,21 @@
{
"name": "r2-explorer-root",
"version": "0.0.0",
"private": true,
"scripts": {
"prepare": "husky install",
"lint": "(eslint . || (eslint . --fix; exit 1)) && (prettier --check '*.{json,js}' 'packages/*/src/**/*.{js,ts}' || (prettier -w '*.{json,js}' 'packages/*/src/**/*.{js,ts}'; exit 1))",
"build-dashboard": "pnpm run --filter r2-explorer-dashboard build",
"build-worker": "pnpm run --filter r2-explorer build",
"build": "pnpm build-dashboard && pnpm build-worker",
"deploy-dashboard": "pnpm run --filter r2-explorer-dashboard deploy",
"deploy-dashboard-dev": "pnpm run --filter r2-explorer-dashboard deploy-dev",
"deploy-dev-worker": "pnpm run --filter r2-explorer-dev-worker deploy",
"publish-npm": "pnpm run --filter worker publish"
},
"devDependencies": {
"esbuild": "0.17.19",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-typescript": "^3.0.0",
"husky": "^9.0.11",
"prettier": "^3.2.5",
"typescript": "^4.7.4"
}
"name": "r2-explorer-root",
"version": "0.0.0",
"private": true,
"scripts": {
"prepare": "husky",
"workers-ci": "export WORKERS_CI=1 && node packages/github-action/prepareDeploy.js",
"lint": "npx @biomejs/biome check template/ packages/dashboard/src packages/worker/src || (npx @biomejs/biome check --write template/ packages/dashboard/src packages/worker/src/; exit 1)",
"build-dashboard": "pnpm run --filter r2-explorer-dashboard build",
"build-worker": "pnpm run --filter r2-explorer build",
"build": "pnpm build-dashboard && pnpm build-worker",
"deploy-dashboard": "pnpm run --filter r2-explorer-dashboard deploy",
"deploy-dashboard-dev": "pnpm run --filter r2-explorer-dashboard deploy-dev",
"deploy-dev-worker": "pnpm run --filter r2-explorer-dev-worker deploy",
"publish-npm": "pnpm run --filter worker publish"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"husky": "^9.1.7"
}
}
+16 -38
View File
@@ -1,39 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": [
"src/*"
],
"app/*": [
"*"
],
"components/*": [
"src/components/*"
],
"layouts/*": [
"src/layouts/*"
],
"pages/*": [
"src/pages/*"
],
"assets/*": [
"src/assets/*"
],
"boot/*": [
"src/boot/*"
],
"stores/*": [
"src/stores/*"
],
"vue$": [
"node_modules/vue/dist/vue.runtime.esm-bundler.js"
]
}
},
"exclude": [
"dist",
".quasar",
"node_modules"
]
}
"compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": ["src/*"],
"app/*": ["*"],
"components/*": ["src/components/*"],
"layouts/*": ["src/layouts/*"],
"pages/*": ["src/pages/*"],
"assets/*": ["src/assets/*"],
"boot/*": ["src/boot/*"],
"stores/*": ["src/stores/*"],
"vue$": ["node_modules/vue/dist/vue.runtime.esm-bundler.js"]
}
},
"exclude": ["dist", ".quasar", "node_modules"]
}
+36 -36
View File
@@ -1,38 +1,38 @@
{
"name": "r2-explorer-dashboard",
"version": "0.0.1",
"description": "R2-Explorer",
"productName": "r2-explorer",
"author": "Gabriel Massadas",
"private": true,
"scripts": {
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev",
"build": "quasar build",
"deploy": "wrangler pages deploy --branch main --project-name r2-explorer-dashboard dist/spa/",
"deploy-dev": "wrangler pages deploy --branch dev --project-name r2-explorer-dashboard dist/spa/"
},
"dependencies": {
"@quasar/extras": "^1.16.9",
"axios": "^1.2.1",
"fflate": "^0.8.1",
"html-to-text": "^9.0.5",
"pdfvuer": "^2.0.1",
"pinia": "^2.0.11",
"postal-mime": "^2.0.1",
"quasar": "^2.14.7",
"vue": "^3.3.11",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@quasar/app-vite": "^1.8.0",
"autoprefixer": "^10.4.2",
"dotenv": "^16.3.1",
"postcss": "^8.4.14"
},
"engines": {
"node": "^20 || ^18 || ^16 || ^14.19",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}
"name": "r2-explorer-dashboard",
"version": "0.0.1",
"description": "R2-Explorer",
"productName": "r2-explorer",
"author": "Gabriel Massadas",
"private": true,
"scripts": {
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev",
"build": "quasar build",
"deploy": "wrangler pages deploy --branch main --project-name r2-explorer-dashboard dist/spa/",
"deploy-dev": "wrangler pages deploy --branch dev --project-name r2-explorer-dashboard dist/spa/"
},
"dependencies": {
"@quasar/extras": "^1.16.9",
"axios": "^1.2.1",
"fflate": "^0.8.1",
"html-to-text": "^9.0.5",
"pdfvuer": "^2.0.1",
"pinia": "^2.0.11",
"postal-mime": "^2.0.1",
"quasar": "^2.14.7",
"vue": "^3.3.11",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@quasar/app-vite": "^1.8.0",
"autoprefixer": "^10.4.2",
"dotenv": "^16.3.1",
"postcss": "^8.4.14"
},
"engines": {
"node": "^20 || ^18 || ^16 || ^14.19",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}
}
+22 -22
View File
@@ -2,26 +2,26 @@
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
plugins: [
// https://github.com/postcss/autoprefixer
require('autoprefixer')({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
})
plugins: [
// https://github.com/postcss/autoprefixer
require("autoprefixer")({
overrideBrowserslist: [
"last 4 Chrome versions",
"last 4 Firefox versions",
"last 4 Edge versions",
"last 4 Safari versions",
"last 4 Android versions",
"last 4 ChromeAndroid versions",
"last 4 FirefoxAndroid versions",
"last 4 iOS versions",
],
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line:
// require('postcss-rtlcss')
]
}
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line:
// require('postcss-rtlcss')
],
};
+154 -171
View File
@@ -8,210 +8,193 @@
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const { configure } = require("quasar/wrappers");
const { configure } = require('quasar/wrappers');
module.exports = configure((/* ctx */) => ({
// eslint: {
// fix: true,
// include: [],
// exclude: [],
// rawOptions: {},
// warnings: true,
// errors: true
// },
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
module.exports = configure(function (/* ctx */) {
return {
// eslint: {
// fix: true,
// include: [],
// exclude: [],
// rawOptions: {},
// warnings: true,
// errors: true
// },
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: ["axios", "auth", "bus"],
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: ["app.scss"],
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [
'axios',
'auth',
'bus',
],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v5',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: [
'app.scss'
],
"roboto-font", // optional, you are not bound to it
"material-icons", // optional, you are not bound to it
],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v5',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: {
env: require("dotenv").config().parsed,
target: {
browser: ["es2019", "edge88", "firefox78", "chrome87", "safari13.1"],
node: "node16",
},
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
vueRouterMode: "history", // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: {
env: require('dotenv').config().parsed,
target: {
browser: [ 'es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1' ],
node: 'node16'
},
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
vueRouterMode: 'history', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
publicPath: "/",
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
// distDir
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
// distDir
// vitePlugins: [
// [ 'package-name', { ..options.. } ]
// ]
},
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
devServer: {
// https: true
open: true, // opens browser window automatically
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {},
// vitePlugins: [
// [ 'package-name', { ..options.. } ]
// ]
},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
devServer: {
// https: true
open: true // opens browser window automatically
},
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {},
// Quasar plugins
plugins: ["Notify"],
},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// registerServiceWorker: 'src-pwa/register-service-worker',
// serviceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// },
// Quasar plugins
plugins: [
'Notify'
]
},
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// extendSSRWebserverConf (esbuildConf) {},
// extendPackageJson (json) {},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// registerServiceWorker: 'src-pwa/register-service-worker',
// serviceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// },
pwa: false,
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
// extendSSRWebserverConf (esbuildConf) {},
// extendPackageJson (json) {},
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
pwa: false,
middlewares: [
"render", // keep this as last one
],
},
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: "generateSW", // or 'injectManifest'
injectPwaMetaTags: true,
swFilename: "sw.js",
manifestFilename: "manifest.json",
useCredentialsForManifestTag: false,
// useFilenameHashes: true,
// extendGenerateSWOptions (cfg) {}
// extendInjectManifestOptions (cfg) {},
// extendManifestJson (json) {}
// extendPWACustomSWConf (esbuildConf) {}
},
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
middlewares: [
'render' // keep this as last one
]
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true,
},
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'generateSW', // or 'injectManifest'
injectPwaMetaTags: true,
swFilename: 'sw.js',
manifestFilename: 'manifest.json',
useCredentialsForManifestTag: false,
// useFilenameHashes: true,
// extendGenerateSWOptions (cfg) {}
// extendInjectManifestOptions (cfg) {},
// extendManifestJson (json) {}
// extendPWACustomSWConf (esbuildConf) {}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf)
// extendElectronPreloadConf (esbuildConf)
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
// specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858,
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true
},
bundler: "packager", // 'packager' or 'builder'
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf)
// extendElectronPreloadConf (esbuildConf)
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
// specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858,
builder: {
// https://www.electron.build/configuration/configuration
bundler: 'packager', // 'packager' or 'builder'
appId: "r2-explorer-dashboard",
},
},
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
contentScripts: ["my-content-script"],
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration/configuration
appId: 'r2-explorer-dashboard'
}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
contentScripts: [
'my-content-script'
],
// extendBexScriptsConf (esbuildConf) {}
// extendBexManifestJson (json) {}
}
}
});
// extendBexScriptsConf (esbuildConf) {}
// extendBexManifestJson (json) {}
},
}));
+3 -3
View File
@@ -3,9 +3,9 @@
</template>
<script>
import { defineComponent } from 'vue'
import { defineComponent } from "vue";
export default defineComponent({
name: 'App'
})
name: "App",
});
</script>
+240 -219
View File
@@ -1,257 +1,278 @@
import { api } from "boot/axios";
import { useMainStore } from "stores/main-store";
export const ROOT_FOLDER = "IA==" // IA== is a space
export const ROOT_FOLDER = "IA=="; // IA== is a space
function mapFile(obj, prefix) {
const date = new Date(obj.uploaded)
const date = new Date(obj.uploaded);
return {
...obj,
hash: encode(obj.key),
nameHash: encode(obj.key.replace(prefix, '')),
name: obj.key.replace(prefix, ''),
lastModified: timeSince(date),
timestamp: date.getTime(),
size: bytesToSize(obj.size),
sizeRaw: obj.size,
type: 'file',
icon: 'article',
color: 'grey',
}
return {
...obj,
hash: encode(obj.key),
nameHash: encode(obj.key.replace(prefix, "")),
name: obj.key.replace(prefix, ""),
lastModified: timeSince(date),
timestamp: date.getTime(),
size: bytesToSize(obj.size),
sizeRaw: obj.size,
type: "file",
icon: "article",
color: "grey",
};
}
export const timeSince = (date) => {
const seconds = Math.floor((new Date() - date) / 1000);
const seconds = Math.floor((new Date() - date) / 1000);
let interval = seconds / 31536000;
let calc;
let interval = seconds / 31536000;
let calc;
if (interval > 1) {
// calc = Math.floor(interval)
// return calc + (calc === 1 ? ' year' : ' years')
return date.toLocaleDateString();
}
interval = seconds / 2592000;
if (interval > 1) {
// calc = Math.floor(interval)
// return calc + (calc === 1 ? ' month' : ' months')
return date.toLocaleDateString();
}
interval = seconds / 86400;
if (interval > 1) {
// calc = Math.floor(interval)
// return calc + (calc === 1 ? ' day' : ' days')
return date.toLocaleDateString();
}
interval = seconds / 3600;
if (interval > 1) {
calc = Math.floor(interval);
return calc + (calc === 1 ? " hour" : " hours");
}
interval = seconds / 60;
if (interval > 1) {
calc = Math.floor(interval);
return calc + (calc === 1 ? " minute" : " minutes");
}
if (interval > 1) {
// calc = Math.floor(interval)
// return calc + (calc === 1 ? ' year' : ' years')
return date.toLocaleDateString();
}
interval = seconds / 2592000;
if (interval > 1) {
// calc = Math.floor(interval)
// return calc + (calc === 1 ? ' month' : ' months')
return date.toLocaleDateString();
}
interval = seconds / 86400;
if (interval > 1) {
// calc = Math.floor(interval)
// return calc + (calc === 1 ? ' day' : ' days')
return date.toLocaleDateString();
}
interval = seconds / 3600;
if (interval > 1) {
calc = Math.floor(interval);
return calc + (calc === 1 ? " hour" : " hours");
}
interval = seconds / 60;
if (interval > 1) {
calc = Math.floor(interval);
return calc + (calc === 1 ? " minute" : " minutes");
}
calc = Math.floor(interval);
return calc + (calc === 1 ? " second" : " seconds");
calc = Math.floor(interval);
return calc + (calc === 1 ? " second" : " seconds");
};
export const bytesToSize = (bytes) => {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte";
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
return Math.round(bytes / Math.pow(1024, i), 2) + " " + sizes[i];
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte";
const i = Number.parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
return `${Math.round(bytes / 1024 ** i, 2)} ${sizes[i]}`;
};
export const bytesToMegabytes = (bytes) => {
return Math.round(bytes / Math.pow(1024, 2));
return Math.round(bytes / 1024 ** 2);
};
export const encode = (key) => {
if (key && key !== '/' && key.startsWith('/')) {
// File keys should never start with /
key = key.slice(1)
}
return btoa(unescape(encodeURIComponent(key)));
if (key && key !== "/" && key.startsWith("/")) {
// File keys should never start with /
key = key.slice(1);
}
return btoa(unescape(encodeURIComponent(key)));
};
export const decode = (key) => {
return decodeURIComponent(escape(atob(key)));
return decodeURIComponent(escape(atob(key)));
};
export const apiHandler = {
createFolder: (key, bucket) => {
return api.post(`/buckets/${bucket}/folder`, {
key: encode(key)
})
},
deleteObject: (key, bucket) => {
return api.post(`/buckets/${bucket}/delete`, {
key: encode(key)
})
},
downloadFile: (bucket, key, previewConfig, onDownloadProgress, abortControl) => {
const extra = {};
if (previewConfig.downloadType === "objectUrl" || previewConfig.downloadType === "blob") {
extra.responseType = "arraybuffer";
}
if (abortControl) {
extra.signal = abortControl.signal;
}
if (onDownloadProgress) {
extra.onDownloadProgress = onDownloadProgress;
}
createFolder: (key, bucket) => {
return api.post(`/buckets/${bucket}/folder`, {
key: encode(key),
});
},
deleteObject: (key, bucket) => {
return api.post(`/buckets/${bucket}/delete`, {
key: encode(key),
});
},
downloadFile: (
bucket,
key,
previewConfig,
onDownloadProgress,
abortControl,
) => {
const extra = {};
if (
previewConfig.downloadType === "objectUrl" ||
previewConfig.downloadType === "blob"
) {
extra.responseType = "arraybuffer";
}
if (abortControl) {
extra.signal = abortControl.signal;
}
if (onDownloadProgress) {
extra.onDownloadProgress = onDownloadProgress;
}
return api.get(
`/buckets/${bucket}/${encode(key)}`,
extra
);
},
headFile: async (bucket, key) => {
let prefix = ''
if (key.includes('/')) {
prefix = key.replace(key.split('/').pop(), '')
}
return api.get(`/buckets/${bucket}/${encode(key)}`, extra);
},
headFile: async (bucket, key) => {
let prefix = "";
if (key.includes("/")) {
prefix = key.replace(key.split("/").pop(), "");
}
const resp = await api.get(`/buckets/${bucket}/${encode(key)}/head`);
const resp = await api.get(`/buckets/${bucket}/${encode(key)}/head`);
if (resp.status === 200) {
return mapFile(resp.data, prefix)
}
},
renameObject: (bucket, oldKey, newKey) => {
return api.post(`/buckets/${bucket}/move`, {
oldKey: encode(oldKey),
newKey: encode(newKey)
})
},
updateMetadata: async (bucket, key, metadata) => {
let prefix = ''
if (key.includes('/')) {
prefix = key.replace(key.split('/').pop(), '')
}
if (resp.status === 200) {
return mapFile(resp.data, prefix);
}
},
renameObject: (bucket, oldKey, newKey) => {
return api.post(`/buckets/${bucket}/move`, {
oldKey: encode(oldKey),
newKey: encode(newKey),
});
},
updateMetadata: async (bucket, key, metadata) => {
let prefix = "";
if (key.includes("/")) {
prefix = key.replace(key.split("/").pop(), "");
}
const resp = await api.post(
`/buckets/${bucket}/${encode(key)}`,
{
customMetadata: metadata
}
)
const resp = await api.post(`/buckets/${bucket}/${encode(key)}`, {
customMetadata: metadata,
});
if (resp.status === 200) {
return mapFile(resp.data, prefix)
}
},
multipartCreate: (file, key, bucket) => {
return api.post(`/buckets/${bucket}/multipart/create`, null, {
params: {
key: encode(key),
httpMetadata: encode(JSON.stringify({
contentType: file.type
}))
}
})
},
multipartComplete: (file, key, bucket, parts, uploadId) => {
return api.post(`/buckets/${bucket}/multipart/complete`, {
key: encode(key),
uploadId,
parts
})
},
multipartUpload: (uploadId, partNumber, bucket, key, chunk, callback) => {
return api.post(`/buckets/${bucket}/multipart/upload`, chunk, {
params: {
key: encode(key),
uploadId,
partNumber
},
onUploadProgress: callback,
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
uploadObjects: (file, key, bucket, callback) => {
// console.log(key)
return api.post(`/buckets/${bucket}/upload`, file, {
params: {
key: encode(key),
httpMetadata: encode(JSON.stringify({
contentType: file.type
}))
},
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: callback
})
},
listObjects: async (bucket, prefix, delimiter = '/', cursor = null) => {
return await api.get(`/buckets/${bucket}?include=customMetadata&include=httpMetadata`, {
params: {
delimiter: delimiter,
prefix: prefix && prefix !== '/' ? encode(prefix) : '',
cursor: cursor
}
})
},
fetchFile: async (bucket, prefix, delimiter = '/') => {
const mainStore = useMainStore();
let truncated = true
let cursor = null
let contentFiles = []
let contentFolders = []
if (resp.status === 200) {
return mapFile(resp.data, prefix);
}
},
multipartCreate: (file, key, bucket) => {
return api.post(`/buckets/${bucket}/multipart/create`, null, {
params: {
key: encode(key),
httpMetadata: encode(
JSON.stringify({
contentType: file.type,
}),
),
},
});
},
multipartComplete: (file, key, bucket, parts, uploadId) => {
return api.post(`/buckets/${bucket}/multipart/complete`, {
key: encode(key),
uploadId,
parts,
});
},
multipartUpload: (uploadId, partNumber, bucket, key, chunk, callback) => {
return api.post(`/buckets/${bucket}/multipart/upload`, chunk, {
params: {
key: encode(key),
uploadId,
partNumber,
},
onUploadProgress: callback,
headers: {
"Content-Type": "multipart/form-data",
},
});
},
uploadObjects: (file, key, bucket, callback) => {
// console.log(key)
return api.post(`/buckets/${bucket}/upload`, file, {
params: {
key: encode(key),
httpMetadata: encode(
JSON.stringify({
contentType: file.type,
}),
),
},
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: callback,
});
},
listObjects: async (bucket, prefix, delimiter = "/", cursor = null) => {
return await api.get(
`/buckets/${bucket}?include=customMetadata&include=httpMetadata`,
{
params: {
delimiter: delimiter,
prefix: prefix && prefix !== "/" ? encode(prefix) : "",
cursor: cursor,
},
},
);
},
fetchFile: async (bucket, prefix, delimiter = "/") => {
const mainStore = useMainStore();
let truncated = true;
let cursor = null;
const contentFiles = [];
const contentFolders = [];
while (truncated) {
const response = await apiHandler.listObjects(bucket, prefix, delimiter, cursor)
while (truncated) {
const response = await apiHandler.listObjects(
bucket,
prefix,
delimiter,
cursor,
);
truncated = response.data.truncated
cursor = response.data.cursor
truncated = response.data.truncated;
cursor = response.data.cursor;
if (response.data.objects) {
const files = response.data.objects.filter(function(obj) {
return !(obj.key.endsWith('/') && delimiter !== '') && obj.key !== prefix // Remove selected folder when delimiter is defined
}).map((obj) => mapFile(obj, prefix)).filter(obj => {
// Remove hidden files
return !(mainStore.showHiddenFiles !== true && obj.name.startsWith('.'))
})
if (response.data.objects) {
const files = response.data.objects
.filter((obj) => {
return (
!(obj.key.endsWith("/") && delimiter !== "") && obj.key !== prefix
); // Remove selected folder when delimiter is defined
})
.map((obj) => mapFile(obj, prefix))
.filter((obj) => {
// Remove hidden files
return !(
mainStore.showHiddenFiles !== true && obj.name.startsWith(".")
);
});
for (const f of files) {
contentFiles.push(f)
}
}
for (const f of files) {
contentFiles.push(f);
}
}
if (response.data.delimitedPrefixes) {
const folders = response.data.delimitedPrefixes.map(function (obj) {
return {
name: obj.replace(prefix, ''),
hash: encode(obj.key),
key: obj,
lastModified: '--',
timestamp: 0,
size: '--',
sizeRaw: 0,
type: 'folder',
icon: 'folder',
color: 'orange',
}
}).filter(obj => {
// Remove hidden files
return !(mainStore.showHiddenFiles !== true && obj.name.startsWith('.'))
})
if (response.data.delimitedPrefixes) {
const folders = response.data.delimitedPrefixes
.map((obj) => ({
name: obj.replace(prefix, ""),
hash: encode(obj.key),
key: obj,
lastModified: "--",
timestamp: 0,
size: "--",
sizeRaw: 0,
type: "folder",
icon: "folder",
color: "orange",
}))
.filter((obj) => {
// Remove hidden files
return !(
mainStore.showHiddenFiles !== true && obj.name.startsWith(".")
);
});
for (const f of folders) {
contentFolders.push(f)
}
}
}
for (const f of folders) {
contentFolders.push(f);
}
}
}
return [
...contentFolders,
...contentFiles
]
}
}
return [...contentFolders, ...contentFiles];
},
};
+11 -10
View File
@@ -1,14 +1,15 @@
import { boot } from 'quasar/wrappers';
import { useAuthStore } from 'stores/auth-store';
import { boot } from "quasar/wrappers";
import { useAuthStore } from "stores/auth-store";
import { useMainStore } from "stores/main-store";
export default boot(async ({router, store}) => {
// Check if theres any auth token stored, if there is, try to fetch or redirect
const authStore = useAuthStore(store);
const authResp = await authStore.CheckLoginInStorage(router);
export default boot(async ({ router, store }) => {
// Check if theres any auth token stored, if there is, try to fetch or redirect
const authStore = useAuthStore(store);
const authResp = await authStore.CheckLoginInStorage(router);
if (authResp === false) { // No auth token stored, try to fetch without auth or redirect
const mainStore = useMainStore(store)
await mainStore.loadServerConfigs(router, true)
}
if (authResp === false) {
// No auth token stored, try to fetch without auth or redirect
const mainStore = useMainStore(store);
await mainStore.loadServerConfigs(router, true);
}
});
+15 -15
View File
@@ -1,5 +1,5 @@
import { boot } from 'quasar/wrappers'
import axios from 'axios'
import axios from "axios";
import { boot } from "quasar/wrappers";
// Be careful when using SSR for cross-request state pollution
// due to creating a Singleton instance here;
@@ -7,22 +7,22 @@ import axios from 'axios'
// good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually
// for each client)
let url = window.location.origin
if (process.env.NODE_ENV === 'development') {
url = process.env.VUE_APP_SERVER_URL || 'http://localhost:8787'
let url = window.location.origin;
if (process.env.NODE_ENV === "development") {
url = process.env.VUE_APP_SERVER_URL || "http://localhost:8787";
}
const api = axios.create({ baseURL: `${url}/api` })
const api = axios.create({ baseURL: `${url}/api` });
export default boot(({ app }) => {
// for use inside Vue files (Options API) through this.$axios and this.$api
// for use inside Vue files (Options API) through this.$axios and this.$api
app.config.globalProperties.$axios = axios
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
// so you won't necessarily have to import axios in each vue file
app.config.globalProperties.$axios = axios;
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
// so you won't necessarily have to import axios in each vue file
app.config.globalProperties.$api = api
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
// so you can easily perform requests against your app's API
})
app.config.globalProperties.$api = api;
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
// so you can easily perform requests against your app's API
});
export { api }
export { api };
+8 -8
View File
@@ -1,12 +1,12 @@
import { EventBus } from 'quasar'
import { boot } from 'quasar/wrappers'
import { EventBus } from "quasar";
import { boot } from "quasar/wrappers";
export default boot(({ app }) => {
const bus = new EventBus()
const bus = new EventBus();
// for Options API
app.config.globalProperties.$bus = bus
// for Options API
app.config.globalProperties.$bus = bus;
// for Composition API
app.provide('bus', bus)
})
// for Composition API
app.provide("bus", bus);
});
@@ -24,68 +24,73 @@
</template>
<script>
import { defineComponent } from "vue";
import { ROOT_FOLDER, apiHandler, decode, encode } from "src/appUtils";
import { useMainStore } from "stores/main-store";
import { apiHandler, decode, encode, ROOT_FOLDER } from "src/appUtils";
import { defineComponent } from "vue";
export default defineComponent({
name: "CreateFile",
data: function() {
return {
modal: false,
newFileName: "",
loading: false
};
},
methods: {
cancel: function() {
this.modal = false;
this.newFileName = "";
this.loading = false
},
onSubmit: async function() {
this.loading = true
name: "CreateFile",
data: () => ({
modal: false,
newFileName: "",
loading: false,
}),
methods: {
cancel: function () {
this.modal = false;
this.newFileName = "";
this.loading = false;
},
onSubmit: async function () {
this.loading = true;
const newFile = new Blob([''], {type: 'text/plain'});
await apiHandler.uploadObjects(newFile, this.selectedFolder + this.newFileName, this.selectedBucket);
const newFile = new Blob([""], { type: "text/plain" });
await apiHandler.uploadObjects(
newFile,
this.selectedFolder + this.newFileName,
this.selectedBucket,
);
this.$bus.emit("fetchFiles");
this.$bus.emit("fetchFiles");
// TODO: open the new file in preview and edit mode
// await this.$router.push({
// name: "files-file",
// params: {
// bucket: this.$route.params.bucket,
// folder: this.$route.params.folder || ROOT_FOLDER,
// file: encode(this.newFileName)
// }
// });
// TODO: open the new file in preview and edit mode
// await this.$router.push({
// name: "files-file",
// params: {
// bucket: this.$route.params.bucket,
// folder: this.$route.params.folder || ROOT_FOLDER,
// file: encode(this.newFileName)
// }
// });
this.loading = false
this.modal = false;
this.newFileName = "";
},
open: function() {
this.modal = true;
}
},
computed: {
selectedBucket: function() {
return this.$route.params.bucket;
},
selectedFolder: function() {
if (this.$route.params.folder && this.$route.params.folder !== ROOT_FOLDER) {
return decode(this.$route.params.folder);
}
return "";
}
},
setup() {
const mainStore = useMainStore();
this.loading = false;
this.modal = false;
this.newFileName = "";
},
open: function () {
this.modal = true;
},
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedFolder: function () {
if (
this.$route.params.folder &&
this.$route.params.folder !== ROOT_FOLDER
) {
return decode(this.$route.params.folder);
}
return "";
},
},
setup() {
const mainStore = useMainStore();
return {
mainStore
};
}
return {
mainStore,
};
},
});
</script>
@@ -24,54 +24,58 @@
</template>
<script>
import { defineComponent } from "vue";
import { ROOT_FOLDER, apiHandler, decode } from "src/appUtils";
import { useMainStore } from "stores/main-store";
import { apiHandler, decode, ROOT_FOLDER } from "src/appUtils";
import { defineComponent } from "vue";
export default defineComponent({
name: "CreateFolder",
data: function() {
return {
modal: false,
newFolderName: "",
loading: false
};
},
methods: {
cancel: function() {
this.modal = false;
this.newFolderName = "";
this.loading = false
},
onSubmit: async function() {
this.loading = true
await apiHandler.createFolder(this.selectedFolder + this.newFolderName + "/", this.selectedBucket);
this.$bus.emit("fetchFiles");
this.loading = false
this.modal = false;
this.newFolderName = "";
},
open: function() {
this.modal = true;
}
},
computed: {
selectedBucket: function() {
return this.$route.params.bucket;
},
selectedFolder: function() {
if (this.$route.params.folder && this.$route.params.folder !== ROOT_FOLDER) {
return decode(this.$route.params.folder);
}
return "";
}
},
setup() {
const mainStore = useMainStore();
name: "CreateFolder",
data: () => ({
modal: false,
newFolderName: "",
loading: false,
}),
methods: {
cancel: function () {
this.modal = false;
this.newFolderName = "";
this.loading = false;
},
onSubmit: async function () {
this.loading = true;
await apiHandler.createFolder(
`${this.selectedFolder + this.newFolderName}/`,
this.selectedBucket,
);
this.$bus.emit("fetchFiles");
this.loading = false;
this.modal = false;
this.newFolderName = "";
},
open: function () {
this.modal = true;
},
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedFolder: function () {
if (
this.$route.params.folder &&
this.$route.params.folder !== ROOT_FOLDER
) {
return decode(this.$route.params.folder);
}
return "";
},
},
setup() {
const mainStore = useMainStore();
return {
mainStore
};
}
return {
mainStore,
};
},
});
</script>
@@ -33,144 +33,156 @@
</template>
<script>
import { defineComponent } from "vue";
import { useMainStore } from "stores/main-store";
import { apiHandler, decode, encode, ROOT_FOLDER } from "src/appUtils";
import { useQuasar } from "quasar";
import { ROOT_FOLDER, apiHandler, decode, encode } from "src/appUtils";
import { useMainStore } from "stores/main-store";
import { defineComponent } from "vue";
export default defineComponent({
name: 'FileOptions',
data: function () {
return {
row: null,
deleteFolderContents: [],
deleteModal: false,
renameModal: false,
deleteFolderInnerFilesCount: null,
newFolderName: '',
renameInput: '',
loading: false,
}
},
methods: {
deleteObject: async function(row) {
this.deleteModal = true
this.row = row
if (row.type === 'folder') {
this.deleteFolderContents = await apiHandler.fetchFile(this.selectedBucket, row.key, '')
this.deleteFolderInnerFilesCount = this.deleteFolderContents.length
}
},
renameObject: async function(row) {
this.renameModal = true
this.row = row
// console.log(row)
this.renameInput = row.name
},
renameConfirm: async function() {
if (this.renameInput.length === 0) {
return
}
name: "FileOptions",
data: () => ({
row: null,
deleteFolderContents: [],
deleteModal: false,
renameModal: false,
deleteFolderInnerFilesCount: null,
newFolderName: "",
renameInput: "",
loading: false,
}),
methods: {
deleteObject: async function (row) {
this.deleteModal = true;
this.row = row;
if (row.type === "folder") {
this.deleteFolderContents = await apiHandler.fetchFile(
this.selectedBucket,
row.key,
"",
);
this.deleteFolderInnerFilesCount = this.deleteFolderContents.length;
}
},
renameObject: async function (row) {
this.renameModal = true;
this.row = row;
// console.log(row)
this.renameInput = row.name;
},
renameConfirm: async function () {
if (this.renameInput.length === 0) {
return;
}
this.loading = true
await apiHandler.renameObject(this.selectedBucket, this.row.key, this.row.key.replace(this.row.name, this.renameInput))
this.loading = true;
await apiHandler.renameObject(
this.selectedBucket,
this.row.key,
this.row.key.replace(this.row.name, this.renameInput),
);
this.$bus.emit('fetchFiles')
this.reset()
this.q.notify({
group: false,
icon: 'done', // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
message: 'File renamed!',
timeout: 2500 // we will timeout it in 2.5s
})
},
deleteConfirm: async function() {
if (this.row.type === 'folder') {
// When deleting folders, first must copy the objects, because the popup close forces a reset on properties
const originalFolder = { ...this.row }
const folderContents = [...this.deleteFolderContents]
const folderContentsCount = this.deleteFolderInnerFilesCount
this.$bus.emit("fetchFiles");
this.reset();
this.q.notify({
group: false,
icon: "done", // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
message: "File renamed!",
timeout: 2500, // we will timeout it in 2.5s
});
},
deleteConfirm: async function () {
if (this.row.type === "folder") {
// When deleting folders, first must copy the objects, because the popup close forces a reset on properties
const originalFolder = { ...this.row };
const folderContents = [...this.deleteFolderContents];
const folderContentsCount = this.deleteFolderInnerFilesCount;
this.deleteModal = false
this.deleteModal = false;
const notif = this.q.notify({
group: false,
spinner: true,
message: 'Deleting files...',
caption: '0%',
timeout: 0
})
const notif = this.q.notify({
group: false,
spinner: true,
message: "Deleting files...",
caption: "0%",
timeout: 0,
});
for (const [i, innerFile] of folderContents.entries()) {
if (innerFile.key) {
await apiHandler.deleteObject(innerFile.key, this.selectedBucket)
}
notif({
caption: `${parseInt(i*100/(folderContentsCount+1))}%` // +1 because still needs to delete the folder
})
}
for (const [i, innerFile] of folderContents.entries()) {
if (innerFile.key) {
await apiHandler.deleteObject(innerFile.key, this.selectedBucket);
}
notif({
caption: `${Number.parseInt((i * 100) / (folderContentsCount + 1))}%`, // +1 because still needs to delete the folder
});
}
await apiHandler.deleteObject(originalFolder.key, this.selectedBucket)
await apiHandler.deleteObject(originalFolder.key, this.selectedBucket);
notif({
icon: 'done', // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
caption: '100%',
message: 'Folder deleted!',
timeout: 2500 // we will timeout it in 2.5s
})
} else {
this.deleteModal = false
await apiHandler.deleteObject(this.row.key, this.selectedBucket)
this.q.notify({
group: false,
icon: 'done', // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
message: 'File deleted!',
timeout: 2500 // we will timeout it in 2.5s
})
}
notif({
icon: "done", // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
caption: "100%",
message: "Folder deleted!",
timeout: 2500, // we will timeout it in 2.5s
});
} else {
this.deleteModal = false;
await apiHandler.deleteObject(this.row.key, this.selectedBucket);
this.q.notify({
group: false,
icon: "done", // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
message: "File deleted!",
timeout: 2500, // we will timeout it in 2.5s
});
}
this.$bus.emit('fetchFiles')
this.reset()
},
reset: function() {
this.loading = false
this.deleteModal = false
this.renameModal = false
this.renameInput = ''
this.row = null
this.deleteFolderInnerFilesCount = null
this.deleteFolderContents = []
},
onSubmit: async function() {
await apiHandler.createFolder(this.selectedFolder + this.newFolderName + '/', this.selectedBucket)
this.$bus.emit('fetchFiles')
this.modal = false
},
open: function() {
this.modal = true
}
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket
},
selectedFolder: function () {
if (this.$route.params.folder && this.$route.params.folder !== ROOT_FOLDER) {
return decode(this.$route.params.folder)
}
return ''
},
},
setup() {
return {
mainStore: useMainStore(),
q: useQuasar()
};
},
})
this.$bus.emit("fetchFiles");
this.reset();
},
reset: function () {
this.loading = false;
this.deleteModal = false;
this.renameModal = false;
this.renameInput = "";
this.row = null;
this.deleteFolderInnerFilesCount = null;
this.deleteFolderContents = [];
},
onSubmit: async function () {
await apiHandler.createFolder(
`${this.selectedFolder + this.newFolderName}/`,
this.selectedBucket,
);
this.$bus.emit("fetchFiles");
this.modal = false;
},
open: function () {
this.modal = true;
},
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedFolder: function () {
if (
this.$route.params.folder &&
this.$route.params.folder !== ROOT_FOLDER
) {
return decode(this.$route.params.folder);
}
return "";
},
},
setup() {
return {
mainStore: useMainStore(),
q: useQuasar(),
};
},
});
</script>
<style scoped>
@@ -19,30 +19,33 @@
</template>
<script>
import { defineComponent } from "vue";
import { useMainStore } from "stores/main-store";
import { defineComponent } from "vue";
export default defineComponent({
name: "BucketPicker",
methods: {
changeBucket: function(bucket) {
this.$router.push({ name: `${this.selectedApp}-home`, params: { bucket: bucket } });
}
},
computed: {
selectedBucket: function() {
return this.$route.params.bucket;
},
selectedApp: function() {
return this.$route.name.split("-")[0];
}
},
setup() {
const mainStore = useMainStore();
name: "BucketPicker",
methods: {
changeBucket: function (bucket) {
this.$router.push({
name: `${this.selectedApp}-home`,
params: { bucket: bucket },
});
},
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedApp: function () {
return this.$route.name.split("-")[0];
},
},
setup() {
const mainStore = useMainStore();
return {
mainStore
};
}
return {
mainStore,
};
},
});
</script>
@@ -114,56 +114,55 @@
</template>
<script>
import { defineComponent } from "vue";
import { useMainStore } from "stores/main-store";
import CreateFolder from "components/files/CreateFolder.vue";
import CreateFile from "components/files/CreateFile.vue";
import CreateFolder from "components/files/CreateFolder.vue";
import { useMainStore } from "stores/main-store";
import { defineComponent } from "vue";
export default defineComponent({
name: "LeftSidebar",
data: function() {
return {
upgradePopup: false,
settingsPopup: false
};
},
components: { CreateFolder, CreateFile },
methods: {
gotoEmail: function() {
if (this.selectedApp !== "email")
this.changeApp("email");
},
gotoFiles: function() {
if (this.selectedApp !== "files")
this.changeApp("files");
},
changeApp: function(app) {
this.$router.push({ name: `${app}-home`, params: { bucket: this.selectedBucket } });
}
},
computed: {
selectedBucket: function() {
return this.$route.params.bucket;
},
selectedApp: function() {
return this.$route.name.split("-")[0];
}
},
mounted: function() {
const alertSeen = localStorage.getItem("DASH_V2_ALERT");
name: "LeftSidebar",
data: () => ({
upgradePopup: false,
settingsPopup: false,
}),
components: { CreateFolder, CreateFile },
methods: {
gotoEmail: function () {
if (this.selectedApp !== "email") this.changeApp("email");
},
gotoFiles: function () {
if (this.selectedApp !== "files") this.changeApp("files");
},
changeApp: function (app) {
this.$router.push({
name: `${app}-home`,
params: { bucket: this.selectedBucket },
});
},
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedApp: function () {
return this.$route.name.split("-")[0];
},
},
mounted: function () {
const alertSeen = localStorage.getItem("DASH_V2_ALERT");
if (!alertSeen) {
this.upgradePopup = true;
localStorage.setItem("DASH_V2_ALERT", true);
}
},
setup() {
const mainStore = useMainStore();
if (!alertSeen) {
this.upgradePopup = true;
localStorage.setItem("DASH_V2_ALERT", true);
}
},
setup() {
const mainStore = useMainStore();
return {
mainStore
};
}
return {
mainStore,
};
},
});
</script>
@@ -19,161 +19,177 @@
</template>
<script>
import { defineComponent } from "vue";
import { useMainStore } from "stores/main-store";
import { defineComponent } from "vue";
export default defineComponent({
name: 'RightSidebar',
data: function () {
return {
columns: [
{
name: 'name',
required: true,
label: 'Dessert (100g serving)',
align: 'left',
field: row => row.name,
format: val => `${val}`,
sortable: true
},
{ name: 'calories', align: 'center', label: 'Calories', field: 'calories', sortable: true },
{ name: 'fat', label: 'Fat (g)', field: 'fat', sortable: true },
{ name: 'carbs', label: 'Carbs (g)', field: 'carbs' },
{ name: 'protein', label: 'Protein (g)', field: 'protein' },
{ name: 'sodium', label: 'Sodium (mg)', field: 'sodium' },
{ name: 'calcium', label: 'Calcium (%)', field: 'calcium', sortable: true, sort: (a, b) => parseInt(a, 10) - parseInt(b, 10) },
{ name: 'iron', label: 'Iron (%)', field: 'iron', sortable: true, sort: (a, b) => parseInt(a, 10) - parseInt(b, 10) }
],
rows: [
{
name: 'Frozen Yogurt',
calories: 159,
fat: 6.0,
carbs: 24,
protein: 4.0,
sodium: 87,
calcium: '14%',
iron: '1%'
},
{
name: 'Ice cream sandwich',
calories: 237,
fat: 9.0,
carbs: 37,
protein: 4.3,
sodium: 129,
calcium: '8%',
iron: '1%'
},
{
name: 'Eclair',
calories: 262,
fat: 16.0,
carbs: 23,
protein: 6.0,
sodium: 337,
calcium: '6%',
iron: '7%'
},
{
name: 'Cupcake',
calories: 305,
fat: 3.7,
carbs: 67,
protein: 4.3,
sodium: 413,
calcium: '3%',
iron: '8%'
},
{
name: 'Gingerbread',
calories: 356,
fat: 16.0,
carbs: 49,
protein: 3.9,
sodium: 327,
calcium: '7%',
iron: '16%'
},
{
name: 'Jelly bean',
calories: 375,
fat: 0.0,
carbs: 94,
protein: 0.0,
sodium: 50,
calcium: '0%',
iron: '0%'
},
{
name: 'Lollipop',
calories: 392,
fat: 0.2,
carbs: 98,
protein: 0,
sodium: 38,
calcium: '0%',
iron: '2%'
},
{
name: 'Honeycomb',
calories: 408,
fat: 3.2,
carbs: 87,
protein: 6.5,
sodium: 562,
calcium: '0%',
iron: '45%'
},
{
name: 'Donut',
calories: 452,
fat: 25.0,
carbs: 51,
protein: 4.9,
sodium: 326,
calcium: '2%',
iron: '22%'
},
{
name: 'KitKat',
calories: 518,
fat: 26.0,
carbs: 65,
protein: 7,
sodium: 54,
calcium: '12%',
iron: '6%'
}
]
}
},
methods: {
openFileDetails: function(row) {
// this.$emit('updateDrawer', true) // TODO: enable
// console.log(row)
},
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket
},
selectedApp: function () {
return this.$route.name.split('-')[0]
}
},
mounted() {
this.$bus.on('openFileDetails', this.openFileDetails)
},
beforeUnmount() {
this.$bus.off('openFileDetails')
},
setup() {
const mainStore = useMainStore();
name: "RightSidebar",
data: () => ({
columns: [
{
name: "name",
required: true,
label: "Dessert (100g serving)",
align: "left",
field: (row) => row.name,
format: (val) => `${val}`,
sortable: true,
},
{
name: "calories",
align: "center",
label: "Calories",
field: "calories",
sortable: true,
},
{ name: "fat", label: "Fat (g)", field: "fat", sortable: true },
{ name: "carbs", label: "Carbs (g)", field: "carbs" },
{ name: "protein", label: "Protein (g)", field: "protein" },
{ name: "sodium", label: "Sodium (mg)", field: "sodium" },
{
name: "calcium",
label: "Calcium (%)",
field: "calcium",
sortable: true,
sort: (a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10),
},
{
name: "iron",
label: "Iron (%)",
field: "iron",
sortable: true,
sort: (a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10),
},
],
rows: [
{
name: "Frozen Yogurt",
calories: 159,
fat: 6.0,
carbs: 24,
protein: 4.0,
sodium: 87,
calcium: "14%",
iron: "1%",
},
{
name: "Ice cream sandwich",
calories: 237,
fat: 9.0,
carbs: 37,
protein: 4.3,
sodium: 129,
calcium: "8%",
iron: "1%",
},
{
name: "Eclair",
calories: 262,
fat: 16.0,
carbs: 23,
protein: 6.0,
sodium: 337,
calcium: "6%",
iron: "7%",
},
{
name: "Cupcake",
calories: 305,
fat: 3.7,
carbs: 67,
protein: 4.3,
sodium: 413,
calcium: "3%",
iron: "8%",
},
{
name: "Gingerbread",
calories: 356,
fat: 16.0,
carbs: 49,
protein: 3.9,
sodium: 327,
calcium: "7%",
iron: "16%",
},
{
name: "Jelly bean",
calories: 375,
fat: 0.0,
carbs: 94,
protein: 0.0,
sodium: 50,
calcium: "0%",
iron: "0%",
},
{
name: "Lollipop",
calories: 392,
fat: 0.2,
carbs: 98,
protein: 0,
sodium: 38,
calcium: "0%",
iron: "2%",
},
{
name: "Honeycomb",
calories: 408,
fat: 3.2,
carbs: 87,
protein: 6.5,
sodium: 562,
calcium: "0%",
iron: "45%",
},
{
name: "Donut",
calories: 452,
fat: 25.0,
carbs: 51,
protein: 4.9,
sodium: 326,
calcium: "2%",
iron: "22%",
},
{
name: "KitKat",
calories: 518,
fat: 26.0,
carbs: 65,
protein: 7,
sodium: 54,
calcium: "12%",
iron: "6%",
},
],
}),
methods: {
openFileDetails: (row) => {
// this.$emit('updateDrawer', true) // TODO: enable
// console.log(row)
},
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedApp: function () {
return this.$route.name.split("-")[0];
},
},
mounted() {
this.$bus.on("openFileDetails", this.openFileDetails);
},
beforeUnmount() {
this.$bus.off("openFileDetails");
},
setup() {
const mainStore = useMainStore();
return {
mainStore,
};
},
})
return {
mainStore,
};
},
});
</script>
@@ -14,12 +14,12 @@
</template>
<script>
import { defineComponent } from "vue";
import BucketPicker from "components/main/BucketPicker.vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "TopBar",
emits: ['toggle'],
components: { BucketPicker }
name: "TopBar",
emits: ["toggle"],
components: { BucketPicker },
});
</script>
@@ -56,33 +56,31 @@
<script>
import PostalMime from "postal-mime";
import { convert } from "html-to-text";
import PostalMime from "postal-mime";
export default {
props: ['filedata'],
data: function () {
return {
emailData: null
}
},
methods: {
async parseEmail() {
try {
const parser = new PostalMime.default();
const parsedEmail = await parser.parse(this.filedata);
props: ["filedata"],
data: () => ({
emailData: null,
}),
methods: {
async parseEmail() {
try {
const parser = new PostalMime.default();
const parsedEmail = await parser.parse(this.filedata);
this.emailData = parsedEmail;
if (parsedEmail.html) {
this.emailData.htmlAsText = convert(parsedEmail.html, {});
}
} catch (error) {
console.error('Error parsing email data:', error);
}
}
},
async mounted() {
await this.parseEmail();
}
}
this.emailData = parsedEmail;
if (parsedEmail.html) {
this.emailData.htmlAsText = convert(parsedEmail.html, {});
}
} catch (error) {
console.error("Error parsing email data:", error);
}
},
},
async mounted() {
await this.parseEmail();
},
};
</script>
@@ -129,310 +129,321 @@
</template>
<script>
import EmailViewer from "components/preview/EmailViewer.vue";
import PdfViewer from "components/preview/PdfViewer.vue";
import LogGz from "components/preview/logGz.vue";
import EmailViewer from "components/preview/EmailViewer.vue";
import { parseMarkdown } from "src/parsers/markdown";
import { apiHandler, bytesToMegabytes, decode, ROOT_FOLDER } from "src/appUtils";
import { useQuasar } from "quasar";
import {
ROOT_FOLDER,
apiHandler,
bytesToMegabytes,
decode,
} from "src/appUtils";
import { parseMarkdown } from "src/parsers/markdown";
export default {
components: {
LogGz,
PdfViewer,
EmailViewer
},
data: function() {
return {
open: false,
editMode: false,
components: {
LogGz,
PdfViewer,
EmailViewer,
},
data: () => ({
open: false,
editMode: false,
downloadProgress: 0,
abortControl: undefined,
type: undefined,
file: undefined,
filename: undefined,
fileData: undefined,
fileDataEdited: undefined,
downloadProgress: 0,
abortControl: undefined,
type: undefined,
file: undefined,
filename: undefined,
fileData: undefined,
fileDataEdited: undefined,
previewConfig: [
{
extensions: ["png", "jpg", "jpeg", "webp", "avif"],
type: "image",
downloadType: "objectUrl"
},
{
extensions: ["mp3"],
type: "audio",
downloadType: "objectUrl"
},
{
extensions: ["mp4", "ogg"],
type: "video",
downloadType: "objectUrl"
},
{
extensions: ["pdf"],
type: "pdf",
downloadType: "objectUrl"
},
{
extensions: ["txt"],
type: "text",
downloadType: "text"
},
{
extensions: ["md"],
type: "markdown",
downloadType: "text"
},
{
extensions: ["csv"],
type: "csv",
downloadType: "text"
},
{
extensions: ["json"],
type: "json",
downloadType: "text"
},
{
extensions: ["html"],
type: "html",
downloadType: "text"
},
{
extensions: ["log.gz"],
type: "logs",
downloadType: "blob"
},
{
extensions: ["eml"],
type: "email",
downloadType: "text"
}
]
};
},
methods: {
getType(filename) {
for (const config of this.previewConfig) {
for (const extension of config.extensions) {
if (filename.endsWith(extension)) {
return { type: config.type, downloadType: config.downloadType };
}
}
}
previewConfig: [
{
extensions: ["png", "jpg", "jpeg", "webp", "avif"],
type: "image",
downloadType: "objectUrl",
},
{
extensions: ["mp3"],
type: "audio",
downloadType: "objectUrl",
},
{
extensions: ["mp4", "ogg"],
type: "video",
downloadType: "objectUrl",
},
{
extensions: ["pdf"],
type: "pdf",
downloadType: "objectUrl",
},
{
extensions: ["txt"],
type: "text",
downloadType: "text",
},
{
extensions: ["md"],
type: "markdown",
downloadType: "text",
},
{
extensions: ["csv"],
type: "csv",
downloadType: "text",
},
{
extensions: ["json"],
type: "json",
downloadType: "text",
},
{
extensions: ["html"],
type: "html",
downloadType: "text",
},
{
extensions: ["log.gz"],
type: "logs",
downloadType: "blob",
},
{
extensions: ["eml"],
type: "email",
downloadType: "text",
},
],
}),
methods: {
getType(filename) {
for (const config of this.previewConfig) {
for (const extension of config.extensions) {
if (filename.endsWith(extension)) {
return { type: config.type, downloadType: config.downloadType };
}
}
}
// Open unknown files as text
return {
type: "unknown",
downloadType: "text"
}
},
async openFile(file) {
if (bytesToMegabytes(file.size) > 200) {
this.q.notify({
message: "File is too big to preview.",
color: "orange"
});
// Open unknown files as text
return {
type: "unknown",
downloadType: "text",
};
},
async openFile(file) {
if (bytesToMegabytes(file.size) > 200) {
this.q.notify({
message: "File is too big to preview.",
color: "orange",
});
return;
}
return;
}
const previewConfig = this.getType(file.name);
// if (previewConfig === undefined) {
// this.q.notify({
// message: "File preview is not supported.",
// color: "orange"
// });
//
// return;
// }
const previewConfig = this.getType(file.name);
// if (previewConfig === undefined) {
// this.q.notify({
// message: "File preview is not supported.",
// color: "orange"
// });
//
// return;
// }
this.abortControl = new AbortController();
this.abortControl = new AbortController();
await this.$router.push({
name: "files-file",
params: {
bucket: this.$route.params.bucket,
folder: this.$route.params.folder || ROOT_FOLDER,
file: file.nameHash
}
});
await this.$router.push({
name: "files-file",
params: {
bucket: this.$route.params.bucket,
folder: this.$route.params.folder || ROOT_FOLDER,
file: file.nameHash,
},
});
// This needs to be set before download to open the modal
this.filename = file.name;
this.file = file;
this.open = true;
// This needs to be set before download to open the modal
this.filename = file.name;
this.file = file;
this.open = true;
if (previewConfig) {
this.type = previewConfig.type;
if (previewConfig) {
this.type = previewConfig.type;
const response = await apiHandler.downloadFile(this.$route.params.bucket, file.key, previewConfig, (progressEvent) => {
this.downloadProgress = progressEvent.loaded / progressEvent.total;
}, this.abortControl);
const response = await apiHandler.downloadFile(
this.$route.params.bucket,
file.key,
previewConfig,
(progressEvent) => {
this.downloadProgress = progressEvent.loaded / progressEvent.total;
},
this.abortControl,
);
let data;
if (previewConfig.downloadType === "objectUrl") {
const blob = new Blob([response.data]);
data = URL.createObjectURL(blob);
} else if (previewConfig.downloadType === "blob") {
data = response.data;
} else {
data = response.data;
}
let data;
if (previewConfig.downloadType === "objectUrl") {
const blob = new Blob([response.data]);
data = URL.createObjectURL(blob);
} else if (previewConfig.downloadType === "blob") {
data = response.data;
} else {
data = response.data;
}
this.fileData = data;
}
this.fileData = data;
}
},
close() {
if (this.abortControl) {
this.abortControl.abort();
}
},
close() {
if (this.abortControl) {
this.abortControl.abort();
}
this.cancelEdit();
this.cancelEdit();
// console.log('call')
if (this.$route.params.file) {
if (this.$route.params.folder === ROOT_FOLDER) {
// File was in root folder, mount files home
this.$router.push({
name: "files-home",
params: {
bucket: this.$route.params.bucket,
},
});
} else {
// File was in folder, mount that folder
this.$router.push({
name: "files-folder",
params: {
bucket: this.$route.params.bucket,
folder: this.$route.params.folder,
},
});
}
}
// console.log('call')
if (this.$route.params.file) {
if (this.$route.params.folder === ROOT_FOLDER) {
this.type = undefined;
this.fileData = undefined;
this.filename = undefined;
this.abortControl = undefined;
this.downloadProgress = 0;
},
markdownParser(text) {
return parseMarkdown(text);
},
csvParser: (text) => {
let result = "";
const rows = text.split("\n");
if (rows.length === 0) {
return "<h2>Empty csv</h2>";
}
// File was in root folder, mount files home
this.$router.push({
name: "files-home",
params: {
bucket: this.$route.params.bucket
}
});
for (const [index, row] of rows.entries()) {
let line = "";
const columns = row
.split(/(\s*"[^"]+"\s*|\s*[^,]+|,)(?=,|$)/g)
.filter((item) => {
return item !== "" && item !== ",";
});
} else {
for (const col of columns) {
if (index === 0) {
line += `<th>${col.replaceAll('"', "")}</th>`;
} else {
line += `<td>${col.replaceAll('"', "")}</td>`;
}
}
// File was in folder, mount that folder
this.$router.push({
name: "files-folder",
params: {
bucket: this.$route.params.bucket,
folder: this.$route.params.folder
}
});
result += `<tr>${line}</tr>`;
}
}
}
return `<table class="table">${result}</table>`;
},
this.type = undefined;
this.fileData = undefined;
this.filename = undefined;
this.abortControl = undefined;
this.downloadProgress = 0;
},
markdownParser(text) {
return parseMarkdown(text);
},
csvParser: function(text) {
let result = "";
const rows = text.split("\n");
if (rows.length === 0) {
return "<h2>Empty csv</h2>";
}
// Edit functions
enableEdit: function () {
if (typeof this.fileData === "object") {
this.fileDataEdited = JSON.stringify(this.fileData, null, 2);
} else {
this.fileDataEdited = this.fileData;
}
this.editMode = true;
},
cancelEdit: function () {
this.editMode = false;
this.fileDataEdited = undefined;
},
validateEdit: (type, content) => {
if (type === "json") {
try {
JSON.parse(content);
return true;
} catch (e) {
return false;
}
}
for (const [index, row] of rows.entries()) {
let line = "";
const columns = row.split(/(\s*"[^"]+"\s*|\s*[^,]+|,)(?=,|$)/g).filter(item => {
return item !== "" && item !== ",";
});
return true;
},
saveEdit: async function () {
const isValid = this.validateEdit(this.type, this.fileDataEdited);
if (!isValid) {
this.q.notify({
type: "negative",
message: `Content is not valid ${this.type}.`,
});
return;
}
for (const col of columns) {
if (index === 0) {
line += `<th>${col.replaceAll("\"", "")}</th>`;
} else {
line += `<td>${col.replaceAll("\"", "")}</td>`;
}
}
const notif = this.q.notify({
group: false,
spinner: true,
message: "Updating file...",
caption: "0%",
timeout: 0,
});
result += `<tr>${line}</tr>`;
}
const blobProperties = {};
if (this.file.httpMetadata?.contentType) {
blobProperties["type"] = this.file.httpMetadata?.contentType;
}
const newFile = new Blob([this.fileDataEdited], blobProperties);
return `<table class="table">${result}</table>`;
},
await apiHandler.uploadObjects(
newFile,
this.file.key,
this.selectedBucket,
(progressEvent) => {
notif({
caption: `${Number.parseInt((progressEvent.loaded * 100) / newFile.size)}%`,
});
},
);
// Edit functions
enableEdit: function() {
if (typeof this.fileData === 'object') {
this.fileDataEdited = JSON.stringify(this.fileData, null, 2)
} else {
this.fileDataEdited = this.fileData;
}
this.editMode = true;
},
cancelEdit: function() {
this.editMode = false;
this.fileDataEdited = undefined;
},
validateEdit: function(type, content) {
if (type === 'json') {
try {
JSON.parse(content)
return true
} catch (e) {
return false
}
}
notif({
icon: "done", // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
caption: "100%",
message: "File updated!",
timeout: 5000, // we will timeout it in 5s
});
return true
},
saveEdit: async function() {
const isValid = this.validateEdit(this.type, this.fileDataEdited)
if (!isValid) {
this.q.notify({
type: 'negative',
message: `Content is not valid ${this.type}.`
})
return
}
const notif = this.q.notify({
group: false,
spinner: true,
message: `Updating file...`,
caption: '0%',
timeout: 0
})
const blobProperties = {}
if (this.file.httpMetadata?.contentType) {
blobProperties['type'] = this.file.httpMetadata?.contentType
}
const newFile = new Blob([this.fileDataEdited], blobProperties);
await apiHandler.uploadObjects(newFile, this.file.key, this.selectedBucket, (progressEvent) => {
notif({
caption: `${parseInt((progressEvent.loaded) * 100 / (newFile.size))}%`
});
});
notif({
icon: 'done', // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
caption: '100%',
message: 'File updated!',
timeout: 5000 // we will timeout it in 5s
})
this.cancelEdit()
this.openFile(this.file)
}
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket
},
},
setup() {
return {
q: useQuasar()
};
}
this.cancelEdit();
this.openFile(this.file);
},
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
},
setup() {
return {
q: useQuasar(),
};
},
};
</script>
@@ -17,36 +17,34 @@
</template>
<script>
import pdf from 'components/preview/PdfVuer.vue'
import pdf from "components/preview/PdfVuer.vue";
export default {
components: {
pdf
},
props: ['pdfUrl'],
data () {
return {
page: 1,
numPages: 0,
pdfdata: undefined,
errors: [],
scale: 'page-width'
}
},
mounted () {
this.getPdf()
},
methods: {
getPdf () {
const self = this
self.pdfdata = pdf.createLoadingTask(this.pdfUrl)
self.pdfdata.then((pdf) => {
self.numPages = pdf.numPages
})
}
}
}
components: {
pdf,
},
props: ["pdfUrl"],
data() {
return {
page: 1,
numPages: 0,
pdfdata: undefined,
errors: [],
scale: "page-width",
};
},
mounted() {
this.getPdf();
},
methods: {
getPdf() {
this.pdfdata = pdf.createLoadingTask(this.pdfUrl);
this.pdfdata.then((pdf) => {
this.numPages = pdf.numPages;
});
},
},
};
</script>
<style lang="css" scoped>
/* Page content */
@@ -8,235 +8,237 @@
</div>
</template>
<script>
'use strict';
import { getDocument } from 'pdfjs-dist';
import { getDocument } from "pdfjs-dist";
import PdfjsWorker from "pdfjs-dist/build/pdf.worker.entry";
import {
DefaultAnnotationLayerFactory,
DefaultTextLayerFactory,
PDFFindController,
PDFLinkService,
PDFPageView,
EventBus
} from 'pdfjs-dist/web/pdf_viewer.js';
import resizeSensor from 'pdfvuer/src/resize.vue'
import PdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry'
DefaultAnnotationLayerFactory,
DefaultTextLayerFactory,
EventBus,
PDFFindController,
PDFLinkService,
PDFPageView,
} from "pdfjs-dist/web/pdf_viewer.js";
import resizeSensor from "pdfvuer/src/resize.vue";
function isPDFDocumentLoadingTask(obj) {
return typeof (obj) === 'object' && obj !== null && obj.__PDFDocumentLoadingTask === true;
return (
typeof obj === "object" &&
obj !== null &&
obj.__PDFDocumentLoadingTask === true
);
}
export function createLoadingTask(src, options) {
var source;
if (typeof (src) === 'string')
source = {
url: src
};
else if (typeof (src) === 'object' && src !== null)
source = Object.assign({}, src);
else
throw new TypeError('invalid src type');
let source;
if (typeof src === "string")
source = {
url: src,
};
else if (typeof src === "object" && src !== null)
source = Object.assign({}, src);
else throw new TypeError("invalid src type");
var loadingTask = getDocument(source).promise;
loadingTask.__PDFDocumentLoadingTask = true; // since PDFDocumentLoadingTask is not public
const loadingTask = getDocument(source).promise;
loadingTask.__PDFDocumentLoadingTask = true; // since PDFDocumentLoadingTask is not public
if (options && options.onPassword)
loadingTask.onPassword = options.onPassword;
if (options?.onPassword) loadingTask.onPassword = options.onPassword;
if (options && options.onProgress)
loadingTask.onProgress = options.onProgress;
if (options?.onProgress) loadingTask.onProgress = options.onProgress;
return loadingTask;
return loadingTask;
}
export default {
createLoadingTask: createLoadingTask,
components: {
resizeSensor
},
props: {
src: {
type: [String, Object, Promise],
default: '',
},
page: {
type: Number,
default: 1,
},
rotate: {
type: Number,
default: 0,
},
scale: {
type: [Number, String],
default: 'page-width',
},
resize: {
type: Boolean,
default: false,
},
annotation: {
type: Boolean,
default: false,
},
text: {
type: Boolean,
default: true,
},
},
data: function () {
return {
internalSrc: this.src,
pdf: null,
pdfViewer: null,
loading: true,
}
},
watch: {
pdf: function (val) {
var pdfInfo = val.pdfInfo || val._pdfInfo
this.$emit('numpages', pdfInfo.numPages);
},
page: function (val) {
var self = this;
this.pdf.getPage(val).then(function (pdfPage) {
self.pdfViewer.setPdfPage(pdfPage);
self.pdfViewer.draw();
});
},
scale: function (val) {
this.drawScaled(val);
},
rotate: function (newRotate) {
if (this.pdfViewer) {
this.pdfViewer.update(this.scale, newRotate);
this.pdfViewer.draw();
}
},
},
mounted: function () {
var self = this;
if (!isPDFDocumentLoadingTask(self.internalSrc)) {
self.internalSrc = createLoadingTask(self.internalSrc);
self.$emit('loading', true);
}
createLoadingTask: createLoadingTask,
components: {
resizeSensor,
},
props: {
src: {
type: [String, Object, Promise],
default: "",
},
page: {
type: Number,
default: 1,
},
rotate: {
type: Number,
default: 0,
},
scale: {
type: [Number, String],
default: "page-width",
},
resize: {
type: Boolean,
default: false,
},
annotation: {
type: Boolean,
default: false,
},
text: {
type: Boolean,
default: true,
},
},
data: function () {
return {
internalSrc: this.src,
pdf: null,
pdfViewer: null,
loading: true,
};
},
watch: {
pdf: function (val) {
const pdfInfo = val.pdfInfo || val._pdfInfo;
this.$emit("numpages", pdfInfo.numPages);
},
page: function (val) {
this.pdf.getPage(val).then((pdfPage) => {
this.pdfViewer.setPdfPage(pdfPage);
this.pdfViewer.draw();
});
},
scale: function (val) {
this.drawScaled(val);
},
rotate: function (newRotate) {
if (this.pdfViewer) {
this.pdfViewer.update(this.scale, newRotate);
this.pdfViewer.draw();
}
},
},
mounted: function () {
if (!isPDFDocumentLoadingTask(this.internalSrc)) {
this.internalSrc = createLoadingTask(this.internalSrc);
this.$emit("loading", true);
}
var container = this.$refs.container;
var eventBus = new EventBus();
const container = this.$refs.container;
const eventBus = new EventBus();
// (Optionally) enable hyperlinks within PDF files.
self.pdfLinkService = new PDFLinkService({
eventBus: eventBus,
externalLinkTarget: 2
});
// (Optionally) enable hyperlinks within PDF files.
this.pdfLinkService = new PDFLinkService({
eventBus: eventBus,
externalLinkTarget: 2,
});
// (Optionally) enable find controller.
self.pdfFindController = new PDFFindController({
eventBus: eventBus,
linkService: self.pdfLinkService
});
// (Optionally) enable find controller.
this.pdfFindController = new PDFFindController({
eventBus: eventBus,
linkService: this.pdfLinkService,
});
let annotationLayer = undefined,
textLayer = undefined;
let annotationLayer = undefined;
let textLayer = undefined;
if (self.annotation) {
annotationLayer = new DefaultAnnotationLayerFactory();
}
if (self.text) {
textLayer = new DefaultTextLayerFactory();
}
if (this.annotation) {
annotationLayer = new DefaultAnnotationLayerFactory();
}
if (this.text) {
textLayer = new DefaultTextLayerFactory();
}
self.internalSrc
.then(function (pdfDocument) {
// Document loaded, retrieving the page.
self.pdf = pdfDocument;
return pdfDocument.getPage(self.page)
}).then(function (pdfPage) {
// Creating the page view with default parameters.
self.pdfViewer = new PDFPageView({
container: container,
id: self.page,
scale: 1,
defaultViewport: pdfPage.getViewport({
scale: 1
}),
eventBus: eventBus,
textLayerFactory: textLayer,
annotationLayerFactory: annotationLayer,
});
// Associates the actual page with the view, and drawing it
self.pdfViewer.setPdfPage(pdfPage);
// Set up a scrollPageIntoView function for our links
var viewer = {
scrollPageIntoView: function(params) {
// Send an event when clicking internal links so we can handle loading/scrolling to the correct page
self.$emit('link-clicked', params);
},
};
self.pdfLinkService.setDocument(self.pdf);
self.pdfLinkService.setViewer(viewer);
self.pdfFindController.setDocument(self.pdf);
self.drawScaled(self.scale);
this.internalSrc
.then((pdfDocument) => {
// Document loaded, retrieving the page.
this.pdf = pdfDocument;
return pdfDocument.getPage(this.page);
})
.then((pdfPage) => {
// Creating the page view with default parameters.
this.pdfViewer = new PDFPageView({
container: container,
id: this.page,
scale: 1,
defaultViewport: pdfPage.getViewport({
scale: 1,
}),
eventBus: eventBus,
textLayerFactory: textLayer,
annotationLayerFactory: annotationLayer,
});
// Associates the actual page with the view, and drawing it
this.pdfViewer.setPdfPage(pdfPage);
// Set up a scrollPageIntoView function for our links
const viewer = {
scrollPageIntoView: (params) => {
// Send an event when clicking internal links so we can handle loading/scrolling to the correct page
this.$emit("link-clicked", params);
},
};
this.pdfLinkService.setDocument(this.pdf);
this.pdfLinkService.setViewer(viewer);
this.pdfFindController.setDocument(this.pdf);
this.drawScaled(this.scale);
self.loading = false;
self.$emit('loading', false);
}).catch(err => {
self.$emit('error', err)
self.loading = false
self.$emit('loading', false);
})
},
beforeUnmount() {
var self = this;
if (self.pdfViewer) {
self.pdfViewer.destroy();
self.pdfViewer = null;
}
},
methods: {
calculateScale: function (width = -1, height = -1) {
this.pdfViewer.update(1, this.rotate); // Reset scaling to 1 so that "this.pdfViewer.viewport.width" gives proper width;
if (width === -1 && height === -1) {
width = this.$refs.container.offsetWidth;
}
this.loading = false;
this.$emit("loading", false);
})
.catch((err) => {
this.$emit("error", err);
this.loading = false;
this.$emit("loading", false);
});
},
beforeUnmount() {
if (this.pdfViewer) {
this.pdfViewer.destroy();
this.pdfViewer = null;
}
},
methods: {
calculateScale: function (width = -1, height = -1) {
this.pdfViewer.update(1, this.rotate); // Reset scaling to 1 so that "this.pdfViewer.viewport.width" gives proper width;
if (width === -1 && height === -1) {
width = this.$refs.container.offsetWidth;
}
return width / this.pdfViewer.viewport.width;
},
calculateScaleHeight: function () {
this.pdfViewer.update(1, this.rotate); // Reset scaling to 1 so that "this.pdfViewer.viewport.width" gives proper width;
var height = this.$refs.container.offsetHeight;
var parentel = this.$refs.container.parentElement.parentElement;
return parentel.offsetHeight / height;
},
drawScaled: function (newScale) {
if (this.pdfViewer) {
if (newScale === 'page-width') {
newScale = this.calculateScale();
this.$emit("update:scale", newScale);
}
else if (newScale === 'page-height') {
newScale = this.calculateScaleHeight();
this.$emit("update:scale", newScale);
}
this.pdfViewer.update(newScale, this.rotate);
// The SimpleLinkService from the DefaultAnnotationLayerFactory doesn't do anything with links so override with our LinkService after it is created
if(this.annotation) {
this.pdfViewer.annotationLayer = this.pdfViewer.annotationLayerFactory.createAnnotationLayerBuilder(this.pdfViewer.div, this.pdfViewer.pdfPage);
this.pdfViewer.annotationLayer.linkService = this.pdfLinkService;
}
this.pdfViewer.draw();
// The findController needs the text layer to have been created in the Draw() function, so link it in now
if (this.text) {
this.pdfViewer.textLayer.findController = this.pdfFindController;
}
this.loading = false;
this.$emit('loading', false);
}
},
resizeScale: function () {
if (this.resize) {
this.drawScaled('page-width');
}
}
}
}
return width / this.pdfViewer.viewport.width;
},
calculateScaleHeight: function () {
this.pdfViewer.update(1, this.rotate); // Reset scaling to 1 so that "this.pdfViewer.viewport.width" gives proper width;
const height = this.$refs.container.offsetHeight;
const parentel = this.$refs.container.parentElement.parentElement;
return parentel.offsetHeight / height;
},
drawScaled: function (newScale) {
if (this.pdfViewer) {
if (newScale === "page-width") {
newScale = this.calculateScale();
this.$emit("update:scale", newScale);
} else if (newScale === "page-height") {
newScale = this.calculateScaleHeight();
this.$emit("update:scale", newScale);
}
this.pdfViewer.update(newScale, this.rotate);
// The SimpleLinkService from the DefaultAnnotationLayerFactory doesn't do anything with links so override with our LinkService after it is created
if (this.annotation) {
this.pdfViewer.annotationLayer =
this.pdfViewer.annotationLayerFactory.createAnnotationLayerBuilder(
this.pdfViewer.div,
this.pdfViewer.pdfPage,
);
this.pdfViewer.annotationLayer.linkService = this.pdfLinkService;
}
this.pdfViewer.draw();
// The findController needs the text layer to have been created in the Draw() function, so link it in now
if (this.text) {
this.pdfViewer.textLayer.findController = this.pdfFindController;
}
this.loading = false;
this.$emit("loading", false);
}
},
resizeScale: function () {
if (this.resize) {
this.drawScaled("page-width");
}
},
},
};
</script>
<style src="pdfjs-dist/web/pdf_viewer.css"></style>
@@ -8,25 +8,23 @@
import * as fflate from "fflate";
export default {
props: ['filedata'],
data: function () {
return {
data: null
}
},
mounted() {
const parsedData = new Uint8Array(this.filedata);
const decompressed = fflate.decompressSync(parsedData);
props: ["filedata"],
data: () => ({
data: null,
}),
mounted() {
const parsedData = new Uint8Array(this.filedata);
const decompressed = fflate.decompressSync(parsedData);
const origText = fflate.strFromU8(decompressed);
const origText = fflate.strFromU8(decompressed);
this.data = origText.split('\n').map(val => {
try {
return JSON.stringify(JSON.parse(val), null, 2)
} catch (e) {
return val
}
})
}
}
this.data = origText.split("\n").map((val) => {
try {
return JSON.stringify(JSON.parse(val), null, 2);
} catch (e) {
return val;
}
});
},
};
</script>
@@ -23,296 +23,320 @@
</template>
<script>
import { useMainStore } from "stores/main-store";
import { apiHandler, decode, ROOT_FOLDER } from "src/appUtils";
import { useQuasar } from "quasar";
import { ROOT_FOLDER, apiHandler, decode } from "src/appUtils";
import { useMainStore } from "stores/main-store";
export default {
data: function () {
return {
isHover: false,
filelist: [], // Store our uploaded files
dragContainer: {
top: 0,
bottom: 0,
left: 0,
right: 0
}
}
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket
},
selectedFolder: function () {
if (this.$route.params.folder && this.$route.params.folder !== ROOT_FOLDER) {
return decode(this.$route.params.folder)
}
return ''
},
},
methods: {
openFilesUploader () {
this.$refs.filesUploader.click()
},
openFoldersUploader () {
this.$refs.foldersUploader.click()
},
dragover (event) {
if (this.mainStore.apiReadonly || this.isHover === true) {
return
}
data: () => ({
isHover: false,
filelist: [], // Store our uploaded files
dragContainer: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
}),
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedFolder: function () {
if (
this.$route.params.folder &&
this.$route.params.folder !== ROOT_FOLDER
) {
return decode(this.$route.params.folder);
}
return "";
},
},
methods: {
openFilesUploader() {
this.$refs.filesUploader.click();
},
openFoldersUploader() {
this.$refs.foldersUploader.click();
},
dragover(event) {
if (this.mainStore.apiReadonly || this.isHover === true) {
return;
}
const coordinates = this.$refs.dragContainer.getBoundingClientRect()
this.dragContainer = {
top: coordinates.top,
bottom: coordinates.bottom,
left: coordinates.left,
right: coordinates.right
}
const coordinates = this.$refs.dragContainer.getBoundingClientRect();
this.dragContainer = {
top: coordinates.top,
bottom: coordinates.bottom,
left: coordinates.left,
right: coordinates.right,
};
this.isHover = true
},
dragleave (event) {
if (this.isHover === false) {
return
}
this.isHover = true;
},
dragleave(event) {
if (this.isHover === false) {
return;
}
if (
event.clientX < this.dragContainer.left ||
event.clientX > this.dragContainer.right ||
event.clientY > this.dragContainer.bottom ||
event.clientY < this.dragContainer.top
) {
this.isHover = false
}
},
inputFiles (event) {
this.uploadFiles({
'': event.target.files
})
},
inputFolders (event) {
const folders = {}
for (const file of event.target.files) {
const lastIndex = file.webkitRelativePath.lastIndexOf('/')
const path = file.webkitRelativePath.slice(0, lastIndex)
if (
event.clientX < this.dragContainer.left ||
event.clientX > this.dragContainer.right ||
event.clientY > this.dragContainer.bottom ||
event.clientY < this.dragContainer.top
) {
this.isHover = false;
}
},
inputFiles(event) {
this.uploadFiles({
"": event.target.files,
});
},
inputFolders(event) {
const folders = {};
for (const file of event.target.files) {
const lastIndex = file.webkitRelativePath.lastIndexOf("/");
const path = file.webkitRelativePath.slice(0, lastIndex);
if (folders[path] === undefined) {
folders[path] = []
}
if (folders[path] === undefined) {
folders[path] = [];
}
folders[path].push(file)
}
folders[path].push(file);
}
this.uploadFiles(folders)
},
async uploadFiles (folders) {
let totalFiles = 0
let totalSize = 0
const filenames = []
this.uploadFiles(folders);
},
async uploadFiles(folders) {
let totalFiles = 0;
let totalSize = 0;
const filenames = [];
// Create folders and count files
for (const [folder, files] of Object.entries(folders)) {
// if (folder.slice(-1) === '/') {
// folder = folder.slice(0, -1)
// }
// Create folders and count files
for (const [folder, files] of Object.entries(folders)) {
// if (folder.slice(-1) === '/') {
// folder = folder.slice(0, -1)
// }
if (folder !== '') {
let folderKey = this.selectedFolder + folder + '/'
if (folder !== "") {
const folderKey = `${this.selectedFolder + folder}/`;
await apiHandler.createFolder(folderKey, this.selectedBucket)
}
await apiHandler.createFolder(folderKey, this.selectedBucket);
}
totalFiles += files.length
totalFiles += files.length;
for (const file of files) {
filenames.push(file.name)
totalSize += file.size
}
}
for (const file of files) {
filenames.push(file.name);
totalSize += file.size;
}
}
this.$bus.emit('fetchFiles')
// self.$store.dispatch('addUploadingFiles', filenames) // TODO!
this.$bus.emit("fetchFiles");
// self.$store.dispatch('addUploadingFiles', filenames) // TODO!
const notif = this.q.notify({
group: false,
spinner: true,
message: `Uploading files 1/${filenames.length}...`,
caption: '0%',
timeout: 0
})
const notif = this.q.notify({
group: false,
spinner: true,
message: `Uploading files 1/${filenames.length}...`,
caption: "0%",
timeout: 0,
});
// Upload files
let uploadCount = 0
let uploadSize = 0
for (const [folder, files] of Object.entries(folders)) {
notif({
message: `Uploading files ${uploadCount+1}/${filenames.length}...`,
caption: `${parseInt((uploadCount+1)*100/(filenames.length))}%` // +1 because still needs to delete the folder
})
// Upload files
let uploadCount = 0;
let uploadSize = 0;
for (const [folder, files] of Object.entries(folders)) {
notif({
message: `Uploading files ${uploadCount + 1}/${filenames.length}...`,
caption: `${Number.parseInt(((uploadCount + 1) * 100) / filenames.length)}%`, // +1 because still needs to delete the folder
});
let targetFolder = this.selectedFolder + folder
if (targetFolder.length > 0 && targetFolder.slice(-1) !== '/') {
targetFolder += '/'
}
let targetFolder = this.selectedFolder + folder;
if (targetFolder.length > 0 && targetFolder.slice(-1) !== "/") {
targetFolder += "/";
}
if(targetFolder === '/' || targetFolder === ROOT_FOLDER) {
targetFolder = ''
}
if (targetFolder === "/" || targetFolder === ROOT_FOLDER) {
targetFolder = "";
}
for (const file of files) {
uploadCount += 1
const key = targetFolder + file.name
for (const file of files) {
uploadCount += 1;
const key = targetFolder + file.name;
const chunkSize = 95 * 1024 * 1024
// Files bigger than 100MB require multipart upload
if (file.size > chunkSize) {
const { uploadId } = (await apiHandler.multipartCreate(file, key, this.selectedBucket)).data
const chunkSize = 95 * 1024 * 1024;
// Files bigger than 100MB require multipart upload
if (file.size > chunkSize) {
const { uploadId } = (
await apiHandler.multipartCreate(file, key, this.selectedBucket)
).data;
let partNumber = 1
const parts = []
// console.log('total: ', file.size)
// console.log('chunk: ', chunkSize)
let partNumber = 1;
const parts = [];
// console.log('total: ', file.size)
// console.log('chunk: ', chunkSize)
for (let start = 0; start < file.size; start += chunkSize) {
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
// console.log(`${start} -> ${end}`)
for (let start = 0; start < file.size; start += chunkSize) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
// console.log(`${start} -> ${end}`)
const { data } = await apiHandler.multipartUpload(uploadId, partNumber, this.selectedBucket, key, chunk, (progressEvent) => {
//console.log((start + progressEvent.loaded) * 100 / file.size)
notif({
caption: `${parseInt((uploadSize+start + progressEvent.loaded)*100/(totalSize))}%`
})
// self.$store.dispatch('setUploadProgress', {
// filename: file.name,
// progress: (start + progressEvent.loaded) * 100 / file.size
// }) // TODO
})
const { data } = await apiHandler.multipartUpload(
uploadId,
partNumber,
this.selectedBucket,
key,
chunk,
(progressEvent) => {
//console.log((start + progressEvent.loaded) * 100 / file.size)
notif({
caption: `${Number.parseInt(((uploadSize + start + progressEvent.loaded) * 100) / totalSize)}%`,
});
// self.$store.dispatch('setUploadProgress', {
// filename: file.name,
// progress: (start + progressEvent.loaded) * 100 / file.size
// }) // TODO
},
);
parts.push(data)
partNumber += 1
}
parts.push(data);
partNumber += 1;
}
await apiHandler.multipartComplete(file, key, this.selectedBucket, parts, uploadId)
} else {
await apiHandler.uploadObjects(file, key, this.selectedBucket, (progressEvent) => {
//console.log(progressEvent.loaded * 100 / file.size)
notif({
caption: `${parseInt((uploadSize+progressEvent.loaded)*100/(totalSize))}%`
})
// self.$store.dispatch('setUploadProgress', {
// filename: file.name,
// progress: progressEvent.loaded * 100 / file.size
// }) // TODO
})
}
await apiHandler.multipartComplete(
file,
key,
this.selectedBucket,
parts,
uploadId,
);
} else {
await apiHandler.uploadObjects(
file,
key,
this.selectedBucket,
(progressEvent) => {
//console.log(progressEvent.loaded * 100 / file.size)
notif({
caption: `${Number.parseInt(((uploadSize + progressEvent.loaded) * 100) / totalSize)}%`,
});
// self.$store.dispatch('setUploadProgress', {
// filename: file.name,
// progress: progressEvent.loaded * 100 / file.size
// }) // TODO
},
);
}
uploadSize += file.size
}
}
uploadSize += file.size;
}
}
notif({
icon: 'done', // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
caption: '100%',
message: 'Files Uploaded!',
timeout: 5000 // we will timeout it in 5s
})
notif({
icon: "done", // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
caption: "100%",
message: "Files Uploaded!",
timeout: 5000, // we will timeout it in 5s
});
this.$bus.emit('fetchFiles')
},
async traverseFileTree (item, path, folders, depth) {
const self = this
path = path || ''
this.$bus.emit("fetchFiles");
},
async traverseFileTree(item, path, folders, depth) {
path = path || "";
// Root files are handled outside
if (item.isFile && depth > 0) {
const filePromise = new Promise(function (resolve, reject) {
item.file(function (file) {
folders[path].push(file)
// Root files are handled outside
if (item.isFile && depth > 0) {
const filePromise = new Promise((resolve, reject) => {
item.file((file) => {
folders[path].push(file);
resolve()
})
})
resolve();
});
});
await filePromise
} else if (item.isDirectory) {
const newPath = path ? path + '/' + item.name : item.name
if (folders[newPath] === undefined) {
folders[newPath] = []
}
await filePromise;
} else if (item.isDirectory) {
const newPath = path ? `${path}/${item.name}` : item.name;
if (folders[newPath] === undefined) {
folders[newPath] = [];
}
// Get folder contents
const dirReader = item.createReader()
// Get folder contents
const dirReader = item.createReader();
const promise = new Promise(function (resolve, reject) {
dirReader.readEntries(async function (entries) {
for (let i = 0; i < entries.length; i++) {
await self.traverseFileTree(entries[i], newPath, folders, depth + 1)
}
resolve()
})
})
const promise = new Promise((resolve, reject) => {
dirReader.readEntries(async (entries) => {
for (let i = 0; i < entries.length; i++) {
await this.traverseFileTree(
entries[i],
newPath,
folders,
depth + 1,
);
}
resolve();
});
});
await promise
}
},
async drop (event) {
if (!this.isHover) {
return
}
await promise;
}
},
async drop(event) {
if (!this.isHover) {
return;
}
// files uploaded on root
const rootFiles = event.dataTransfer.files
// files uploaded on root
const rootFiles = event.dataTransfer.files;
// folders and files
const items = await event.dataTransfer.items
const folders = {}
// folders and files
const items = await event.dataTransfer.items;
const folders = {};
for (const item of items) {
// webkitGetAsEntry is where the magic happens
const entry = item.webkitGetAsEntry()
if (entry) {
await this.traverseFileTree(entry, '', folders, 0)
}
}
for (const item of items) {
// webkitGetAsEntry is where the magic happens
const entry = item.webkitGetAsEntry();
if (entry) {
await this.traverseFileTree(entry, "", folders, 0);
}
}
// Root files also include the root folders
const cleanedRootFiles = []
for (const rootFile of rootFiles) {
if (folders[rootFile.name] === undefined) {
cleanedRootFiles.push(rootFile)
}
}
// Root files also include the root folders
const cleanedRootFiles = [];
for (const rootFile of rootFiles) {
if (folders[rootFile.name] === undefined) {
cleanedRootFiles.push(rootFile);
}
}
folders[''] = cleanedRootFiles
folders[""] = cleanedRootFiles;
// console.log(cleanedRootFiles)
// console.log(folders)
// console.log(cleanedRootFiles)
// console.log(folders)
this.uploadFiles(folders)
this.uploadFiles(folders);
this.isHover = false
}
},
mounted() {
this.$bus.on('openFilesUploader', this.openFilesUploader)
this.$bus.on('openFoldersUploader', this.openFoldersUploader)
},
beforeUnmount() {
this.$bus.off('openFilesUploader')
this.$bus.off('openFoldersUploader')
},
setup () {
return {
mainStore: useMainStore(),
q: useQuasar()
}
},
}
this.isHover = false;
},
},
mounted() {
this.$bus.on("openFilesUploader", this.openFilesUploader);
this.$bus.on("openFoldersUploader", this.openFoldersUploader);
},
beforeUnmount() {
this.$bus.off("openFilesUploader");
this.$bus.off("openFoldersUploader");
},
setup() {
return {
mainStore: useMainStore(),
q: useQuasar(),
};
},
};
</script>
<style lang="scss" scoped>
@@ -30,12 +30,12 @@
<script>
export default {
methods: {
close: function () {
this.$store.dispatch('clearUploadingFiles')
}
}
}
methods: {
close: function () {
this.$store.dispatch("clearUploadingFiles");
},
},
};
</script>
<style scoped lang="scss">
@@ -7,11 +7,11 @@
</template>
<script>
import { defineComponent } from 'vue';
import { defineComponent } from "vue";
export default defineComponent({
name: 'AuthLayout',
name: "AuthLayout",
components: {},
components: {},
});
</script>
+33 -30
View File
@@ -25,41 +25,44 @@
</template>
<script>
import { ref } from 'vue'
import { useMainStore } from "stores/main-store";
import LeftSidebar from "components/main/LeftSidebar.vue";
import RightSidebar from "components/main/RightSidebar.vue";
import TopBar from "components/main/Topbar.vue";
import { useMainStore } from "stores/main-store";
import { ref } from "vue";
export default {
name: 'MainLayout',
components: { TopBar, RightSidebar, LeftSidebar },
created() {
const mainStore = useMainStore()
mainStore.loadUserDisks().then((buckets) => {
if (this.$route.path === '/') {
this.$router.push({ name: `files-home`, params: { bucket: buckets[0].name }})
}
});
},
setup () {
const leftDrawerOpen = ref(false)
const rightDrawerOpen = ref(false)
name: "MainLayout",
components: { TopBar, RightSidebar, LeftSidebar },
created() {
const mainStore = useMainStore();
mainStore.loadUserDisks().then((buckets) => {
if (this.$route.path === "/") {
this.$router.push({
name: "files-home",
params: { bucket: buckets[0].name },
});
}
});
},
setup() {
const leftDrawerOpen = ref(false);
const rightDrawerOpen = ref(false);
return {
leftDrawerOpen,
toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value
},
return {
leftDrawerOpen,
toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
},
rightDrawerOpen,
updateRightDrawer (state) {
rightDrawerOpen.value = state
}
}
},
mounted() {
this.updateRightDrawer(false)
}
}
rightDrawerOpen,
updateRightDrawer(state) {
rightDrawerOpen.value = state;
},
};
},
mounted() {
this.updateRightDrawer(false);
},
};
</script>
@@ -23,9 +23,9 @@
</template>
<script>
import { defineComponent } from 'vue'
import { defineComponent } from "vue";
export default defineComponent({
name: 'ErrorNotFound'
})
name: "ErrorNotFound",
});
</script>
+8 -8
View File
@@ -8,15 +8,15 @@
</template>
<script>
import { defineComponent } from 'vue'
import { useMainStore } from "stores/main-store";
import { defineComponent } from "vue";
export default defineComponent({
name: 'HomePage',
setup () {
return {
mainStore: useMainStore()
}
},
})
name: "HomePage",
setup() {
return {
mainStore: useMainStore(),
};
},
});
</script>
+30 -30
View File
@@ -45,37 +45,37 @@
</template>
<script>
import { defineComponent } from 'vue';
import { useAuthStore } from 'stores/auth-store';
const authStore = useAuthStore()
import { useAuthStore } from "stores/auth-store";
import { defineComponent } from "vue";
const authStore = useAuthStore();
export default defineComponent({
name: 'login-page',
components: {},
data() {
return {
loading: false,
showError: '',
form: {
username: '',
password: '',
remind: true
}
};
},
methods: {
async onSubmit() {
this.loading = true
try {
await authStore.LogIn(this.$router, this.form)
this.showError = '';
} catch (error) {
this.showError = error.message;
throw error;
} finally {
this.loading = false
}
}
}
name: "login-page",
components: {},
data() {
return {
loading: false,
showError: "",
form: {
username: "",
password: "",
remind: true,
},
};
},
methods: {
async onSubmit() {
this.loading = true;
try {
await authStore.LogIn(this.$router, this.form);
this.showError = "";
} catch (error) {
this.showError = error.message;
throw error;
} finally {
this.loading = false;
}
},
},
});
</script>
@@ -91,165 +91,192 @@
</template>
<script>
import { defineComponent } from "vue";
import { api } from "boot/axios";
import { useMainStore } from "stores/main-store";
import { apiHandler, decode, encode, timeSince } from "../../appUtils";
import { useQuasar } from "quasar";
import { useMainStore } from "stores/main-store";
import { defineComponent } from "vue";
import { apiHandler, decode, encode, timeSince } from "../../appUtils";
export default defineComponent({
name: "EmailFolderPage",
data: function() {
return {
srcdoc: null,
file: null,
fileHead: null,
timeInterval: null,
attachments: []
};
},
computed: {
selectedBucket: function() {
return this.$route.params.bucket;
},
selectedFolder: function() {
return this.$route.params.folder;
},
selectedFile: function() {
return this.$route.params.file;
},
filePath: function() {
const fileName = decode(this.selectedFile)
return `.r2-explorer/emails/${this.selectedFolder}/${fileName}`
}
},
watch: {
selectedBucket(newVal) {
this.$router.push({ name: `email-folder`, params: { bucket: newVal, folder: encode(this.selectedFolder) }})
}
},
methods: {
timeSince,
contentFinishedLoading() {
clearInterval(this.timeInterval)
this.timeInterval = null
name: "EmailFolderPage",
data: () => ({
srcdoc: null,
file: null,
fileHead: null,
timeInterval: null,
attachments: [],
}),
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedFolder: function () {
return this.$route.params.folder;
},
selectedFile: function () {
return this.$route.params.file;
},
filePath: function () {
const fileName = decode(this.selectedFile);
return `.r2-explorer/emails/${this.selectedFolder}/${fileName}`;
},
},
watch: {
selectedBucket(newVal) {
this.$router.push({
name: "email-folder",
params: { bucket: newVal, folder: encode(this.selectedFolder) },
});
},
},
methods: {
timeSince,
contentFinishedLoading() {
clearInterval(this.timeInterval);
this.timeInterval = null;
this.resizeIframe()
},
resizeIframe() {
if (this.$refs.renderWindow) {
this.$refs.renderWindow.style.height = this.$refs.renderWindow.contentWindow.document.documentElement.scrollHeight + "px";
}
},
this.resizeIframe();
},
resizeIframe() {
if (this.$refs.renderWindow) {
this.$refs.renderWindow.style.height = `${
this.$refs.renderWindow.contentWindow.document.documentElement
.scrollHeight
}px`;
}
},
fetchEmail: async function() {
const self = this
const fileName = decode(this.selectedFile)
fetchEmail: async function () {
const fileName = decode(this.selectedFile);
const fileData = await apiHandler.downloadFile(this.selectedBucket, this.filePath, {})
const fileData = await apiHandler.downloadFile(
this.selectedBucket,
this.filePath,
{},
);
const filename = fileName.split(".json")[0];
const filename = fileName.split(".json")[0];
this.file = fileData.data;
let htmlContent = fileData.data.html
this.file = fileData.data;
let htmlContent = fileData.data.html;
if (htmlContent) {
// Add target blank to all links
htmlContent = htmlContent.replaceAll(/<a(.*?)>(.*?)<\/a>/gi, '<a$1 target="_blank">$2</a>');
if (htmlContent) {
// Add target blank to all links
htmlContent = htmlContent.replaceAll(
/<a(.*?)>(.*?)<\/a>/gi,
'<a$1 target="_blank">$2</a>',
);
// Inject attachment url and replace in html to point to correct path
for (const att of fileData.data.attachments) {
att.display = true
att.downloadUrl = `${this.mainStore.serverUrl}/api/buckets/${this.selectedBucket}/${encode(`.r2-explorer/emails/${this.selectedFolder}/${filename}/${att.filename}`)}`;
// Inject attachment url and replace in html to point to correct path
for (const att of fileData.data.attachments) {
att.display = true;
att.downloadUrl = `${this.mainStore.serverUrl}/api/buckets/${this.selectedBucket}/${encode(`.r2-explorer/emails/${this.selectedFolder}/${filename}/${att.filename}`)}`;
let contentId = att.contentId
if (contentId){
if (contentId.startsWith('<') && contentId.endsWith('>')){
contentId = contentId.substring(1, contentId.length-1);
}
let contentId = att.contentId;
if (contentId) {
if (contentId.startsWith("<") && contentId.endsWith(">")) {
contentId = contentId.substring(1, contentId.length - 1);
}
const matchString = `cid:${contentId}`
if (htmlContent.includes(matchString)) {
htmlContent = htmlContent.replaceAll(`cid:${contentId}`, att.downloadUrl)
att.display = false
}
}
}
const matchString = `cid:${contentId}`;
if (htmlContent.includes(matchString)) {
htmlContent = htmlContent.replaceAll(
`cid:${contentId}`,
att.downloadUrl,
);
att.display = false;
}
}
}
this.srcdoc = htmlContent
}
this.srcdoc = htmlContent;
}
this.attachments = fileData.data.attachments.filter((obj) => obj.display)
this.attachments = fileData.data.attachments.filter((obj) => obj.display);
apiHandler.headFile(this.selectedBucket, this.filePath).then(async (obj) => {
if (obj.customMetadata.read === 'false') {
self.fileHead = await apiHandler.updateMetadata(self.selectedBucket, self.filePath, {
...obj.customMetadata,
read: true
});
} else {
self.fileHead = obj
}
})
apiHandler
.headFile(this.selectedBucket, this.filePath)
.then(async (obj) => {
if (obj.customMetadata.read === "false") {
this.fileHead = await apiHandler.updateMetadata(
this.selectedBucket,
this.filePath,
{
...obj.customMetadata,
read: true,
},
);
} else {
this.fileHead = obj;
}
});
setTimeout(function() {
self.contentFinishedLoading()
}, 10000)
setTimeout(() => {
this.contentFinishedLoading();
}, 10000);
this.timeInterval = setInterval(function() {
self.resizeIframe()
}, 400);
},
markAsUnread: async function () {
this.fileHead = await apiHandler.updateMetadata(this.selectedBucket, this.filePath, {
...this.fileHead.customMetadata,
read: false
});
this.timeInterval = setInterval(() => {
this.resizeIframe();
}, 400);
},
markAsUnread: async function () {
this.fileHead = await apiHandler.updateMetadata(
this.selectedBucket,
this.filePath,
{
...this.fileHead.customMetadata,
read: false,
},
);
this.q.notify({
group: false,
icon: 'done', // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
message: 'Email marked as unread!',
timeout: 2500 // we will timeout it in 2.5s
})
},
markAsRead: async function () {
this.fileHead = await apiHandler.updateMetadata(this.selectedBucket, this.filePath, {
...this.fileHead.customMetadata,
read: true
});
this.q.notify({
group: false,
icon: "done", // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
message: "Email marked as unread!",
timeout: 2500, // we will timeout it in 2.5s
});
},
markAsRead: async function () {
this.fileHead = await apiHandler.updateMetadata(
this.selectedBucket,
this.filePath,
{
...this.fileHead.customMetadata,
read: true,
},
);
this.q.notify({
group: false,
icon: 'done', // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
message: 'Email marked as read!',
timeout: 2500 // we will timeout it in 2.5s
})
},
downloadAtt: function(attachment) {
console.log(attachment)
// return
const link = document.createElement('a')
link.download = attachment.filename
this.q.notify({
group: false,
icon: "done", // we add an icon
spinner: false, // we reset the spinner setting so the icon can be displayed
message: "Email marked as read!",
timeout: 2500, // we will timeout it in 2.5s
});
},
downloadAtt: (attachment) => {
console.log(attachment);
// return
const link = document.createElement("a");
link.download = attachment.filename;
link.href = attachment.downloadUrl
link.href = attachment.downloadUrl;
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
},
created() {
this.fetchEmail();
},
setup() {
return {
mainStore: useMainStore(),
q: useQuasar()
};
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
},
created() {
this.fetchEmail();
},
setup() {
return {
mainStore: useMainStore(),
q: useQuasar(),
};
},
});
</script>
@@ -103,220 +103,227 @@
</template>
<script>
import { defineComponent } from "vue";
import { api } from "boot/axios";
import { useMainStore } from "stores/main-store";
import { defineComponent } from "vue";
import { apiHandler, encode, timeSince } from "../../appUtils";
export default defineComponent({
name: "EmailFolderPage",
data: function() {
return {
timeInterval: null,
indexCursors: null,
loading: false,
loadMoreAutomatically: true,
hasMorePages: true,
rows: [],
columns: [
{
name: "sender",
required: true,
field: "sender",
align: "left",
sortable: false
},
{
name: "subject",
required: true,
field: "subject",
align: "left",
sortable: false
},
{
name: "lastModified",
required: true,
align: "left",
field: "lastModified",
sortable: false
},
{
name: "has_attachments",
required: true,
align: "left",
field: "has_attachments",
sortable: false
}
]
};
},
computed: {
selectedBucket: function() {
return this.$route.params.bucket;
},
selectedFolder: function() {
return "inbox";
}
},
watch: {
selectedBucket(newVal) {
this.fetchFiles();
}
},
methods: {
rowClass: function(prop) {
return prop.row.customMetadata.read === "true" ? "email-read" : "email-unread";
},
rowClick: function(evt, row, index) {
const file = row.key.replace(/^.*[\\/]/, "");
// const folder = row.key.replace(file, '')
name: "EmailFolderPage",
data: () => ({
timeInterval: null,
indexCursors: null,
loading: false,
loadMoreAutomatically: true,
hasMorePages: true,
rows: [],
columns: [
{
name: "sender",
required: true,
field: "sender",
align: "left",
sortable: false,
},
{
name: "subject",
required: true,
field: "subject",
align: "left",
sortable: false,
},
{
name: "lastModified",
required: true,
align: "left",
field: "lastModified",
sortable: false,
},
{
name: "has_attachments",
required: true,
align: "left",
field: "has_attachments",
sortable: false,
},
],
}),
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedFolder: () => "inbox",
},
watch: {
selectedBucket(newVal) {
this.fetchFiles();
},
},
methods: {
rowClass: (prop) =>
prop.row.customMetadata.read === "true" ? "email-read" : "email-unread",
rowClick: function (evt, row, index) {
const file = row.key.replace(/^.*[\\/]/, "");
// const folder = row.key.replace(file, '')
this.$router.push({
name: `email-file`,
params: { bucket: this.selectedBucket, folder: this.selectedFolder, file: encode(file) }
});
},
createOrUpdateIndex: async function(currentIndex) {
let truncated = true;
this.$router.push({
name: "email-file",
params: {
bucket: this.selectedBucket,
folder: this.selectedFolder,
file: encode(file),
},
});
},
createOrUpdateIndex: async function (currentIndex) {
let truncated = true;
// fallback when there's no previous index
let cursor = null;
let pageNum = 0;
let indexData = {
version: 1,
cursors: []
};
// fallback when there's no previous index
let cursor = null;
let pageNum = 0;
let indexData = {
version: 1,
cursors: [],
};
if (currentIndex) {
// There is a previous index
indexData = currentIndex;
if (currentIndex.cursors.length > 0) {
// Pop last page, as its going to be updated
const lastPage = currentIndex.cursors.pop();
pageNum = lastPage.page;
cursor = lastPage.cursor;
}
}
if (currentIndex) {
// There is a previous index
indexData = currentIndex;
if (currentIndex.cursors.length > 0) {
// Pop last page, as its going to be updated
const lastPage = currentIndex.cursors.pop();
pageNum = lastPage.page;
cursor = lastPage.cursor;
}
}
while (truncated) {
console.log(`Updating index page ${pageNum}`);
const response = await api.get(`/buckets/${this.selectedBucket}?include=customMetadata&include=httpMetadata`, {
params: {
delimiter: "/",
prefix: encode(`.r2-explorer/emails/${this.selectedFolder}/`),
cursor: cursor
}
});
while (truncated) {
console.log(`Updating index page ${pageNum}`);
const response = await api.get(
`/buckets/${this.selectedBucket}?include=customMetadata&include=httpMetadata`,
{
params: {
delimiter: "/",
prefix: encode(`.r2-explorer/emails/${this.selectedFolder}/`),
cursor: cursor,
},
},
);
indexData.cursors.push({
page: pageNum,
cursor: cursor,
items: response.data.objects.length
});
indexData.cursors.push({
page: pageNum,
cursor: cursor,
items: response.data.objects.length,
});
// update cursor for next page
cursor = response.data.cursor;
truncated = response.data.truncated;
pageNum++;
}
// update cursor for next page
cursor = response.data.cursor;
truncated = response.data.truncated;
pageNum++;
}
return indexData;
},
getOrCreateIndex: async function() {
const indexKey = `.r2-explorer/emails/index-${this.selectedFolder}.json`;
const fileData = await apiHandler.downloadFile(this.selectedBucket, indexKey, {}).then((obj) => obj.data).catch((obj) => null);
return indexData;
},
getOrCreateIndex: async function () {
const indexKey = `.r2-explorer/emails/index-${this.selectedFolder}.json`;
const fileData = await apiHandler
.downloadFile(this.selectedBucket, indexKey, {})
.then((obj) => obj.data)
.catch((obj) => null);
const updatedIndex = await this.createOrUpdateIndex(fileData);
const updatedIndex = await this.createOrUpdateIndex(fileData);
const blob = new Blob([JSON.stringify(updatedIndex)], {
type: "application/json"
});
try {
await apiHandler.uploadObjects(blob, indexKey, this.selectedBucket);
} catch (e) {}
const blob = new Blob([JSON.stringify(updatedIndex)], {
type: "application/json",
});
try {
await apiHandler.uploadObjects(blob, indexKey, this.selectedBucket);
} catch (e) {}
return updatedIndex;
},
loadNextPage: async function(index, done) {
const page = this.indexCursors[index];
return updatedIndex;
},
loadNextPage: async function (index, done) {
const page = this.indexCursors[index];
if (page) {
await this.loadIndexPage(page);
} else {
// No more pages to load
this.loadMoreAutomatically = true;
this.hasMorePages = false;
}
if (page) {
await this.loadIndexPage(page);
} else {
// No more pages to load
this.loadMoreAutomatically = true;
this.hasMorePages = false;
}
done();
},
fetchFiles: async function() {
this.loading = true;
this.rows = []
done();
},
fetchFiles: async function () {
this.loading = true;
this.rows = [];
const indexData = await this.getOrCreateIndex();
const indexData = await this.getOrCreateIndex();
this.indexCursors = indexData.cursors.reverse();
this.indexCursors = indexData.cursors.reverse();
await this.loadNextPage(0, () => {
});
await this.$refs.infScroll.setIndex(0); // First page is 0
await this.$refs.infScroll.poll();
await this.loadNextPage(0, () => {});
await this.$refs.infScroll.setIndex(0); // First page is 0
await this.$refs.infScroll.poll();
this.loadMoreAutomatically = false;
this.loadMoreAutomatically = false;
this.loading = false;
},
loadIndexPage: async function(page) {
const response = await apiHandler.listObjects(
this.selectedBucket,
`.r2-explorer/emails/${this.selectedFolder}/`,
"/",
page.cursor
);
this.loading = false;
},
loadIndexPage: async function (page) {
const response = await apiHandler.listObjects(
this.selectedBucket,
`.r2-explorer/emails/${this.selectedFolder}/`,
"/",
page.cursor,
);
if (response.data.objects) {
const files = response.data.objects.filter(function(obj) {
return !obj.key.endsWith("/"); // Remove selected folder
}).map(function(obj) {
const date = new Date(parseInt(obj.customMetadata.timestamp));
if (response.data.objects) {
const files = response.data.objects
.filter((obj) => {
return !obj.key.endsWith("/"); // Remove selected folder
})
.map((obj) => {
const date = new Date(
Number.parseInt(obj.customMetadata.timestamp),
);
return {
...obj,
sender: obj.customMetadata.from_name || obj.customMetadata.from_address,
subject: obj.customMetadata.subject,
has_attachments: obj.customMetadata.has_attachments === "true",
read: obj.customMetadata.read,
lastModified: timeSince(date),
timestamp: parseInt(obj.customMetadata.timestamp)
};
});
return {
...obj,
sender:
obj.customMetadata.from_name || obj.customMetadata.from_address,
subject: obj.customMetadata.subject,
has_attachments: obj.customMetadata.has_attachments === "true",
read: obj.customMetadata.read,
lastModified: timeSince(date),
timestamp: Number.parseInt(obj.customMetadata.timestamp),
};
});
for (const f of files.reverse()) {
this.rows.push(f);
}
}
}
},
unmounted () {
clearInterval(this.timeInterval)
this.timeInterval = null
},
mounted () {
const self = this
this.timeInterval = setInterval(() => {
self.fetchFiles()
}, 300000) // 5 minutes
},
created() {
this.fetchFiles();
},
setup() {
return {
mainStore: useMainStore()
};
}
for (const f of files.reverse()) {
this.rows.push(f);
}
}
},
},
unmounted() {
clearInterval(this.timeInterval);
this.timeInterval = null;
},
mounted() {
this.timeInterval = setInterval(() => {
this.fetchFiles();
}, 300000); // 5 minutes
},
created() {
this.fetchFiles();
},
setup() {
return {
mainStore: useMainStore(),
};
},
});
</script>
@@ -18,88 +18,97 @@
</q-list>
</template>
<script>
import { decode, encode, ROOT_FOLDER } from "src/appUtils";
import { useMainStore } from "stores/main-store";
import { useQuasar } from "quasar";
import { ROOT_FOLDER, decode, encode } from "src/appUtils";
import { useMainStore } from "stores/main-store";
export default {
name: "FileContextMenu",
props: {
prop: {},
},
computed: {
selectedBucket: function() {
return this.$route.params.bucket
},
selectedFolder: function() {
if (this.$route.params.folder && this.$route.params.folder !== ROOT_FOLDER) {
return decode(this.$route.params.folder)
}
return ''
},
},
methods: {
renameObject: function() {
this.$emit('renameObject', this.prop.row)
},
openObject: function() {
this.$emit('openObject', this.prop.row)
},
deleteObject: function() {
this.$emit('deleteObject', this.prop.row)
},
shareObject: async function() {
let url
if (this.prop.row.type === 'folder') {
url = window.location.origin + this.$router.resolve({
name: 'files-folder',
params: {
bucket: this.selectedBucket,
folder: encode(this.prop.row.key)
}
}).href
} else {
url = window.location.origin + this.$router.resolve({
name: 'files-file',
params: {
bucket: this.selectedBucket,
folder: this.selectedFolder ? encode(this.selectedFolder) : ROOT_FOLDER,
file: this.prop.row.nameHash
}
}).href
}
name: "FileContextMenu",
props: {
prop: {},
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedFolder: function () {
if (
this.$route.params.folder &&
this.$route.params.folder !== ROOT_FOLDER
) {
return decode(this.$route.params.folder);
}
return "";
},
},
methods: {
renameObject: function () {
this.$emit("renameObject", this.prop.row);
},
openObject: function () {
this.$emit("openObject", this.prop.row);
},
deleteObject: function () {
this.$emit("deleteObject", this.prop.row);
},
shareObject: async function () {
let url;
if (this.prop.row.type === "folder") {
url =
window.location.origin +
this.$router.resolve({
name: "files-folder",
params: {
bucket: this.selectedBucket,
folder: encode(this.prop.row.key),
},
}).href;
} else {
url =
window.location.origin +
this.$router.resolve({
name: "files-file",
params: {
bucket: this.selectedBucket,
folder: this.selectedFolder
? encode(this.selectedFolder)
: ROOT_FOLDER,
file: this.prop.row.nameHash,
},
}).href;
}
try {
await navigator.clipboard.writeText(url);
this.q.notify({
message: 'Link to file copied to clipboard!',
timeout: 5000,
type: 'positive',
})
} catch (err) {
this.q.notify({
message: 'Failed to copy: ' + err,
timeout: 5000,
type: 'negative',
})
}
},
downloadObject: function() {
const link = document.createElement('a')
link.download = this.prop.row.name
try {
await navigator.clipboard.writeText(url);
this.q.notify({
message: "Link to file copied to clipboard!",
timeout: 5000,
type: "positive",
});
} catch (err) {
this.q.notify({
message: `Failed to copy: ${err}`,
timeout: 5000,
type: "negative",
});
}
},
downloadObject: function () {
const link = document.createElement("a");
link.download = this.prop.row.name;
link.href = `${this.mainStore.serverUrl}/api/buckets/${this.selectedBucket}/${encode(this.prop.row.key)}`
link.href = `${this.mainStore.serverUrl}/api/buckets/${this.selectedBucket}/${encode(this.prop.row.key)}`;
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
},
setup () {
return {
mainStore: useMainStore(),
q: useQuasar()
}
},
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
},
setup() {
return {
mainStore: useMainStore(),
q: useQuasar(),
};
},
};
</script>
@@ -79,192 +79,215 @@
</template>
<script>
import { defineComponent } from "vue";
import { useMainStore } from "stores/main-store";
import { apiHandler, decode, encode, ROOT_FOLDER } from "../../appUtils";
import FileOptions from "components/files/FileOptions.vue";
import FilePreview from "components/preview/FilePreview.vue";
import DragAndDrop from "components/utils/DragAndDrop.vue";
import FileOptions from "components/files/FileOptions.vue";
import { useQuasar } from "quasar";
import FileContextMenu from "pages/files/FileContextMenu.vue";
import { useQuasar } from "quasar";
import { useMainStore } from "stores/main-store";
import { defineComponent } from "vue";
import { ROOT_FOLDER, apiHandler, decode, encode } from "../../appUtils";
export default defineComponent({
name: 'FilesIndexPage',
components: { FileContextMenu, FileOptions, DragAndDrop, FilePreview },
data: function () {
return {
loading: false,
rows: [],
columns: [
{
name: 'name',
required: true,
label: 'Name',
align: 'left',
field: 'name',
sortable: true,
sort: (a, b, rowA, rowB) => {
if (rowA.type === 'folder') {
if (rowB.type === 'folder') {
// both are folders
return a.localeCompare(b)
} else {
// only first is folder
return 1
}
} else if (rowB.type === 'folder') {
// only second is folder
return -1
} else {
// none is folder
return a.localeCompare(b)
}
}
},
{
name: 'lastModified',
required: true,
label: 'Last Modified',
align: 'left',
field: 'lastModified',
sortable: true,
sort: (a, b, rowA, rowB) => {
return rowA.timestamp - rowB.timestamp
}
},
{
name: 'size',
required: true,
label: 'Size',
align: 'left',
field: 'size',
sortable: true,
sort: (a, b, rowA, rowB) => {
return rowA.sizeRaw - rowB.sizeRaw
}
},
{
name: 'options',
label: '',
sortable: false,
},
]
}
},
computed: {
selectedBucket: function () {
return this.$route.params.bucket
},
selectedFolder: function () {
if (this.$route.params.folder && this.$route.params.folder !== ROOT_FOLDER) {
return decode(this.$route.params.folder)
}
return ''
},
breadcrumbs: function () {
if (this.selectedFolder) {
return [{
name: this.selectedBucket,
path: '/'
}, ...this.selectedFolder.split('/')
.filter((obj) => obj !== '')
.map((item, index, arr) => {
return {
name: item,
path: arr.slice(0, index + 1).join('/').replace('Home/', '') + '/'
}
})
]
} else {
return [{
name: this.selectedBucket,
path: '/'
}]
}
}
},
watch: {
selectedBucket(newVal) {
this.fetchFiles()
},
selectedFolder(newVal) {
this.fetchFiles()
},
},
methods: {
openRowClick: function(evt, row, index) {
evt.preventDefault()
this.openObject(row)
},
openRowDlbClick: function(evt, row, index) {
evt.preventDefault()
this.$bus.emit("openFileDetails", row);
},
breadcrumbsClick: function(obj) {
this.$router.push({ name: `files-folder`, params: { bucket: this.selectedBucket, folder: encode(obj.path) }})
},
rowClick: function(evt, row) {
if (row.type === 'folder') {
this.$router.push({ name: `files-folder`, params: { bucket: this.selectedBucket, folder: encode(row.key) }})
} else {
// console.log(row)
this.$refs.preview.openFile(row)
}
},
openObject: function(row) {
if (row.type === 'folder') {
this.$router.push({ name: `files-folder`, params: { bucket: this.selectedBucket, folder: encode(row.key) }})
} else {
// console.log(row)
this.$refs.preview.openFile(row)
}
},
renameObject: function(row) {
if (row.type === 'folder') {
this.$router.push({ name: `files-folder`, params: { bucket: this.selectedBucket, folder: encode(row.key) }})
} else {
// console.log(row)
this.$refs.preview.openFile(row)
}
},
fetchFiles: async function () {
this.loading = true
name: "FilesIndexPage",
components: { FileContextMenu, FileOptions, DragAndDrop, FilePreview },
data: () => ({
loading: false,
rows: [],
columns: [
{
name: "name",
required: true,
label: "Name",
align: "left",
field: "name",
sortable: true,
sort: (a, b, rowA, rowB) => {
if (rowA.type === "folder") {
if (rowB.type === "folder") {
// both are folders
return a.localeCompare(b);
}
// only first is folder
return 1;
}
if (rowB.type === "folder") {
// only second is folder
return -1;
}
// none is folder
return a.localeCompare(b);
},
},
{
name: "lastModified",
required: true,
label: "Last Modified",
align: "left",
field: "lastModified",
sortable: true,
sort: (a, b, rowA, rowB) => {
return rowA.timestamp - rowB.timestamp;
},
},
{
name: "size",
required: true,
label: "Size",
align: "left",
field: "size",
sortable: true,
sort: (a, b, rowA, rowB) => {
return rowA.sizeRaw - rowB.sizeRaw;
},
},
{
name: "options",
label: "",
sortable: false,
},
],
}),
computed: {
selectedBucket: function () {
return this.$route.params.bucket;
},
selectedFolder: function () {
if (
this.$route.params.folder &&
this.$route.params.folder !== ROOT_FOLDER
) {
return decode(this.$route.params.folder);
}
return "";
},
breadcrumbs: function () {
if (this.selectedFolder) {
return [
{
name: this.selectedBucket,
path: "/",
},
...this.selectedFolder
.split("/")
.filter((obj) => obj !== "")
.map((item, index, arr) => {
return {
name: item,
path: `${arr
.slice(0, index + 1)
.join("/")
.replace("Home/", "")}/`,
};
}),
];
}
return [
{
name: this.selectedBucket,
path: "/",
},
];
},
},
watch: {
selectedBucket(newVal) {
this.fetchFiles();
},
selectedFolder(newVal) {
this.fetchFiles();
},
},
methods: {
openRowClick: function (evt, row, index) {
evt.preventDefault();
this.openObject(row);
},
openRowDlbClick: function (evt, row, index) {
evt.preventDefault();
this.$bus.emit("openFileDetails", row);
},
breadcrumbsClick: function (obj) {
this.$router.push({
name: "files-folder",
params: { bucket: this.selectedBucket, folder: encode(obj.path) },
});
},
rowClick: function (evt, row) {
if (row.type === "folder") {
this.$router.push({
name: "files-folder",
params: { bucket: this.selectedBucket, folder: encode(row.key) },
});
} else {
// console.log(row)
this.$refs.preview.openFile(row);
}
},
openObject: function (row) {
if (row.type === "folder") {
this.$router.push({
name: "files-folder",
params: { bucket: this.selectedBucket, folder: encode(row.key) },
});
} else {
// console.log(row)
this.$refs.preview.openFile(row);
}
},
renameObject: function (row) {
if (row.type === "folder") {
this.$router.push({
name: "files-folder",
params: { bucket: this.selectedBucket, folder: encode(row.key) },
});
} else {
// console.log(row)
this.$refs.preview.openFile(row);
}
},
fetchFiles: async function () {
this.loading = true;
this.rows = await apiHandler.fetchFile(this.selectedBucket, this.selectedFolder, '/')
this.loading = false
},
openPreviewFromKey: async function () {
let key = `${decode(this.$route.params.file)}`
if (this.selectedFolder && this.selectedFolder !== ROOT_FOLDER) {
key = `${this.selectedFolder}${decode(this.$route.params.file)}`
}
this.rows = await apiHandler.fetchFile(
this.selectedBucket,
this.selectedFolder,
"/",
);
this.loading = false;
},
openPreviewFromKey: async function () {
let key = `${decode(this.$route.params.file)}`;
if (this.selectedFolder && this.selectedFolder !== ROOT_FOLDER) {
key = `${this.selectedFolder}${decode(this.$route.params.file)}`;
}
const file = await apiHandler.headFile(this.selectedBucket, key)
this.$refs.preview.openFile(file)
}
},
created() {
this.fetchFiles()
},
mounted() {
this.$refs.table.sort('name')
const file = await apiHandler.headFile(this.selectedBucket, key);
this.$refs.preview.openFile(file);
},
},
created() {
this.fetchFiles();
},
mounted() {
this.$refs.table.sort("name");
this.$bus.on('fetchFiles', this.fetchFiles)
this.$bus.on("fetchFiles", this.fetchFiles);
if (this.$route.params.file) {
this.openPreviewFromKey()
}
},
beforeUnmount() {
this.$bus.off('fetchFiles')
},
setup () {
return {
mainStore: useMainStore(),
q: useQuasar()
}
},
})
if (this.$route.params.file) {
this.openPreviewFromKey();
}
},
beforeUnmount() {
this.$bus.off("fetchFiles");
},
setup() {
return {
mainStore: useMainStore(),
q: useQuasar(),
};
},
});
</script>
<style>
+87 -65
View File
@@ -1,11 +1,8 @@
/*** Regex Markdown Parser by chalarangelo ***/
// Replaces 'regex' with 'replacement' in 'str'
// Curry function, usage: replaceRegex(regexVar, replacementVar) (strVar)
const replaceRegex = function(regex, replacement){
return function(str){
return str.replace(regex, replacement);
}
}
const replaceRegex = (regex, replacement) => (str) =>
str.replace(regex, replacement);
// Regular expressions for Markdown (a bit strict, but they work)
const codeBlockRegex = /((\n\t)(.*))+/g;
const inlineCodeRegex = /(`)(.*?)\1/g;
@@ -18,48 +15,46 @@ const blockquoteRegex = /\n(&gt;|\>)(.*)/g;
const horizontalRuleRegex = /\n((\-{3,})|(={3,}))/g;
const unorderedListRegex = /(\n\s*(\-|\+)\s.*)+/g;
const orderedListRegex = /(\n\s*([0-9]+\.)\s.*)+/g;
const paragraphRegex = /\n+(?!<pre>)(?!<h)(?!<ul>)(?!<blockquote)(?!<hr)(?!\t)([^\n]+)\n/g;
const paragraphRegex =
/\n+(?!<pre>)(?!<h)(?!<ul>)(?!<blockquote)(?!<hr)(?!\t)([^\n]+)\n/g;
// Replacer functions for Markdown
const codeBlockReplacer = function(fullMatch){
return '\n<pre>' + fullMatch + '</pre>';
}
const inlineCodeReplacer = function(fullMatch, tagStart, tagContents){
return '<code>' + tagContents + '</code>';
}
const imageReplacer = function(fullMatch, tagTitle, tagURL){
return '<img src="' + tagURL + '" alt="' + tagTitle + '" />';
}
const linkReplacer = function(fullMatch, tagTitle, tagURL){
return '<a href="' + tagURL + '">' + tagTitle + '</a>';
}
const headingReplacer = function(fullMatch, tagStart, tagContents){
return '\n<h' + tagStart.trim().length + '>' + tagContents + '</h' + tagStart.trim().length + '>';
}
const boldItalicsReplacer = function(fullMatch, tagStart, tagContents){
return '<' + ( (tagStart.trim().length==1)?('em'):('strong') ) + '>'+ tagContents + '</' + ( (tagStart.trim().length==1)?('em'):('strong') ) + '>';
}
const strikethroughReplacer = function(fullMatch, tagStart, tagContents){
return '<del>' + tagContents + '</del>';
}
const blockquoteReplacer = function(fullMatch, tagStart, tagContents){
return '\n<blockquote>' + tagContents + '</blockquote>';
}
const horizontalRuleReplacer = function(fullMatch){
return '\n<hr />';
}
const unorderedListReplacer = function(fullMatch){
let items = '';
fullMatch.trim().split('\n').forEach( item => { items += '<li>' + item.substring(2) + '</li>'; } );
return '\n<ul>' + items + '</ul>';
}
const orderedListReplacer = function(fullMatch){
let items = '';
fullMatch.trim().split('\n').forEach( item => { items += '<li>' + item.substring(item.indexOf('.')+2) + '</li>'; } );
return '\n<ol>' + items + '</ol>';
}
const paragraphReplacer = function(fullMatch, tagContents){
return '<p>' + tagContents + '</p>';
}
const codeBlockReplacer = (fullMatch) => `\n<pre>${fullMatch}</pre>`;
const inlineCodeReplacer = (fullMatch, tagStart, tagContents) =>
`<code>${tagContents}</code>`;
const imageReplacer = (fullMatch, tagTitle, tagURL) =>
`<img src="${tagURL}" alt="${tagTitle}" />`;
const linkReplacer = (fullMatch, tagTitle, tagURL) =>
`<a href="${tagURL}">${tagTitle}</a>`;
const headingReplacer = (fullMatch, tagStart, tagContents) =>
`\n<h${tagStart.trim().length}>${tagContents}</h${tagStart.trim().length}>`;
const boldItalicsReplacer = (fullMatch, tagStart, tagContents) =>
`<${tagStart.trim().length === 1 ? "em" : "strong"}>${tagContents}</${tagStart.trim().length === 1 ? "em" : "strong"}>`;
const strikethroughReplacer = (fullMatch, tagStart, tagContents) =>
`<del>${tagContents}</del>`;
const blockquoteReplacer = (fullMatch, tagStart, tagContents) =>
`\n<blockquote>${tagContents}</blockquote>`;
const horizontalRuleReplacer = (fullMatch) => "\n<hr />";
const unorderedListReplacer = (fullMatch) => {
let items = "";
fullMatch
.trim()
.split("\n")
.forEach((item) => {
items += `<li>${item.substring(2)}</li>`;
});
return `\n<ul>${items}</ul>`;
};
const orderedListReplacer = (fullMatch) => {
let items = "";
fullMatch
.trim()
.split("\n")
.forEach((item) => {
items += `<li>${item.substring(item.indexOf(".") + 2)}</li>`;
});
return `\n<ol>${items}</ol>`;
};
const paragraphReplacer = (fullMatch, tagContents) => `<p>${tagContents}</p>`;
// Rules for Markdown parsing (use in order of appearance for best results)
const replaceCodeBlocks = replaceRegex(codeBlockRegex, codeBlockReplacer);
const replaceInlineCodes = replaceRegex(inlineCodeRegex, inlineCodeReplacer);
@@ -67,33 +62,60 @@ const replaceImages = replaceRegex(imageRegex, imageReplacer);
const replaceLinks = replaceRegex(linkRegex, linkReplacer);
const replaceHeadings = replaceRegex(headingRegex, headingReplacer);
const replaceBoldItalics = replaceRegex(boldItalicsRegex, boldItalicsReplacer);
const replaceceStrikethrough = replaceRegex(strikethroughRegex, strikethroughReplacer);
const replaceceStrikethrough = replaceRegex(
strikethroughRegex,
strikethroughReplacer,
);
const replaceBlockquotes = replaceRegex(blockquoteRegex, blockquoteReplacer);
const replaceHorizontalRules = replaceRegex(horizontalRuleRegex, horizontalRuleReplacer);
const replaceUnorderedLists = replaceRegex(unorderedListRegex, unorderedListReplacer);
const replaceHorizontalRules = replaceRegex(
horizontalRuleRegex,
horizontalRuleReplacer,
);
const replaceUnorderedLists = replaceRegex(
unorderedListRegex,
unorderedListReplacer,
);
const replaceOrderedLists = replaceRegex(orderedListRegex, orderedListReplacer);
const replaceParagraphs = replaceRegex(paragraphRegex, paragraphReplacer);
// Fix for tab-indexed code blocks
const codeBlockFixRegex = /\n(<pre>)((\n|.)*)(<\/pre>)/g;
const codeBlockFixer = function(fullMatch, tagStart, tagContents, lastMatch, tagEnd){
let lines = '';
tagContents.split('\n').forEach( line => { lines += line.substring(1) + '\n'; } );
const codeBlockFixer = (
fullMatch,
tagStart,
tagContents,
lastMatch,
tagEnd,
) => {
let lines = "";
tagContents.split("\n").forEach((line) => {
lines += `${line.substring(1)}\n`;
});
return tagStart + lines + tagEnd;
}
};
const fixCodeBlocks = replaceRegex(codeBlockFixRegex, codeBlockFixer);
// Replacement rule order function for Markdown
// Do not use as-is, prefer parseMarkdown as seen below
const replaceMarkdown = function(str) {
return replaceParagraphs(replaceOrderedLists(replaceUnorderedLists(
replaceHorizontalRules(replaceBlockquotes(replaceceStrikethrough(
replaceBoldItalics(replaceHeadings(replaceLinks(replaceImages(
replaceInlineCodes(replaceCodeBlocks(str))
))))
)))
)));
}
const replaceMarkdown = (str) =>
replaceParagraphs(
replaceOrderedLists(
replaceUnorderedLists(
replaceHorizontalRules(
replaceBlockquotes(
replaceceStrikethrough(
replaceBoldItalics(
replaceHeadings(
replaceLinks(
replaceImages(replaceInlineCodes(replaceCodeBlocks(str))),
),
),
),
),
),
),
),
),
);
// Parser for Markdown (fixes code, adds empty lines around for parsing)
// Usage: parseMarkdown(strVar)
export const parseMarkdown = function(str) {
return fixCodeBlocks(replaceMarkdown('\n' + str + '\n')).trim();
}
export const parseMarkdown = (str) =>
fixCodeBlocks(replaceMarkdown(`\n${str}\n`)).trim();
+24 -17
View File
@@ -1,6 +1,11 @@
import { route } from 'quasar/wrappers'
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
import routes from './routes'
import { route } from "quasar/wrappers";
import {
createMemoryHistory,
createRouter,
createWebHashHistory,
createWebHistory,
} from "vue-router";
import routes from "./routes";
/*
* If not building with SSR mode, you can
@@ -11,20 +16,22 @@ import routes from './routes'
* with the Router instance.
*/
export default route(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)
export default route((/* { store, ssrContext } */) => {
const createHistory = process.env.SERVER
? createMemoryHistory
: process.env.VUE_ROUTER_MODE === "history"
? createWebHistory
: createWebHashHistory;
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE)
})
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE),
});
return Router
})
return Router;
});
+95 -79
View File
@@ -1,86 +1,102 @@
const routes = [
{
path: "/auth",
component: () => import("layouts/AuthLayout.vue"),
children: [
{ path: "login", name: "login", component: () => import("pages/auth/LoginPage.vue") }
]
},
{
path: "/",
component: () => import("layouts/MainLayout.vue"),
children: [
{ path: "/", name: "home", component: () => import("pages/HomePage.vue") },
{ path: "/:bucket/files", name: "files-home", component: () => import("pages/files/FilesFolderPage.vue") },
{
path: "/:bucket/files/:folder",
name: "files-folder",
component: () => import("pages/files/FilesFolderPage.vue")
},
{
path: "/:bucket/files/:folder/:file",
name: "files-file",
component: () => import("pages/files/FilesFolderPage.vue")
},
{
path: "/auth",
component: () => import("layouts/AuthLayout.vue"),
children: [
{
path: "login",
name: "login",
component: () => import("pages/auth/LoginPage.vue"),
},
],
},
{
path: "/",
component: () => import("layouts/MainLayout.vue"),
children: [
{
path: "/",
name: "home",
component: () => import("pages/HomePage.vue"),
},
{
path: "/:bucket/files",
name: "files-home",
component: () => import("pages/files/FilesFolderPage.vue"),
},
{
path: "/:bucket/files/:folder",
name: "files-folder",
component: () => import("pages/files/FilesFolderPage.vue"),
},
{
path: "/:bucket/files/:folder/:file",
name: "files-file",
component: () => import("pages/files/FilesFolderPage.vue"),
},
{ path: "/:bucket/email", name: "email-home", component: () => import("pages/email/EmailFolderPage.vue") },
{
path: "/:bucket/email/:folder",
name: "email-folder",
component: () => import("pages/email/EmailFolderPage.vue")
},
{
path: "/:bucket/email/:folder/:file",
name: "email-file",
component: () => import("pages/email/EmailFilePage.vue")
},
{
path: "/:bucket/email",
name: "email-home",
component: () => import("pages/email/EmailFolderPage.vue"),
},
{
path: "/:bucket/email/:folder",
name: "email-folder",
component: () => import("pages/email/EmailFolderPage.vue"),
},
{
path: "/:bucket/email/:folder/:file",
name: "email-file",
component: () => import("pages/email/EmailFilePage.vue"),
},
// backwards compatibility
{
path: "/storage/:bucket",
redirect: to => {
return {
name: "files-home",
params: {
bucket: to.params.bucket
}
};
}
},
{
path: "/storage/:bucket/:folder",
redirect: to => {
return {
name: "files-folder",
params: {
bucket: to.params.bucket,
folder: to.params.folder
}
};
}
},
{
path: "/storage/:bucket/:folder/:file",
redirect: to => {
return {
name: "files-file",
params: {
bucket: to.params.bucket,
folder: to.params.folder,
file: to.params.file
}
};
}
}
]
},
// backwards compatibility
{
path: "/storage/:bucket",
redirect: (to) => {
return {
name: "files-home",
params: {
bucket: to.params.bucket,
},
};
},
},
{
path: "/storage/:bucket/:folder",
redirect: (to) => {
return {
name: "files-folder",
params: {
bucket: to.params.bucket,
folder: to.params.folder,
},
};
},
},
{
path: "/storage/:bucket/:folder/:file",
redirect: (to) => {
return {
name: "files-file",
params: {
bucket: to.params.bucket,
folder: to.params.folder,
file: to.params.file,
},
};
},
},
],
},
// Always leave this as last one,
// but you can also remove it
{
path: "/:catchAll(.*)*",
component: () => import("pages/ErrorNotFound.vue")
}
// Always leave this as last one,
// but you can also remove it
{
path: "/:catchAll(.*)*",
component: () => import("pages/ErrorNotFound.vue"),
},
];
export default routes;
+66 -63
View File
@@ -1,77 +1,80 @@
import { defineStore } from 'pinia';
import { api } from 'boot/axios';
import { api } from "boot/axios";
import { defineStore } from "pinia";
import { useMainStore } from "stores/main-store";
const SESSION_KEY = 'r2_explorer_session_token';
const SESSION_KEY = "r2_explorer_session_token";
export const useAuthStore = defineStore('auth', {
state: () => ({
user: '',
loginMethod: '',
}),
getters: {
isAuthenticated: state => !!state.user,
StateUser: state => state.user
},
actions: {
async LogIn(router, form) {
const mainStore = useMainStore();
const token = btoa(form.username + ':' + form.password)
export const useAuthStore = defineStore("auth", {
state: () => ({
user: "",
loginMethod: "",
}),
getters: {
isAuthenticated: (state) => !!state.user,
StateUser: (state) => state.user,
},
actions: {
async LogIn(router, form) {
const mainStore = useMainStore();
const token = btoa(`${form.username}:${form.password}`);
api.defaults.headers.common['Authorization'] = `Basic ${token}`;
try {
await mainStore.loadServerConfigs(router);
} catch (e) {
console.log(e)
delete api.defaults.headers.common['Authorization'];
throw new Error('Invalid username or password')
}
api.defaults.headers.common["Authorization"] = `Basic ${token}`;
try {
await mainStore.loadServerConfigs(router);
} catch (e) {
console.log(e);
delete api.defaults.headers.common["Authorization"];
throw new Error("Invalid username or password");
}
api.defaults.headers.common.Authorization = `Basic ${token}`
api.defaults.headers.common.Authorization = `Basic ${token}`;
this.loginMethod = 'basic'
this.user = form.email
this.loginMethod = "basic";
this.user = form.email;
if (form.remind === true) {
localStorage.setItem(SESSION_KEY, token);
} else {
sessionStorage.setItem(SESSION_KEY, token);
}
if (form.remind === true) {
localStorage.setItem(SESSION_KEY, token);
} else {
sessionStorage.setItem(SESSION_KEY, token);
}
router.replace(router.currentRoute.value.query?.next || '/');
},
async CheckLoginInStorage(router) {
let token = sessionStorage.getItem(SESSION_KEY);
if (!token) {
token = localStorage.getItem(SESSION_KEY);
}
router.replace(router.currentRoute.value.query?.next || "/");
},
async CheckLoginInStorage(router) {
let token = sessionStorage.getItem(SESSION_KEY);
if (!token) {
token = localStorage.getItem(SESSION_KEY);
}
if (!token) {
return false;
}
if (!token) {
return false;
}
const mainStore = useMainStore();
api.defaults.headers.common['Authorization'] = `Basic ${token}`;
const mainStore = useMainStore();
api.defaults.headers.common["Authorization"] = `Basic ${token}`;
try {
await mainStore.loadServerConfigs(router);
} catch (e) {
// Auth token expired
delete api.defaults.headers.common['Authorization'];
await router.replace({ name: 'login', query: { next: router.currentRoute.fullPath } });
return
}
try {
await mainStore.loadServerConfigs(router);
} catch (e) {
// Auth token expired
delete api.defaults.headers.common["Authorization"];
await router.replace({
name: "login",
query: { next: router.currentRoute.fullPath },
});
return;
}
this.user = atob(token).split(':')[0];
this.loginMethod = 'basic'
},
async LogOut(router) {
localStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(SESSION_KEY);
this.user = atob(token).split(":")[0];
this.loginMethod = "basic";
},
async LogOut(router) {
localStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(SESSION_KEY);
this.user = '';
this.loginMethod = '';
await router.replace({ name: 'login'});
},
}
this.user = "";
this.loginMethod = "";
await router.replace({ name: "login" });
},
},
});
+7 -7
View File
@@ -1,5 +1,5 @@
import { store } from 'quasar/wrappers'
import { createPinia } from 'pinia'
import { createPinia } from "pinia";
import { store } from "quasar/wrappers";
/*
* If not building with SSR mode, you can
@@ -11,10 +11,10 @@ import { createPinia } from 'pinia'
*/
export default store((/* { ssrContext } */) => {
const pinia = createPinia()
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia
})
return pinia;
});
+60 -63
View File
@@ -1,71 +1,68 @@
import { defineStore } from 'pinia';
import { api } from "boot/axios";
import { defineStore } from "pinia";
export const useMainStore = defineStore('main', {
state: () => ({
// Config
apiReadonly: true,
username: '',
version: '',
dashboardUrl: '',
showHiddenFiles: false,
export const useMainStore = defineStore("main", {
state: () => ({
// Config
apiReadonly: true,
username: "",
version: "",
dashboardUrl: "",
showHiddenFiles: false,
// Frontend data
buckets: [],
}),
getters: {
serverUrl() {
if (process.env.NODE_ENV === 'development') {
return process.env.VUE_APP_SERVER_URL || 'http://localhost:8787'
}
return window.location.origin
}
},
actions: {
async loadUserDisks() {
const response = await api.get('/buckets')
// Frontend data
buckets: [],
}),
getters: {
serverUrl() {
if (process.env.NODE_ENV === "development") {
return process.env.VUE_APP_SERVER_URL || "http://localhost:8787";
}
return window.location.origin;
},
},
actions: {
async loadUserDisks() {
const response = await api.get("/buckets");
this.buckets = response.data.buckets
return response.data.buckets
},
async loadServerConfigs(router, handleError = false) {
// This is the initial requests to server, that also checks if user needs auth
this.buckets = response.data.buckets;
return response.data.buckets;
},
async loadServerConfigs(router, handleError = false) {
// This is the initial requests to server, that also checks if user needs auth
try {
const response = await api.get('/server/config', {
validateStatus: function (status) {
return status >= 200 && status < 300
}
})
try {
const response = await api.get("/server/config", {
validateStatus: (status) => status >= 200 && status < 300,
});
this.apiReadonly = response.data.config.readonly;
this.username = response.data.config.user?.username;
this.version = response.data.config.version;
this.dashboardUrl = response.data.config.dashboardUrl;
this.showHiddenFiles = response.data.config.showHiddenFiles;
this.apiReadonly = response.data.config.readonly;
this.username = response.data.config.user?.username;
this.version = response.data.config.version;
this.dashboardUrl = response.data.config.dashboardUrl;
this.showHiddenFiles = response.data.config.showHiddenFiles;
} catch (error) {
console.log(error);
if (error.response.status === 302) {
// Handle cloudflare access login page
const nextUrl = error.response.headers.Location;
if (nextUrl) {
window.location.replace(nextUrl);
}
}
} catch (error) {
console.log(error)
if (error.response.status === 302) {
// Handle cloudflare access login page
const nextUrl = error.response.headers.Location
if (nextUrl) {
window.location.replace(nextUrl)
}
}
if (handleError) {
if (error.response?.status === 401) {
await router.push({ name: 'login', query: { next: router.currentRoute.value.fullPath } })
return
}
} else {
throw error
}
}
},
},
if (handleError) {
if (error.response?.status === 401) {
await router.push({
name: "login",
query: { next: router.currentRoute.value.fullPath },
});
return;
}
} else {
throw error;
}
}
},
},
});
+3 -3
View File
@@ -4,7 +4,7 @@
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
store: true;
}
interface QuasarFeatureFlags {
store: true;
}
}
+9 -9
View File
@@ -1,11 +1,11 @@
{
"name": "r2-explorer-github-action",
"version": "0.0.1",
"private": true,
"devDependencies": {
"wrangler": "^3.6.0"
},
"scripts": {
"publish": "wrangler publish"
}
"name": "r2-explorer-github-action",
"version": "0.0.1",
"private": true,
"devDependencies": {
"wrangler": "^3.6.0"
},
"scripts": {
"publish": "wrangler publish"
}
}
+39 -30
View File
@@ -1,30 +1,37 @@
// @ts-ignore
const fs = require("fs");
const fs = require("node:fs");
const R2EXPLORER_WORKER_NAME = process.env.R2EXPLORER_WORKER_NAME;
const WORKERS_CI = process.env.WORKERS_CI;
let R2EXPLORER_WORKER_NAME = process.env.R2EXPLORER_WORKER_NAME;
const R2EXPLORER_BUCKETS = process.env.R2EXPLORER_BUCKETS;
const R2EXPLORER_CONFIG = process.env.R2EXPLORER_CONFIG;
const R2EXPLORER_DOMAIN = process.env.R2EXPLORER_DOMAIN;
const CF_API_TOKEN = process.env.CF_API_TOKEN;
let baseDir = __dirname;
if (WORKERS_CI === "1") {
baseDir = process.env.PWD;
R2EXPLORER_WORKER_NAME = R2EXPLORER_WORKER_NAME || "r2-explorer";
} else {
if (!CF_API_TOKEN) {
console.error("CF_API_TOKEN variable is required to continue!");
process.exit(1);
}
}
if (!R2EXPLORER_WORKER_NAME) {
console.error("R2EXPLORER_WORKER_NAME variable is required to continue!");
process.exit(1);
console.error("R2EXPLORER_WORKER_NAME variable is required to continue!");
process.exit(1);
}
if (!R2EXPLORER_BUCKETS) {
console.error("R2EXPLORER_BUCKETS variable is required to continue!");
process.exit(1);
console.error("R2EXPLORER_BUCKETS variable is required to continue!");
process.exit(1);
}
if (!R2EXPLORER_CONFIG) {
console.error("R2EXPLORER_CONFIG variable is required to continue!");
process.exit(1);
}
if (!CF_API_TOKEN) {
console.error("CF_API_TOKEN variable is required to continue!");
process.exit(1);
console.error("R2EXPLORER_CONFIG variable is required to continue!");
process.exit(1);
}
let wranglerConfig = `
@@ -34,49 +41,51 @@ main = "src/index.ts"
`;
if (R2EXPLORER_DOMAIN) {
wranglerConfig += `
wranglerConfig += `
workers_dev = false
routes = [
{ pattern = "${R2EXPLORER_DOMAIN}", custom_domain = true }
]
`;
} else {
wranglerConfig += `
wranglerConfig += `
workers_dev = true
`;
}
for (const bucket of R2EXPLORER_BUCKETS.split("\n")) {
const split = bucket.trim().split(":");
if (split.length !== 2) {
console.error("R2EXPLORER_BUCKETS is not set correctly!");
console.error(`"${split}" is not in the correct format`);
process.exit(1);
}
const split = bucket.trim().split(":");
if (split.length !== 2) {
console.error("R2EXPLORER_BUCKETS is not set correctly!");
console.error(`"${split}" is not in the correct format`);
process.exit(1);
}
wranglerConfig += `
wranglerConfig += `
[[r2_buckets]]
binding = '${split[0]}'
bucket_name = '${split[1]}'
preview_bucket_name = '${split[1]}'
`;
}
console.log(wranglerConfig)
fs.writeFileSync(__dirname + "/wrangler.toml", wranglerConfig);
console.log(wranglerConfig);
fs.writeFileSync(`${baseDir}/wrangler.toml`, wranglerConfig);
if (!fs.existsSync(__dirname + "/src/")){
fs.mkdirSync(__dirname + "/src/");
if (!fs.existsSync(`${baseDir}/src/`)) {
fs.mkdirSync(`${baseDir}/src/`);
}
console.log(`
import { R2Explorer } from "r2-explorer";
export default R2Explorer(${R2EXPLORER_CONFIG});
`)
fs.writeFileSync(__dirname + "/src/index.ts", `
`);
fs.writeFileSync(
`${baseDir}/src/index.ts`,
`
import { R2Explorer } from "r2-explorer";
export default R2Explorer(${R2EXPLORER_CONFIG});
`);
`,
);
+19 -16
View File
@@ -1,23 +1,26 @@
const fs = require('fs')
const fs = require("node:fs");
// Apply version numbers
const files = ['packages/worker/src/settings.ts', 'packages/worker/package.json']
const files = [
"packages/worker/src/settings.ts",
"packages/worker/package.json",
];
for (const file of files) {
fs.readFile(file, 'utf8', function (err, data) {
if (err) {
console.log(err)
process.exit(1)
}
fs.readFile(file, "utf8", (err, data) => {
if (err) {
console.log(err);
process.exit(1);
}
const version = process.env.RELEASE_VERSION.replace('v', '')
const result = data.replace('0.0.1', version)
const version = process.env.RELEASE_VERSION.replace("v", "");
const result = data.replace("0.0.1", version);
fs.writeFile(file, result, 'utf8', function (err) {
if (err) {
console.log(err)
process.exit(1)
}
})
})
fs.writeFile(file, result, "utf8", (err) => {
if (err) {
console.log(err);
process.exit(1);
}
});
});
}
+17 -17
View File
@@ -1,24 +1,24 @@
import { R2Explorer } from "../src";
const baseConfig = {
readonly: false,
cors: true,
showHiddenFiles: true,
dashboardUrl: "https://dev.r2-explorer-dashboard.pages.dev/",
cacheAssets: false,
readonly: false,
cors: true,
showHiddenFiles: true,
dashboardUrl: "https://dev.r2-explorer-dashboard.pages.dev/",
cacheAssets: false,
};
export default {
async email(event, env, context) {
await R2Explorer(baseConfig).email(event, env, context);
},
async fetch(request, env, context) {
return R2Explorer({
...baseConfig,
basicAuth: {
username: env.BASIC_USERNAME,
password: env.BASIC_PASSWORD
}
}).fetch(request, env, context);
}
async email(event, env, context) {
await R2Explorer(baseConfig).email(event, env, context);
},
async fetch(request, env, context) {
return R2Explorer({
...baseConfig,
basicAuth: {
username: env.BASIC_USERNAME,
password: env.BASIC_PASSWORD,
},
}).fetch(request, env, context);
},
};
+10 -7
View File
@@ -1,9 +1,12 @@
{
"name": "r2-explorer-dev-worker",
"devDependencies": {
"wrangler": "^3.15.0"
},
"scripts": {
"deploy": "wrangler deploy"
}
"name": "r2-explorer-dev-worker",
"devDependencies": {
"wrangler": "^3.15.0"
},
"scripts": {
"deploy": "wrangler deploy"
},
"dependencies": {
"chanfana": "https://prerelease-registry.devprod.cloudflare.dev/chanfana/runs/12189183779/npm-package-chanfana-208"
}
}
+60 -99
View File
@@ -1,101 +1,62 @@
{
"name": "r2-explorer",
"version": "0.0.1",
"description": "A Google Drive Interface for your Cloudflare R2 Buckets",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/src/index.d.ts",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"bin",
"dist",
"LICENSE",
"README.md"
],
"scripts": {
"prepublishOnly": "pinst --disable",
"postpublish": "pinst --enable",
"build": "node tools/cleanup umd && rollup --config rollup.config.js",
"clean": "node tools/cleanup",
"package": "npm run build && npm pack",
"test": "jest --no-cache --runInBand",
"test:cov": "jest --coverage --no-cache --runInBand",
"addscope": "node tools/packagejson name @g4brym/r2-explorer",
"prettify": "prettier . --write --ignore-unknown",
"watch": "npm-watch build",
"publish": "npm publish --access public"
},
"watch": {
"build": {
"patterns": [
"src"
],
"extensions": "js,ts",
"legacyWatch": true,
"runOnChangeOnly": false
}
},
"publishConfig": {
"access": "public"
},
"keywords": [
"cloudflare",
"worker",
"workers",
"serverless",
"cloudflare r2",
"r2",
"r2 storage",
"drive",
"google drive",
"ui",
"cf",
"typescript",
"npm",
"package",
"cjs",
"esm",
"umd",
"typed"
],
"author": "Gabriel Massadas",
"license": "MIT",
"homepage": "https://r2explorer.dev",
"repository": {
"type": "git",
"url": "[email protected]:G4brym/R2-Explorer.git"
},
"bugs": {
"url": "https://github.com/G4brym/R2-Explorer/issues"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240222.0",
"@rollup/plugin-terser": "^0.4.3",
"@rollup/plugin-typescript": "^11.1.2",
"@types/node": "^18.11.18",
"@types/service-worker-mock": "^2.0.1",
"npm-watch": "^0.11.0",
"pinst": "^2.1.6",
"rollup": "^2.36.1",
"rollup-plugin-bundle-size": "^1.0.3",
"rollup-plugin-copy": "^3.4.0",
"service-worker-mock": "^2.0.5",
"ts-node": "^10.9.1",
"typescript": "^4.7.4",
"wrangler": "^3.32.0"
},
"dependencies": {
"@cloudflare/itty-router-openapi": "1.0.7",
"itty-router": "4.0.26",
"postal-mime": "^2.0.2"
},
"bin": {
"r2-explorer": "bin/r2-explorer.js"
}
"name": "r2-explorer",
"version": "0.0.1",
"description": "A Google Drive Interface for your Cloudflare R2 Buckets",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": ["dist", "LICENSE", "README.md"],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"lint": "npx @biomejs/biome check src/ tests/ || (npx @biomejs/biome check --write src/ tests/; exit 1)",
"test": "vitest run --root tests",
"prepare": "husky",
"package": "npm run build && npm pack"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"cloudflare",
"worker",
"workers",
"serverless",
"cloudflare r2",
"r2",
"r2 storage",
"drive",
"google drive",
"ui",
"cf",
"typescript",
"npm",
"package",
"cjs",
"esm",
"umd",
"typed"
],
"author": "Gabriel Massadas",
"license": "MIT",
"homepage": "https://r2explorer.dev",
"repository": {
"type": "git",
"url": "[email protected]:G4brym/R2-Explorer.git"
},
"bugs": {
"url": "https://github.com/G4brym/R2-Explorer/issues"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241127.0",
"@types/node": "^22.10.1",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"wrangler": "^3.91.0"
},
"dependencies": {
"chanfana": "^2.4.2",
"hono": "^4.6.12",
"postal-mime": "^2.3.2",
"zod": "^3.23.8"
}
}
-30
View File
@@ -1,30 +0,0 @@
import bundleSize from 'rollup-plugin-bundle-size'
import { defineConfig } from 'rollup'
import terser from '@rollup/plugin-terser'
import typescript from '@rollup/plugin-typescript'
import copy from 'rollup-plugin-copy'
export default defineConfig({
input: 'src/index.ts',
output: [
{ format: 'cjs', file: 'dist/index.js' },
{ format: 'es', file: 'dist/index.mjs' },
],
plugins: [
typescript({
sourceMap: false,
filterRoot: 'src'
}),
terser(),
bundleSize(),
copy({
targets: [
{
src: ['../LICENSE', '../README.md'],
dest: '.',
},
],
}),
],
external: ['itty-router', 'zod', '@cloudflare/itty-router-openapi', 'postal-mime'],
})
@@ -1,170 +0,0 @@
// This var will hold already imported jwt keys, this reduces the load of importing the key on every request
import {Context, R2ExplorerConfig} from "../../interfaces";
import {getCurrentTimestampSeconds} from "../../dates";
let builtJWTKeys: Record<string, CryptoKey> = {}
let JWTExpiration = 0
function getAccessHost(teamName: string): string {
return `https://${teamName}.cloudflareaccess.com`
}
export async function validateAccessJwt(request: any, env: any, context: Context) {
let decodedJwt: any = false
try {
decodedJwt = await isValidJwt(request, context.config)
} catch (e) {
}
if (decodedJwt === false) {
return Response.json({
success: false,
errors: [{code: 10000, message: 'Authentication error! Verify that the Cloudflare Access Team name is correct'}]
}, {status: 401})
}
context.username = decodedJwt.payload.email
}
async function getPublicKeys(config: R2ExplorerConfig): Promise<Record<string, CryptoKey>> {
let jwtUrl = `${getAccessHost(config.cfAccessTeamName)}/cdn-cgi/access/certs`
const result = await fetch(jwtUrl, {
method: 'GET',
// @ts-ignore
cf: {
// Dont cache error responses
cacheTtlByStatus: {'200-299': 30, '300-599': 0},
},
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
})
const data: any = await result.json()
JWTExpiration = getCurrentTimestampSeconds() + 3600 // 1h
const importedKeys: Record<string, CryptoKey> = {}
for (const key of data.keys) {
importedKeys[key.kid] = await crypto.subtle.importKey(
'jwk',
key,
{
name: 'RSASSA-PKCS1-v1_5',
hash: 'SHA-256',
},
false,
['verify'],
)
}
return importedKeys
}
export async function isValidJwt(request, config: R2ExplorerConfig) {
// Get raw jwt token
const encodedToken = getJwt(request)
if (encodedToken === null) {
return false
}
// Load jwt keys if they are not in memory or already expired
if (Object.keys(builtJWTKeys).length === 0 || Math.floor(Date.now() / 1000) < JWTExpiration) {
builtJWTKeys = await getPublicKeys(config)
}
// Decode payload
let token
try {
token = decodeJwt(encodedToken)
} catch (err) {
return false
}
// Is the token expired?
const expiryDate = new Date(token.payload.exp * 1000)
const currentDate = new Date(Date.now())
if (expiryDate <= currentDate) {
console.log('expired token')
return false
}
if (token.payload?.iss !== getAccessHost(config.cfAccessTeamName)) {
console.log('invalid access signer')
return false
}
// Check is token is valid against all public keys
if (!isValidJwtSignature(token)) {
return false
}
// All good, return payload
return token
}
/**
* For this example, the JWT is passed in as part of the Authorization header,
* after the Bearer scheme.
* Parse the JWT out of the header and return it.
*/
function getJwt(request) {
const authHeader = request.headers.get('cf-access-jwt-assertion')
if (!authHeader) {
return null
}
return authHeader.trim()
}
/**
* Parse and decode a JWT.
* A JWT is three, base64 encoded, strings concatenated with .:
* a header, a payload, and the signature.
* The signature is “URL safe”, in that /+ characters have been replaced by _-
*
* Steps:
* 1. Split the token at the . character
* 2. Base64 decode the individual parts
* 3. Retain the raw Bas64 encoded strings to verify the signature
*/
function decodeJwt(token) {
const parts = token.split('.')
const header = JSON.parse(atob(parts[0]))
const payload = JSON.parse(atob(parts[1]))
const signature = atob(parts[2].replace(/_/g, '/').replace(/-/g, '+'))
// console.log(header)
return {
header: header,
payload: payload,
signature: signature,
raw: {header: parts[0], payload: parts[1], signature: parts[2]},
}
}
/**
* Validate the JWT.
*
* Steps:
* Reconstruct the signed message from the Base64 encoded strings.
* Load the RSA public key into the crypto library.
* Verify the signature with the message and the key.
*/
async function isValidJwtSignature(token) {
const encoder = new TextEncoder()
const data = encoder.encode([token.raw.header, token.raw.payload].join('.'))
// @ts-ignore
const signature = new Uint8Array(Array.from(token.signature).map((c) => c.charCodeAt(0)))
for (const key of Object.values(builtJWTKeys)) {
const isValid = await validateSingleKey(key, signature, data)
if (isValid) return true
}
return false
}
async function validateSingleKey(key, signature, data): Promise<boolean> {
return crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, data)
}
@@ -1,41 +0,0 @@
import { Context } from "../../interfaces";
export async function validateBasicAuth(request: any, env: any, context: Context) {
let decodedCredentials: any = false;
let username = undefined
try {
decodedCredentials = getBasicAuth(request).split(':')
if (!Array.isArray(context.config.basicAuth)) {
context.config.basicAuth = [context.config.basicAuth]
}
for (const credentials of context.config.basicAuth) {
if (credentials.username === decodedCredentials[0] && credentials.password === decodedCredentials[1]) {
username = credentials.username
break
}
}
} catch (e) {
}
if (decodedCredentials === false || username === undefined) {
return Response.json({
success: false,
errors: [{ code: 10000, message: "Authentication error! This server requires Basic Auth" }]
}, { status: 401 });
}
context.username = username;
}
function getBasicAuth(request) {
const authHeader = request.headers.get("Authorization");
if (!authHeader) {
return null;
}
return atob(authHeader.replace('Basic', '').trim());
}
@@ -1,32 +0,0 @@
import {OpenAPIRoute, Path, Query} from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from "zod";
export class CreateFolder extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'post-bucket-create-folder',
tags: ['Buckets'],
summary: 'Create folder',
parameters: {
bucket: Path(String),
},
requestBody: {
key: z.string().describe('base64 encoded file key'),
}
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
if (context.config.readonly === true) return Response.json({msg: 'unauthorized'}, {status: 401})
const bucket = env[data.params.bucket]
const key = decodeURIComponent(escape(atob(data.body.key)))
return await bucket.put(key, 'R2 Explorer Folder')
}
}
@@ -1,34 +0,0 @@
import {OpenAPIRoute, Path, Query} from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from "zod";
export class DeleteObject extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'post-bucket-delete-object',
tags: ['Buckets'],
summary: 'Delete object',
parameters: {
bucket: Path(String),
},
requestBody: {
key: z.string().describe('base64 encoded file key'),
}
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
if (context.config.readonly === true) return Response.json({msg: 'unauthorized'}, {status: 401})
const bucket = env[data.params.bucket]
const key = decodeURIComponent(escape(atob(data.body.key)))
await bucket.delete(key)
return {}
}
}
@@ -1,53 +0,0 @@
import {OpenAPIRoute, Path} from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from 'zod'
export class GetObject extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'get-bucket-object',
tags: ['Buckets'],
summary: 'Get Object',
parameters: {
bucket: Path(String),
key: Path(z.string().describe('base64 encoded file key')),
},
responses: {
'200': {
description: 'File binary',
schema: z.string().openapi({format: 'binary'}),
},
},
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
const bucket = env[data.params.bucket]
let filePath
try {
filePath = decodeURIComponent(escape(atob(data.params.key)));
} catch (e) {
filePath = decodeURIComponent(escape(atob(decodeURIComponent(data.params.key))));
}
const object = await bucket.get(filePath)
if (object === null) {
return Response.json({msg: 'Object Not Found'}, {status: 404})
}
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('etag', object.httpEtag)
headers.set('Content-Disposition', `attachment; filename="${filePath.split('/').pop()}"`)
return new Response(object.body, {
headers,
})
}
}
@@ -1,40 +0,0 @@
import {OpenAPIRoute, Path, Query} from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from 'zod'
export class HeadObject extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'Head-bucket-object',
tags: ['Buckets'],
summary: 'Get Object',
parameters: {
bucket: Path(String),
key: Path(z.string().describe('base64 encoded file key')),
},
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
const bucket = env[data.params.bucket]
let filePath
try {
filePath = decodeURIComponent(escape(atob(data.params.key)));
} catch (e) {
filePath = decodeURIComponent(escape(atob(decodeURIComponent(data.params.key))));
}
const object = await bucket.head(filePath)
if (object === null) {
return Response.json({msg: 'Object Not Found'}, {status: 404})
}
return object
}
}
@@ -1,31 +0,0 @@
import {OpenAPIRoute, Path} from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
export class ListBuckets extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'get-bucket-list',
tags: ['Buckets'],
summary: 'List buckets',
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
const buckets = []
for (const [key, value] of Object.entries(env)) {
// @ts-ignore - check if the field in Env is actually a R2 bucket by its properties
if (value.get && value.put) {
buckets.push({name: key})
}
}
return {
buckets: buckets,
}
}
}
@@ -1,37 +0,0 @@
import { OpenAPIRoute, Path, Query } from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from 'zod'
export class ListObjects extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'get-bucket-list-objects',
tags: ['Buckets'],
summary: 'List objects',
parameters: {
bucket: Path(String),
limit: Query(Number, {required: false}),
prefix: Query(z.string().nullable().optional().describe('base64 encoded prefix'),),
cursor: Query(z.string().nullable().optional()),
delimiter: Query(z.string().nullable().optional()),
include: Query(z.string().array().optional()),
},
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
const bucket = env[data.params.bucket]
return await bucket.list({
limit: data.query.limit,
prefix: data.query.prefix ? decodeURIComponent(escape(atob(data.query.prefix))) : undefined,
cursor: data.query.cursor,
delimiter: (data.query.delimiter) ? data.query.delimiter : '',
include: data.query.include,
})
}
}
@@ -1,39 +0,0 @@
import {OpenAPIRoute, Path, Query} from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from "zod";
export class MoveObject extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'post-bucket-move-object',
tags: ['Buckets'],
summary: 'Move object',
parameters: {
bucket: Path(String),
},
requestBody: {
oldKey: z.string().describe('base64 encoded file key'),
newKey: z.string().describe('base64 encoded file key'),
}
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
if (context.config.readonly === true) return Response.json({msg: 'unauthorized'}, {status: 401})
const bucket = env[data.params.bucket]
const oldKey = decodeURIComponent(escape(atob(data.body.oldKey)))
const newKey = decodeURIComponent(escape(atob(data.body.newKey)))
const object = await bucket.get(oldKey)
const resp = await bucket.put(newKey, object.body, {customMetadata: object.customMetadata, httpMetadata: object.httpMetadata})
await bucket.delete(oldKey)
return resp
}
}
@@ -1,53 +0,0 @@
import {Int, OpenAPIRoute, Path, Query} from "@cloudflare/itty-router-openapi";
import {Context} from "../../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from "zod";
export class CompleteUpload extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'post-multipart-complete-upload',
tags: ['Multipart'],
summary: 'Complete upload',
parameters: {
bucket: Path(String),
},
requestBody: {
uploadId: String,
key: z.string().describe('base64 encoded file key'),
parts: [{
etag: String,
partNumber: Int
}]
}
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
if (context.config.readonly === true) return Response.json({msg: 'unauthorized'}, {status: 401})
const bucket = env[data.params.bucket]
const uploadId = data.body.uploadId
const key = decodeURIComponent(escape(atob(data.body.key)))
const parts = data.body.parts
const multipartUpload = await bucket.resumeMultipartUpload(key, uploadId);
try {
const resp = await multipartUpload.complete(parts);
return {
success: true,
str: resp,
}
} catch (error: any) {
return Response.json({msg: error.message}, {status: 400});
}
}
}
@@ -1,43 +0,0 @@
import {OpenAPIRoute, Path, Query} from "@cloudflare/itty-router-openapi";
import {Context} from "../../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from "zod";
export class CreateUpload extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'post-multipart-create-upload',
tags: ['Multipart'],
summary: 'Create upload',
parameters: {
bucket: Path(String),
key: Query(z.string().describe('base64 encoded file key')),
customMetadata: Query(z.string().nullable().optional().describe('base64 encoded json string')),
httpMetadata: Query(z.string().nullable().optional().describe('base64 encoded json string')),
}
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
if (context.config.readonly === true) return Response.json({msg: 'unauthorized'}, {status: 401})
const bucket = env[data.params.bucket]
const key = decodeURIComponent(escape(atob(data.query.key)))
let customMetadata = undefined
if (data.query.customMetadata) {
customMetadata = JSON.parse(decodeURIComponent(escape(atob(data.query.customMetadata))))
}
let httpMetadata = undefined
if (data.query.httpMetadata) {
httpMetadata = JSON.parse(decodeURIComponent(escape(atob(data.query.httpMetadata))))
}
return await bucket.createMultipartUpload(key, {customMetadata: customMetadata, httpMetadata: httpMetadata});
}
}
@@ -1,50 +0,0 @@
import { Int, OpenAPIRoute, Path, Query, RequestBody } from "@cloudflare/itty-router-openapi";
import {Context} from "../../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from "zod";
export class PartUpload extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'post-multipart-part-upload',
tags: ['Multipart'],
summary: 'Part upload',
requestBody: new RequestBody({
content: {
'application/octet-stream': {
schema: {
type: 'string',
format: 'binary',
},
},
},
}),
parameters: {
bucket: Path(String),
key: Query(z.string().describe('base64 encoded file key')),
uploadId: Query(String),
partNumber: Query(Int),
}
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
if (context.config.readonly === true) return Response.json({msg: 'unauthorized'}, {status: 401})
const bucket = env[data.params.bucket]
const key = decodeURIComponent(escape(atob(data.query.key)))
const multipartUpload = bucket.resumeMultipartUpload(key, data.query.uploadId);
try {
return await multipartUpload.uploadPart(data.query.partNumber, request.body);
} catch (error: any) {
return new Response(error.message, {status: 400});
}
}
}
@@ -1,40 +0,0 @@
import {OpenAPIRoute, Path, Query} from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from 'zod'
export class PutMetadata extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'post-bucket-put-object-metadata',
tags: ['Buckets'],
summary: 'Update object metadata',
parameters: {
bucket: Path(String),
key: Path(z.string().describe('base64 encoded file key')),
},
requestBody: z.object({
customMetadata: z.record(z.string(), z.any())
}).openapi("Object metadata")
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
if (context.config.readonly === true) return Response.json({msg: 'unauthorized'}, {status: 401})
const bucket = env[data.params.bucket]
let filePath
try {
filePath = decodeURIComponent(escape(atob(data.params.key)));
} catch (e) {
filePath = decodeURIComponent(escape(atob(decodeURIComponent(data.params.key))));
}
const object = await bucket.get(filePath)
return await bucket.put(filePath, object.body, {customMetadata: data.body.customMetadata})
}
}
@@ -1,53 +0,0 @@
import { OpenAPIRoute, Path, Query, RequestBody } from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {z} from 'zod'
export class PutObject extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'post-bucket-upload-object',
tags: ['Buckets'],
summary: 'Upload object',
requestBody: new RequestBody({
content: {
'application/octet-stream': {
schema: {
type: 'string',
format: 'binary',
},
},
},
}),
parameters: {
bucket: Path(String),
key: Query(z.string().describe('base64 encoded file key')),
customMetadata: Query(z.string().nullable().optional().describe('base64 encoded json string')),
httpMetadata: Query(z.string().nullable().optional().describe('base64 encoded json string')),
},
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
if (context.config.readonly === true) return Response.json({msg: 'unauthorized'}, {status: 401})
const bucket = env[data.params.bucket]
let key = decodeURIComponent(escape(atob(data.query.key)))
let customMetadata = undefined
if (data.query.customMetadata) {
customMetadata = JSON.parse(decodeURIComponent(escape(atob(data.query.customMetadata))))
}
let httpMetadata = undefined
if (data.query.httpMetadata) {
httpMetadata = JSON.parse(decodeURIComponent(escape(atob(data.query.httpMetadata))))
}
return await bucket.put(key, request.body, {customMetadata: customMetadata, httpMetadata: httpMetadata})
}
}
-35
View File
@@ -1,35 +0,0 @@
import {config} from "../settings";
import {ListBuckets} from "./api/listBuckets";
import {ListObjects} from "./api/listObjects";
import {MoveObject} from "./api/moveObject";
import {CreateFolder} from "./api/createFolder";
import {PutObject} from "./api/putObject";
import {DeleteObject} from "./api/deleteObject";
import {CreateUpload} from "./api/multipart/createUpload";
import {PartUpload} from "./api/multipart/partUpload";
import {CompleteUpload} from "./api/multipart/completeUpload";
import {OpenAPIRouter} from "@cloudflare/itty-router-openapi";
import {GetObject} from "./api/getObject";
import {HeadObject} from "./api/headObject";
import {PutMetadata} from "./api/putMetadata";
export const bucketsRouter = OpenAPIRouter({
base: '/api/buckets',
raiseUnknownParameters: config.raiseUnknownParameters,
generateOperationIds: config.generateOperationIds,
})
bucketsRouter.get('', ListBuckets)
bucketsRouter.get('/:bucket', ListObjects)
bucketsRouter.post('/:bucket/move', MoveObject)
bucketsRouter.post('/:bucket/folder', CreateFolder)
bucketsRouter.post('/:bucket/upload', PutObject)
bucketsRouter.post('/:bucket/multipart/create', CreateUpload)
bucketsRouter.post('/:bucket/multipart/upload', PartUpload)
bucketsRouter.post('/:bucket/multipart/complete', CompleteUpload)
bucketsRouter.post('/:bucket/delete', DeleteObject)
bucketsRouter.head('/:bucket/:key', HeadObject)
bucketsRouter.get('/:bucket/:key/head', HeadObject) // There are some issues with calling the head method
bucketsRouter.get('/:bucket/:key', GetObject)
bucketsRouter.post('/:bucket/:key', PutMetadata)
-56
View File
@@ -1,56 +0,0 @@
import {Context} from "./interfaces";
export async function dashboardProxy(request: any, env: any, context: Context) {
// Initialize the default cache
//@ts-ignore
const cache = caches.default
let path = new URL(request.url).pathname
// Support page navigation
if (!path.includes('.')) {
path = "/"
}
let result
if (context.config.cacheAssets !== false) {
// use .match() to see if we have a cache hit, if so return the caches response early
result = await cache.match(request)
if (result) {
return result
}
}
let dashboardUrl = 'https://demo.r2explorer.dev'
if (context.config?.dashboardUrl) {
if (context.config.dashboardUrl.endsWith('/')) {
dashboardUrl = context.config.dashboardUrl.slice(0, -1)
} else {
dashboardUrl = context.config.dashboardUrl
}
}
// we'll chain our await calls to get the JSON response in one line
const response = await fetch(
`${dashboardUrl}${path}`
)
result = new Response(await response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type'),
'Access-Control-Allow-Origin': '*',
// We set a max-age of 300 seconds which is equivalent to 5 minutes.
// If the last response is older than that the cache.match() call returns nothing and and a new response is fetched
'Cache-Control': 'max-age: 300',
},
})
if (response.status === 200 && context.config.cacheAssets !== false) {
// before returning the response we put a clone of our response object into the cache so it can be resolved later
context.executionContext.waitUntil(cache.put(request, result.clone()))
}
return result
}
@@ -1,55 +0,0 @@
import { OpenAPIRoute } from "@cloudflare/itty-router-openapi";
import { Context } from "../../interfaces";
import { OpenAPIRouteSchema } from "@cloudflare/itty-router-openapi/dist/src/types";
export class SendEmail extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: "post-email-send",
tags: ["Emails"],
summary: "Send Email",
requestBody: {
subject: "Look! No servers",
from: {
email: "[email protected]",
name: "Workers - MailChannels integration"
},
to: [{ email: "[email protected]", name: "Test Recipient" }],
content: {
"text/plain": "And no email service accounts and all for free too!"
}
}
};
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
if (context.config.readonly === true) return Response.json({ msg: "unauthorized" }, { status: 401 });
const emailResp = await fetch("https://api.mailchannels.net/tx/v1/send", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
personalizations: [
{
to: data.body.to
}
],
from: data.body.from,
subject: data.body.subject,
content: [
{
type: "text/plain",
value: data.body.content["text/plain"]
}
]
})
});
return { msg: await emailResp.text() };
}
}
@@ -1,60 +0,0 @@
import {getCurrentTimestampMilliseconds} from "../dates";
import {Context} from "../interfaces";
import PostalMime from 'postal-mime';
async function streamToArrayBuffer(stream, streamSize) {
let result = new Uint8Array(streamSize);
let bytesRead = 0;
const reader = stream.getReader();
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
result.set(value, bytesRead);
bytesRead += value.length;
}
return result;
}
export async function receiveEmail(event, env, ctx: Context) {
let bucket = undefined
if (ctx.config?.emailRouting?.targetBucket && env[ctx.config.emailRouting.targetBucket]) {
bucket = env[ctx.config.emailRouting.targetBucket]
}
if (!bucket) {
// Bucket not set, default to first defined
for (const [key, value] of Object.entries(env)) {
// @ts-ignore
if (value.get && value.put) {
bucket = value
break
}
}
}
const rawEmail = await streamToArrayBuffer(event.raw, event.rawSize);
const parser = new PostalMime()
const parsedEmail = await parser.parse(rawEmail);
const emailPath = `${getCurrentTimestampMilliseconds()}-${crypto.randomUUID()}`
await bucket.put(`.r2-explorer/emails/inbox/${emailPath}.json`, JSON.stringify(parsedEmail), {
customMetadata: {
subject: parsedEmail.subject,
from_address: parsedEmail.from?.address,
from_name: parsedEmail.from?.name,
to_address: (parsedEmail.to.length > 0) ? parsedEmail.to[0].address : null,
to_name: (parsedEmail.to.length > 0) ? parsedEmail.to[0].name : null,
has_attachments: parsedEmail.attachments.length > 0,
read: false,
timestamp: Date.now()
}
})
for (const att of parsedEmail.attachments) {
await bucket.put(`.r2-explorer/emails/inbox/${emailPath}/${att.filename}`, att.content)
}
}
-12
View File
@@ -1,12 +0,0 @@
import { config } from "../settings";
import { OpenAPIRouter } from "@cloudflare/itty-router-openapi";
import { SendEmail } from "./api/sendEmail";
export const emailsRouter = OpenAPIRouter({
base: "/api/emails",
raiseUnknownParameters: config.raiseUnknownParameters,
generateOperationIds: config.generateOperationIds
});
emailsRouter.post("/send", SendEmail);
@@ -0,0 +1,55 @@
import type { AppContext } from "../types";
export async function dashboardProxy(c: AppContext) {
// Initialize the default cache
//@ts-ignore
const cache = caches.default;
let path = new URL(c.req.raw.url).pathname;
// Support page navigation
if (!path.includes(".")) {
path = "/";
}
let result;
const config = c.get("config");
if (config.cacheAssets !== false) {
// use .match() to see if we have a cache hit, if so return the caches response early
result = await cache.match(c.req.raw);
if (result) {
return result;
}
}
let dashboardUrl = "https://demo.r2explorer.dev";
if (config.dashboardUrl) {
if (config.dashboardUrl.endsWith("/")) {
dashboardUrl = config.dashboardUrl.slice(0, -1);
} else {
dashboardUrl = config.dashboardUrl;
}
}
// we'll chain our await calls to get the JSON response in one line
const response = await fetch(`${dashboardUrl}${path}`);
result = new Response(await response.body, {
status: response.status,
headers: {
"Content-Type": response.headers.get("Content-Type"),
"Access-Control-Allow-Origin": "*",
// We set a max-age of 300 seconds which is equivalent to 5 minutes.
// If the last response is older than that the cache.match() call returns nothing and and a new response is fetched
"Cache-Control": "max-age: 300",
},
});
if (response.status === 200 && config.cacheAssets !== false) {
// before returning the response we put a clone of our response object into the cache so it can be resolved later
c.executionCtx.waitUntil(cache.put(c.req.raw, result.clone()));
}
return result;
}
@@ -1,7 +1,7 @@
export function getCurrentTimestampSeconds(): number {
return Math.floor(Date.now() / 1000)
return Math.floor(Date.now() / 1000);
}
export function getCurrentTimestampMilliseconds(): number {
return Math.floor(Date.now())
return Math.floor(Date.now());
}
@@ -0,0 +1,258 @@
import type { AppContext } from "../../types";
import { getCurrentTimestampSeconds } from "../dates";
// This var will hold already imported jwt keys, this reduces the load of importing the key on every request
let builtJWTKeys: Record<string, CryptoKey> = {};
let JWTExpiration = 0;
function getAccessHost(teamName: string): string {
return `https://${teamName}.cloudflareaccess.com`;
}
export async function accessMiddleware(c: AppContext, next: CallableFunction) {
const encodedToken = getJwt(c);
if (encodedToken === null) {
return Response.json(
{
success: false,
errors: [
{
code: 10000,
message: "Authentication error: Missing bearer token",
},
],
},
{ status: 401 },
);
}
const cfAccessTeamName = c.get("config").cfAccessTeamName as string;
let decodedJwt: any = false;
try {
decodedJwt = await isValidJwt(encodedToken, cfAccessTeamName);
} catch (e) {}
if (decodedJwt === false) {
return Response.json(
{
success: false,
errors: [
{
code: 10001,
message: "Authentication error: Unable to decode Bearer token",
},
],
},
{ status: 401 },
);
}
c.set("username", decodedJwt.payload.email);
await next();
}
async function getPublicKeys(
cfAccessTeamName: string,
): Promise<Record<string, CryptoKey>> {
const jwtUrl = `${getAccessHost(cfAccessTeamName)}/cdn-cgi/access/certs`;
const result = await fetch(jwtUrl, {
method: "GET",
// @ts-ignore
cf: {
// Dont cache error responses
cacheTtlByStatus: { "200-299": 30, "300-599": 0 },
},
headers: {
"Content-Type": "application/json; charset=UTF-8",
},
});
const data: any = await result.json();
JWTExpiration = getCurrentTimestampSeconds() + 3600; // 1h
const importedKeys: Record<string, CryptoKey> = {};
for (const key of data.keys) {
importedKeys[key.kid] = await crypto.subtle.importKey(
"jwk",
key,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
false,
["verify"],
);
}
return importedKeys;
}
export async function isValidJwt(
encodedToken: string,
cfAccessTeamName: string,
) {
// Load jwt keys if they are not in memory or already expired
if (
Object.keys(builtJWTKeys).length === 0 ||
Math.floor(Date.now() / 1000) < JWTExpiration
) {
builtJWTKeys = await getPublicKeys(cfAccessTeamName);
}
// Decode payload
let token;
try {
token = decodeJwt(encodedToken);
} catch (err) {
return Response.json(
{
success: false,
errors: [
{
code: 10001,
message: "Authentication error: Unable to decode Bearer token",
},
],
},
{ status: 401 },
);
}
// Is the token expired?
const expiryDate = new Date(token.payload.exp * 1000);
const currentDate = new Date(Date.now());
if (expiryDate <= currentDate) {
return Response.json(
{
success: false,
errors: [
{
code: 10002,
message: "Authentication error: Token is expired",
},
],
},
{ status: 401 },
);
}
if (token.payload?.iss !== getAccessHost(cfAccessTeamName)) {
return Response.json(
{
success: false,
errors: [
{
code: 10003,
message: `Authentication error: Expected team name ${cfAccessTeamName}, but received ${token.payload?.iss}`,
},
],
},
{ status: 401 },
);
}
// Check is token is valid against all public keys
if (!(await isValidJwtSignature(token))) {
return Response.json(
{
success: false,
errors: [
{
code: 10004,
message: "Authentication error: Invalid Token",
},
],
},
{ status: 401 },
);
}
// All good, return payload
return token;
}
/**
* For this example, the JWT is passed in as part of the Authorization header,
* after the Bearer scheme.
* Parse the JWT out of the header and return it.
*/
function getJwt(c: AppContext) {
const authHeader = c.req.header("cf-access-jwt-assertion");
if (!authHeader) {
return null;
}
return authHeader.trim();
}
/**
* Parse and decode a JWT.
* A JWT is three, base64 encoded, strings concatenated with .:
* a header, a payload, and the signature.
* The signature is “URL safe”, in that /+ characters have been replaced by _-
*
* Steps:
* 1. Split the token at the . character
* 2. Base64 decode the individual parts
* 3. Retain the raw Bas64 encoded strings to verify the signature
*/
type DecodedToken = {
header: object;
payload: { iss?: string; exp: number };
signature: string;
raw: { header?: string; payload?: string; signature?: string };
};
function decodeJwt(token: string): DecodedToken {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid token");
}
const header = JSON.parse(atob(parts[0] as string));
const payload = JSON.parse(atob(parts[1] as string));
const signature = atob(
(parts[2] as string).replace(/_/g, "/").replace(/-/g, "+"),
);
return {
header: header,
payload: payload,
signature: signature,
raw: { header: parts[0], payload: parts[1], signature: parts[2] },
};
}
/**
* Validate the JWT.
*
* Steps:
* Reconstruct the signed message from the Base64 encoded strings.
* Load the RSA public key into the crypto library.
* Verify the signature with the message and the key.
*/
async function isValidJwtSignature(token: DecodedToken) {
const encoder = new TextEncoder();
const data = encoder.encode([token.raw.header, token.raw.payload].join("."));
// @ts-ignore
const signature = new Uint8Array(
Array.from(token.signature).map((c) => c.charCodeAt(0)),
);
for (const key of Object.values(builtJWTKeys)) {
const isValid = await validateSingleKey(key, signature, data);
if (isValid) return true;
}
return false;
}
async function validateSingleKey(
key: CryptoKey,
signature: Uint8Array,
data: Uint8Array,
): Promise<boolean> {
return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, data);
}
@@ -0,0 +1,26 @@
import type { AppContext } from "../../types";
export async function readOnlyMiddleware(
c: AppContext,
next: CallableFunction,
) {
const config = c.get("config");
if (config.readonly === true && !["GET", "HEAD"].includes(c.req.method)) {
return Response.json(
{
success: false,
errors: [
{
code: 10005,
message:
"This instance is in ReadOnly Mode, no changes are allowed!",
},
],
},
{ status: 401 },
);
}
await next();
}
@@ -0,0 +1,3 @@
export const settings = {
version: "0.0.1",
};
+138 -72
View File
@@ -1,87 +1,153 @@
import { createCors } from "itty-router";
import { R2ExplorerConfig } from "./interfaces";
import { OpenAPIRouter } from "@cloudflare/itty-router-openapi";
import { bucketsRouter } from "./buckets/router";
import { validateAccessJwt } from "./authentication/api/access";
import { dashboardProxy } from "./dashbord";
import { receiveEmail } from "./emails/receiveEmail";
import { serverRouter } from "./server/router";
import { config as settings } from "./settings";
import { validateBasicAuth } from "./authentication/api/basic";
import { emailsRouter } from "./emails/router";
import {
type OpenAPIObjectConfigV31,
extendZodWithOpenApi,
fromHono,
} from "chanfana";
import { type ExecutionContext, Hono } from "hono";
import { basicAuth } from "hono/basic-auth";
import { cors } from "hono/cors";
import { z } from "zod";
import { dashboardProxy } from "./foundation/dashbord";
import { accessMiddleware } from "./foundation/middlewares/authentication";
import { readOnlyMiddleware } from "./foundation/middlewares/readonly";
import { settings } from "./foundation/settings";
import { CreateFolder } from "./modules/buckets/createFolder";
import { DeleteObject } from "./modules/buckets/deleteObject";
import { GetObject } from "./modules/buckets/getObject";
import { HeadObject } from "./modules/buckets/headObject";
import { ListBuckets } from "./modules/buckets/listBuckets";
import { ListObjects } from "./modules/buckets/listObjects";
import { MoveObject } from "./modules/buckets/moveObject";
import { CompleteUpload } from "./modules/buckets/multipart/completeUpload";
import { CreateUpload } from "./modules/buckets/multipart/createUpload";
import { PartUpload } from "./modules/buckets/multipart/partUpload";
import { PutMetadata } from "./modules/buckets/putMetadata";
import { PutObject } from "./modules/buckets/putObject";
import { receiveEmail } from "./modules/emails/receiveEmail";
import { SendEmail } from "./modules/emails/sendEmail";
import { GetInfo } from "./modules/server/getInfo";
import type {
AppContext,
AppEnv,
AppVariables,
BasicAuth,
R2ExplorerConfig,
} from "./types";
export function R2Explorer(config?: R2ExplorerConfig) {
config = config || {};
if (config.readonly !== false) config.readonly = true;
extendZodWithOpenApi(z);
config = config || {};
if (config.readonly !== false) config.readonly = true;
const openapiSchema = {
info: {
title: "R2 Explorer API",
version: settings.version
}
};
const openapiSchema: OpenAPIObjectConfigV31 = {
openapi: "3.1.0",
info: {
title: "R2 Explorer API",
version: settings.version,
},
};
if (config.basicAuth) {
openapiSchema["security"] = [
{
basicAuth: []
}
];
}
if (config.basicAuth) {
openapiSchema["security"] = [
{
basicAuth: [],
},
];
}
const router = OpenAPIRouter({
schema: openapiSchema
});
const { preflight, corsify } = createCors({
methods: ['*']
});
const app = new Hono<{ Bindings: AppEnv; Variables: AppVariables }>();
app.use("*", async (c, next) => {
c.set("config", config);
await next();
});
if (config.cors === true) {
router.all("/api*", preflight);
}
const openapi = fromHono(app, {
schema: openapiSchema,
raiseUnknownParameters: true,
generateOperationIds: false,
});
// Check Access JWT first
if (config.cfAccessTeamName) {
router.all("*", validateAccessJwt);
}
if (config.cors === true) {
app.use(
"*",
cors({
origin: "*",
allowMethods: ["*"],
credentials: true,
}),
);
}
// Check basic auth
if (config.basicAuth) {
router.registry.registerComponent(
"securitySchemes",
"basicAuth",
{
type: "http",
scheme: "basic"
}
);
router.all("/api*", validateBasicAuth);
}
if (config.readonly === true) {
app.use(readOnlyMiddleware);
}
router.all("/api/server/*", serverRouter);
router.all("/api/buckets/*", bucketsRouter);
router.all("/api/emails/*", emailsRouter);
if (config.cfAccessTeamName) {
app.use(accessMiddleware);
}
router.original.get("*", dashboardProxy);
if (config.basicAuth) {
openapi.registry.registerComponent("securitySchemes", "basicAuth", {
type: "http",
scheme: "basic",
});
app.use(
basicAuth({
verifyUser: (username, password, c: AppContext) => {
const users = (
Array.isArray(c.get("config").basicAuth)
? c.get("config").basicAuth
: [c.get("config").basicAuth]
) as BasicAuth[];
router.all("*", () => Response.json({ msg: "404, not found!" }, { status: 404 }));
for (const user of users) {
if (user.username === username && user.password === password) {
c.set("username", username);
return true;
}
}
return {
async email(event, env, context) {
await receiveEmail(event, env, {
executionContext: context, config: config
});
},
async fetch(request, env, context) {
let resp = await router.handle(request, env, {
executionContext: context, config: config
});
return false;
},
}),
);
}
if (config.cors === true) {
resp = corsify(resp);
}
openapi.get("/api/server/config", GetInfo);
return resp;
}
};
openapi.get("/api/buckets", ListBuckets);
openapi.get("/api/buckets/:bucket", ListObjects);
openapi.post("/api/buckets/:bucket/move", MoveObject);
openapi.post("/api/buckets/:bucket/folder", CreateFolder);
openapi.post("/api/buckets/:bucket/upload", PutObject);
openapi.post("/api/buckets/:bucket/multipart/create", CreateUpload);
openapi.post("/api/buckets/:bucket/multipart/upload", PartUpload);
openapi.post("/api/buckets/:bucket/multipart/complete", CompleteUpload);
openapi.post("/api/buckets/:bucket/delete", DeleteObject);
openapi.on("head", "/api/buckets/:bucket/:key", HeadObject);
openapi.get("/api/buckets/:bucket/:key/head", HeadObject); // There are some issues with calling the head method
openapi.get("/api/buckets/:bucket/:key", GetObject);
openapi.post("/api/buckets/:bucket/:key", PutMetadata);
openapi.post("/api/emails/send", SendEmail);
app.get("*", dashboardProxy);
app.all("*", () =>
Response.json({ msg: "404, not found!" }, { status: 404 }),
);
return {
// TODO: improve event type
async email(
event: { raw: unknown; rawSize: unknown },
env: AppEnv,
context: ExecutionContext,
) {
await receiveEmail(event, env, context, config);
},
async fetch(request: Request, env: AppEnv, context: ExecutionContext) {
return app.fetch(request, env, context);
},
};
}
-23
View File
@@ -1,23 +0,0 @@
export interface BasicAuth {
username: string
password: string
}
export interface R2ExplorerConfig {
readonly?: boolean
cors?: boolean
cfAccessTeamName?: string
dashboardUrl?: string
emailRouting?: {
targetBucket: string
},
showHiddenFiles?: boolean
cacheAssets?: boolean
basicAuth?: BasicAuth | BasicAuth[]
}
export interface Context {
config: R2ExplorerConfig
username?: string
executionContext: ExecutionContext
}
@@ -0,0 +1,34 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../types";
export class CreateFolder extends OpenAPIRoute {
schema = {
operationId: "post-bucket-create-folder",
tags: ["Buckets"],
summary: "Create folder",
request: {
params: z.object({
bucket: z.string(),
}),
body: {
content: {
"application/json": {
schema: z.object({
key: z.string().describe("base64 encoded file key"),
}),
},
},
},
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
const key = decodeURIComponent(escape(atob(data.body.key)));
return await bucket.put(key, "R2 Explorer Folder");
}
}
@@ -0,0 +1,36 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../types";
export class DeleteObject extends OpenAPIRoute {
schema = {
operationId: "post-bucket-delete-object",
tags: ["Buckets"],
summary: "Delete object",
request: {
params: z.object({
bucket: z.string(),
}),
body: {
content: {
"application/json": {
schema: z.object({
key: z.string().describe("base64 encoded file key"),
}),
},
},
},
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
const key = decodeURIComponent(escape(atob(data.body.key)));
await bucket.delete(key);
return { success: true };
}
}
@@ -0,0 +1,56 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../types";
export class GetObject extends OpenAPIRoute {
schema = {
operationId: "get-bucket-object",
tags: ["Buckets"],
summary: "Get Object",
request: {
params: z.object({
bucket: z.string(),
key: z.string().describe("base64 encoded file key"),
}),
},
responses: {
"200": {
description: "File binary",
schema: z.string().openapi({ format: "binary" }),
},
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
let filePath;
try {
filePath = decodeURIComponent(escape(atob(data.params.key)));
} catch (e) {
filePath = decodeURIComponent(
escape(atob(decodeURIComponent(data.params.key))),
);
}
const object = await bucket.get(filePath);
if (object === null) {
return Response.json({ msg: "Object Not Found" }, { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
headers.set(
"Content-Disposition",
`attachment; filename="${filePath.split("/").pop()}"`,
);
return new Response(object.body, {
headers,
});
}
}
@@ -0,0 +1,40 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../types";
export class HeadObject extends OpenAPIRoute {
schema = {
operationId: "Head-bucket-object",
tags: ["Buckets"],
summary: "Get Object",
request: {
params: z.object({
bucket: z.string(),
key: z.string().describe("base64 encoded file key"),
}),
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
let filePath;
try {
filePath = decodeURIComponent(escape(atob(data.params.key)));
} catch (e) {
filePath = decodeURIComponent(
escape(atob(decodeURIComponent(data.params.key))),
);
}
const object = await bucket.head(filePath);
if (object === null) {
return Response.json({ msg: "Object Not Found" }, { status: 404 });
}
return object;
}
}
@@ -0,0 +1,25 @@
import { OpenAPIRoute } from "chanfana";
import type { AppContext } from "../../types";
export class ListBuckets extends OpenAPIRoute {
schema = {
operationId: "get-bucket-list",
tags: ["Buckets"],
summary: "List buckets",
};
async handle(c: AppContext) {
const buckets = [];
for (const [key, value] of Object.entries(c.env)) {
// @ts-ignore - check if the field in Env is actually a R2 bucket by its properties
if (value.get && value.put) {
buckets.push({ name: key });
}
}
return {
buckets: buckets,
};
}
}
@@ -0,0 +1,46 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../types";
export class ListObjects extends OpenAPIRoute {
schema = {
operationId: "get-bucket-list-objects",
tags: ["Buckets"],
summary: "List objects",
request: {
params: z.object({
bucket: z.string(),
}),
query: z.object({
limit: z.number().optional(),
prefix: z
.string()
.nullable()
.optional()
.describe("base64 encoded prefix"),
cursor: z.string().nullable().optional(),
delimiter: z.string().nullable().optional(),
startAfter: z.string().nullable().optional(),
include: z.enum(["httpMetadata", "customMetadata"]).array().optional(),
}),
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
return await bucket.list({
limit: data.query.limit,
prefix: data.query.prefix
? decodeURIComponent(escape(atob(data.query.prefix)))
: undefined,
cursor: data.query.cursor,
startAfter: data.query.startAfter,
delimiter: data.query.delimiter ? data.query.delimiter : "",
// @ts-ignore
include: data.query.include,
});
}
}
@@ -0,0 +1,44 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../types";
export class MoveObject extends OpenAPIRoute {
schema = {
operationId: "post-bucket-move-object",
tags: ["Buckets"],
summary: "Move object",
request: {
params: z.object({
bucket: z.string(),
}),
body: {
content: {
"application/json": {
schema: z.object({
oldKey: z.string().describe("base64 encoded file key"),
newKey: z.string().describe("base64 encoded file key"),
}),
},
},
},
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
const oldKey = decodeURIComponent(escape(atob(data.body.oldKey)));
const newKey = decodeURIComponent(escape(atob(data.body.newKey)));
const object = await bucket.get(oldKey);
const resp = await bucket.put(newKey, object.body, {
customMetadata: object.customMetadata,
httpMetadata: object.httpMetadata,
});
await bucket.delete(oldKey);
return resp;
}
}
@@ -0,0 +1,55 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../../types";
export class CompleteUpload extends OpenAPIRoute {
schema = {
operationId: "post-multipart-complete-upload",
tags: ["Multipart"],
summary: "Complete upload",
request: {
params: z.object({
bucket: z.string(),
}),
body: {
content: {
"application/json": {
schema: z.object({
uploadId: z.string(),
parts: z
.object({
etag: z.string(),
partNumber: z.number().int(),
})
.array(),
key: z.string().describe("base64 encoded file key"),
}),
},
},
},
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
const uploadId = data.body.uploadId;
const key = decodeURIComponent(escape(atob(data.body.key)));
const parts = data.body.parts;
const multipartUpload = await bucket.resumeMultipartUpload(key, uploadId);
try {
const resp = await multipartUpload.complete(parts);
return {
success: true,
str: resp,
};
} catch (error: any) {
return Response.json({ msg: error.message }, { status: 400 });
}
}
}
@@ -0,0 +1,56 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../../types";
export class CreateUpload extends OpenAPIRoute {
schema = {
operationId: "post-multipart-create-upload",
tags: ["Multipart"],
summary: "Create upload",
request: {
params: z.object({
bucket: z.string(),
}),
query: z.object({
key: z.string().describe("base64 encoded file key"),
customMetadata: z
.string()
.nullable()
.optional()
.describe("base64 encoded json string"),
httpMetadata: z
.string()
.nullable()
.optional()
.describe("base64 encoded json string"),
}),
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
const key = decodeURIComponent(escape(atob(data.query.key)));
let customMetadata = undefined;
if (data.query.customMetadata) {
customMetadata = JSON.parse(
decodeURIComponent(escape(atob(data.query.customMetadata))),
);
}
let httpMetadata = undefined;
if (data.query.httpMetadata) {
httpMetadata = JSON.parse(
decodeURIComponent(escape(atob(data.query.httpMetadata))),
);
}
return await bucket.createMultipartUpload(key, {
customMetadata: customMetadata,
httpMetadata: httpMetadata,
});
}
}
@@ -0,0 +1,53 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../../types";
export class PartUpload extends OpenAPIRoute {
schema = {
operationId: "post-multipart-part-upload",
tags: ["Multipart"],
summary: "Part upload",
request: {
body: {
content: {
"application/octet-stream": {
schema: {
type: "string",
format: "binary",
},
},
},
},
params: z.object({
bucket: z.string(),
}),
query: z.object({
key: z.string().describe("base64 encoded file key"),
uploadId: z.string(),
partNumber: z.number().int(),
}),
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
const key = decodeURIComponent(escape(atob(data.query.key)));
const multipartUpload = bucket.resumeMultipartUpload(
key,
data.query.uploadId,
);
try {
return await multipartUpload.uploadPart(
data.query.partNumber,
c.req.raw.body,
);
} catch (error: any) {
return new Response(error.message, { status: 400 });
}
}
}
@@ -0,0 +1,48 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../types";
export class PutMetadata extends OpenAPIRoute {
schema = {
operationId: "post-bucket-put-object-metadata",
tags: ["Buckets"],
summary: "Update object metadata",
request: {
params: z.object({
bucket: z.string(),
key: z.string().describe("base64 encoded file key"),
}),
body: {
content: {
"application/json": {
schema: z
.object({
customMetadata: z.record(z.string(), z.any()),
})
.openapi("Object metadata"),
},
},
},
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
let filePath;
try {
filePath = decodeURIComponent(escape(atob(data.params.key)));
} catch (e) {
filePath = decodeURIComponent(
escape(atob(decodeURIComponent(data.params.key))),
);
}
const object = await bucket.get(filePath);
return await bucket.put(filePath, object.body, {
customMetadata: data.body.customMetadata,
});
}
}
@@ -0,0 +1,66 @@
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../types";
export class PutObject extends OpenAPIRoute {
schema = {
operationId: "post-bucket-upload-object",
tags: ["Buckets"],
summary: "Upload object",
request: {
body: {
content: {
"application/octet-stream": {
schema: {
type: "string",
format: "binary",
},
},
},
},
params: z.object({
bucket: z.string(),
}),
query: z.object({
key: z.string().describe("base64 encoded file key"),
customMetadata: z
.string()
.nullable()
.optional()
.describe("base64 encoded json string"),
httpMetadata: z
.string()
.nullable()
.optional()
.describe("base64 encoded json string"),
}),
},
};
async handle(c: AppContext) {
const data = await this.getValidatedData<typeof this.schema>();
const bucket = c.env[data.params.bucket];
const key = decodeURIComponent(escape(atob(data.query.key)));
let customMetadata = undefined;
if (data.query.customMetadata) {
customMetadata = JSON.parse(
decodeURIComponent(escape(atob(data.query.customMetadata))),
);
}
let httpMetadata = undefined;
if (data.query.httpMetadata) {
httpMetadata = JSON.parse(
decodeURIComponent(escape(atob(data.query.httpMetadata))),
);
}
return await bucket.put(key, c.req.raw.body, {
customMetadata: customMetadata,
httpMetadata: httpMetadata,
});
}
}
@@ -0,0 +1,77 @@
import type { ExecutionContext } from "hono";
import PostalMime from "postal-mime";
import { getCurrentTimestampMilliseconds } from "../../foundation/dates";
import type { AppEnv, R2ExplorerConfig } from "../../types";
async function streamToArrayBuffer(stream, streamSize) {
const result = new Uint8Array(streamSize);
let bytesRead = 0;
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
result.set(value, bytesRead);
bytesRead += value.length;
}
return result;
}
export async function receiveEmail(
event: { raw: unknown; rawSize: unknown },
env: AppEnv,
ctx: ExecutionContext,
config: R2ExplorerConfig,
) {
let bucket;
if (
config?.emailRouting?.targetBucket &&
env[config.emailRouting.targetBucket]
) {
bucket = env[config.emailRouting.targetBucket];
}
if (!bucket) {
// Bucket not set, default to first defined
for (const [key, value] of Object.entries(env)) {
// @ts-ignore
if (value.get && value.put) {
bucket = value;
break;
}
}
}
const rawEmail = await streamToArrayBuffer(event.raw, event.rawSize);
const parser = new PostalMime();
const parsedEmail = await parser.parse(rawEmail);
const emailPath = `${getCurrentTimestampMilliseconds()}-${crypto.randomUUID()}`;
await bucket.put(
`.r2-explorer/emails/inbox/${emailPath}.json`,
JSON.stringify(parsedEmail),
{
customMetadata: {
subject: parsedEmail.subject,
from_address: parsedEmail.from?.address,
from_name: parsedEmail.from?.name,
to_address:
parsedEmail.to.length > 0 ? parsedEmail.to[0].address : null,
to_name: parsedEmail.to.length > 0 ? parsedEmail.to[0].name : null,
has_attachments: parsedEmail.attachments.length > 0,
read: false,
timestamp: Date.now(),
},
},
);
for (const att of parsedEmail.attachments) {
await bucket.put(
`.r2-explorer/emails/inbox/${emailPath}/${att.filename}`,
att.content,
);
}
}
@@ -0,0 +1,68 @@
import { OpenAPIRoute, Str } from "chanfana";
import { z } from "zod";
import type { AppContext } from "../../types";
export class SendEmail extends OpenAPIRoute {
schema = {
operationId: "post-email-send",
tags: ["Emails"],
summary: "Send Email",
request: {
body: {
content: {
"application/json": {
schema: z.object({
subject: Str({ example: "Look! No servers" }),
from: z.object({
email: Str({ example: "[email protected]" }),
name: Str({ example: "Workers - MailChannels integration" }),
}),
to: z
.object({
email: Str({ example: "[email protected]" }),
name: Str({ example: "Test Recipient" }),
})
.array(),
content: z.object({}).catchall(z.string()),
}),
},
},
},
},
};
async handle(c: AppContext) {
if (c.get("config").readonly === true)
return Response.json({ msg: "unauthorized" }, { status: 401 });
// TODO: re-enable this with cloudflare workers
// const emailResp = await fetch("https://api.mailchannels.net/tx/v1/send", {
// method: "POST",
// headers: {
// "content-type": "application/json",
// },
// body: JSON.stringify({
// personalizations: [
// {
// to: data.body.to,
// },
// ],
// from: data.body.from,
// subject: data.body.subject,
// content: [
// {
// type: "text/plain",
// value: data.body.content["text/plain"],
// },
// ],
// }),
// });
return {
success: false,
error: "unavailable",
};
// return { msg: await emailResp.text() };
}
}
@@ -0,0 +1,23 @@
import { OpenAPIRoute } from "chanfana";
import { settings } from "../../foundation/settings";
import type { AppContext } from "../../types";
export class GetInfo extends OpenAPIRoute {
schema = {
operationId: "get-server-info",
tags: ["Server"],
summary: "Get server info",
};
async handle(c: AppContext) {
const { basicAuth, ...config } = c.get("config");
return {
version: settings.version,
config: config,
user: {
username: c.get("username"),
},
};
}
}
-30
View File
@@ -1,30 +0,0 @@
import {OpenAPIRoute} from "@cloudflare/itty-router-openapi";
import {Context} from "../../interfaces";
import {OpenAPIRouteSchema} from "@cloudflare/itty-router-openapi/dist/src/types";
import {config} from "../../settings";
export class GetInfo extends OpenAPIRoute {
static schema: OpenAPIRouteSchema = {
operationId: 'get-server-info',
tags: ['Server'],
summary: 'Get server info',
}
async handle(
request: Request,
env: any,
context: Context,
data: any
) {
const serverConfig = {...context.config}
delete serverConfig.basicAuth
return {
version: config.version,
config: serverConfig,
user: {
username: context.username
}
}
}
}
-12
View File
@@ -1,12 +0,0 @@
import {config} from "../settings";
import {GetInfo} from "./api/getInfo";
import {OpenAPIRouter} from "@cloudflare/itty-router-openapi";
export const serverRouter = OpenAPIRouter({
base: '/api/server',
raiseUnknownParameters: config.raiseUnknownParameters,
generateOperationIds: config.generateOperationIds,
})
serverRouter.get('/config', GetInfo)
-5
View File
@@ -1,5 +0,0 @@
export const config = {
raiseUnknownParameters: true,
generateOperationIds: false,
version: '0.0.1'
}
+28
View File
@@ -0,0 +1,28 @@
import type { Context } from "hono";
export type BasicAuth = {
username: string;
password: string;
};
export type R2ExplorerConfig = {
readonly?: boolean;
cors?: boolean;
cfAccessTeamName?: string;
dashboardUrl?: string;
emailRouting?: {
targetBucket: string;
};
showHiddenFiles?: boolean;
cacheAssets?: boolean;
basicAuth?: BasicAuth | BasicAuth[];
};
export type AppEnv = {
[key: string]: R2Bucket;
};
export type AppVariables = {
config: R2ExplorerConfig;
username?: string;
};
export type AppContext = Context<{ Bindings: AppEnv; Variables: AppVariables }>;
-29
View File
@@ -1,29 +0,0 @@
/* eslint-disable */
const fs = require('fs')
const Path = require('path')
/* eslint-enable */
const deleteFolderRecursive = (path) => {
if (fs.existsSync(path)) {
fs.readdirSync(path).forEach((file) => {
const curPath = Path.join(path, file)
if (fs.lstatSync(curPath).isDirectory()) {
deleteFolderRecursive(curPath)
} else {
fs.unlinkSync(curPath)
}
})
fs.rmdirSync(path)
}
}
const folder = process.argv.slice(2)[0]
if (folder) {
deleteFolderRecursive(Path.join(__dirname, '../dist', folder))
} else {
deleteFolderRecursive(Path.join(__dirname, '../dist/cjs'))
deleteFolderRecursive(Path.join(__dirname, '../dist/esm'))
deleteFolderRecursive(Path.join(__dirname, '../dist/umd'))
deleteFolderRecursive(Path.join(__dirname, '../dist/types'))
}
-21
View File
@@ -1,21 +0,0 @@
/* eslint-disable */
const fs = require('fs')
const Path = require('path')
const fileName = '../package.json'
const file = require(fileName)
/* eslint-enable */
const args = process.argv.slice(2)
for (let i = 0, l = args.length; i < l; i++) {
if (i % 2 === 0) {
file[args[i]] = args[i + 1]
}
}
fs.writeFile(Path.join(__dirname, fileName), JSON.stringify(file, null, 2), (err) => {
if (err) {
return console.log(err)
}
console.log('Writing to ' + fileName)
})
+12 -26
View File
@@ -1,28 +1,14 @@
{
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"baseUrl": "src",
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,
"inlineSourceMap": false,
"lib": ["esnext"],
"listEmittedFiles": false,
"listFiles": false,
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"pretty": true,
"resolveJsonModule": true,
"rootDir": ".",
"skipLibCheck": true,
"strict": false,
"traceResolution": false,
"outDir": "",
"target": "esnext",
"module": "esnext",
"types": ["@types/node", "@types/service-worker-mock", "@cloudflare/workers-types"]
},
"exclude": ["node_modules", "dist"],
"include": ["src", "scripts"]
"compilerOptions": {
"target": "esNext",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": false,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"types": ["@types/node", "@cloudflare/workers-types"]
},
"include": ["src/*.ts", "src/**/*.ts"]
}
+3619 -4123
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More