├── .nvmrc ├── public ├── pug │ ├── partials │ │ ├── head_custom.pug │ │ ├── footer.pug │ │ └── head.pug │ ├── admin.pug │ ├── download.pug │ ├── upload.pug │ └── error.pug ├── app │ └── .gitignore ├── robots.txt └── assets │ ├── favicon.ico │ └── polyfill.js ├── cli.js ├── app ├── .gitignore ├── README.md ├── src │ ├── download.js │ ├── admin.js │ ├── upload.js │ ├── common │ │ ├── util.js │ │ ├── Modal.vue │ │ ├── FileIcon.vue │ │ └── Clipboard.vue │ ├── Upload │ │ ├── store.js │ │ ├── store │ │ │ ├── config.js │ │ │ └── upload.js │ │ ├── Settings.vue │ │ └── Files.vue │ ├── Download │ │ └── PreviewModal.vue │ ├── Admin.vue │ ├── Upload.vue │ └── Download.vue ├── package.json └── webpack.config.js ├── docs ├── psitransfer.gif ├── PsiTransfer-Admin.png ├── psitransfer.service ├── layout-customization.md ├── deployment-docker.md ├── deployment-systemd.md ├── nginx-ssl-example.conf └── configuration.md ├── .gitignore ├── .dockerignore ├── lib ├── eventBus.js ├── tusboy │ ├── handlers │ │ ├── error.js │ │ ├── options.js │ │ ├── head.js │ │ ├── patch.js │ │ └── post.js │ ├── store │ │ └── errors.js │ ├── constants.js │ ├── tus-metadata.js │ ├── errors.js │ ├── index.js │ └── tus-header-parser.js ├── utils.js ├── store.js ├── db.js └── endpoints.js ├── .editorconfig ├── plugins ├── file-uploaded-webhook.js └── file-downloaded-webhook.js ├── config.dev.js ├── Dockerfile ├── tests └── e2e │ ├── 02_download.js │ ├── helper.js │ └── 01_upload.js ├── LICENSE ├── scripts ├── create-bundle.sh ├── traffic-limit.sh └── browserstack-test.sh ├── .github └── workflows │ ├── docker_build_master.yaml │ └── build_release.yaml ├── package.json ├── lang ├── zh.js ├── zh-tw.js ├── ko.js ├── ja.js ├── tr.js ├── ru.js ├── en.js ├── nl.js ├── sv.js ├── pl.js ├── pt.js ├── it.js ├── de.js ├── el.js ├── fr.js └── es.js ├── app.js ├── config.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 2 | -------------------------------------------------------------------------------- /public/pug/partials/head_custom.pug: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./app'); 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /docs/psitransfer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/psitransfer/HEAD/docs/psitransfer.gif -------------------------------------------------------------------------------- /public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/psitransfer/HEAD/public/assets/favicon.ico -------------------------------------------------------------------------------- /docs/PsiTransfer-Admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psi-4ward/psitransfer/HEAD/docs/PsiTransfer-Admin.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .history 3 | screenshots 4 | data 5 | temp 6 | node_modules 7 | npm-debug.log 8 | _releases 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | data 3 | .travis.yml 4 | screenshots 5 | tmp 6 | temp 7 | node_modules 8 | app/node_modules 9 | npm-debug.log 10 | scripts 11 | docs 12 | _releases 13 | -------------------------------------------------------------------------------- /public/pug/partials/footer.pug: -------------------------------------------------------------------------------- 1 | footer.footer 2 | .container.text-right 3 | span.text-muted 4 | a(href='https://github.com/psi-4ward/psitransfer' target='_blank') Powered by PsiTransfer 5 | -------------------------------------------------------------------------------- /lib/eventBus.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const emitter = new EventEmitter(); 3 | 4 | emitter.on('uncaughtException', function(err) { 5 | console.error(err); 6 | }); 7 | 8 | module.exports = emitter; 9 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # PsiTransfer Upload / Download App 2 | 3 | ## Build Setup 4 | 5 | ``` bash 6 | # install dependencies 7 | npm install 8 | 9 | # serve with hot reload at localhost:8080 10 | npm run dev 11 | 12 | # build for production with minification 13 | npm run build 14 | ``` 15 | -------------------------------------------------------------------------------- /public/assets/polyfill.js: -------------------------------------------------------------------------------- 1 | // stupid old browsers 2 | if(typeof Promise === 'undefined') { 3 | (function(d, script) { 4 | script = d.createElement('script'); 5 | script.type = 'text/javascript'; 6 | script.async = false; 7 | script.src = '/assets/babel-polyfill.js'; 8 | d.getElementsByTagName('head')[0].appendChild(script); 9 | }(document)); 10 | } 11 | -------------------------------------------------------------------------------- /docs/psitransfer.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=PsiTransfer 3 | Documentation=https://github.com/psi-4ward/psitransfer 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=psitransfer 9 | Restart=on-failure 10 | 11 | Environment=NODE_ENV=production 12 | Environment=PSITRANSFER_PORT=8080 13 | 14 | WorkingDirectory=/opt/psitransfer 15 | ExecStart=/usr/bin/node app.js 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | end_of_line = lf 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | 16 | [*.{md,pug}] 17 | trim_trailing_whitespace = false 18 | 19 | [*.yml] 20 | indent_style = space 21 | -------------------------------------------------------------------------------- /lib/tusboy/handlers/error.js: -------------------------------------------------------------------------------- 1 | module.exports = (err, req, res, next) => { 2 | if (res.headersSent) return next(err) 3 | if (!err.status) { 4 | // console.error(err) 5 | return next(err) 6 | } 7 | if (!err.expose) return next(err) 8 | if (!(err instanceof Error)) return next(err) 9 | 10 | res.statusCode = err.status 11 | res.json({ 12 | name: err.name, 13 | message: err.message, 14 | details: err.details, 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /lib/tusboy/store/errors.js: -------------------------------------------------------------------------------- 1 | class StoreError extends Error { 2 | } 3 | 4 | class UploadNotFound extends StoreError { 5 | } 6 | 7 | class OffsetMismatch extends StoreError { 8 | } 9 | 10 | class KeyNotFound extends StoreError { 11 | } 12 | 13 | class UploadLocked extends StoreError { 14 | } 15 | 16 | module.exports = StoreError; 17 | module.exports.UploadNotFound = UploadNotFound; 18 | module.exports.OffsetMismatch = OffsetMismatch; 19 | module.exports.KeyNotFound = KeyNotFound; 20 | module.exports.UploadLocked = UploadLocked; 21 | -------------------------------------------------------------------------------- /lib/tusboy/handlers/options.js: -------------------------------------------------------------------------------- 1 | const cors = require('cors') 2 | const { ALLOWED_HEADERS, ALLOWED_METHODS, MAX_AGE } = require ('../constants'); 3 | 4 | const tusExtension = (extensions = []) => { 5 | if (!extensions.length) { 6 | return (req, res, next) => next() 7 | } 8 | return (req, res, next) => { 9 | res.set('Tus-Extension', extensions.join(',')) 10 | next() 11 | } 12 | } 13 | 14 | const corsPreflight = (extraMethods) => cors({ 15 | methods: [...ALLOWED_METHODS, ...extraMethods], 16 | allowedHeaders: ALLOWED_HEADERS, 17 | maxAge: MAX_AGE, 18 | }) 19 | 20 | module.exports = (extensions, extraMethods = []) => ([ 21 | tusExtension(extensions), 22 | corsPreflight(extraMethods), 23 | ]) 24 | -------------------------------------------------------------------------------- /plugins/file-uploaded-webhook.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('psitransfer:plugin:file-uploaded-webook'); 2 | const axios = require('axios'); 3 | 4 | module.exports = function setupFileUploadedWebhook(eventBus, app, config, db) { 5 | debug('Setup plugin'); 6 | if (!config.fileUploadedWebhook) { 7 | debug('No fileUploadedWebhook configured. Plugin disabled.'); 8 | return; 9 | } 10 | 11 | function downloadWebook({ metadata }) { 12 | debug('Trigger: ' + config.fileUploadedWebhook); 13 | axios.post(config.fileUploadedWebhook, { 14 | metadata, 15 | date: Date.now() 16 | }).catch(err => console.error(err)); 17 | } 18 | 19 | eventBus.on('fileUploaded', downloadWebook); 20 | } 21 | -------------------------------------------------------------------------------- /config.dev.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "accessLog": 'dev', 3 | "retentions": { 4 | "one-time": "one time download", 5 | "60": "1 Minute", 6 | "300": "5 Minutes", 7 | "3600": "1 Hour", 8 | "21600": "6 Hours", 9 | "86400": "1 Day", 10 | "259200": "3 Days", 11 | "604800": "1 Week", 12 | "1209600": "2 Weeks" 13 | }, 14 | "defaultRetention": "3600", 15 | "adminPass": "admin", 16 | "uploadPass": false, 17 | "baseUrl": '/', 18 | "uploadAppPath": '/', 19 | // "maxFileSize": Math.pow(2, 20) * 15, 20 | // "maxBucketSize": Math.pow(2, 20) * 20, 21 | "mailFrom": "PsiTransfer " 22 | // "sslKeyFile": './tmp/cert.key', 23 | // "sslCertFile": './tmp/cert.pem', 24 | }; 25 | -------------------------------------------------------------------------------- /lib/tusboy/constants.js: -------------------------------------------------------------------------------- 1 | const HEADERS = [ 2 | 'Content-Type', 3 | 'Location', 4 | 'Tus-Extension', 5 | 'Tus-Max-Size', 6 | 'Tus-Resumable', 7 | 'Tus-Version', 8 | 'Upload-Defer-Length', 9 | 'Upload-Length', 10 | 'Upload-Metadata', 11 | 'Upload-Offset', 12 | 'X-HTTP-Method-Override', 13 | 'X-Requested-With', 14 | ]; 15 | 16 | const MAX_AGE = 86400; 17 | const ALLOWED_HEADERS = HEADERS; 18 | const EXPOSED_HEADERS = HEADERS; 19 | 20 | const ALLOWED_METHODS = [ 21 | 'POST', 22 | 'HEAD', 23 | 'PATCH', 24 | 'OPTIONS', 25 | ]; 26 | 27 | const TUS_VERSION = '1.0.0'; 28 | 29 | module.exports = { 30 | MAX_AGE, 31 | ALLOWED_HEADERS, 32 | EXPOSED_HEADERS, 33 | ALLOWED_METHODS, 34 | TUS_VERSION, 35 | } 36 | -------------------------------------------------------------------------------- /plugins/file-downloaded-webhook.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('psitransfer:plugin:file-downloaded-webook'); 2 | const axios = require('axios'); 3 | 4 | module.exports = function setupFileDownloadedWebhook(eventBus, app, config, db) { 5 | debug('Setup plugin'); 6 | 7 | if (!config.fileDownloadedWebhook) { 8 | debug('No fileDownloadedWebhook configured. Plugin disabled.'); 9 | return; 10 | } 11 | 12 | function downloadWebook({ sid, file }) { 13 | debug('Trigger: ' + config.fileDownloadedWebhook); 14 | axios.post(config.fileDownloadedWebhook, { 15 | fid: sid, 16 | file, 17 | date: Date.now() 18 | }).catch(err => console.error(err)); 19 | } 20 | 21 | eventBus.on('archiveDownloaded', downloadWebook); 22 | eventBus.on('fileDownloaded', downloadWebook); 23 | } 24 | -------------------------------------------------------------------------------- /public/pug/partials/head.pug: -------------------------------------------------------------------------------- 1 | base(href=baseUrl) 2 | meta(charset='utf-8') 3 | title PsiTransfer 4 | link(href='assets/favicon.ico' rel='icon' type='image/x-icon') 5 | meta(name='viewport' content='width=device-width, initial-scale=1') 6 | meta(name='robots' content='noindex,nofollow') 7 | meta(http-equiv='x-ua-compatible' content='IE=edge') 8 | link(href='assets/styles.css' rel='stylesheet') 9 | script. 10 | // Add ployfills for stupid IE 11 | if (document.documentMode || /Edge/.test(navigator.userAgent)) { 12 | (function(d, script) { 13 | script = d.createElement('script'); 14 | script.type = 'text/javascript'; 15 | script.async = false; 16 | script.src = 'assets/babel-polyfill.js'; 17 | d.getElementsByTagName('head')[0].appendChild(script); 18 | }(document)); 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | ENV PSITRANSFER_UPLOAD_DIR=/data \ 4 | NODE_ENV=production 5 | 6 | MAINTAINER Christoph Wiechert 7 | 8 | RUN apk add --no-cache tzdata 9 | 10 | WORKDIR /app 11 | 12 | ADD *.js package.json package-lock.json README.md /app/ 13 | ADD lib /app/lib 14 | ADD app /app/app 15 | ADD lang /app/lang 16 | ADD plugins /app/plugins 17 | ADD public /app/public 18 | 19 | # Rebuild the frontend apps 20 | RUN cd app && \ 21 | NODE_ENV=dev npm ci && \ 22 | npm run build && \ 23 | cd .. && \ 24 | mkdir /data && \ 25 | chown node /data && \ 26 | npm ci && \ 27 | rm -rf app 28 | 29 | EXPOSE 3000 30 | VOLUME ["/data"] 31 | 32 | USER node 33 | 34 | # HEALTHCHECK CMD wget -O /dev/null -q http://localhost:3000 35 | 36 | CMD ["node", "app.js"] 37 | -------------------------------------------------------------------------------- /lib/tusboy/tus-metadata.js: -------------------------------------------------------------------------------- 1 | const toObject = require('to-object-reducer'); 2 | 3 | // return object 4 | const base64decode = str => Buffer.from(str, 'base64').toString('utf8') 5 | const base64encode = str => Buffer.from(str, 'utf8').toString('base64') 6 | 7 | module.exports.decode = (str = '') => { 8 | const keypairs = str 9 | .split(',') 10 | // .map(s => s.trim()) 11 | const keyvals = keypairs 12 | .map(s => s.split(' ')) 13 | .filter(arr => arr.length === 2) 14 | .filter(([key]) => key !== '') 15 | .map(([key, val]) => [key, base64decode(val)]) 16 | return keyvals.reduce(toObject, {}) 17 | } 18 | 19 | module.exports.encode = (obj = {}) => Object 20 | .keys(obj) 21 | .map(key => [key, obj[key]]) 22 | .map(([key, val]) => [key, base64encode(val)]) 23 | .map(kvArr => kvArr.join(' ')) 24 | .join(',') 25 | -------------------------------------------------------------------------------- /public/pug/admin.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | head 4 | include partials/head 5 | include partials/head_custom 6 | body 7 | .container 8 | h1 9 | svg.fa-icon(role='presentation' viewbox='0 0 1920 1792' style='fill: #0275D8') 10 | path(d='M1280 864q0-14-9-23l-352-352q-9-9-23-9t-23 9l-351 351q-10 12-10 24 0 14 9 23t23 9h224v352q0 13 9.5 22.5t22.5 9.5h192q13 0 22.5-9.5t9.5-22.5v-352h224q13 0 22.5-9.5t9.5-22.5zM1920 1152q0 159-112.5 271.5t-271.5 112.5h-1088q-185 0-316.5-131.5t-131.5-316.5q0-130 70-240t188-165q-2-30-2-43 0-212 150-362t362-150q156 0 285.5 87t188.5 231q71-62 166-62 106 0 181 75t75 181q0 76-41 138 130 31 213.5 135.5t83.5 238.5z') 11 | | PsiTransfer Admin 12 | hr 13 | #admin 14 | include partials/footer 15 | script(src='app/common.js') 16 | script(src='app/admin.js') 17 | -------------------------------------------------------------------------------- /public/pug/download.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | head 4 | include partials/head 5 | include partials/head_custom 6 | body 7 | .container 8 | h1 9 | svg.fa-icon(role='presentation' viewbox='0 0 1920 1792' style='fill: #0275D8') 10 | path(d='M1280 928q0-14-9-23t-23-9h-224v-352q0-13-9.5-22.5t-22.5-9.5h-192q-13 0-22.5 9.5t-9.5 22.5v352h-224q-13 0-22.5 9.5t-9.5 22.5q0 14 9 23l352 352q9 9 23 9t23-9l351-351q10-12 10-24zM1920 1152q0 159-112.5 271.5t-271.5 112.5h-1088q-185 0-316.5-131.5t-131.5-316.5q0-130 70-240t188-165q-2-30-2-43 0-212 150-362t362-150q156 0 285.5 87t188.5 231q71-62 166-62 106 0 181 75t75 181q0 76-41 138 130 31 213.5 135.5t83.5 238.5z') 11 | | PsiTransfer 12 | hr 13 | #download 14 | include partials/footer 15 | script(src='app/common.js') 16 | script(src='app/download.js') 17 | -------------------------------------------------------------------------------- /docs/layout-customization.md: -------------------------------------------------------------------------------- 1 | # Layout customization 2 | 3 | It is easy to customize the look of PsiTransfer. Almost all functional components are encapsulated 4 | in the upload- and download-app and they only need a root element where they can be mounted. 5 | This elements must have the attribute `id="upload"` and `id="download"`, respectively. 6 | 7 | PsiTransfer uses [Bootstrap 3](http://getbootstrap.com/) as CSS-Framework and custom styles can 8 | be found in [public/assets/styles.css](https://github.com/psi-4ward/psitransfer/blob/master/public/assets/styles.css). 9 | 10 | If you want to give PsiTransfer your own custom look and feel just edit the files in `public/html` and/or 11 | the `styles.css`. If you need to deliver assets like a custom logo put it into the `assets` folder. 12 | 13 | But consider: You have to adopt further changes on PsiTransfer updates. This sould not happen very often. 14 | 15 | -------------------------------------------------------------------------------- /public/pug/upload.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | head 4 | include partials/head 5 | include partials/head_custom 6 | body 7 | #dropHelper 8 | .container 9 | h1 10 | svg.fa-icon(role='presentation' viewbox='0 0 1920 1792' style='fill: #0275D8') 11 | path(d='M1280 864q0-14-9-23l-352-352q-9-9-23-9t-23 9l-351 351q-10 12-10 24 0 14 9 23t23 9h224v352q0 13 9.5 22.5t22.5 9.5h192q13 0 22.5-9.5t9.5-22.5v-352h224q13 0 22.5-9.5t9.5-22.5zM1920 1152q0 159-112.5 271.5t-271.5 112.5h-1088q-185 0-316.5-131.5t-131.5-316.5q0-130 70-240t188-165q-2-30-2-43 0-212 150-362t362-150q156 0 285.5 87t188.5 231q71-62 166-62 106 0 181 75t75 181q0 76-41 138 130 31 213.5 135.5t83.5 238.5z') 12 | | PsiTransfer 13 | hr 14 | #upload 15 | include partials/footer 16 | script(src='app/common.js') 17 | script PSITRANSFER_UPLOAD_PATH = '#{uploadAppPath}'; 18 | script(src='app/upload.js') 19 | -------------------------------------------------------------------------------- /app/src/download.js: -------------------------------------------------------------------------------- 1 | import "regenerator-runtime/runtime.js"; 2 | 3 | if(!String.prototype.startsWith) { 4 | String.prototype.startsWith = function(searchString, position) { 5 | position = position || 0; 6 | return this.indexOf(searchString, position) === position; 7 | }; 8 | } 9 | 10 | import Vue from 'vue'; 11 | import { httpGet } from "./common/util"; 12 | import Download from './Download.vue'; 13 | import Icon from 'vue-awesome/components/Icon' 14 | 15 | Vue.component('icon', Icon); 16 | 17 | new Vue({ 18 | el: '#download', 19 | data: { 20 | baseURI: document.head.getElementsByTagName('base')[0].href.replace(/\/$/,''), 21 | lang: {}, 22 | }, 23 | async beforeCreate() { 24 | // Fetch translations 25 | try { 26 | this.lang = await httpGet('lang.json'); 27 | } 28 | catch (e) { 29 | alert(e); 30 | } 31 | }, 32 | render: h => h(Download) 33 | }); 34 | 35 | window.PSITRANSFER_VERSION = PSITRANSFER_VERSION; 36 | -------------------------------------------------------------------------------- /app/src/admin.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Admin from './Admin.vue'; 3 | import Icon from 'vue-awesome/components/Icon' 4 | 5 | function parseDate(str) { 6 | if(!str) return str; 7 | return new Date(str); 8 | } 9 | 10 | function formatDate(dt) { 11 | if(dt === null) return ""; 12 | const f = function(d) { 13 | return d < 10 ? '0' + d : d; 14 | }; 15 | return dt.getFullYear() + '-' + f(dt.getMonth() + 1) + '-' + f(dt.getDate()) + ' ' + f(dt.getHours()) + ':' + f(dt.getMinutes()); 16 | } 17 | function isDate(d) { 18 | return Object.prototype.toString.call(d) === '[object Date]'; 19 | } 20 | 21 | Vue.filter('date', function(val, format) { 22 | if(!isDate(val)) { 23 | val = parseDate(val); 24 | } 25 | return isDate(val) ? formatDate(val, format) : val; 26 | }); 27 | 28 | Vue.component('icon', Icon); 29 | 30 | new Vue({ 31 | el: '#admin', 32 | data: { 33 | baseURI: document.head.getElementsByTagName('base')[0].href 34 | }, 35 | render: h => h(Admin) 36 | }); 37 | 38 | window.PSITRANSFER_VERSION = PSITRANSFER_VERSION; 39 | -------------------------------------------------------------------------------- /app/src/upload.js: -------------------------------------------------------------------------------- 1 | import "regenerator-runtime/runtime.js"; 2 | 3 | import Vue from 'vue'; 4 | import Upload from './Upload.vue'; 5 | import store from './Upload/store.js'; 6 | import Icon from 'vue-awesome/components/Icon' 7 | import {httpGet} from "./common/util"; 8 | 9 | Vue.component('icon', Icon); 10 | 11 | new Vue({ 12 | el: '#upload', 13 | data: { 14 | baseURI: document.head.getElementsByTagName('base')[0].href.replace(/\/$/), 15 | configFetched: false, 16 | lang: {}, 17 | }, 18 | store, 19 | render: h => h(Upload), 20 | async beforeCreate() { 21 | // Fetch translations 22 | try { 23 | this.lang = await httpGet('lang.json'); 24 | this.$store.commit('LANG', this.lang); 25 | } catch (e) { 26 | alert(e); 27 | } 28 | 29 | // Fetch config 30 | try { 31 | await this.$store.dispatch('config/fetch'); 32 | } catch(e) { 33 | if(e.code !== 'PWDREQ') { 34 | console.error(e); 35 | } 36 | } 37 | this.configFetched = true; 38 | } 39 | }); 40 | 41 | window.PSITRANSFER_VERSION = PSITRANSFER_VERSION; 42 | -------------------------------------------------------------------------------- /app/src/common/util.js: -------------------------------------------------------------------------------- 1 | export async function httpGet(url) { 2 | return new Promise((resolve, reject) => { 3 | const xhr = new XMLHttpRequest(); 4 | xhr.open('GET', url); 5 | xhr.onload = () => { 6 | if (xhr.status === 200) { 7 | try { 8 | resolve(JSON.parse(xhr.responseText)) 9 | } 10 | catch (e) { 11 | reject(e); 12 | } 13 | } else { 14 | reject(new Error(`HTTP-GET error: ${ xhr.status } ${ xhr.statusText }`)) 15 | } 16 | }; 17 | xhr.send(); 18 | }); 19 | } 20 | 21 | export async function httpPost(url, data) { 22 | return new Promise((resolve, reject) => { 23 | const xhr = new XMLHttpRequest(); 24 | xhr.open('POST', url); 25 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 26 | xhr.onload = () => { 27 | if (xhr.status === 200) { 28 | try { 29 | resolve(JSON.parse(xhr.responseText)) 30 | } 31 | catch (e) { 32 | reject(e); 33 | } 34 | } else { 35 | reject(new Error(`HTTP-GET error: ${ xhr.status } ${ xhr.statusText }`)) 36 | } 37 | }; 38 | xhr.send(JSON.stringify(data)); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /tests/e2e/02_download.js: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | import { clearDownloadedFile, compareFiles, sharedData, waitForFileDownload } from './helper'; 3 | 4 | const pwdField = Selector('.well input[type=password]'); 5 | const fileToDownload = 'psitransfer.gif'; 6 | 7 | fixture('PsiTransfer Download') 8 | 9 | test.before(async t => { 10 | clearDownloadedFile(fileToDownload) 11 | }) 12 | ('Download', async t => { 13 | // Open download page 14 | await t.navigateTo(sharedData.shareLink); 15 | 16 | // Expect a password field 17 | await t 18 | .expect(pwdField.exists).ok(); 19 | 20 | // Enter the passowrd 21 | await t 22 | .typeText(pwdField, 'bacon') 23 | .click(Selector('button.decrypt')) 24 | .expect(pwdField.exists).notOk() 25 | .wait(5000) 26 | 27 | // Check file list 28 | await t 29 | .expect(Selector('table.files tr').count).eql(2); 30 | 31 | // Download file and compare the contents 32 | // TODO: find out how to use it with browserstack 33 | // await t.click(Selector('table.files tr').withText(fileToDownload)); 34 | // await waitForFileDownload(fileToDownload); 35 | // await t.expect(await compareFiles('docs/' + fileToDownload, fileToDownload)).ok(); 36 | 37 | }); 38 | -------------------------------------------------------------------------------- /app/src/common/Modal.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 42 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "psitransfer", 3 | "description": "A Vue.js project", 4 | "version": "0.0.0", 5 | "author": "Christoph Wiechert ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "cross-env NODE_ENV=development webpack-dev-server", 9 | "build": "cross-env NODE_ENV=production webpack", 10 | "analyze": "cross-env ANALYZE=true npm run build" 11 | }, 12 | "dependencies": { 13 | "@nuintun/qrcode": "^5.0.2", 14 | "crypto-js": "^4.2.0", 15 | "drag-drop": "^7.2.0", 16 | "regenerator-runtime": "^0.14.1", 17 | "tus-js-client": "^2.3.1", 18 | "uuid": "^11.1.0", 19 | "vue": "^2.7.16", 20 | "vue-awesome": "^4.5.0", 21 | "vuex": "^3.6.2" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.27.4", 25 | "@babel/preset-env": "^7.27.2", 26 | "babel-loader": "^8.2.5", 27 | "cross-env": "^7.0.3", 28 | "css-loader": "^5.2.7", 29 | "file-loader": "^6.2.0", 30 | "pug": "^3.0.3", 31 | "pug-plain-loader": "^1.1.0", 32 | "vue-loader": "^15.9.8", 33 | "vue-template-compiler": "^2.7.16", 34 | "webpack": "^5.99.9", 35 | "webpack-bundle-analyzer": "^4.10.2", 36 | "webpack-cli": "^4.9.2", 37 | "webpack-dev-server": "^4.9.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Christoph Wiechert 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /docs/deployment-docker.md: -------------------------------------------------------------------------------- 1 | # Deployment using Docker 2 | 3 | Using Docker is the most easy and recommended way to run PsiTransfer. There is an 4 | [official Container](https://hub.docker.com/r/psitrax/psitransfer/) which is 5 | updated whenever a GitHub push occurs. 6 | 7 | ```bash 8 | docker run -d -v $PWD/data:/data -p 3000:3000 psitrax/psitransfer 9 | ``` 10 | The above command starts the PsiTransfer Docker container and 11 | * `-d` puts the process into background (daemon mode) 12 | * `-v` mounts the data volume into the container 13 | * `-p` forwards the traffic from port 3000 into the container 14 | 15 | **Protipp**: There are several [container tags](https://hub.docker.com/r/psitrax/psitransfer/tags/) 16 | if you want to use a specific version. E.g. `1` is always the latest stable `1.x.x` and `1.1` 17 | correlates with `1.1.x`. 18 | 19 | If you want to customize some PsiTransfer configurations use environment parameters 20 | by adding `-e` flags to the `docker run` command. 21 | 22 | ```bash 23 | docker run -v $PWD/data:/data -p 3000:8080 \ 24 | -e PSITRANSFER_PORT=8080 \ 25 | -e PSITRANSFER_DEFAULT_RETENTION=3600 \ 26 | psitrax/psitransfer 27 | ``` 28 | 29 | **Protipp**: By adding `--restart always` Docker will autostart the container after reboots. 30 | -------------------------------------------------------------------------------- /app/src/Upload/store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import Vue from 'vue'; 3 | import Vuex from 'vuex'; 4 | Vue.use(Vuex); 5 | 6 | import config from './store/config.js'; 7 | import upload from './store/upload.js'; 8 | 9 | export default new Vuex.Store({ 10 | modules: { 11 | config, 12 | upload 13 | }, 14 | 15 | state: { 16 | error: '', 17 | // disable all input fields 18 | disabled: false, 19 | /* States: 20 | * new: can modify settings and add/remove files 21 | * uploading: probably let user pause/cancel upload 22 | * uploaded: show download link 23 | * uploadError: show retry btn */ 24 | state: 'new', 25 | lang: {} 26 | }, 27 | 28 | getters: { 29 | error: (state, getters) => { 30 | return state.error || getters['upload/bucketSizeError']; 31 | }, 32 | disabled: (state, getters) => { 33 | return !!getters.error || state.disabled; 34 | } 35 | }, 36 | 37 | mutations: { 38 | ERROR(state, msg) { 39 | state.error = msg; 40 | state.disabled = true; 41 | }, 42 | DISABLE(state) { 43 | state.disabled = true; 44 | }, 45 | STATE(state, val) { 46 | state.state = val; 47 | if(val !== 'new') state.disabled = true; 48 | }, 49 | LANG(state, val) { 50 | state.lang = val; 51 | } 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /scripts/create-bundle.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | cd $DIR 5 | 6 | # Get the current tag or use commit hash if there's none 7 | COMMIT=$(git log -n1 --pretty='%h' 2>/dev/null || echo current) 8 | NAME=$(git describe --exact-match --tags $COMMIT 2>/dev/null || echo $COMMIT) 9 | 10 | 11 | echo "### Building frontend apps" 12 | echo "======================================================" 13 | cd $DIR/../app 14 | npm ci 15 | npm run build 16 | 17 | 18 | echo 19 | echo "### Bundling to _releases/psitransfer-$NAME.tar.gz" 20 | echo "======================================================" 21 | cd $DIR/.. 22 | mkdir -p _releases 23 | 24 | tar -czf _releases/psitransfer-$NAME.tar.gz --transform "s~^~psitransfer-$NAME/~" \ 25 | LICENSE \ 26 | README.md \ 27 | Dockerfile \ 28 | .dockerignore \ 29 | app.js \ 30 | cli.js \ 31 | config.js \ 32 | package.json \ 33 | package-lock.json \ 34 | docs \ 35 | lib \ 36 | lang \ 37 | plugins \ 38 | public 39 | 40 | 41 | cd $DIR/.. 42 | #if [ -d .git ]; then 43 | # LAST_TAG=$(git tag | head -n 2 | tail -n 1) 44 | # echo 45 | # echo "### Changelog $LAST_TAG..HEAD" 46 | # echo "======================================================" 47 | # [ -z "$LAST_TAG" ] && git log --oneline || git log $LAST_TAG..HEAD --oneline 48 | #fi 49 | -------------------------------------------------------------------------------- /.github/workflows/docker_build_master.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Build Master 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build_docker: 10 | name: Docker build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup QEMU 17 | uses: docker/setup-qemu-action@v1 18 | 19 | - name: Setup Docker buildx 20 | uses: docker/setup-buildx-action@v1 21 | 22 | - name: Login to DockerHub 23 | uses: docker/login-action@v1 24 | with: 25 | username: psitrax 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Login to GitHub Container Registry 29 | uses: docker/login-action@v1 30 | with: 31 | registry: ghcr.io 32 | username: ${{ github.repository_owner }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Build Docker image 36 | uses: docker/build-push-action@v2 37 | env: 38 | DOCKER_TAG: latest 39 | with: 40 | file: Dockerfile 41 | platforms: linux/amd64,linux/arm64 42 | #platforms: linux/amd64,linux/arm64,linux/arm/v7 43 | push: true 44 | tags: | 45 | psitrax/psitransfer:latest 46 | ghcr.io/psi-4ward/psitransfer:latest 47 | -------------------------------------------------------------------------------- /scripts/traffic-limit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Helper script to limit traffic for iface:port 5 | # http://www.codeoriented.com/how-to-limit-network-bandwidth-for-a-specific-tcp-port-on-ubuntu-linux/ 6 | # https://wiki.archlinux.org/index.php/Advanced_traffic_control 7 | 8 | if ! which tc &>/dev/null ; then 9 | 2>&1 echo Error: tc executable not found 10 | 2>&1 echo Please install iproute2 package 11 | exit 1 12 | fi 13 | 14 | IFACE=$1 15 | RATE=$3 16 | PORT=$2 17 | 18 | if [ -z "$3" ] ; then 19 | echo "Traffic limitter" 20 | echo "Usage: $0 IFACE PORT RATE" 21 | echo 22 | echo "Available interfaces: $(ls -m /sys/class/net)" 23 | echo 24 | echo "Rate units: " 25 | echo " kbps: Kilobytes per Second" 26 | echo " mbps: Megabytes per Second" 27 | echo " gbps: Gigabytes per Second" 28 | echo " off: No rate limit" 29 | echo 30 | echo "Examples:" 31 | echo " $0 wlan0 8080 10kbps" 32 | echo " $0 wlan0 8080 off" 33 | exit 1 34 | fi 35 | 36 | sudo tc qdisc del root dev $IFACE &>/dev/null || true 37 | 38 | [ "$RATE" = "off" ] && exit 39 | 40 | sudo tc qdisc add dev $IFACE root handle 1:0 htb default 10 41 | sudo tc class add dev $IFACE parent 1:0 classid 1:10 htb rate $RATE prio 0 42 | sudo tc filter add dev $IFACE parent 1:0 prio 0 protocol ip u32 match ip protocol 4 0xff match ip dport $PORT 0xffff flowid 1:10 43 | sudo tc qdisc show dev $IFACE 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "psitransfer", 3 | "version": "0.0.0", 4 | "description": "Simple open source self-hosted file sharing solution", 5 | "keywords": [ 6 | "share", 7 | "upload", 8 | "transfer", 9 | "files", 10 | "wetransfer" 11 | ], 12 | "repository": "psi-4ward/psitransfer", 13 | "bugs": "https://github.com/psi-4ward/psitransfer/issues", 14 | "main": "app.js", 15 | "dependencies": { 16 | "archiver": "^5.3.1", 17 | "axios": "^1.9.0", 18 | "common-streams": "^1.4.0", 19 | "compression": "^1.7.4", 20 | "cors": "^2.8.5", 21 | "crypto-js": "^4.2.0", 22 | "debug": "^4.3.4", 23 | "express": "^4.18.2", 24 | "fs-promise": "^2.0.3", 25 | "http-errors": "^2.0.0", 26 | "method-override": "^3.0.0", 27 | "morgan": "^1.10.0", 28 | "pug": "^3.0.2", 29 | "tar-stream": "^2.2.0", 30 | "to-object-reducer": "^1.0.1", 31 | "uuid": "^9.0.1" 32 | }, 33 | "bin": { 34 | "psitransfer": "cli.js" 35 | }, 36 | "scripts": { 37 | "start": "NODE_ENV=production node app.js", 38 | "dev": "NODE_ENV=dev DEBUG=psitransfer:* nodemon -i app -i dist -i data app.js", 39 | "debug": "node --inspect app.js" 40 | }, 41 | "engines": { 42 | "node": ">= 10", 43 | "npm": ">= 3" 44 | }, 45 | "author": "Christoph Wiechert ", 46 | "contributors": [], 47 | "license": "BSD-2-Clause" 48 | } 49 | -------------------------------------------------------------------------------- /docs/deployment-systemd.md: -------------------------------------------------------------------------------- 1 | # Deployment as Systemd service 2 | 3 | You can also install PsiTransfer as (Linux) system service. Most distributions 4 | use Systemd as main init system. You should **not** run PsiTransfer with root privileges! 5 | 6 | **Preparation** 7 | 8 | ```bash 9 | # Create a target folder for PsiTransfer 10 | mkdir -p /opt/psitransfer 11 | cd /opt/psitransfer 12 | 13 | # Download and extract a prebuild 14 | curl -sL https://github.com/psi-4ward/psitransfer/releases/download/1.1.0-beta/psitransfer-1.1.0-beta.tar.gz | tar xz --strip 1 15 | 16 | # Install dependencies 17 | npm install --production 18 | 19 | # Add a user psitransfer 20 | sudo useradd --system psitransfer 21 | 22 | # Make psitransfer owner of /opt/psitransfer 23 | sudo chown -R psitransfer:psitransfer /opt/psitransfer 24 | ``` 25 | 26 | **Systemd unit file** 27 | 28 | Grab the [psitransfer.service](https://github.com/psi-4ward/psitransfer/blob/master/docs/psitransfer.service) 29 | sample file, put it in `/etc/systemd/system/` and adjust to your needs. 30 | 31 | ```bash 32 | cd /etc/systemd/system 33 | sudo wget https://raw.githubusercontent.com/psi-4ward/psitransfer/master/docs/psitransfer.service 34 | 35 | # Start the service 36 | sudo systemctl start psitransfer 37 | 38 | # Show the status 39 | sudo systemctl status psitransfer 40 | 41 | # Enable autostart on boot 42 | sudo systemctl enable psitransfer 43 | ``` 44 | -------------------------------------------------------------------------------- /public/pug/error.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | head 4 | include partials/head 5 | style. 6 | html { 7 | position: relative; 8 | min-height: 100%; 9 | } 10 | body > .container { 11 | padding-bottom: 50px; 12 | position: relative; 13 | } 14 | .footer { 15 | position: absolute; 16 | bottom: 0; 17 | width: 100%; 18 | height: 40px; 19 | line-height: 40px; 20 | } 21 | include partials/head_custom 22 | body 23 | .container 24 | h1 25 | svg.fa-icon(role='presentation' viewbox='0 0 1920 1792' style='fill: #0275D8') 26 | path(d='M1280 864q0-14-9-23l-352-352q-9-9-23-9t-23 9l-351 351q-10 12-10 24 0 14 9 23t23 9h224v352q0 13 9.5 22.5t22.5 9.5h192q13 0 22.5-9.5t9.5-22.5v-352h224q13 0 22.5-9.5t9.5-22.5zM1920 1152q0 159-112.5 271.5t-271.5 112.5h-1088q-185 0-316.5-131.5t-131.5-316.5q0-130 70-240t188-165q-2-30-2-43 0-212 150-362t362-150q156 0 285.5 87t188.5 231q71-62 166-62 106 0 181 75t75 181q0 76-41 138 130 31 213.5 135.5t83.5 238.5z') 27 | | PsiTransfer 28 | hr 29 | p.alert.alert-danger 30 | strong 31 | i.fa.fa-fw.fa-exclamation-triangle 32 | | #{error} 33 | p 34 | a.btn.btn-sm.btn-info(title='New Upload' href=uploadAppPath) 35 | i.fa.fa-fw.fa-cloud-upload 36 | | new upload 37 | include partials/footer 38 | -------------------------------------------------------------------------------- /lib/tusboy/errors.js: -------------------------------------------------------------------------------- 1 | const createError = require('http-errors'); 2 | 3 | const tusResumableHeaderMissing = () => createError(412, 'Tus-Resumable Required') 4 | 5 | const invalidHeaders = (headers) => ( 6 | createError(412, 'Precondition Failed', { 7 | details: { headers }, 8 | }) 9 | ) 10 | 11 | const invalidHeader = (header, val) => ( 12 | createError(412, 'Precondition Failed', { 13 | details: { headers: [[header, val]] }, 14 | }) 15 | ) 16 | 17 | const missingHeader = (header) => invalidHeader(header) 18 | 19 | const entityTooLarge = (msg, props) => createError(413, msg, props) 20 | 21 | const preconditionError = (msg, props) => createError(412, msg, props) 22 | 23 | const offsetMismatch = (actual, expected) => ( 24 | createError(409, `Offset mismatch, got ${actual} but expected ${expected}`) 25 | ) 26 | 27 | // For store implementations: 28 | const unknownResource = (key) => ( 29 | createError(404, `Unknown resource: ${key}`) 30 | ) 31 | 32 | const concurrentWrite = () => ( 33 | createError(409, 'Concurrent write detected') 34 | ) 35 | 36 | const uploadLengthAlreadySet = () => ( 37 | createError(409, 'Upload length is already set') 38 | ) 39 | 40 | module.exports = { 41 | tusResumableHeaderMissing, 42 | invalidHeaders, 43 | invalidHeader, 44 | missingHeader, 45 | entityTooLarge, 46 | preconditionError, 47 | offsetMismatch, 48 | unknownResource, 49 | concurrentWrite, 50 | uploadLengthAlreadySet, 51 | } 52 | -------------------------------------------------------------------------------- /tests/e2e/helper.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import os from 'os'; 4 | import filecompare from 'filecompare'; 5 | import { t } from 'testcafe'; 6 | 7 | export const sharedData = { 8 | shareLink: null 9 | }; 10 | 11 | /** 12 | * Wait for download to finish (max 5s) 13 | * @param {string} file name file 14 | * @return {Promise} 15 | */ 16 | export async function waitForFileDownload(file) { 17 | const f = getFileDownloadPath(file); 18 | for (let i = 0; i < 10; i++) { 19 | if (fs.existsSync(f)) { 20 | return true; 21 | 22 | } 23 | await t.wait(500); 24 | } 25 | return fs.existsSync(f); 26 | } 27 | 28 | /** 29 | * Get full download path for a file 30 | * @param {string} file 31 | * @return {string} 32 | */ 33 | export function getFileDownloadPath(file) { 34 | return path.join(os.homedir(), 'Downloads', file); 35 | } 36 | 37 | /** 38 | * Deletes a previously downladed file if it exists 39 | * @param {string} file 40 | */ 41 | export function clearDownloadedFile(file) { 42 | const f = getFileDownloadPath(file); 43 | if (fs.existsSync(f)) { 44 | fs.unlinkSync(f); 45 | } 46 | } 47 | 48 | export async function compareFiles(sourceFile, downloadedFile) { 49 | return new Promise((resolve, reject) => { 50 | const dlFile = getFileDownloadPath(downloadedFile); 51 | const srcFile = path.resolve(__dirname, '../../', sourceFile); 52 | filecompare(dlFile, srcFile, resolve); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | // Control chars incl. NUL. Keep it strict and cross-platform. 4 | const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F]/g; 5 | 6 | function toSafeBasename(input, fallback = 'file', opts = {}) { 7 | const maxLength = Number.isInteger(opts.maxLength) ? opts.maxLength : 255; 8 | 9 | let name = ''; 10 | if (typeof input === 'string') name = input; 11 | else if (input == null) name = ''; 12 | else name = String(input); 13 | 14 | // Remove NUL/control chars early (these can truncate paths in some tooling). 15 | name = name.replace(CONTROL_CHARS_RE, ''); 16 | 17 | // Normalize separators to POSIX then force basename (flat archives). 18 | name = name.replace(/\\/g, '/'); 19 | name = path.posix.basename(name); 20 | 21 | // Trim to avoid whitespace-only names; keep the caller’s original if they need strict equality. 22 | name = name.trim(); 23 | 24 | // Disallow dot/dotdot and empty. 25 | if (!name || name === '.' || name === '..') name = ''; 26 | 27 | // Length cap. 28 | if (name.length > maxLength) name = name.slice(0, maxLength); 29 | 30 | if (!name) { 31 | const fb = (fallback == null ? '' : String(fallback)).replace(CONTROL_CHARS_RE, '').trim(); 32 | return fb ? fb.slice(0, maxLength) : 'file'; 33 | } 34 | 35 | return name; 36 | } 37 | 38 | function isSafeBasename(name, opts = {}) { 39 | if (typeof name !== 'string') return false; 40 | return toSafeBasename(name, '', opts) === name; 41 | } 42 | 43 | module.exports = { 44 | toSafeBasename, 45 | isSafeBasename, 46 | }; 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/Upload/store/config.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | export default { 4 | namespaced: true, 5 | 6 | state: { 7 | uploadPassRequired: false, 8 | uploadPass: null, 9 | 10 | }, 11 | 12 | mutations: { 13 | SET(state, val) { 14 | for (let k in val) { 15 | Vue.set(state, k, val[k]); 16 | } 17 | } 18 | }, 19 | 20 | actions: { 21 | fetch({commit, state}) { 22 | return new Promise((resolve, reject) => { 23 | const xhr = new XMLHttpRequest(); 24 | xhr.open('GET', 'config.json'); 25 | state.uploadPass && xhr.setRequestHeader('x-passwd', state.uploadPass); 26 | xhr.onload = () => { 27 | if(xhr.status === 200) { 28 | try { 29 | const conf = JSON.parse(xhr.responseText); 30 | commit('SET', conf); 31 | commit('upload/RETENTION', conf.defaultRetention, {root:true}); 32 | } 33 | catch(e) { 34 | commit('ERROR', `Config parse Error: ${e.message}`, {root: true}); 35 | } 36 | } 37 | else if (xhr.status === 401 || xhr.status === 403) { 38 | commit('SET', {uploadPassRequired: true, uploadPass: null}); 39 | const e = new Error('Password required'); 40 | e.code = "PWDREQ"; 41 | reject(e); 42 | } 43 | else { 44 | commit('ERROR', `Config load error: ${xhr.status} ${xhr.statusText}`, {root: true}); 45 | } 46 | resolve(); 47 | }; 48 | xhr.send(); 49 | }); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lang/zh.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "zh", 3 | oldBrowserError: "你的浏览器版本不支持使用.", 4 | dropFilesHere: "点击上传或将文件拖动到这里", 5 | noPreviewAvailable: "无可用预览", 6 | preview: "预览", 7 | comment: "备注", 8 | settings: "设置", 9 | retention: "有效期", 10 | password: "提取码", 11 | required: "必选", 12 | optional: "可选", 13 | generateRandomPassword: "生成随机提取码", 14 | newUpload: "新的上传", 15 | accessDenied: "验证失败!", 16 | login: "登录", 17 | email: "邮件", 18 | clipboard: "复制链接", 19 | copyToClipboard: "复制到剪贴板", 20 | sendViaMail: "发送邮件", 21 | showQrCode: "显示二维码", 22 | uploadCompleted: "上传完成", 23 | downloadLink: "下载链接", 24 | upload: "上传", 25 | retry: "重试", 26 | createNewUploadSession: "创建新的上传会话?", 27 | decrypt: "解锁", 28 | files: "文件上传列表", 29 | zipDownload: " 压缩打包下载(zip格式)", 30 | tarGzDownload: "压缩打包下载(tar.gz格式)", 31 | oneTimeDownloadExpired: "该链接被设置为阅后即焚,文件已销毁.", 32 | fileSizeExceed: "文件大小 %% 超过最大体积 of %%", 33 | bucketSizeExceed: "上传大小 %% 超过最大体积 %%", 34 | mailInvalid: "邮件地址不正确", 35 | mailTo: "发送到", 36 | mailToPlaceholder: "邮件地址", 37 | mailFrom: "我的邮件地址", 38 | mailFromPlaceholder: "邮件地址", 39 | mailMessage: "我的消息", 40 | mailDownloadNotification: "下载文件时通知我.", 41 | mailSendBtn: "发送电子邮件", 42 | mailsSent: "电子邮件已经发送了.", 43 | mailSubjectUploader: "文件快传-上传", 44 | mailSubjectDownloader: "文件快传-下载", 45 | mailSubjectFileDownloaded: "文件已下载", 46 | retentions: { 47 | "one-time": "仅限下载一次(阅后即焚)", 48 | "3600": "1 小时", 49 | "21600": "6 小时", 50 | "86400": "1 天", 51 | "259200": "3 天", 52 | "604800": "1 周", 53 | "1209600": "2 周", 54 | "2419200": "1 月", 55 | "4838400": "8 周" 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /app/src/common/FileIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 44 | -------------------------------------------------------------------------------- /lang/zh-tw.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "zh-tw", 3 | oldBrowserError: "您的瀏覽器版本不支援此功能。", 4 | dropFilesHere: "在此拖放檔案", 5 | noPreviewAvailable: "沒有預覽", 6 | preview: "預覽", 7 | comment: "備註", 8 | settings: "設定", 9 | retention: "有效期限", 10 | password: "密碼", 11 | required: "必填", 12 | optional: "選填", 13 | generateRandomPassword: "產生隨機密碼", 14 | newUpload: "新增上傳", 15 | accessDenied: "密碼錯誤!", 16 | login: "登入", 17 | email: "電子郵件", 18 | clipboard: "複製連結", 19 | copyToClipboard: "複製到剪貼板", 20 | sendViaMail: "透過電郵傳送", 21 | showQrCode: "顯示 QR 碼", 22 | uploadCompleted: "上傳成功", 23 | downloadLink: "下載連結", 24 | upload: "上傳", 25 | retry: "重試", 26 | createNewUploadSession: "要建立新的上傳?", 27 | decrypt: "解鎖", 28 | files: "檔案列表", 29 | zipDownload: "將所有檔案下載為 zip 壓縮檔 (無法續傳)", 30 | tarGzDownload: " 將所有檔案下載為 tar.gz 壓縮檔 (無法續傳)", 31 | oneTimeDownloadExpired: "此檔案僅限單次下載,已無法使用。", 32 | fileSizeExceed: "檔案大小 %% 超過最大容量 %%", 33 | bucketSizeExceed: "總上傳大小 %% 超過最大容量 of %%", 34 | mailInvalid: "電郵地址不正確", 35 | mailTo: "傳送到", 36 | mailToPlaceholder: "電郵地址", 37 | mailFrom: "我的電郵地址", 38 | mailFromPlaceholder: "電郵地址", 39 | mailMessage: "我的訊息", 40 | mailDownloadNotification: "檔案被下載時通知我。", 41 | mailSendBtn: "傳送電子郵件", 42 | mailsSent: "電子郵件已傳送", 43 | mailSubjectUploader: "PsiTransfer 檔案上傳", 44 | mailSubjectDownloader: "PsiTransfer 檔案下載", 45 | mailSubjectFileDownloaded: "檔案已被下載", 46 | retentions: { 47 | "one-time": "單次下載", 48 | "3600": "1 小時", 49 | "21600": "6 小時", 50 | "86400": "1 天", 51 | "259200": "3 天", 52 | "604800": "1 週", 53 | "1209600": "2 週", 54 | "2419200": "4 週", 55 | "4838400": "8 週" 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const fs = require('fs'); 3 | const config = require('./config'); 4 | const app = require('./lib/endpoints'); 5 | const eventBus = require('./lib/eventBus'); 6 | 7 | /** 8 | * Naming: 9 | * sid: Group of files 10 | * key: File 11 | * fid: {sid}++{key} 12 | */ 13 | 14 | let server; 15 | if(config.port) { 16 | // HTTP Server 17 | server = app.listen(config.port, config.iface, () => { 18 | console.log(`PsiTransfer listening on http://${config.iface}:${config.port}`); 19 | eventBus.emit('listen', server); 20 | }); 21 | } 22 | 23 | let httpsServer; 24 | if(config.sslPort && config.sslKeyFile && config.sslCertFile) { 25 | // HTTPS Server 26 | const sslOpts = { 27 | key: fs.readFileSync(config.sslKeyFile), 28 | cert: fs.readFileSync(config.sslCertFile) 29 | }; 30 | httpsServer = https.createServer(sslOpts, app) 31 | .listen(config.sslPort, config.iface, () => { 32 | console.log(`PsiTransfer listening on https://${config.iface}:${config.sslPort}`); 33 | eventBus.emit('listen', httpsServer); 34 | }); 35 | } 36 | 37 | 38 | // graceful shutdown 39 | function shutdown() { 40 | console.log('PsiTransfer shutting down...'); 41 | eventBus.emit('shutdown', server || httpsServer); 42 | if(server) { 43 | server.close(() => { 44 | server = false; 45 | if(!server && !httpsServer) process.exit(0); 46 | }); 47 | } 48 | if(httpsServer) { 49 | httpsServer.close(() => { 50 | httpsServer = false; 51 | if(!server && !httpsServer) process.exit(0); 52 | }); 53 | } 54 | setTimeout(function() { 55 | console.log('Could not close connections in time, forcefully shutting down'); 56 | process.exit(1); 57 | }, 15 * 1000); 58 | } 59 | process.on('SIGTERM', shutdown); 60 | process.on('SIGINT', shutdown); 61 | -------------------------------------------------------------------------------- /lang/ko.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "ko", 3 | oldBrowserError: "브라우저가 너무 오래되어서 이 기능을 지원하지 않습니다.", 4 | dropFilesHere: "여기에 파일을 놓으세요", 5 | noPreviewAvailable: "미리보기 사용 불가", 6 | preview: "미리보기", 7 | comment: "코멘트", 8 | settings: "설정", 9 | retention: "보유", 10 | password: "비밀번호", 11 | required: "필수 사항", 12 | optional: "선택 사항", 13 | generateRandomPassword: "렌덤 비밀번호를 생성하기", 14 | newUpload: "새 업로드", 15 | accessDenied: "접근 거부!", 16 | login: "로드인", 17 | email: "이메일", 18 | clipboard: "클립보드", 19 | copyToClipboard: "클립보드에 복사하기", 20 | sendViaMail: "이메일로 보내기", 21 | showQrCode: "QR 코드 보기", 22 | uploadCompleted: "업로드 완료됨", 23 | downloadLink: "링크 다운로드하기", 24 | upload: "업로드", 25 | retry: "재시도", 26 | createNewUploadSession: "새로운 업로드 세션 창조하기?", 27 | decrypt: "해독", 28 | files: "파일", 29 | zipDownload: ".zip로 모든 파일을 다운로드하기 (재시작 불가능)", 30 | tarGzDownload: ".tar.gz로 모든 파일을 다운로드하기 (재시작 불가능)", 31 | oneTimeDownloadExpired: "1회만의 다운로드: 파일은 이미 사용 불가입니다.", 32 | fileSizeExceed: "파일 크기 %% 은/는 최대의 %% 초과합니다", 33 | bucketSizeExceed: "업로드 크기 %% 은/는 최대의 %% 초과합니다", 34 | mailInvalid: "이메일 주소를 불완전합니다", 35 | mailTo: "...로 보내기", 36 | mailToPlaceholder: "이메일 주소", 37 | mailFrom: "내 이메일 주소", 38 | mailFromPlaceholder: "이메일 주소", 39 | mailMessage: "내 메시지", 40 | mailDownloadNotification: "파일 다운로드할 때 내게 알리기.", 41 | mailSendBtn: "이메일 보내기", 42 | mailsSent: "이메일(들)은 서공적으로 보낼.", 43 | mailSubjectUploader: "PsiTransfer 파일 업로드", 44 | mailSubjectDownloader: "PsiTransfer 파일 다운로드", 45 | mailSubjectFileDownloaded: "파일은 다운로드됨", 46 | retentions: { 47 | "one-time": "1회만 다운로드", 48 | "3600": "1 시간", 49 | "21600": "6 시간", 50 | "86400": "1 일", 51 | "259200": "3 일", 52 | "604800": "1 주일", 53 | "1209600": "2 주일", 54 | "2419200": "4 주일", 55 | "4838400": "8 주일" 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /app/src/common/Clipboard.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | 22 | 23 | 67 | -------------------------------------------------------------------------------- /scripts/browserstack-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Browser Buckets, note the trailing comma, slashes and blank lines 4 | BROWSERS=( 5 | "browserstack:ie@11.0:Windows 10" 6 | "browserstack:edge@15.0:Windows 10" 7 | "browserstack:iPad Pro 11 2018" 8 | "browserstack:opera@69.0:Windows 10" 9 | "browserstack:firefox@75.0:Windows 10" 10 | "browserstack:chrome@80.0:Windows 10" 11 | "browserstack:safari@12.1:OS X Mojave" 12 | ) 13 | 14 | 15 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 16 | cd $DIR/.. 17 | 18 | TC=node_modules/.bin/testcafe 19 | 20 | function err { 21 | >&2 echo Error: "$*" 22 | exit 1 23 | } 24 | 25 | if [[ ! -x "$TC" ]]; then 26 | err $TC does not exist or has not execute permissions 27 | fi 28 | 29 | export BROWSERSTACK_PROJECT_NAME=PsiTransfer 30 | export BROWSERSTACK_BUILD_ID=${BROWSERSTACK_BUILD_ID:-dev} 31 | export BROWSERSTACK_PARALLEL_RUNS=1 32 | export BROWSERSTACK_CONSOLE=warnings 33 | 34 | if [[ -z "$BROWSERSTACK_USERNAME" ]]; then 35 | err Env Var BROWSERSTACK_USERNAME is empty. 36 | fi 37 | if [[ -z "$BROWSERSTACK_ACCESS_KEY" ]]; then 38 | err Env Var BROWSERSTACK_ACCESS_KEY is empty. 39 | fi 40 | 41 | function abort { 42 | echo Aborting ... 43 | kill ${PID} 2>/dev/null || true 44 | wait ${PID} 45 | exit 1 46 | } 47 | trap abort SIGINT SIGTERM SIGHUP 48 | 49 | export TEST_URL="http://localhost:3030" 50 | (PSITRANSFER_PORT=3030 PROCESS_ENV=production node app)& 51 | PID=$! 52 | sleep 2 # Give PsiTransfer some time to start 53 | echo PsiTransfer PID ${PID} 54 | 55 | # Seems parallel tests with browserstack provider are buggy 56 | EXIT_CODE=0 57 | for BROWSER in "${BROWSERS[@]}"; do 58 | echo Testing "${BROWSER}" ... 59 | $TC \ 60 | "${BROWSER}" \ 61 | tests/e2e 62 | if [[ $? -gt 0 ]]; then 63 | EXIT_CODE=$? 64 | fi 65 | done 66 | 67 | kill ${PID} 68 | wait ${PID} 69 | 70 | exit ${EXIT_CODE} 71 | -------------------------------------------------------------------------------- /lang/ja.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "ja", 3 | oldBrowserError: "お使いの Web ブラウザが古すぎるため、この機能をサポートしていません。", 4 | dropFilesHere: "ここにファイルをドロップ", 5 | noPreviewAvailable: "プレビューを表示できません", 6 | preview: "プレビュー", 7 | comment: "コメント", 8 | settings: "設定", 9 | retention: "有効期限", 10 | password: "パスワード", 11 | required: "必須", 12 | optional: "任意", 13 | generateRandomPassword: "適当なパスワードを生成", 14 | newUpload: "新規アップロード", 15 | accessDenied: "パスワードが違います。アクセスが拒否されました!", 16 | login: "ログイン", 17 | email: "メールアドレス", 18 | clipboard: "クリップボード", 19 | copyToClipboard: "クリップボードにコピーする", 20 | sendViaMail: "メールで送る", 21 | showQrCode: "QR コードを表示する", 22 | uploadCompleted: "アップロードが完了しました", 23 | downloadLink: "ダウンロード リンク", 24 | upload: "アップロード", 25 | retry: "再試行", 26 | createNewUploadSession: "新たにアップロードしますか?", 27 | decrypt: "認証する", 28 | files: "ファイル", 29 | zipDownload: "すべてのファイルを Zip ファイルとしてダウンロードする (中途からのダウンロード再開不可)", 30 | tarGzDownload: "すべてのファイルを tar.gz ファイルとしてダウンロードする (中途からのダウンロード再開不可)", 31 | oneTimeDownloadExpired: "1度だけのダウンロード: 該当ファイルは今後ダウンロードできなくなります", 32 | fileSizeExceed: "ファイルサイズが %% あり、最大値の %% を超えています", 33 | bucketSizeExceed: "アップロードサイズが %% あり、最大値の %% を超えています", 34 | mailInvalid: "メールアドレスが無効です", 35 | mailTo: "送信先メールアドレス", 36 | mailToPlaceholder: "送信先メールアドレス", 37 | mailFrom: "発信元メールアドレス", 38 | mailFromPlaceholder: "発信元メールアドレス", 39 | mailMessage: "メッセージ", 40 | mailDownloadNotification: "先方がダウンロードしたら通知する", 41 | mailSendBtn: "メール送信", 42 | mailsSent: "メールを送信しました。", 43 | mailSubjectUploader: "PsiTransfer ファイル送信", 44 | mailSubjectDownloader: "PsiTransfer ファイル受信", 45 | mailSubjectFileDownloaded: "ファイルがダウンロードされました", 46 | retentions: { 47 | "one-time": "一度だけ", 48 | "3600": "1 時間", 49 | "21600": "6 時間", 50 | "86400": "1 日", 51 | "259200": "3 日", 52 | "604800": "1 週間", 53 | "1209600": "2 週間", 54 | "2419200": "4 週間", 55 | "4838400": "8 週間" 56 | }, 57 | uploadPassword: "送信パスワード", 58 | } 59 | -------------------------------------------------------------------------------- /lib/tusboy/handlers/head.js: -------------------------------------------------------------------------------- 1 | // HEAD 2 | // 3 | // The Server MUST always include the Upload-Offset header in the 4 | // response for a HEAD request, even if the offset is 0, or the upload 5 | // is already considered completed. If the size of the upload is known, 6 | // the Server MUST include the Upload-Length header in the response. If 7 | // the resource is not found, the Server SHOULD return either the 404 8 | // Not Found, 410 Gone or 403 Forbidden status without the Upload-Offset 9 | // header. 10 | // 11 | // The Server MUST prevent the client and/or proxies from caching the 12 | // response by adding the Cache-Control: no-store header to the 13 | // response. 14 | // 15 | 16 | const encodeMetadata = require('../tus-metadata').encode; 17 | 18 | module.exports = (store) => ( 19 | async (req, res) => { 20 | res.set('Cache-Control', 'no-store') 21 | const { uploadId } = req.params 22 | const upload = await store.info(uploadId) 23 | // The Server MUST always include the Upload-Offset header in the 24 | // response for a HEAD request, even if the offset is 0, or the upload 25 | // is already considered completed. 26 | res.set('Upload-Offset', upload.offset) 27 | 28 | if (!('uploadLength' in upload)) { 29 | // As long as the length of the upload is not known, the Server 30 | // MUST set Upload-Defer-Length: 1 in all responses to HEAD requests. 31 | res.set('Upload-Defer-Length', '1') 32 | } else { 33 | // If the size of the upload is known, the Server MUST include 34 | // the Upload-Length header in the response 35 | res.set('Upload-Length', upload.uploadLength) 36 | } 37 | // If an upload contains additional metadata, responses to HEAD 38 | // requests MUST include the Upload-Metadata header and its value as 39 | // specified by the Client during the creation. 40 | const encodedMetadata = encodeMetadata(upload.metadata) 41 | if (encodedMetadata !== '') { 42 | res.set('Upload-Metadata', encodedMetadata) 43 | } 44 | res.end() 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /lang/tr.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "tr", 3 | oldBrowserError: "Tarayıcınız bu fonksiyonu kullanmak için çok eski", 4 | dropFilesHere: "Dosyalarınızı buraya sürükleyin", 5 | noPreviewAvailable: "Önizleme yok", 6 | preview: "Önizleme", 7 | comment: "Yorum", 8 | settings: "Ayarlar", 9 | retention: "Geçerlilik Süresi", 10 | password: "Parola", 11 | required: "Zorunlu", 12 | optional: "Opsiyonel", 13 | generateRandomPassword: "Rastgele praola oluştur", 14 | newUpload: "Yeni Yükleme", 15 | accessDenied: "Erişim Engellendi", 16 | login: "Giriş", 17 | email: "E-Posta", 18 | clipboard: "Pano", 19 | copyToClipboard: "Panoya Kopyala", 20 | sendViaMail: "E-Posta ile Gönder", 21 | showQrCode: "QR Kod Göster", 22 | uploadCompleted: "Yükleme Tamamlandı", 23 | downloadLink: "İndirme Linki", 24 | upload: "Yükle", 25 | retry: "Tekrar", 26 | createNewUploadSession: "Yeni Yükleme?", 27 | decrypt: "decrypt", 28 | files: "Dosyalar", 29 | zipDownload: "Tüm Dosyaları ZIP olarak indir", 30 | tarGzDownload: "Tüm Dosyaları tar.gz olarak indir", 31 | oneTimeDownloadExpired: "Tek seferlik indirme. Süre dolmuş", 32 | fileSizeExceed: "Dosya Boyutu Büyük %% Maksimum: %%", 33 | bucketSizeExceed: "Yükleme Boyutu Büyük %% Maksimum: %%", 34 | mailInvalid: "E-Posta adresi hatalı gözüküyor", 35 | mailTo: "Gönder:", 36 | mailToPlaceholder: "E-Posta Adresleri", 37 | mailFrom: "E-Posta Adresim", 38 | mailFromPlaceholder: "E-Posta Adresi", 39 | mailMessage: "Mesajım", 40 | mailDownloadNotification: "Dosyam indirilince bilgi ver", 41 | mailSendBtn: "E-Posta gönder", 42 | mailsSent: "E-Postalar gönderildi", 43 | mailSubjectUploader: "PsiTransfer File-Upload", 44 | mailSubjectDownloader: "PsiTransfer Dosya Yüklemesi", 45 | mailSubjectFileDownloaded: "Dosya indirildi", 46 | retentions: { 47 | "one-time": "Tek Seferlik", 48 | "3600": "1 Saat", 49 | "21600": "6 Saat", 50 | "86400": "1 Gün", 51 | "259200": "3 Gün", 52 | "604800": "1 Hafta", 53 | "1209600": "2 Hafta", 54 | "2419200": "4 Hafta", 55 | "4838400": "8 Hafta" 56 | }, 57 | uploadPassword: "Yükleme için parola", 58 | } 59 | -------------------------------------------------------------------------------- /lang/ru.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "ru", 3 | oldBrowserError: "Ваш браузер устарел и больше не поддерживается", 4 | dropFilesHere: "Перетащите сюда файлы для загрузки", 5 | noPreviewAvailable: "Предпросмотр недоступен", 6 | preview: "Предпросмотр", 7 | comment: "Комментарий", 8 | settings: "Настройки", 9 | retention: "Срок хранения", 10 | password: "Пароль", 11 | required: "Обязательно", 12 | optional: "Не обязательно", 13 | generateRandomPassword: "Сгенерировать случайный пароль", 14 | newUpload: "Новая загрузка", 15 | accessDenied: "Доступ запрещён!", 16 | login: "Логин", 17 | email: "Эл. почта", 18 | clipboard: "Ссылка", 19 | copyToClipboard: "Скопировать ссылку", 20 | sendViaMail: "Отправить по эл. почте", 21 | showQrCode: "Показать QR-код", 22 | uploadCompleted: "Отправка завершена", 23 | downloadLink: "Ссылка для скачивания", 24 | upload: "отправка", 25 | retry: "повтор", 26 | createNewUploadSession: "Отправить другой файл?", 27 | decrypt: "Расшифровать", 28 | files: "Файлы", 29 | zipDownload: "Скачать все файлы одним ZIP-архивом (без дозагрузки)", 30 | tarGzDownload: "Скачать все файлы одним TAR-архивом (без дозагрузки)", 31 | oneTimeDownloadExpired: "Одноразовая загрузка: Файл больше недоступен", 32 | fileSizeExceed: "Размер файла (%%) больше допустимого (%%)", 33 | bucketSizeExceed: "Размер файла (%%) больше допустимого (%%)", 34 | mailInvalid: "Неверный адрес эл. почты", 35 | mailTo: "Отправить", 36 | mailToPlaceholder: "Эл. адрес", 37 | mailFrom: "Мой эл. адрес", 38 | mailFromPlaceholder: "Эл. адрес", 39 | mailMessage: "Сообщение", 40 | mailDownloadNotification: "Уведомить меня, когда файл будет скачан", 41 | mailSendBtn: "Отправить по эл. почте", 42 | mailsSent: "Эл. письмо было отправлено", 43 | mailSubjectUploader: "Файл загружен", 44 | mailSubjectDownloader: "Файл скачан", 45 | mailSubjectFileDownloaded: "Файл был скачан", 46 | retentions: { 47 | "one-time": "Одноразовая загрузка", 48 | "3600": "1 час", 49 | "21600": "6 часов", 50 | "86400": "1 день", 51 | "259200": "3 дня", 52 | "604800": "1 неделя", 53 | "1209600": "2 недели", 54 | "2419200": "4 недели", 55 | "4838400": "8 недель" 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /lang/en.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "en", 3 | oldBrowserError: "Your browser is too old and does not support this function.", 4 | dropFilesHere: "Drop your files here", 5 | noPreviewAvailable: "No preview available", 6 | preview: "Preview", 7 | comment: "Comment", 8 | settings: "Settings", 9 | retention: "Retention", 10 | password: "Password", 11 | required: "required", 12 | optional: "optional", 13 | generateRandomPassword: "Generate a random password", 14 | newUpload: "New upload", 15 | accessDenied: "Access denied!", 16 | login: "Login", 17 | email: "E-Mail", 18 | clipboard: "Clipboard", 19 | copyToClipboard: "Copy to clipboard", 20 | sendViaMail: "Send via E-Mail", 21 | showQrCode: "Show QR-Code", 22 | uploadCompleted: "Upload completed", 23 | downloadLink: "Download Link", 24 | upload: "upload", 25 | retry: "retry", 26 | createNewUploadSession: "Create a new upload session?", 27 | decrypt: "decrypt", 28 | files: "Files", 29 | zipDownload: "Download all files as ZIP (not resumeable)", 30 | tarGzDownload: "Download all files as tar.gz (not resumeable)", 31 | oneTimeDownloadExpired: "One-Time Download: File is not available anymore.", 32 | fileSizeExceed: "File size %% exceeds maximum of %%", 33 | bucketSizeExceed: "Upload size %% exceeds maximum of %%", 34 | mailInvalid: "The e-mail address looks faulty", 35 | mailTo: "Send to", 36 | mailToPlaceholder: "E-Mail addresses", 37 | mailFrom: "My E-Mail address", 38 | mailFromPlaceholder: "E-Mail address", 39 | mailMessage: "My message", 40 | mailDownloadNotification: "Notify me when a file has been downloaded.", 41 | mailSendBtn: "Send E-Mail", 42 | mailsSent: "The e-mails have been sent.", 43 | mailSubjectUploader: "PsiTransfer File-Upload", 44 | mailSubjectDownloader: "PsiTransfer File-Download", 45 | mailSubjectFileDownloaded: "File has been downloaded", 46 | retentions: { 47 | "one-time": "one time download", 48 | "3600": "1 Hour", 49 | "21600": "6 Hours", 50 | "86400": "1 Day", 51 | "259200": "3 Days", 52 | "604800": "1 Week", 53 | "1209600": "2 Weeks", 54 | "2419200": "4 Weeks", 55 | "4838400": "8 Weeks" 56 | }, 57 | uploadPassword: "Password for uploading", 58 | } 59 | -------------------------------------------------------------------------------- /lang/nl.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "nl", 3 | oldBrowserError: "Uw browser is te oud en ondersteunt deze functie niet.", 4 | dropFilesHere: "Sleep uw bestanden in dit vak", 5 | noPreviewAvailable: "Geen voorbeeld beschikbaar", 6 | preview: "Voorbeeld", 7 | comment: "Opmerking", 8 | settings: "Instellingen", 9 | retention: "Vervaltermijn", 10 | password: "Wachtwoord", 11 | required: "verplicht", 12 | optional: "optioneel", 13 | generateRandomPassword: "Genereer een willekeurig wachtwoord", 14 | newUpload: "Nieuwe upload", 15 | accessDenied: "Geen toegang!", 16 | login: "Login", 17 | email: "E-Mail", 18 | clipboard: "Clipboard", 19 | copyToClipboard: "Kopieer naar clipboard", 20 | sendViaMail: "Verstuur via e-mail", 21 | showQrCode: "Toon QR-Code", 22 | uploadCompleted: "Upload voltooid", 23 | downloadLink: "Download link", 24 | upload: "upload", 25 | retry: "probeer opnieuw", 26 | createNewUploadSession: "Nieuwe uploadsessie aanmaken?", 27 | decrypt: "decoderen", 28 | files: "Bestanden", 29 | zipDownload: "Download alle bestanden als ZIP (niet hervatbaar)", 30 | tarGzDownload: "Download alle bestanden als tar.gz (niet hervatbaar)", 31 | oneTimeDownloadExpired: "Eenmalige download: Bestand is niet meer beschikbaar.", 32 | fileSizeExceed: "Bestandsgrootte %% overschrijdt maximum van %%", 33 | bucketSizeExceed: "Uploadgrootte %% overschrijdt maximum van %%", 34 | mailInvalid: "Het e-mailadres ziet er niet correct uit", 35 | mailTo: "Verstuur naar", 36 | mailToPlaceholder: "E-mailaddressen", 37 | mailFrom: "Mijn e-mailadres", 38 | mailFromPlaceholder: "E-mailadres", 39 | mailMessage: "Mijn bericht", 40 | mailDownloadNotification: "Laat het me weten als er een bestand is gedownload.", 41 | mailSendBtn: "Vestuur e-mail", 42 | mailsSent: "De e-mails zijn verzonden.", 43 | mailSubjectUploader: "PsiTransfer bestand upload", 44 | mailSubjectDownloader: "PsiTransfer bestand download", 45 | mailSubjectFileDownloaded: "Bestand is gedownload", 46 | retentions: { 47 | "one-time": "eenmalige download", 48 | "3600": "1 uur", 49 | "21600": "6 uur", 50 | "86400": "1 dag", 51 | "259200": "3 dagen", 52 | "604800": "1 week", 53 | "1209600": "2 weken", 54 | "2419200": "4 weken", 55 | "4838400": "8 weken" 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /lang/sv.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "sv", 3 | oldBrowserError: "Din webbläsare är för gammal och stöder inte den här funktionen.", 4 | dropFilesHere: "Släpp dina filer här", 5 | noPreviewAvailable: "Ingen förhandsvisning tillgänglig", 6 | preview: "Förhandsgranskning", 7 | comment: "Kommentar", 8 | settings: "Inställningar", 9 | retention: "Lagringstid", 10 | password: "Lösenord", 11 | required: "obligatoriskt", 12 | optional: "valfritt", 13 | generateRandomPassword: "Generera ett slumpmässigt lösenord", 14 | newUpload: "Ny uppladdning", 15 | accessDenied: "Åtkomst nekad!", 16 | login: "Logga in", 17 | email: "E-post", 18 | clipboard: "Urklipp", 19 | copyToClipboard: "Kopiera till urklipp", 20 | sendViaMail: "Skicka via e-post", 21 | showQrCode: "Visa QR-kod", 22 | uploadCompleted: "Uppladdning klar", 23 | downloadLink: "Nedladdningslänk", 24 | upload: "uppladding", 25 | retry: "försök igen", 26 | createNewUploadSession: "Skapa ny uppladdningssession?", 27 | decrypt: "dekryptera", 28 | files: "Filer", 29 | zipDownload: "Ladda ner alla filer som ZIP (går inte att återuppta)", 30 | tarGzDownload: "Ladda ner alla filer som tar.gz (går inte att återuppta)", 31 | oneTimeDownloadExpired: "Engångsnedladdning: Filen är inte tillgänglig längre.", 32 | fileSizeExceed: "Filens storlek %% överstiger maximum %%", 33 | bucketSizeExceed: "Överföringsstorlek %% överstiger maximalt %%", 34 | mailInvalid: "E-postadressen ser inte korrekt ut", 35 | mailTo: "Skicka till", 36 | mailToPlaceholder: "E-postadresser", 37 | mailFrom: "Min e-postadress", 38 | mailFromPlaceholder: "E-postadress", 39 | mailMessage: "Mitt meddelande", 40 | mailDownloadNotification: "Meddela mig när en fil har hämtats.", 41 | mailSendBtn: "Skicka e-post", 42 | mailsSent: "E-postmeddelandena har skickats.", 43 | mailSubjectUploader: "PsiTransfer File-Upload", 44 | mailSubjectDownloader: "PsiTransfer File-Download", 45 | mailSubjectFileDownloaded: "Filen har laddats ner", 46 | retentions: { 47 | "one-time": "engångs nedladdning", 48 | "3600": "1 timme", 49 | "21600": "6 timmar", 50 | "86400": "1 dag", 51 | "259200": "3 dagar", 52 | "604800": "1 vecka", 53 | "1209600": "2 veckor", 54 | "2419200": "4 veckor", 55 | "4838400": "8 veckor" 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /lang/pl.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "pl", 3 | oldBrowserError: "Twoja przeglądarka jest za stara i nie wspiera tej funkcji.", 4 | dropFilesHere: "Upuść pliki tutaj", 5 | noPreviewAvailable: "Podgląd jest niedostępny", 6 | preview: "Podgląd", 7 | comment: "Komentarz", 8 | settings: "Ustawienia", 9 | retention: "Czas retencji plików", 10 | password: "Hasło", 11 | required: "wymagane", 12 | optional: "opcjonalne", 13 | generateRandomPassword: "Wygeneruj losowe hasło", 14 | newUpload: "Nowe przesyłanie", 15 | accessDenied: "Dostęp zabroniony!", 16 | login: "Zaloguj", 17 | email: "E-Mail", 18 | clipboard: "Schowek", 19 | copyToClipboard: "Skopiuj do schowka", 20 | sendViaMail: "Wyślij poprzez E-Mail", 21 | showQrCode: "Pokaż kod QR", 22 | uploadCompleted: "Przesyłanie ukończone", 23 | downloadLink: "Odnośnik do pobierania", 24 | upload: "prześlij", 25 | retry: "powtórz", 26 | createNewUploadSession: "Utworzyć nową sesję przesyłania?", 27 | decrypt: "odszyfruj", 28 | files: "Pliki", 29 | zipDownload: "Pobierz wszystkie pliki jako archiwum ZIP (ponawianie niemożliwe)", 30 | tarGzDownload: "Pobierz wszystkie pliki jako tar.gz (ponawianie niemożliwe)", 31 | oneTimeDownloadExpired: "Jednorazowe pobieranie: Plik nie jest już dostępny.", 32 | fileSizeExceed: "Rozmiar pliku %% przekracza maksymalny rozmiar %%", 33 | bucketSizeExceed: "Wysyłane dane %% przekraczają maksymalny rozmiar %%", 34 | mailInvalid: "Adres e-mail wygląda na nieprawidłowy", 35 | mailTo: "Wyślij do", 36 | mailToPlaceholder: "Adres E-Mail", 37 | mailFrom: "Twój adres E-Mail", 38 | mailFromPlaceholder: "Adres E-Mail", 39 | mailMessage: "Twoja wiadomość", 40 | mailDownloadNotification: "Powiadom mnie kiedy plik zostanie pobrany.", 41 | mailSendBtn: "Wyślij E-Mail", 42 | mailsSent: "Wiadomości E-Mail zostały wysłane.", 43 | mailSubjectUploader: "PsiTransfer przesyłanie plików", 44 | mailSubjectDownloader: "PsiTransfer pobieranie plików", 45 | mailSubjectFileDownloaded: "Plik został pobrany", 46 | retentions: { 47 | "one-time": "pobieranie jednorazowe", 48 | "3600": "1 godzina", 49 | "21600": "6 godzin ", 50 | "86400": "1 dzień", 51 | "259200": "3 dni", 52 | "604800": "1 tydzień", 53 | "1209600": "2 tygodnie", 54 | "2419200": "4 tygodnie", 55 | "4838400": "8 tygodni" 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /lang/pt.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "pt", 3 | oldBrowserError: "Seu navegador é obsoleto e não suporta esta funcionalidade.", 4 | dropFilesHere: "Arraste seus arquivos aqui", 5 | noPreviewAvailable: "Pré-visualização indisponível", 6 | preview: "Visualizar", 7 | comment: "Comentário", 8 | settings: "Configurações", 9 | retention: "Retenção", 10 | password: "Senha", 11 | required: "obrigatório", 12 | optional: "opcional", 13 | generateRandomPassword: "Gerar senha aleatória", 14 | newUpload: "Novo envio", 15 | accessDenied: "Acesso negado!", 16 | login: "Login", 17 | email: "E-Mail", 18 | clipboard: "Área de transferência", 19 | copyToClipboard: "Copiar para área de transferência", 20 | sendViaMail: "Enviar por e-mail", 21 | showQrCode: "Exibir QR-Code", 22 | uploadCompleted: "Envio completo", 23 | downloadLink: "Link para download", 24 | upload: "envio", 25 | retry: "tentar novamente", 26 | createNewUploadSession: "Criar uma nova sessão de envio?", 27 | decrypt: "decriptografar", 28 | files: "Arquivos", 29 | zipDownload: "Baixar todos os arquivos como ZIP (não resumível)", 30 | tarGzDownload: "Baixar todos os arquivos como tar.gz (não resumível)", 31 | oneTimeDownloadExpired: "Download Único: O arquivo não está mais disponível.", 32 | fileSizeExceed: "O tamanho do arquivo %% excede o máximo permitido de %%", 33 | bucketSizeExceed: "O tamanho do envio %% excede o máximo permitido de %%", 34 | mailInvalid: "O endereço de e-mail parece inválido", 35 | mailTo: "Enviar para", 36 | mailToPlaceholder: "Endereço de e-mail", 37 | mailFrom: "Meu endereço de e-mail", 38 | mailFromPlaceholder: "Endereço de e-mail", 39 | mailMessage: "Minha mensagem", 40 | mailDownloadNotification: "Notificar-me quando um arquivo for baixado.", 41 | mailSendBtn: "Enviar e-mail", 42 | mailsSent: "Os e-mails foram enviados.", 43 | mailSubjectUploader: "PsiTransfer File-Upload", 44 | mailSubjectDownloader: "PsiTransfer File-Download", 45 | mailSubjectFileDownloaded: "O arquivo foi baixado", 46 | retentions: { 47 | "one-time": "baixar uma única vez", 48 | "3600": "1 Hora", 49 | "21600": "6 Horas", 50 | "86400": "1 Dia", 51 | "259200": "3 Dias", 52 | "604800": "1 Semana", 53 | "1209600": "2 Semanas", 54 | "2419200": "4 Semanas", 55 | "4838400": "8 Semanas" 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /docs/nginx-ssl-example.conf: -------------------------------------------------------------------------------- 1 | # don't send the nginx version number in error pages and Server header 2 | server_tokens off; 3 | 4 | # redirect all http traffic to https 5 | server { 6 | listen 80; 7 | server_name transfer.example.com; 8 | return 301 https://$host$request_uri; 9 | } 10 | 11 | server { 12 | listen 443 ssl; 13 | server_name transfer.example.com; 14 | 15 | error_log /var/log/nginx/transfer-error.log warn; 16 | access_log /var/log/nginx/transfer-access.log main; 17 | 18 | ### SSL ### 19 | ssl_certificate /etc/nginx/certs/transfer.example.com/fullchain.pem; 20 | ssl_certificate_key /etc/nginx/certs/transfer.example.com/key.pem; 21 | 22 | # enable session resumption to improve https performance 23 | # http://vincent.bernat.im/en/blog/2011-ssl-session-reuse-rfc5077.html 24 | ssl_session_cache shared:SSL:50m; 25 | ssl_session_timeout 5m; 26 | 27 | # openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048 28 | ssl_dhparam /etc/nginx/ssl/dhparams.pem; 29 | 30 | # enables server-side protection from BEAST attacks 31 | # http://blog.ivanristic.com/2013/09/is-beast-still-a-threat.html 32 | ssl_prefer_server_ciphers on; 33 | # disable SSLv3(enabled by default since nginx 0.8.19) since it's less secure then TLS http://en.wikipedia.org/wiki/Secure_Sockets_Layer#SSL_3.0 34 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 35 | # ciphers chosen for forward secrecy and compatibility 36 | # http://blog.ivanristic.com/2013/08/configuring-apache-nginx-and-openssl-for-forward-secrecy.html 37 | ssl_ciphers "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; 38 | 39 | 40 | location / { 41 | # Bind PsiTransfer to localhost interface 42 | # ie with PSITRANSFER_IFACE=127.0.0.1 43 | proxy_pass "http://127.0.0.1:3000"; 44 | 45 | proxy_http_version 1.1; 46 | proxy_buffering off; 47 | proxy_request_buffering off; # needs nginx version >= 1.7.11 48 | proxy_set_header Host $http_host; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lang/it.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | langCode: "it", 3 | oldBrowserError: "Il tuo browser è troppo vecchio e non supporta questa funzione.", 4 | dropFilesHere: "Metti il tuo file qui", 5 | noPreviewAvailable: "Nessuna anteprima disponibile", 6 | preview: "Anteprima", 7 | comment: "Commento", 8 | settings: "Impostazioni", 9 | retention: "Ritenzione", 10 | password: "Password", 11 | required: "richiesto", 12 | optional: "opzionale", 13 | generateRandomPassword: "Genera una password randomica", 14 | newUpload: "Nuovo caricamento", 15 | accessDenied: "Accesso rifiutato!", 16 | login: "Login", 17 | email: "E-Mail", 18 | clipboard: "Appunti", 19 | copyToClipboard: "Copia negli appunti", 20 | sendViaMail: "Invia tramite E-Mail", 21 | showQrCode: "Mostra il QR-Code", 22 | uploadCompleted: "Caricamento completato", 23 | downloadLink: "Link per lo scaricamento", 24 | upload: "caricamento", 25 | retry: "ritenta", 26 | createNewUploadSession: "Creare una nuova sessione di caricamento?", 27 | decrypt: "decripta", 28 | files: "Files", 29 | zipDownload: "Scarica tutti i file come ZIP (non ripristinabile)", 30 | tarGzDownload: "Scarica tutti i file come tar.gz (non ripristinabile)", 31 | oneTimeDownloadExpired: "Scaricamento singolo: Il File non sarà più disponibile", 32 | fileSizeExceed: "La dimensione del File %% eccede il massiomo di %%", 33 | bucketSizeExceed: "Grandezza di caricamento %% eccede il massimo di %%", 34 | mailInvalid: "L'indirizzo e-mail sembra errato", 35 | mailTo: "Invia a", 36 | mailToPlaceholder: "Indirizzo E-Mail", 37 | mailFrom: "Il mio indirizzo E-Mail", 38 | mailFromPlaceholder: "Indirizzo E-Mail", 39 | mailMessage: "Il mio messaggio", 40 | mailDownloadNotification: "Notificami quando il file viene scaricato.", 41 | mailSendBtn: "Invia E-Mail", 42 | mailsSent: "L'e-mails é stata inviata.", 43 | mailSubjectUploader: "PsiTransfer File-Upload", 44 | mailSubjectDownloader: "PsiTransfer File-Download", 45 | mailSubjectFileDownloaded: "Il file é stato scaricato", 46 | retentions: { 47 | "one-time": "Scaricamento singolo", 48 | "3600": "1 Ora", 49 | "21600": "6 Ore", 50 | "86400": "1 Giorno", 51 | "259200": "3 Giorni", 52 | "604800": "1 Settimana", 53 | "1209600": "2 Settimane", 54 | "2419200": "4 Settimane", 55 | "4838400": "8 Settimane" 56 | }, 57 | uploadPassword: "Password per il caricamento", 58 | } 59 | -------------------------------------------------------------------------------- /tests/e2e/01_upload.js: -------------------------------------------------------------------------------- 1 | import { ClientFunction, Selector } from 'testcafe'; 2 | import { sharedData } from './helper'; 3 | 4 | const uploadUrl = process.env.TEST_URL || 'http://localhost:3000/'; 5 | const fileInputField = Selector('#fileInput'); 6 | const passwordField = Selector('#password'); 7 | const retentionField = Selector('#retention'); 8 | const uploadBtn = Selector('#uploadBtn'); 9 | 10 | fixture`PsiTransfer Upload` 11 | .page`${uploadUrl}`; 12 | 13 | test('Upload', async t => { 14 | // Set Password 15 | await t 16 | .typeText(passwordField, 'bacon') 17 | .expect(passwordField.value).eql('bacon'); 18 | 19 | // Set retention 20 | await t 21 | .click(retentionField) 22 | .click(retentionField.find('option[value="3600"]')) 23 | .expect(retentionField.value).eql("3600"); 24 | 25 | // Add files 26 | const fileInputFiles = ClientFunction(() => { 27 | const res = []; 28 | const files = fileInputField().files; 29 | for (let i=0; i 2 | modal.preview-modal(v-if="current", @close="current=false", :has-header="true", @next="next", @prev="prev") 3 | div.header(slot="header") 4 | p 5 | strong {{current.metadata.name}} 6 | div 7 | small {{currentIndex+1}} / {{files.length}} 8 | span.btn-group 9 | a.btn.btn-sm.btn-default(title="previous", @click="prev", v-show="currentIndex > 0") 10 | icon(name="arrow-left") 11 | a.btn.btn-sm.btn-default(title="next", @click="next", v-show="currentIndex < files.length-1") 12 | icon(name="arrow-right") 13 | a.btn.btn-sm.btn-default(title="toggle line wrap", @click="lineWrap = !lineWrap", :class="{active:lineWrap}", v-show="current.previewType === 'text'") 14 | icon(name="undo-alt", flip="vertical") 15 | div(slot="body") 16 | div(v-if="current.previewType === 'image'", style="text-align:center") 17 | img(:src="current.url", style="max-width: 100%; height:auto") 18 | div(v-if="current.previewType === 'text'") 19 | pre(:style="{'white-space':lineWrap?'pre-wrap':'pre'}") {{ previewText }} 20 | p(v-if="current.previewType === false", style="text-align:center") 21 | strong.text-danger {{ $root.lang.noPreviewAvailable }} 22 | 23 | 24 | 25 | 85 | -------------------------------------------------------------------------------- /lib/tusboy/index.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express'); 2 | const tusHeaderParser = require('./tus-header-parser'); 3 | const methodOverride = require('method-override'); 4 | const cors = require('cors'); 5 | 6 | const constants = require('./constants'); 7 | const errors = require('./errors'); 8 | 9 | const errorHandler = require ('./handlers/error'); 10 | const options = require ('./handlers/options'); 11 | const head = require ('./handlers/head'); 12 | const patch = require ('./handlers/patch'); 13 | const post = require ('./handlers/post'); 14 | 15 | /** 16 | * Returns a route handler for Express that calls the passed in function 17 | * @param {Function} fn The asynchronous the route needs to call 18 | * @return {Promise} 19 | */ 20 | function w(fn) { 21 | if (fn.length <= 3) { 22 | return function(req, res, next) { 23 | return fn(req, res, next).catch(next); 24 | }; 25 | } else { 26 | return function(err, req, res, next) { 27 | return fn(err, req, res, next).catch(next); 28 | }; 29 | } 30 | } 31 | 32 | const detectExtensions = (store) => { 33 | return [ 34 | 'create', 35 | () => { if (store.del) return 'delete' }, 36 | ].filter(ele => typeof ele === 'string') 37 | } 38 | 39 | const versionSupported = (/* versionStr */) => true 40 | 41 | const setTusResumableHeader = (req, res, next) => { 42 | res.set('Tus-Resumable', constants.TUS_VERSION) 43 | next() 44 | } 45 | 46 | // The Tus-Resumable header MUST be included in every request and 47 | // response except for OPTIONS requests. The value MUST be the version 48 | // of the protocol used by the Client or the Server. 49 | const assertTusResumableHeader = (req, res, next) => { 50 | if (!('tusResumable' in req.tus)) { 51 | res.set('Tus-Version', constants.TUS_VERSION) 52 | return next(errors.preconditionError('Tus-Resumable header missing')) 53 | } else if (!versionSupported(req.tus.tusResumable)) { 54 | res.set('Tus-Version', constants.TUS_VERSION) 55 | return next(errors.preconditionError('Tus-Resumable version not supported')) 56 | } 57 | next() 58 | } 59 | 60 | const setCorsHeaders = cors({ 61 | origin: true, 62 | exposedHeaders: constants.EXPOSED_HEADERS, 63 | }) 64 | 65 | module.exports = (store, opts = {}) => { 66 | const { handleErrors = true } = opts 67 | const extensions = detectExtensions(store) 68 | 69 | const nextRouter = new Router({ mergeParams: true }) 70 | .use(assertTusResumableHeader) 71 | .post('/', w(post(store, opts))) 72 | .head('/:uploadId', w(head(store, opts))) 73 | .patch('/:uploadId', w(patch(store, opts))) 74 | 75 | if (handleErrors) nextRouter.use(errorHandler) 76 | 77 | const router = new Router({ mergeParams: true }) 78 | router 79 | .use(methodOverride('X-HTTP-Method-Override')) 80 | .use(tusHeaderParser()) 81 | .options('*', options(extensions, opts.extraCorsMethods)) 82 | .use(setCorsHeaders) 83 | .use(setTusResumableHeader) 84 | .use((req, res, next) => { 85 | if (req.method === 'GET') return next() 86 | return nextRouter(req, res, next) 87 | }) 88 | 89 | return router 90 | } 91 | -------------------------------------------------------------------------------- /app/src/Upload/Settings.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 89 | -------------------------------------------------------------------------------- /lib/tusboy/handlers/patch.js: -------------------------------------------------------------------------------- 1 | // PATCH 2 | // 3 | // The Server SHOULD accept PATCH requests against any upload URL and 4 | // apply the bytes contained in the message at the given offset 5 | // specified by the Upload-Offset header. All PATCH requests MUST use 6 | // Content-Type: application/offset+octet-stream. 7 | // 8 | // The Upload-Offset header’s value MUST be equal to the current offset 9 | // of the resource. In order to achieve parallel upload the 10 | // Concatenation extension MAY be used. If the offsets do not match, the 11 | // Server MUST respond with the 409 Conflict status without modifying 12 | // the upload resource. 13 | // 14 | // The Client SHOULD send all the remaining bytes of an upload in a 15 | // single PATCH request, but MAY also use multiple small requests 16 | // successively for scenarios where this is desirable, for example, if 17 | // the Checksum extension is used. 18 | // 19 | // The Server MUST acknowledge successful PATCH requests with the 204 No 20 | // Content status. It MUST include the Upload-Offset header containing 21 | // the new offset. The new offset MUST be the sum of the offset before 22 | // the PATCH request and the number of bytes received and processed or 23 | // stored during the current PATCH request. 24 | // 25 | // Both, Client and Server, SHOULD attempt to detect and handle network 26 | // errors predictably. They MAY do so by checking for read/write socket 27 | // errors, as well as setting read/write timeouts. A timeout SHOULD be 28 | // handled by closing the underlying connection. 29 | // 30 | // The Server SHOULD always attempt to store as much of the received 31 | // data as possible. 32 | const storeErrors = require('../store/errors'); 33 | const errors = require('../errors'); 34 | 35 | module.exports = (store, { 36 | onComplete, 37 | beforeComplete = async () => {}, 38 | afterComplete = async () => {}, 39 | }) => (async (req, res) => { 40 | const after = onComplete || afterComplete 41 | 42 | // The request MUST include a Upload-Offset header 43 | if (!('uploadOffset' in req.tus)) { 44 | throw errors.missingHeader('upload-offset') 45 | } 46 | 47 | // The request MUST include a Content-Type header 48 | if (typeof req.get('content-type') === 'undefined') { 49 | throw errors.missingHeader('content-type') 50 | } 51 | 52 | // All PATCH requests MUST use Content-Type: application/offset+octet-stream 53 | if (req.get('content-type') !== 'application/offset+octet-stream') { 54 | throw errors.invalidHeader('content-type', req.get('content-type')) 55 | } 56 | 57 | const uploadId = req.params.uploadId 58 | 59 | try { 60 | const { 61 | offset, 62 | upload, 63 | } = await store.append(uploadId, req, req.tus.uploadOffset, { 64 | beforeComplete: (...args) => beforeComplete(req, ...args), 65 | uploadLength: req.tus.uploadLength, 66 | }) 67 | if (upload && upload.uploadLength === offset) { 68 | await after(req, upload, uploadId) 69 | } 70 | // It MUST include the Upload-Offset header containing the new offset. 71 | res.set('Upload-Offset', offset) 72 | // The Server MUST acknowledge successful PATCH requests 73 | // with the 204 No Content status. 74 | res.status(204) 75 | res.end() 76 | } catch (err) { 77 | if (err instanceof storeErrors.OffsetMismatch) { 78 | throw errors.offsetMismatch() 79 | } 80 | throw err 81 | } 82 | }) 83 | -------------------------------------------------------------------------------- /app/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin; 4 | const { VueLoaderPlugin } = require('vue-loader'); 5 | const execSync = require('child_process').execSync; 6 | 7 | let commitShaId; 8 | 9 | try { 10 | commitShaId = '#' + execSync('git rev-parse HEAD').toString().substr(0, 10); 11 | } 12 | catch (e) { 13 | } 14 | 15 | const mode = process.env.NODE_ENV || 'development'; 16 | 17 | module.exports = { 18 | mode, 19 | entry: { 20 | upload: './src/upload.js', 21 | download: './src/download.js', 22 | admin: './src/admin.js', 23 | }, 24 | output: { 25 | path: path.resolve(__dirname, '../public/app'), 26 | publicPath: '/app/', 27 | filename: '[name].js' 28 | }, 29 | optimization: { 30 | splitChunks: { 31 | chunks: 'all', 32 | minChunks: 2, 33 | name: 'common' 34 | } 35 | }, 36 | devtool: 'source-map', 37 | // devtool: 'none', 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.vue$/, 42 | loader: 'vue-loader', 43 | }, 44 | { 45 | test: /\.js$/, 46 | exclude: /node_modules\/(?!(vue-awesome|drag-drop)\/).*/, 47 | use: { 48 | loader: 'babel-loader', 49 | options: { 50 | presets: [['@babel/preset-env', { 51 | modules: false, 52 | useBuiltIns: false, 53 | targets: "last 2 versions, ie 11, not dead", 54 | }]], 55 | } 56 | } 57 | }, 58 | { 59 | test: /\.pug$/, 60 | oneOf: [ 61 | // this applies to `