+~]|"+ie+")"+ie+"*"),v=new RegExp(ie+"|>"),l=new RegExp(a),p=new RegExp("^"+t+"$"),y={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+o),PSEUDO:new RegExp("^"+a),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ie+"*(even|odd|(([+-]|)(\\d*)n|)"+ie+"*(?:([+-]|)"+ie+"*(\\d+)|))"+ie+"*\\)|)","i"),bool:new RegExp("^(?:checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped)$","i"),needsContext:new RegExp("^"+ie+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ie+"*((?:-\\d)?\\d*)"+ie+"*\\)|)(?=[^-]|$)","i")},m=/^(?:input|select|textarea|button)$/i,S=/^h\d$/i,D=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,A=/[+~]/,j=new RegExp("\\\\[\\da-fA-F]{1,6}"+ie+"?|\\\\([^\\r\\n\\f])","g"),N=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},k=function(){F()},O=X(function(e){return!0===e.disabled&&ne(e,"fieldset")},{dir:"parentNode",next:"legend"});function L(e){throw new Error("Syntax error, unrecognized expression: "+e)}function P(t,e,n,r){var i,o,a,u,s,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(F(e),e=e||w,T)){if(11!==p&&(s=D.exec(t)))if(i=s[1]){if(9===p)return(a=e.getElementById(i))&&J.call(n,a),n;if(f&&(a=f.getElementById(i))&&te.contains(e,a))return J.call(n,a),n}else{if(s[2])return J.apply(n,e.getElementsByTagName(t)),n;if((i=s[3])&&e.getElementsByClassName)return J.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||oe&&oe.test(t))){if(c=t,f=e,1===p&&(v.test(t)||g.test(t))){(f=A.test(t)&&_(e.parentNode)||e)===e&&ee.scope||((u=e.getAttribute("id"))?u=te.escapeSelector(u):e.setAttribute("id",u=E)),o=(l=U(t)).length;while(o--)l[o]=(u?"#"+u:":scope")+" "+W(l[o]);c=l.join(",")}try{return J.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{u===E&&e.removeAttribute("id")}}}return function(e,t,n,r){var i,o,a,u,s,l="function"==typeof e&&e,c=!r&&U(e=l.selector||e);if(n=n||[],1===c.length){if(2<(o=c[0]=c[0].slice(0)).length&&"ID"===(a=o[0]).type&&9===t.nodeType&&T&&b.relative[o[1].type]){if(!(t=(b.find.ID(a.matches[0].replace(j,N),t)||[])[0]))return n;l&&(t=t.parentNode),e=e.slice(o.shift().value.length)}i=y.needsContext.test(e)?0:o.length;while(i--){if(a=o[i],b.relative[u=a.type])break;if((s=b.find[u])&&(r=s(a.matches[0].replace(j,N),A.test(o[0].type)&&_(t.parentNode)||t))){if(o.splice(i,1),!(e=r.length&&W(o)))return J.apply(n,r),n;break}}}return(l||K(e,c))(r,t,!T,n,!t||A.test(e)&&_(t.parentNode)||t),n}(t.replace(d,"$1"),e,n,r)}function R(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function $(e){return e[E]=!0,e}function q(t){return function(e){return ne(e,"input")&&e.type===t}}function I(t){return function(e){return(ne(e,"input")||ne(e,"button"))&&e.type===t}}function B(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&O(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function M(a){return $(function(o){return o=+o,$(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function _(e){return e&&void 0!==e.getElementsByTagName&&e}function F(e){var t,n=e?e.ownerDocument||e:ae;n!=w&&9===n.nodeType&&(r=(w=n).documentElement,T=!te.isXMLDoc(w),ae!=w&&(t=w.defaultView)&&t.top!==t&&t.addEventListener("unload",k))}for(e in P.matches=function(e,t){return P(e,null,null,t)},P.matchesSelector=function(e,t){if(F(e),T&&!h[t+" "]&&(!oe||!oe.test(t)))try{return ue.call(e,t)}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(j,N),e[3]=(e[3]||e[4]||e[5]||"").replace(j,N),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||L(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&L(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return y.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&l.test(n)&&(t=U(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{ID:function(e){var t=e.replace(j,N);return function(e){return e.getAttribute("id")===t}},TAG:function(e){var t=e.replace(j,N).toLowerCase();return"*"===e?function(){return!0}:function(e){return ne(e,t)}},CLASS:function(e){var t=i[e+" "];return t||(t=new RegExp("(^|"+ie+")"+e+"("+ie+"|$)"))&&i(e,function(e){return t.test("string"==typeof e.className&&e.className||void 0!==e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=te.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function O(e,n,r){return"function"==typeof n?te.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?te.grep(e,function(e){return e===n!==r}):"string"!=typeof n?te.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(te.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||L,"string"!=typeof e)return e.nodeType?(this[0]=e,this.length=1,this):"function"==typeof e?void 0!==n.ready?n.ready(e):e(te):te.makeArray(e,this);if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:P.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof te?t[0]:t,te.merge(this,te.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:l,!0)),k.test(r[1])&&te.isPlainObject(t))for(r in t)"function"==typeof this[r]?this[r](t[r]):this.attr(r,t[r]);return this}return(i=l.getElementById(r[2]))&&(this[0]=i,this.length=1),this}).prototype=te.fn,L=te(l);var R=/^(?:parents|prev(?:Until|All))/,$={children:!0,contents:!0,next:!0,prev:!0};function q(e,t){while((e=e[t])&&1!==e.nodeType);return e}te.fn.extend({has:function(e){var t=te(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Te=/^$|^module$|\/(?:java|ecma)script/i,Ee={thead:["table"],col:["colgroup","table"],tr:["tbody","table"],td:["tr","tbody","table"]};function Ce(e,t){var n;return n=void 0!==e.getElementsByTagName?e.getElementsByTagName(t||"*"):void 0!==e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&ne(e,t)?te.merge([e],n):n}function Se(e,t){for(var n=0,r=e.length;n\s*$/g;function ke(e,t){return ne(e,"table")&&ne(11!==t.nodeType?t:t.firstChild,"tr")&&te(e).children("tbody")[0]||e}function Oe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Le(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Pe(e,t){var n,r,i,o,a,u;if(1===t.nodeType){if(W.hasData(e)&&(u=W.get(e).events))for(i in W.remove(t,"handle events"),u)for(n=0,r=u[i].length;n
--------------------------------------------------------------------------------
/client/svg/add-folder.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/arrows-h.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/arrows-v.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/check.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/code.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/cog.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/copy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/create-file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/create-folder.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/cross.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/disk.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/download.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/exclamation.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/eye.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/find.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/fullscreen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/home.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/info.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/link.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/loop.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/menu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/open.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/paste.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/pause.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/pencil.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/play.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/plus.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/previous.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/reload.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/rename.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/scissors.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/shuffle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/signin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/signout.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/skip.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/spinner.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/stop.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/trash.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/triangle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/unfullscreen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/up-arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/upload-cloud.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/user-cog.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/user.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/volume-high.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/volume-low.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/volume-medium.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/volume-mute.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/window-cross.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/wordwrap.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/zoomin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/svg/zoomout.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/templates/directory.hbs:
--------------------------------------------------------------------------------
1 |
7 | {{#each entries}}
8 | {{#is type "f"}}
9 |
10 |
11 | {{#if playable}}
12 | {{{svg "play"}}}
13 | {{else if viewableImage}}
14 | {{{svg "eye"}}}
15 | {{else if viewablePdf}}
16 | {{{svg "eye"}}}
17 | {{else if viewableVideo}}
18 | {{{svg "play"}}}
19 | {{/if}}
20 |
21 |
{{name}}
22 |
{{age}}
23 |
{{psize}}
24 |
25 | {{{svg "link"}}}
26 | {{{svg "download"}}}
27 | {{{svg "trash"}}}
28 |
29 |
30 |
31 | {{/is}}
32 | {{#is type "d"}}
33 |
45 | {{/is}}
46 | {{#is type "e"}}
47 |
51 | {{/is}}
52 | {{else}}
53 | {{#if isSearch}}
54 | No results
55 | {{else}}
56 |
57 | {{{svg "upload-cloud"}}}
58 |
Add files
59 |
60 | {{/if}}
61 | {{/each}}
62 |
--------------------------------------------------------------------------------
/client/templates/document.hbs:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/client/templates/file-header.hbs:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/client/templates/list-user.hbs:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/client/templates/login.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{#if first}}
6 | Set your login credentials.
7 | {{else}}
8 | {{{svg "logo"}}}droppy
9 | {{/if}}
10 |
11 |
12 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/client/templates/main.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
21 |
22 |
23 | Move
24 | Copy
25 | View
26 |
27 |
--------------------------------------------------------------------------------
/client/templates/media.hbs:
--------------------------------------------------------------------------------
1 |
53 |
--------------------------------------------------------------------------------
/client/templates/new-file.hbs:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/templates/new-folder.hbs:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/templates/options.hbs:
--------------------------------------------------------------------------------
1 |
2 | {{#each opts}}
3 |
4 | {{this.label}}
5 |
6 | {{#select this.selected}}
7 | {{#each this.values}}
8 | {{@key}}
9 | {{/each}}
10 | {{/select}}
11 |
12 |
13 | {{/each}}
14 |
15 |
--------------------------------------------------------------------------------
/client/templates/pdf.hbs:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/templates/upload-info.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
0%
4 |
{{title}}
5 |
6 |
7 |
8 | {{{svg "cross"}}}
9 |
10 |
11 |
--------------------------------------------------------------------------------
/client/templates/video.hbs:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/templates/view.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{{svg "add-file"}}}
5 | {{{svg "add-folder"}}}
6 | {{{svg "create-file"}}}
7 | {{{svg "create-folder"}}}
8 |
9 |
10 |
11 | {{{svg "find"}}}
12 |
13 |
14 |
{{{svg "reload"}}}
15 |
{{{svg "window"}}}
16 |
{{{svg "cog"}}}
17 |
{{{svg "info"}}}
18 |
{{{svg "signout"}}}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
{{{svg "link"}}}
28 |
29 |
30 |
31 |
32 | {{{svg "copy"}}}
Copy
33 |
34 |
35 | {{{svg "check"}}}
Is DL
36 |
37 |
38 |
39 |
40 | {{{svg "paste"}}}
41 | Paste here
42 | {{{svg "triangle"}}}
43 |
44 |
45 |
{{{svg "volume-medium"}}}
46 |
49 |
{{{svg "previous"}}}
50 |
{{{svg "pause"}}}
51 |
{{{svg "stop"}}}
52 |
{{{svg "shuffle"}}}
53 |
{{{svg "next"}}}
54 |
55 |
56 |
57 | /
58 |
59 |
60 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/client/tooltips.css:
--------------------------------------------------------------------------------
1 | .tip {
2 | position: relative;
3 | }
4 |
5 | .tip:after {
6 | position: absolute;
7 | z-index: 65535;
8 | display: none;
9 | padding: 2px 8px;
10 | font: 14px/1.5 Helvetica, Arial, sans-serif;
11 | color: #fff;
12 | pointer-events: none;
13 | content: attr(aria-label);
14 | background: rgba(0,0,0,.85);
15 | box-shadow: 0 4px 8px rgba(0,0,0,.25);
16 | border-radius: 4px;
17 | white-space: pre;
18 | }
19 |
20 | .tip:before {
21 | position: absolute;
22 | z-index: 1000;
23 | display: none;
24 | width: 0;
25 | height: 0;
26 | color: rgba(0,0,0,.85);
27 | pointer-events: none;
28 | content: "";
29 | border: 5px solid transparent;
30 | }
31 |
32 | .tip[aria-label=""]:before, .tip[aria-label=""]:after,
33 | .tip.disabled:before, .tip.disabled:after {
34 | display: none !important;
35 | }
36 |
37 | .tip:hover:before, .tip:hover:after {
38 | display: inline-block;
39 | text-decoration: none;
40 | }
41 |
42 | .tip-s:after, .tip-se:after, .tip-sw:after {
43 | top: 100%;
44 | right: 50%;
45 | margin-top: 5px;
46 | }
47 |
48 | .tip-se:after {
49 | right:auto;
50 | left:50%;
51 | margin-left:-15px
52 | }
53 |
54 | .tip-sw:after {
55 | margin-right:-15px
56 | }
57 |
58 | .tip-s:before, .tip-se:before, .tip-sw:before {
59 | top: auto;
60 | right: 50%;
61 | bottom: -6px;
62 | margin-right: -5px;
63 | border-bottom-color: rgba(0,0,0,.85);
64 | }
65 |
66 | .tip-n:after, .tip-ne:after, .tip-nw:after {
67 | right: 50%;
68 | bottom: 100%;
69 | margin-bottom: 5px;
70 | }
71 |
72 | .tip-n:before, .tip-ne:before, .tip-nw:before {
73 | top: -6px;
74 | right: 50%;
75 | bottom: auto;
76 | margin-right: -5px;
77 | border-top-color: rgba(0,0,0,.85);
78 | }
79 |
80 | .tip-ne:after {
81 | right:auto;
82 | left:50%;
83 | margin-left:-15px
84 | }
85 |
86 | .tip-nw:after {
87 | margin-right:-15px
88 | }
89 |
90 | .tip-s:after, .tip-n:after {
91 | transform: translateX(50%);
92 | }
93 |
94 | .tip-w:after {
95 | right: 100%;
96 | bottom: 50%;
97 | margin-right: 5px;
98 | transform: translateY(50%);
99 | }
100 |
101 | .tip-w:before {
102 | top: 50%;
103 | bottom: 50%;
104 | left: -5px;
105 | margin-top: -5px;
106 | border-left-color: rgba(0,0,0,.85);
107 | }
108 |
109 | .tip-e:after {
110 | bottom: 50%;
111 | left: 100%;
112 | margin-left: 5px;
113 | transform: translateY(50%);
114 | }
115 |
116 | .tip-e:before {
117 | top: 50%;
118 | right: -5px;
119 | bottom: 50%;
120 | margin-top: -5px;
121 | border-right-color: rgba(0,0,0,.85);
122 | }
123 |
--------------------------------------------------------------------------------
/docker-start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | [ -z "$UID" ] && UID=0
4 | [ -z "$GID" ] && GID=0
5 |
6 | # echo >> /etc/xxx and not adduser/addgroup because adduser/addgroup
7 | # won't work if uid/gid already exists.
8 | echo -e "droppy:x:${UID}:${GID}:droppy:/droppy:/bin/false\n" >> /etc/passwd
9 | echo -e "droppy:x:${GID}:droppy\n" >> /etc/group
10 |
11 | # it's better to do that (mkdir and chown) here than in the Dockerfile
12 | # because it will be executed even on volumes if mounted.
13 | mkdir -p /config
14 | mkdir -p /files
15 |
16 | chown -R droppy:droppy /config
17 | chown droppy:droppy /files
18 |
19 | exec /bin/su -p -s "/bin/sh" -c "exec /usr/bin/droppy start --color -f /files -c /config" droppy
20 |
--------------------------------------------------------------------------------
/droppy.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | "use strict";
3 |
4 | const fs = require("fs");
5 | const pkg = require("./package.json");
6 | const untildify = require("untildify");
7 | const path = require("path");
8 |
9 | require("util").inspect.defaultOptions.depth = 4;
10 |
11 | const argv = require("minimist")(process.argv.slice(2), {
12 | boolean: ["color", "d", "daemon", "dev"]
13 | });
14 |
15 | if (!argv.dev) {
16 | process.env.NODE_ENV = "production";
17 | }
18 |
19 | process.title = pkg.name;
20 | process.chdir(__dirname);
21 |
22 | const cmds = {
23 | start: "start Start the server",
24 | stop: "stop Stop all daemonized servers",
25 | config: "config Edit the config",
26 | list: "list List users",
27 | add: "add [p] Add or update a user. Specify 'p' for privileged",
28 | del: "del Delete a user",
29 | build: "build Build client resources",
30 | version: "version, -v Print version",
31 | };
32 |
33 | const opts = {
34 | configdir: "-c, --configdir Config directory. Default: ~/.droppy/config",
35 | filesdir: "-f, --filesdir Files directory. Default: ~/.droppy/files",
36 | daemon: "-d, --daemon Daemonize (background) process",
37 | log: "-l, --log Log to file instead of stdout",
38 | dev: "--dev Enable developing mode",
39 | color: "--color Force-enable colored log output",
40 | nocolor: "--no-color Force-disable colored log output",
41 | };
42 |
43 | if (argv.v || argv.V || argv.version) {
44 | console.info(pkg.version);
45 | process.exit(0);
46 | }
47 |
48 | if (argv.daemon || argv.d) {
49 | require("daemonize-process")();
50 | }
51 |
52 | if (argv._[0] === "build") {
53 | console.info("Building resources ...");
54 | require("./server/resources.js").build(err => {
55 | console.info(err || "Resources built successfully");
56 | process.exit(err ? 1 : 0);
57 | });
58 | }
59 |
60 | if (argv.configdir || argv.filesdir || argv.c || argv.f) {
61 | require("./server/paths.js").seed(argv.configdir || argv.c, argv.filesdir || argv.f);
62 | }
63 |
64 | if (argv.log || argv.l) {
65 | try {
66 | require("./server/log.js").setLogFile(fs.openSync(untildify(path.resolve(argv.log || argv.l)), "a", "644"));
67 | } catch (err) {
68 | console.error(`Unable to open log file for writing: ${err.message}`);
69 | process.exit(1);
70 | }
71 | }
72 |
73 | if (!argv._.length) {
74 | printHelp();
75 | process.exit(0);
76 | }
77 |
78 | const cmd = argv._[0];
79 | const args = argv._.slice(1);
80 |
81 | if (cmds[cmd]) {
82 | let db;
83 | if (cmd === "start") {
84 | require("./server/server.js")(null, true, argv.dev, err => {
85 | if (err) {
86 | require("./server/log.js").error(err);
87 | process.exit(1);
88 | }
89 | });
90 | } else if (cmd === "stop") {
91 | const ps = require("ps-node");
92 | const log = require("./server/log.js");
93 | ps.lookup({command: pkg.name}, async (err, procs) => {
94 | if (err) {
95 | log.error(err);
96 | process.exit(1);
97 | } else {
98 | procs = procs.filter(proc => Number(proc.pid) !== process.pid);
99 | if (!procs.length) {
100 | log.info("No processes found");
101 | process.exit(0);
102 | }
103 |
104 | const pids = await Promise.all(procs.map(proc => {
105 | return new Promise(resolve => {
106 | ps.kill(proc.pid, err => {
107 | if (err) {
108 | log.error(err);
109 | return process.exit(1);
110 | }
111 | resolve(proc.pid);
112 | });
113 | });
114 | }));
115 |
116 | if (pids.length) {
117 | console.info(`Killed PIDs: ${pids.join(", ")}`);
118 | }
119 | process.exit(0);
120 | }
121 | });
122 | } else if (cmd === "version") {
123 | console.info(pkg.version);
124 | } else if (cmd === "config") {
125 | const paths = require("./server/paths.js").get();
126 | const cfg = require("./server/cfg.js");
127 | const edit = function() {
128 | findEditor(editor => {
129 | if (!editor) return console.error(`No suitable editor found, please edit ${paths.cfgFile}`);
130 | require("child_process").spawn(editor, [paths.cfgFile], {stdio: "inherit"});
131 | });
132 | };
133 | fs.stat(paths.cfgFile, err => {
134 | if (err && err.code === "ENOENT") {
135 | fs.mkdir(paths.config, {recursive: true}, () => {
136 | cfg.init(null, err => {
137 | if (err) return console.error(new Error(err.message || err).stack);
138 | edit();
139 | });
140 | });
141 | } else {
142 | edit();
143 | }
144 | });
145 | } else if (cmd === "list") {
146 | db = require("./server/db.js");
147 | db.load(() => {
148 | printUsers(db.get("users"));
149 | });
150 | } else if (cmd === "add") {
151 | if (args.length !== 2 && args.length !== 3) printHelp();
152 | db = require("./server/db.js");
153 | db.load(() => {
154 | db.addOrUpdateUser(args[0], args[1], args[2] === "p", () => {
155 | printUsers(db.get("users"));
156 | });
157 | });
158 | } else if (cmd === "del") {
159 | if (args.length !== 1) printHelp();
160 | db = require("./server/db.js");
161 | db.load(() => {
162 | db.delUser(args[0], () => {
163 | printUsers(db.get("users"));
164 | });
165 | });
166 | }
167 | } else {
168 | printHelp();
169 | }
170 |
171 | function printHelp() {
172 | let help = `Usage: ${pkg.name} command [options]\n\n Commands:`;
173 |
174 | Object.keys(cmds).forEach(command => {
175 | help += `\n ${cmds[command]}`;
176 | });
177 |
178 | help += "\n\n Options:";
179 |
180 | Object.keys(opts).forEach(option => {
181 | help += `\n ${opts[option]}`;
182 | });
183 |
184 | console.info(help);
185 | process.exit();
186 | }
187 |
188 | function printUsers(users) {
189 | if (Object.keys(users).length === 0) {
190 | console.info("No users defined. Use 'add' to add one.");
191 | } else {
192 | console.info(`Current Users:\n${Object.keys(users).map(user => {
193 | return ` - ${user}`;
194 | }).join("\n")}`);
195 | }
196 | }
197 |
198 | function findEditor(cb) {
199 | const editors = ["vim", "nano", "vi", "npp", "pico", "emacs", "notepad"];
200 | const basename = require("path").basename;
201 | const which = require("which");
202 | const userEditor = basename(process.env.VISUAL || process.env.EDITOR);
203 |
204 | if (!editors.includes(userEditor)) {
205 | editors.unshift(userEditor);
206 | }
207 |
208 | (function find(editor) {
209 | try {
210 | cb(which.sync(editor));
211 | } catch {
212 | if (editors.length) {
213 | find(editors.shift());
214 | } else {
215 | cb();
216 | }
217 | }
218 | })(editors.shift());
219 | }
220 |
--------------------------------------------------------------------------------
/examples/Caddyfile:
--------------------------------------------------------------------------------
1 | droppy.your.domain {
2 | proxy / 127.0.0.1:8989 {
3 | websocket
4 | transparent
5 | }
6 | timeouts none
7 | }
8 |
--------------------------------------------------------------------------------
/examples/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '2'
2 | services:
3 | droppy:
4 | container_name: droppy
5 | image: silverwind/droppy
6 | ports:
7 | - '127.0.0.1:8989:8989'
8 | volumes:
9 | - ./config:/config
10 | - ./data:/files
11 | restart: unless-stopped
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "droppy",
3 | "version": "12.2.0",
4 | "description": "Self-hosted file storage",
5 | "author": "silverwind ",
6 | "repository": "silverwind/droppy",
7 | "license": "BSD-2-Clause",
8 | "preferGlobal": true,
9 | "bin": "./droppy.js",
10 | "engines": {
11 | "node": ">= 12.10.0"
12 | },
13 | "scripts": {
14 | "start": "node droppy.js start",
15 | "test": "make test"
16 | },
17 | "dependencies": {
18 | "busboy": "0.3.1",
19 | "chokidar": "3.4.2",
20 | "colorette": "1.2.1",
21 | "content-disposition": "0.5.3",
22 | "daemonize-process": "3.0.0",
23 | "escape-string-regexp": "4.0.0",
24 | "etag": "1.8.1",
25 | "file-extension": "4.0.5",
26 | "image-size": "0.8.3",
27 | "isbinaryfile": "4.0.6",
28 | "json-buffer": "3.0.1",
29 | "lodash.debounce": "4.0.8",
30 | "lodash.throttle": "4.1.1",
31 | "mime-types": "2.1.27",
32 | "minimist": "1.2.5",
33 | "mv": "2.1.1",
34 | "original-url": "1.2.3",
35 | "plyr": "3.6.2",
36 | "ps-node": "0.1.6",
37 | "rfdc": "1.1.4",
38 | "rrdir": "8.1.1",
39 | "send": "0.17.1",
40 | "strip-ansi": "6.0.0",
41 | "untildify": "4.0.0",
42 | "valid-filename": "3.1.0",
43 | "which": "2.0.2",
44 | "ws": "7.3.1",
45 | "yazl": "2.5.1"
46 | },
47 | "devDependencies": {
48 | "autoprefixer": "9.8.6",
49 | "clean-css": "4.2.3",
50 | "codemirror": "5.57.0",
51 | "eslint": "7.7.0",
52 | "eslint-config-silverwind": "18.0.2",
53 | "eslint-plugin-unicorn": "21.0.0",
54 | "handlebars": "4.7.6",
55 | "html-minifier": "4.0.0",
56 | "mousetrap": "1.6.5",
57 | "pdfjs-dist": "2.4.456",
58 | "photoswipe": "4.1.3",
59 | "postcss": "7.0.32",
60 | "screenfull": "5.0.2",
61 | "stylelint": "13.6.1",
62 | "stylelint-config-silverwind": "2.0.8",
63 | "svgstore": "3.0.0-2",
64 | "terser": "5.2.1",
65 | "updates": "10.3.5",
66 | "uppie": "1.1.3",
67 | "versions": "8.4.3"
68 | },
69 | "files": [
70 | "client",
71 | "server",
72 | "dist",
73 | "droppy.js",
74 | "docker-start.sh"
75 | ],
76 | "keywords": [
77 | "self-hosted",
78 | "personal",
79 | "file",
80 | "server",
81 | "http",
82 | "https",
83 | "media",
84 | "cloud",
85 | "storage",
86 | "self",
87 | "hosted"
88 | ],
89 | "browserslist": "defaults"
90 | }
91 |
--------------------------------------------------------------------------------
/server/cfg.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const cfg = module.exports = {};
4 | const fs = require("fs");
5 | const {dirname} = require("path");
6 |
7 | const configFile = require("./paths.js").get().cfgFile;
8 |
9 | const defaults = {
10 | listeners: [
11 | {
12 | host: ["0.0.0.0", "::"],
13 | port: 8989,
14 | protocol: "http"
15 | }
16 | ],
17 | public: false,
18 | timestamps: true,
19 | linkLength: 5,
20 | linkExtensions: false,
21 | logLevel: 2,
22 | maxFileSize: 0,
23 | updateInterval: 1000,
24 | pollingInterval: 0,
25 | keepAlive: 20000,
26 | uploadTimeout: 604800000,
27 | allowFrame: false,
28 | readOnly: false,
29 | ignorePatterns: [],
30 | watch: true,
31 | headers: {},
32 | };
33 |
34 | const hiddenOpts = ["dev"];
35 |
36 | cfg.init = function(config, callback) {
37 | if (typeof config === "object" && config !== null) {
38 | config = Object.assign({}, defaults, config);
39 | callback(null, config);
40 | } else {
41 | fs.stat(configFile, err => {
42 | if (err) {
43 | if (err.code === "ENOENT") {
44 | config = defaults;
45 | fs.mkdir(dirname(configFile), {recursive: true}, (err) => {
46 | if (err) return callback(err);
47 | write(config, err => {
48 | callback(err || null, config);
49 | });
50 | });
51 | } else {
52 | callback(err);
53 | }
54 | } else {
55 | fs.readFile(configFile, (err, data) => {
56 | if (err) return callback(err);
57 |
58 | try {
59 | config = JSON.parse(String(data));
60 | } catch (err2) {
61 | return callback(err2);
62 | }
63 |
64 | if (!config) config = {};
65 |
66 | config = Object.assign({}, defaults, config);
67 |
68 | // TODO: validate more options
69 | if (typeof config.pollingInterval !== "number") {
70 | return callback(new TypeError("Expected a number for the 'pollingInterval' option"));
71 | }
72 |
73 | // Remove options no longer present
74 | Object.keys(config).forEach(key => {
75 | if (defaults[key] === undefined && !hiddenOpts.includes(key)) {
76 | delete config[key];
77 | }
78 | });
79 |
80 | write(config, err => {
81 | callback(err || null, config);
82 | });
83 | });
84 | }
85 | });
86 | }
87 | };
88 |
89 | function write(config, callback) {
90 | fs.writeFile(configFile, JSON.stringify(config, null, 2), callback);
91 | }
92 |
--------------------------------------------------------------------------------
/server/cookies.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const cookies = module.exports = {};
4 | const db = require("./db.js");
5 | const utils = require("./utils.js");
6 |
7 | // TODO: set secure flag on cookie. Requires X-Forwarded-Proto from the proxy
8 | const cookieParams = ["HttpOnly", "SameSite=strict"];
9 |
10 | cookies.parse = function(cookie) {
11 | const entries = {};
12 | if (typeof cookie === "string" && cookie.length) {
13 | cookie.split("; ").forEach(entry => {
14 | const parts = entry.trim().split("=");
15 | entries[parts[0]] = parts[1];
16 | });
17 | }
18 | return entries;
19 | };
20 |
21 | cookies.get = function(cookie) {
22 | const entries = cookies.parse(cookie);
23 | if (!entries || !entries.s) return false;
24 | const sessions = Object.keys(db.get("sessions") || {});
25 | if (!sessions.includes(entries.s)) return false;
26 | return entries.s;
27 | };
28 |
29 | cookies.free = function(_req, res, _postData) {
30 | const sessions = db.get("sessions");
31 | const sid = utils.createSid();
32 | // TODO: obtain path
33 | res.setHeader("Set-Cookie", cookieHeaders(sid, "/", inOneYear()));
34 | sessions[sid] = {
35 | privileged: true,
36 | lastSeen: Date.now(),
37 | };
38 | db.set("sessions", sessions);
39 | };
40 |
41 | cookies.create = function(_req, res, postData) {
42 | const sessions = db.get("sessions");
43 | const sid = utils.createSid();
44 | const expires = postData.remember ? inOneYear() : null;
45 | res.setHeader("Set-Cookie", cookieHeaders(sid, postData.path, expires));
46 | sessions[sid] = {
47 | privileged: db.get("users")[postData.username].privileged,
48 | username: postData.username,
49 | lastSeen: Date.now(),
50 | };
51 | db.set("sessions", sessions);
52 | };
53 |
54 | cookies.unset = function(req, res, postData) {
55 | if (!req.headers.cookie) return;
56 | const session = cookies.parse(req.headers.cookie).s;
57 | if (!session) return;
58 | const sessions = db.get("sessions");
59 | delete sessions[session];
60 | db.set("sessions", sessions);
61 | res.setHeader("Set-Cookie", cookieHeaders("gone", postData.path, epoch()));
62 | };
63 |
64 | function inOneYear() {
65 | return new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
66 | }
67 |
68 | function epoch() {
69 | return new Date(0).toUTCString();
70 | }
71 |
72 | function cookieHeaders(sid, path, expires) {
73 | const realCookie = {s: sid, path: path || "/"};
74 | const deleteCookie = {s: "gone", expires: epoch(), path: "/"};
75 | if (path === "/" || !path) {
76 | if (expires) realCookie.expires = inOneYear();
77 | return cookieString(realCookie);
78 | } else {
79 | // expire a possible invalid old cookie on the / path
80 | if (expires) realCookie.expires = inOneYear();
81 | return [cookieString(deleteCookie), cookieString(realCookie)];
82 | }
83 | }
84 |
85 | function cookieString(params) {
86 | return Object.keys(params).map(param => {
87 | return `${param}=${params[param]}`;
88 | }).concat(cookieParams).join("; ");
89 | }
90 |
--------------------------------------------------------------------------------
/server/csrf.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const csrf = module.exports = {};
4 | const crypto = require("crypto");
5 | let tokens = [];
6 |
7 | csrf.create = function() {
8 | const token = crypto.randomBytes(16).toString("hex");
9 | tokens.unshift(token);
10 | tokens = tokens.slice(0, 500);
11 | return token;
12 | };
13 |
14 | csrf.validate = function(token) {
15 | return tokens.some(storedToken => {
16 | return storedToken === token;
17 | });
18 | };
19 |
--------------------------------------------------------------------------------
/server/db.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const db = module.exports = {};
4 | const chokidar = require("chokidar");
5 | const fs = require("fs");
6 | const crypto = require("crypto");
7 | const path = require("path");
8 |
9 | const log = require("./log.js");
10 | const dbFile = require("./paths.js").get().db;
11 | const defaults = {users: {}, sessions: {}, links: {}};
12 |
13 | let database, watching;
14 |
15 | db.load = function(callback) {
16 | fs.stat(dbFile, err => {
17 | if (err) {
18 | if (err.code === "ENOENT") {
19 | database = defaults;
20 | fs.mkdir(path.dirname(dbFile), {recursive: true}, err => {
21 | if (err) return callback(err);
22 | write();
23 | callback();
24 | });
25 | } else {
26 | callback(err);
27 | }
28 | } else {
29 | db.parse(err => {
30 | if (err) return callback(err);
31 | let modified = false;
32 |
33 | // migrate old shortlinks
34 | if (database.shortlinks) {
35 | modified = true;
36 | database.sharelinks = database.shortlinks;
37 | delete database.shortlinks;
38 | }
39 | if (database.sharelinks) {
40 | modified = true;
41 | database.links = {};
42 | Object.keys(database.sharelinks).forEach(hash => {
43 | database.links[hash] = {
44 | location: database.sharelinks[hash],
45 | attachment: false
46 | };
47 | });
48 | delete database.sharelinks;
49 | }
50 |
51 | if (database.sessions) {
52 | Object.keys(database.sessions).forEach(session => {
53 | // invalidate session not containing a username
54 | if (!database.sessions[session].username) {
55 | modified = true;
56 | delete database.sessions[session];
57 | }
58 | // invalidate pre-1.7 session tokens
59 | if (session.length !== 48) {
60 | modified = true;
61 | delete database.sessions[session];
62 | }
63 | });
64 | }
65 |
66 | // remove unused values
67 | if (database.version) {
68 | modified = true;
69 | delete database.version;
70 | }
71 |
72 | if (modified) write();
73 | callback();
74 | });
75 | }
76 | });
77 | };
78 |
79 | db.parse = function(cb) {
80 | fs.readFile(dbFile, "utf8", (err, data) => {
81 | if (err) return cb(err);
82 |
83 | if (data.trim() !== "") {
84 | try {
85 | database = JSON.parse(data);
86 | } catch (err2) {
87 | return cb(err2);
88 | }
89 | } else {
90 | database = {};
91 | }
92 | database = Object.assign({}, defaults, database);
93 | cb();
94 | });
95 | };
96 |
97 | db.get = function(key) {
98 | return database[key];
99 | };
100 |
101 | db.set = function(key, value) {
102 | database[key] = value;
103 | write();
104 | };
105 |
106 | db.addOrUpdateUser = function addOrUpdateUser(user, password, privileged) {
107 | const salt = crypto.randomBytes(4).toString("hex");
108 |
109 | database.users[user] = {
110 | hash: `${getHash(password + salt + user)}$${salt}`,
111 | privileged
112 | };
113 |
114 | write();
115 | };
116 |
117 | db.delUser = function(user) {
118 | if (database.users[user]) {
119 | // delete user
120 | delete database.users[user];
121 |
122 | // delete user sessions
123 | Object.keys(database.sessions).forEach(sid => {
124 | if (database.sessions[sid].username === user) {
125 | delete database.sessions[sid];
126 | }
127 | });
128 |
129 | write();
130 | return true;
131 | } else {
132 | return false;
133 | }
134 | };
135 |
136 | db.authUser = function(user, pass) {
137 | let parts;
138 |
139 | if (database.users[user]) {
140 | parts = database.users[user].hash.split("$");
141 | if (parts.length === 2 && parts[0] === getHash(pass + parts[1] + user)) {
142 | return true;
143 | }
144 | }
145 |
146 | return false;
147 | };
148 |
149 | db.watch = function(config) {
150 | chokidar.watch(dbFile, {
151 | ignoreInitial: true,
152 | usePolling: Boolean(config.pollingInterval),
153 | interval: config.pollingInterval,
154 | binaryInterval: config.pollingInterval
155 | }).on("error", log.error).on("change", () => {
156 | if (!watching) return;
157 | db.parse(err => {
158 | if (err) return log.error(err);
159 | log.info("db.json reloaded because it was changed");
160 | });
161 | }).on("ready", () => {
162 | watching = true;
163 | });
164 | };
165 |
166 | // TODO: async
167 | function write() {
168 | watching = false;
169 | fs.writeFileSync(dbFile, JSON.stringify(database, null, 2));
170 |
171 | // watch the file 1 second after last write
172 | setTimeout(() => {
173 | watching = true;
174 | }, 1000);
175 | }
176 |
177 | function getHash(string) {
178 | return crypto.createHmac("sha256", string).digest("hex");
179 | }
180 |
--------------------------------------------------------------------------------
/server/filetree.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const filetree = module.exports = new (require("events").EventEmitter)();
4 |
5 | const debounce = require("lodash.debounce");
6 | const chokidar = require("chokidar");
7 | const escRe = require("escape-string-regexp");
8 | const fs = require("fs");
9 | const path = require("path");
10 | const rrdir = require("rrdir");
11 | const rfdc = require("rfdc");
12 | const util = require("util");
13 |
14 | const log = require("./log.js");
15 | const paths = require("./paths.js").get();
16 | const utils = require("./utils.js");
17 |
18 | const clone = rfdc();
19 | const lstat = util.promisify(fs.lstat);
20 |
21 | let dirs = {};
22 | let todoDirs = [];
23 | let initial = true;
24 | let watching = true;
25 | let timer = null;
26 | let cfg = null;
27 |
28 | const WATCHER_DELAY = 3000;
29 |
30 | filetree.init = function(config) {
31 | cfg = config;
32 | };
33 |
34 | filetree.watch = function() {
35 | chokidar.watch(paths.files, {
36 | alwaysStat: true,
37 | ignoreInitial: true,
38 | usePolling: Boolean(cfg.pollingInterval),
39 | interval: cfg.pollingInterval,
40 | binaryInterval: cfg.pollingInterval
41 | }).on("error", log.error).on("all", () => {
42 | // TODO: only update what's really necessary
43 | if (watching) filetree.updateAll();
44 | });
45 | };
46 |
47 | filetree.updateAll = debounce(() => {
48 | log.debug("Updating file tree because of local filesystem changes");
49 | filetree.updateDir(null, () => {
50 | filetree.emit("updateall");
51 | });
52 | }, WATCHER_DELAY);
53 |
54 | function lookAway() {
55 | watching = false;
56 | clearTimeout(timer);
57 | timer = setTimeout(() => {
58 | watching = true;
59 | }, WATCHER_DELAY);
60 | }
61 |
62 | function filterDirs(dirs) {
63 | return dirs.sort((a, b) => {
64 | return utils.countOccurences(a, "/") - utils.countOccurences(b, "/");
65 | }).filter((path, _, self) => {
66 | return self.every(another => {
67 | return another === path || path.indexOf(`${another}/`) !== 0;
68 | });
69 | }).filter((path, index, self) => {
70 | return self.indexOf(path) === index;
71 | });
72 | }
73 |
74 | const debouncedUpdate = debounce(() => {
75 | filterDirs(todoDirs).forEach(dir => {
76 | filetree.emit("update", dir);
77 | });
78 | todoDirs = [];
79 | }, 100, {trailing: true});
80 |
81 | function update(dir) {
82 | updateDirSizes();
83 | todoDirs.push(dir);
84 | debouncedUpdate();
85 | }
86 |
87 | filetree.updateDir = async function(dir) {
88 | if (dir === null) {
89 | dir = "/";
90 | dirs = {};
91 | }
92 |
93 | const fullDir = utils.addFilesPath(dir);
94 |
95 | let stats;
96 | try {
97 | stats = await lstat(fullDir);
98 | } catch (err) {
99 | log.error(err);
100 | }
101 |
102 | let entries = [];
103 | if (initial) { // sync walk for performance
104 | initial = false;
105 | try {
106 | entries = rrdir.sync(fullDir, {stats: true, exclude: cfg.ignorePatterns});
107 | } catch (err) {
108 | log.error(err);
109 | }
110 | } else {
111 | try {
112 | entries = await rrdir.async(fullDir, {stats: true, exclude: cfg.ignorePatterns});
113 | } catch (err) {
114 | log.error(err);
115 | }
116 | }
117 |
118 | for (const entry of (entries || [])) {
119 | if (entry.err) {
120 | if (entry.err.code === "ENOENT" && dirs[utils.removeFilesPath(entry.path)]) {
121 | delete dirs[utils.removeFilesPath(entry.path)];
122 | }
123 | }
124 | }
125 |
126 | const readDirs = entries.filter(entry => entry.directory);
127 | const readFiles = entries.filter(entry => !entry.directory);
128 |
129 | updateDirInCache(dir, stats, readDirs, readFiles);
130 | };
131 |
132 | function updateDirInCache(root, stat, readDirs, readFiles) {
133 | dirs[root] = {files: {}, size: 0, mtime: stat ? stat.mtime.getTime() : Date.now()};
134 |
135 | const readDirObj = {}, readDirKeys = [];
136 | readDirs.sort((a, b) => utils.naturalSort(a.path, b.path)).forEach(d => {
137 | const path = normalize(utils.removeFilesPath(d.path));
138 | readDirObj[path] = d.stats;
139 | readDirKeys[path] = path;
140 | });
141 |
142 | // Remove deleted dirs
143 | Object.keys(dirs).forEach(path => {
144 | if (path.indexOf(root) === 0 && readDirKeys.includes(path) && path !== root) {
145 | delete dirs[path];
146 | }
147 | });
148 |
149 | // Add dirs
150 | Object.keys(readDirObj).forEach(path => {
151 | dirs[path] = {
152 | files: {}, size: 0, mtime: readDirObj[path].mtime.getTime() || 0
153 | };
154 | });
155 |
156 | // Add files
157 | readFiles.sort((a, b) => {
158 | return utils.naturalSort(a.path, b.path);
159 | }).forEach(f => {
160 | const parentDir = normalize(utils.removeFilesPath(path.dirname(f.path)));
161 | const size = (f.stats && f.stats.size) ? f.stats.size : 0;
162 | const mtime = (f.stats && f.stats.mtime && f.stats.mtime.getTime) ? f.stats.mtime.getTime() : 0;
163 | dirs[parentDir].files[normalize(path.basename(f.path))] = {size, mtime};
164 | dirs[parentDir].size += size;
165 | });
166 |
167 | update(root);
168 | }
169 |
170 | function updateDirSizes() {
171 | const todo = Object.keys(dirs);
172 |
173 | todo.sort((a, b) => {
174 | return utils.countOccurences(b, "/") - utils.countOccurences(a, "/");
175 | });
176 |
177 | todo.forEach(d => {
178 | dirs[d].size = 0;
179 | Object.keys(dirs[d].files).forEach(f => {
180 | dirs[d].size += dirs[d].files[f].size;
181 | });
182 | });
183 |
184 | todo.forEach(d => {
185 | if (path.dirname(d) !== "/" && dirs[path.dirname(d)]) {
186 | dirs[path.dirname(d)].size += dirs[d].size;
187 | }
188 | });
189 | }
190 |
191 | filetree.del = function(dir) {
192 | fs.stat(utils.addFilesPath(dir), (err, stats) => {
193 | if (err) log.error(err);
194 | if (!stats) return;
195 | if (stats.isFile()) {
196 | filetree.unlink(dir);
197 | } else if (stats.isDirectory()) {
198 | filetree.unlinkdir(dir);
199 | }
200 | });
201 | };
202 |
203 | filetree.unlink = function(dir) {
204 | lookAway();
205 | utils.rm(utils.addFilesPath(dir), err => {
206 | if (err) log.error(err);
207 | delete dirs[path.dirname(dir)].files[path.basename(dir)];
208 | update(path.dirname(dir));
209 | });
210 | };
211 |
212 | filetree.unlinkdir = function(dir) {
213 | lookAway();
214 | utils.rm(utils.addFilesPath(dir), err => {
215 | if (err) log.error(err);
216 | delete dirs[dir];
217 | Object.keys(dirs).forEach(d => {
218 | if (new RegExp(`^${escRe(dir)}/`).test(d)) delete dirs[d];
219 | });
220 | update(path.dirname(dir));
221 | });
222 | };
223 |
224 | filetree.clipboard = function(src, dst, type) {
225 | fs.stat(utils.addFilesPath(src), (err, stats) => {
226 | lookAway();
227 | if (err) log.error(err);
228 | if (stats.isFile()) {
229 | filetree[type === "cut" ? "mv" : "cp"](src, dst);
230 | } else if (stats.isDirectory()) {
231 | filetree[type === "cut" ? "mvdir" : "cpdir"](src, dst);
232 | }
233 | });
234 | };
235 |
236 | filetree.mk = function(dir, cb) {
237 | lookAway();
238 | fs.stat(utils.addFilesPath(dir), err => {
239 | if (err && err.code === "ENOENT") {
240 | fs.open(utils.addFilesPath(dir), "wx", (err, fd) => {
241 | if (err) {
242 | log.error(err);
243 | if (cb) cb(err);
244 | return;
245 | }
246 | fs.close(fd, error => {
247 | if (error) log.error(error);
248 | dirs[path.dirname(dir)].files[path.basename(dir)] = {size: 0, mtime: Date.now()};
249 | update(path.dirname(dir));
250 | if (cb) cb();
251 | });
252 | });
253 | } else if (err) {
254 | log.error(err);
255 | if (cb) cb(err);
256 | } else {
257 | if (cb) cb();
258 | }
259 | });
260 | };
261 |
262 | filetree.mkdir = function(dir, cb) {
263 | lookAway();
264 | fs.stat(utils.addFilesPath(dir), err => {
265 | if (err && err.code === "ENOENT") {
266 | utils.mkdir(utils.addFilesPath(dir), err => {
267 | if (err) {
268 | log.error(err);
269 | if (cb) cb(err);
270 | return;
271 | }
272 | dirs[dir] = {files: {}, size: 0, mtime: Date.now()};
273 | update(path.dirname(dir));
274 | if (cb) cb();
275 | });
276 | } else if (err) {
277 | log.error(err);
278 | if (cb) cb(err);
279 | } else {
280 | if (cb) cb();
281 | }
282 | });
283 | };
284 |
285 | filetree.move = function(src, dst, cb) {
286 | lookAway();
287 | fs.stat(utils.addFilesPath(src), (err, stats) => {
288 | if (err) log.error(err);
289 | if (stats.isFile()) {
290 | filetree.mv(src, dst, cb);
291 | } else if (stats.isDirectory()) {
292 | filetree.mvdir(src, dst, cb);
293 | }
294 | });
295 | };
296 |
297 | filetree.mv = function(src, dst, cb) {
298 | lookAway();
299 | utils.move(utils.addFilesPath(src), utils.addFilesPath(dst), err => {
300 | if (err) log.error(err);
301 | dirs[path.dirname(dst)].files[path.basename(dst)] = dirs[path.dirname(src)].files[path.basename(src)];
302 | delete dirs[path.dirname(src)].files[path.basename(src)];
303 | update(path.dirname(src));
304 | update(path.dirname(dst));
305 | if (cb) cb();
306 | });
307 | };
308 |
309 | filetree.mvdir = function(src, dst, cb) {
310 | lookAway();
311 | utils.move(utils.addFilesPath(src), utils.addFilesPath(dst), err => {
312 | if (err) log.error(err);
313 | // Basedir
314 | dirs[dst] = dirs[src];
315 | delete dirs[src];
316 | // Subdirs
317 | Object.keys(dirs).forEach(dir => {
318 | if (new RegExp(`^${escRe(src)}/`).test(dir) && dir !== src && dir !== dst) {
319 | dirs[dir.replace(new RegExp(`^${escRe(src)}/`), `${dst}/`)] = dirs[dir];
320 | delete dirs[dir];
321 | }
322 | });
323 | update(path.dirname(src));
324 | update(path.dirname(dst));
325 | if (cb) cb();
326 | });
327 | };
328 |
329 | filetree.cp = function(src, dst, cb) {
330 | lookAway();
331 | utils.copyFile(utils.addFilesPath(src), utils.addFilesPath(dst), () => {
332 | dirs[path.dirname(dst)].files[path.basename(dst)] = clone(dirs[path.dirname(src)].files[path.basename(src)]);
333 | dirs[path.dirname(dst)].files[path.basename(dst)].mtime = Date.now();
334 | update(path.dirname(dst));
335 | if (cb) cb();
336 | });
337 | };
338 |
339 | filetree.cpdir = async function(src, dst, cb) {
340 | lookAway();
341 | await utils.copyDir(utils.addFilesPath(src), utils.addFilesPath(dst));
342 |
343 | // Basedir
344 | dirs[dst] = clone(dirs[src]);
345 | dirs[dst].mtime = Date.now();
346 | // Subdirs
347 | Object.keys(dirs).forEach(dir => {
348 | if (new RegExp(`^${escRe(src)}/`).test(dir) && dir !== src && dir !== dst) {
349 | dirs[dir.replace(new RegExp(`^${escRe(src)}/`), `${dst}/`)] = clone(dirs[dir]);
350 | dirs[dir.replace(new RegExp(`^${escRe(src)}/`), `${dst}/`)].mtime = Date.now();
351 | }
352 | });
353 | update(path.dirname(dst));
354 | if (cb) cb();
355 | };
356 |
357 | filetree.save = function(dst, data, cb) {
358 | lookAway();
359 | fs.stat(utils.addFilesPath(dst), err => {
360 | if (err && err.code !== "ENOENT") return cb(err);
361 | fs.writeFile(utils.addFilesPath(dst), data, err => {
362 | dirs[path.dirname(dst)].files[path.basename(dst)] = {size: Buffer.byteLength(data), mtime: Date.now()};
363 | update(path.dirname(dst));
364 | if (cb) cb(err);
365 | });
366 | });
367 | };
368 |
369 | function entries(files, folders, relativePaths, base) {
370 | const entries = {};
371 | files.forEach(file => {
372 | const f = dirs[path.dirname(file)].files[path.basename(file)];
373 | const mtime = Math.round(f.mtime / 1e3);
374 | const name = relativePaths ? path.relative(base, file) : path.basename(file);
375 | entries[name] = ["f", mtime, f.size].join("|");
376 | });
377 | folders.forEach(folder => {
378 | if (dirs[folder]) {
379 | const d = dirs[folder];
380 | const mtime = Math.round(d.mtime / 1e3);
381 | const name = relativePaths ? path.relative(base, folder) : path.basename(folder);
382 | entries[name] = ["d", mtime, d.size].join("|");
383 | }
384 | });
385 | return entries;
386 | }
387 |
388 | filetree.search = function(query, p) {
389 | if (!dirs[p] || typeof query !== "string" || !query) return null;
390 | const files = [];
391 | const folders = [];
392 | query = query.toLowerCase();
393 | Object.keys(dirs).filter(dir => {
394 | return dir.indexOf(p) === 0;
395 | }).forEach(dir => {
396 | if (dir.toLowerCase().includes(query) && dir !== p) {
397 | folders.push(dir);
398 | }
399 | Object.keys(dirs[dir].files).forEach(file => {
400 | if (file.toLowerCase().includes(query)) {
401 | files.push(path.posix.join(dir, file));
402 | }
403 | });
404 | });
405 | const e = entries(files, folders, true, p);
406 | if (!Object.keys(e).length) return null;
407 | return e;
408 | };
409 |
410 | filetree.ls = function(p) {
411 | if (!dirs[p]) return;
412 | const files = Object.keys(dirs[p].files).map(file => {
413 | return path.posix.join(p, file);
414 | });
415 | const folders = [];
416 | Object.keys(dirs).forEach(dir => {
417 | if (path.dirname(dir) === p && path.basename(dir)) {
418 | folders.push(dir);
419 | }
420 | });
421 | return entries(files, folders);
422 | };
423 |
424 | filetree.lsFilter = function(p, re) {
425 | if (!dirs[p]) return;
426 | return Object.keys(dirs[p].files).filter(file => {
427 | return re.test(file);
428 | });
429 | };
430 |
431 | function normalize(str) {
432 | return String.prototype.normalize ? str.normalize() : str;
433 | }
434 |
--------------------------------------------------------------------------------
/server/log.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const fs = require("fs");
4 | const {red, blue, yellow, green, cyan, magenta, reset} = require("colorette");
5 | const stripAnsi = require("strip-ansi");
6 | const {isIPv6} = require("net");
7 |
8 | const utils = require("./utils.js");
9 |
10 | const logColors = [reset, red, yellow, cyan];
11 | const logLabels = ["", "ERROR", "INFO", "DEBG"];
12 | let opts, logfile;
13 |
14 | const log = module.exports = function(req, res, logLevel, ...elems) {
15 | if (opts && opts.logLevel < logLevel) return;
16 | let statusCode;
17 |
18 | if (req && req.time) elems.unshift(`[${magenta(`${Date.now() - req.time}ms`)}]`);
19 |
20 | if (res) {
21 | if (res.statusCode) {
22 | statusCode = res.statusCode;
23 | switch (String(statusCode).charAt(0)) {
24 | case "2":
25 | statusCode = `[${green(statusCode)}]`;
26 | break;
27 | case "3":
28 | statusCode = `[${yellow(statusCode)}]`;
29 | break;
30 | case "4":
31 | case "5":
32 | statusCode = `[${red(statusCode)}]`;
33 | break;
34 | }
35 | elems.unshift(statusCode);
36 | }
37 | }
38 |
39 | if (req) {
40 | if (req.url) elems.unshift(decodeURIComponent(decodeURIComponent(req.url))); // For some reason, this need double decoding for upload URLs
41 | if (req.method) elems.unshift(yellow(req.method.toUpperCase()));
42 |
43 | const ip = utils.ip(req);
44 |
45 | if (ip) {
46 | elems.unshift(log.formatHostPort(ip, utils.port(req) || "0"));
47 | }
48 | }
49 |
50 | if (logLevel > 0) {
51 | elems.unshift(`[${logColors[logLevel](logLabels[logLevel])}]`);
52 | }
53 |
54 | if (opts && opts.timestamps) {
55 | elems.unshift(log.timestamp());
56 | }
57 |
58 | elems.forEach((part, index) => {
59 | if (part === "") {
60 | elems.splice(index, 1);
61 | }
62 | });
63 |
64 | if (logfile) {
65 | fs.write(logfile, `${stripAnsi(elems.join(" "))}\n`);
66 | } else {
67 | console.info(...elems);
68 | }
69 | };
70 |
71 | log.init = function(o) {
72 | opts = o;
73 | };
74 |
75 | log.setLogFile = function(fd) {
76 | logfile = fd;
77 | };
78 |
79 | log.debug = function(...args) {
80 | const [req, res, ...elems] = args;
81 | if (req && (req.headers || req.addr)) {
82 | log(req, res, 3, elems.join(""));
83 | } else {
84 | log(null, null, 3, args.join(""));
85 | }
86 | };
87 |
88 | log.info = function(...args) {
89 | const [req, res, ...elems] = args;
90 | if (req && (req.headers || req.addr)) {
91 | log(req, res, 2, elems.join(""));
92 | } else {
93 | log(null, null, 2, args.join(""));
94 | }
95 | };
96 |
97 | log.error = function(...args) {
98 | const [req, res, ...elems] = args;
99 | if (req && (req.headers || req.addr)) {
100 | log(req, res, 1, red(log.formatError(elems.length === 1 ? elems[0] : elems.join(" "))));
101 | } else {
102 | log(null, null, 1, red(log.formatError(args.length === 1 ? args[0] : args.join(" "))));
103 | }
104 | };
105 |
106 | log.plain = function(...args) {
107 | if (opts && opts.logLevel < 2) return;
108 | log(null, null, 0, args.join(""));
109 | };
110 |
111 | log.timestamp = function() {
112 | const now = new Date();
113 | let day = now.getDate();
114 | let month = now.getMonth() + 1;
115 | const year = now.getFullYear();
116 | let hrs = now.getHours();
117 | let mins = now.getMinutes();
118 | let secs = now.getSeconds();
119 |
120 | if (month < 10) month = `0${month}`;
121 | if (day < 10) day = `0${day}`;
122 | if (hrs < 10) hrs = `0${hrs}`;
123 | if (mins < 10) mins = `0${mins}`;
124 | if (secs < 10) secs = `0${secs}`;
125 | return `${year}-${month}-${day} ${hrs}:${mins}:${secs}`;
126 | };
127 |
128 | log.logo = function(line1, line2, line3) {
129 | log.plain(blue([
130 | "\n",
131 | " .:.\n",
132 | ` ::: .:::::. ${line1}\n`,
133 | ` ..:::.. ::: ${line2}\n`,
134 | ` ':::' ::: ${line3}\n`,
135 | " '\n",
136 | ].join("")));
137 | };
138 |
139 | log.formatHostPort = function(hostname, port, proto) {
140 | if (proto === "http" && port === "80" || proto === "https" && port === "443") {
141 | port = "";
142 | } else {
143 | port = blue(`:${port}`);
144 | }
145 |
146 | return cyan(isIPv6(hostname) ? `[${hostname}]` : hostname) + port;
147 | };
148 |
149 | log.formatError = function(err) {
150 | let output;
151 | if (err instanceof Error) {
152 | output = err.stack;
153 | } else if (!err) {
154 | output = `${new Error("Error handler called without an argument").stack}\nerr = ${err}`;
155 | } else if (typeof err === "string") {
156 | output = err;
157 | } else {
158 | output = `${err}\n${(new Error()).stack}`;
159 | }
160 |
161 | return output.replace(/^Error: /, "");
162 | };
163 |
--------------------------------------------------------------------------------
/server/manifest.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const pkg = require("../package.json");
4 | const originalUrl = require("original-url");
5 |
6 | module.exports = function manifest(req) {
7 | return JSON.stringify({
8 | name: pkg.name,
9 | start_url: (originalUrl(req).full || "").replace("!/res/manifest.json", ""),
10 | lang: "en-US",
11 | background_color: "#181818",
12 | theme_color: "#181818",
13 | display: "fullscreen",
14 | orientation: "any",
15 | icons: [
16 | {src: "logo32.png", sizes: "32x32", type: "image/png"},
17 | {src: "logo120.png", sizes: "120x120", type: "image/png"},
18 | {src: "logo128.png", sizes: "128x128", type: "image/png"},
19 | {src: "logo152.png", sizes: "152x152", type: "image/png"},
20 | {src: "logo180.png", sizes: "180x180", type: "image/png"},
21 | {src: "logo192.png", sizes: "192x192", type: "image/png"}
22 | ]
23 | });
24 | };
25 |
--------------------------------------------------------------------------------
/server/paths.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const paths = module.exports = {};
4 | const fs = require("fs");
5 | const path = require("path");
6 | const untildify = require("untildify");
7 |
8 | let configDir = "~/.droppy/config";
9 | let filesDir = "~/.droppy/files";
10 |
11 | paths.get = function() {
12 | return {
13 | files: resolve(filesDir),
14 | config: resolve(configDir),
15 |
16 | pid: resolve(configDir, "droppy.pid"),
17 | temp: resolve(configDir, "temp"),
18 | cfgFile: resolve(configDir, "config.json"),
19 | db: resolve(configDir, "db.json"),
20 | tlsKey: resolve(configDir, "tls.key"),
21 | tlsCert: resolve(configDir, "tls.cert"),
22 | tlsCA: resolve(configDir, "tls.ca"),
23 |
24 | mod: resolve(__dirname, ".."),
25 | server: resolve(__dirname, "..", "server"),
26 | client: resolve(__dirname, "..", "client"),
27 | templates: resolve(__dirname, "..", "client", "templates"),
28 | svg: resolve(__dirname, "..", "client", "svg")
29 | };
30 | };
31 |
32 | paths.seed = function(config, files) {
33 | if (config) configDir = config;
34 | if (files) filesDir = files;
35 | };
36 |
37 | function resolve(...args) {
38 | let p = path.join.apply(null, args);
39 | p = path.resolve(p.startsWith("~") ? untildify(p) : p);
40 | try {
41 | p = fs.realpathSync(p);
42 | } catch {}
43 | return p;
44 | }
45 |
--------------------------------------------------------------------------------
/server/resources.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const resources = module.exports = {};
4 | const etag = require("etag");
5 | const fs = require("fs");
6 | const jb = require("json-buffer");
7 | const path = require("path");
8 | const vm = require("vm");
9 | const {constants, gzip, brotliCompress} = require("zlib");
10 | const {stat, mkdir, readdir, readFile, writeFile} = require("fs").promises;
11 | const {promisify} = require("util");
12 |
13 | const log = require("./log.js");
14 | const paths = require("./paths.js").get();
15 | const utils = require("./utils.js");
16 |
17 | const themesPath = path.join(paths.mod, "/node_modules/codemirror/theme");
18 | const modesPath = path.join(paths.mod, "/node_modules/codemirror/mode");
19 | const cachePath = path.join(paths.mod, "dist", "cache.json");
20 |
21 | const gzipEncode = (data) => promisify(gzip)(data, {level: constants.Z_BEST_COMPRESSION});
22 | const brotliEncode = (data) => promisify(brotliCompress)(data, {[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY});
23 |
24 | let minify;
25 |
26 | const opts = {
27 | terser: {
28 | mangle: true,
29 | compress: {
30 | booleans: true,
31 | collapse_vars: true,
32 | conditionals: true,
33 | comparisons: true,
34 | dead_code: true,
35 | keep_fargs: false,
36 | drop_debugger: true,
37 | evaluate: true,
38 | hoist_funs: true,
39 | if_return: true,
40 | negate_iife: true,
41 | join_vars: true,
42 | loops: true,
43 | properties: true,
44 | reduce_vars: true,
45 | sequences: true,
46 | toplevel: true,
47 | unsafe: true,
48 | unsafe_proto: true,
49 | unused: true,
50 | },
51 | },
52 | cleanCSS: {
53 | level: {
54 | 1: {
55 | specialComments: 0,
56 | },
57 | 2: {
58 | all: false,
59 | mergeMedia: true,
60 | removeDuplicateMediaBlocks: true,
61 | removeDuplicateRules: true,
62 | },
63 | },
64 | rebase: false,
65 | },
66 | autoprefixer: {
67 | cascade: false,
68 | },
69 | htmlMinifier: {
70 | caseSensitive: true,
71 | collapseBooleanAttributes: true,
72 | collapseInlineTagWhitespace: true,
73 | collapseWhitespace: true,
74 | customAttrSurround: [[/{{#.+?}}/, /{{\/.+?}}/]],
75 | decodeEntities: true,
76 | ignoreCustomComments: [],
77 | ignoreCustomFragments: [/{{[\s\S]*?}}/],
78 | includeAutoGeneratedTags: false,
79 | minifyCSS: {
80 | specialComments: 0,
81 | rebase: false,
82 | },
83 | removeAttributeQuotes: true,
84 | removeComments: true,
85 | removeOptionalTags: true,
86 | removeRedundantAttributes: true,
87 | removeTagWhitespace: true,
88 | }
89 | };
90 |
91 | let autoprefixer, cleanCSS, postcss, terser, htmlMinifier, svg, handlebars;
92 | try {
93 | autoprefixer = require("autoprefixer");
94 | cleanCSS = new (require("clean-css"))(opts.cleanCSS);
95 | handlebars = require("handlebars");
96 | htmlMinifier = require("html-minifier");
97 | postcss = require("postcss");
98 | terser = require("terser");
99 | svg = require("./svg.js");
100 | } catch {}
101 |
102 | resources.files = {
103 | css: [
104 | "client/style.css",
105 | "client/sprites.css",
106 | "client/tooltips.css",
107 | ],
108 | js: [
109 | "node_modules/handlebars/dist/handlebars.runtime.min.js",
110 | "node_modules/file-extension/file-extension.js",
111 | "node_modules/screenfull/dist/screenfull.js",
112 | "node_modules/mousetrap/mousetrap.min.js",
113 | "node_modules/uppie/uppie.js",
114 | "client/jquery-custom.min.js",
115 | "client/client.js",
116 | ],
117 | other: [
118 | "client/images/logo.svg",
119 | "client/images/logo32.png",
120 | "client/images/logo120.png",
121 | "client/images/logo128.png",
122 | "client/images/logo152.png",
123 | "client/images/logo180.png",
124 | "client/images/logo192.png",
125 | "client/images/sprites.png",
126 | ]
127 | };
128 |
129 | // On-demand loadable libs. Will be available as !/res/lib/[prop]
130 | const libs = {
131 | // plyr
132 | "plyr.js": ["node_modules/plyr/dist/plyr.polyfilled.min.js"],
133 | "plyr.css": ["node_modules/plyr/dist/plyr.css"],
134 | "plyr.svg": ["node_modules/plyr/dist/plyr.svg"],
135 | "blank.mp4": ["node_modules/plyr/dist/blank.mp4"],
136 | // codemirror
137 | "cm.js": [
138 | "node_modules/codemirror/lib/codemirror.js",
139 | "node_modules/codemirror/mode/meta.js",
140 | "node_modules/codemirror/addon/comment/comment.js",
141 | "node_modules/codemirror/addon/mode/overlay.js",
142 | "node_modules/codemirror/addon/dialog/dialog.js",
143 | "node_modules/codemirror/addon/selection/active-line.js",
144 | "node_modules/codemirror/addon/selection/mark-selection.js",
145 | "node_modules/codemirror/addon/search/searchcursor.js",
146 | "node_modules/codemirror/addon/edit/matchbrackets.js",
147 | "node_modules/codemirror/addon/search/search.js",
148 | "node_modules/codemirror/keymap/sublime.js"
149 | ],
150 | "cm.css": ["node_modules/codemirror/lib/codemirror.css"],
151 | // photoswipe
152 | "ps.js": [
153 | "node_modules/photoswipe/dist/photoswipe.min.js",
154 | "node_modules/photoswipe/dist/photoswipe-ui-default.min.js",
155 | ],
156 | "ps.css": [
157 | "node_modules/photoswipe/dist/photoswipe.css",
158 | "node_modules/photoswipe/dist/default-skin/default-skin.css",
159 | ],
160 | // photoswipe skin files included by their CSS
161 | "default-skin.png": ["node_modules/photoswipe/dist/default-skin/default-skin.png"],
162 | "default-skin.svg": ["node_modules/photoswipe/dist/default-skin/default-skin.svg"],
163 | "pdf.js": ["node_modules/pdfjs-dist/build/pdf.js"],
164 | "pdf.worker.js": ["node_modules/pdfjs-dist/build/pdf.worker.js"],
165 | };
166 |
167 | resources.load = function(dev, cb) {
168 | minify = !dev;
169 |
170 | if (dev) return compile(false, cb);
171 | fs.readFile(cachePath, (err, data) => {
172 | if (err) {
173 | log.info(err.code, " ", cachePath, ", ", "building cache ...");
174 | return compile(true, cb);
175 | }
176 | try {
177 | cb(null, jb.parse(data));
178 | } catch (err2) {
179 | log.error(err2);
180 | compile(false, cb);
181 | }
182 | });
183 | };
184 |
185 | resources.build = function(cb) {
186 | isCacheFresh(fresh => {
187 | if (fresh) {
188 | fs.readFile(cachePath, (err, data) => {
189 | if (err) return compile(true, cb);
190 | try {
191 | jb.parse(data);
192 | cb(null);
193 | } catch {
194 | compile(true, cb);
195 | }
196 | });
197 | } else {
198 | minify = true;
199 | compile(true, cb);
200 | }
201 | });
202 | };
203 |
204 | async function isCacheFresh(cb) {
205 | let stats;
206 | try {
207 | stats = await stat(cachePath);
208 | } catch {
209 | return cb(false);
210 | }
211 |
212 | const files = [];
213 | for (const type of Object.keys(resources.files)) {
214 | resources.files[type].forEach(file => {
215 | files.push(path.join(paths.mod, file));
216 | });
217 | }
218 |
219 | for (const file of Object.keys(libs)) {
220 | if (typeof libs[file] === "string") {
221 | files.push(path.join(paths.mod, libs[file]));
222 | } else {
223 | libs[file].forEach(file => {
224 | files.push(path.join(paths.mod, file));
225 | });
226 | }
227 | }
228 |
229 | const fileStats = await Promise.all(files.map(file => stat(file)));
230 | const times = fileStats.map(stat => stat.mtime.getTime());
231 | cb(stats.mtime.getTime() >= Math.max(...times));
232 | }
233 |
234 | async function compile(write, cb) {
235 | if (!autoprefixer) {
236 | return cb(new Error("Missing devDependencies to compile resource cache, " +
237 | "please reinstall or run `npm install --only=dev` inside the project directory"));
238 | }
239 |
240 | const cache = {res: {}, themes: {}, modes: {}, lib: {}};
241 |
242 | cache.res = await compileAll();
243 |
244 | for (const [theme, data] of Object.entries(await readThemes())) {
245 | cache.themes[theme] = {
246 | data,
247 | etag: etag(data),
248 | mime: utils.contentType("css"),
249 | };
250 | }
251 |
252 | for (const [mode, data] of Object.entries(await readModes())) {
253 | cache.modes[mode] = {
254 | data,
255 | etag: etag(data),
256 | mime: utils.contentType("js"),
257 | };
258 | }
259 |
260 | for (const [file, data] of Object.entries(await readLibs())) {
261 | cache.lib[file] = {
262 | data,
263 | etag: etag(data),
264 | mime: utils.contentType(file),
265 | };
266 | }
267 |
268 | for (const entries of Object.values(cache)) {
269 | await Promise.all(Object.values(entries).map(async props => {
270 | props.gzip = await gzipEncode(props.data);
271 | props.brotli = await brotliEncode(props.data);
272 | }));
273 | }
274 |
275 | if (write) {
276 | await mkdir(path.dirname(cachePath), {recursive: true});
277 | await writeFile(cachePath, jb.stringify(cache));
278 | }
279 | cb(null, cache);
280 | }
281 |
282 | async function readThemes() {
283 | const themes = {};
284 |
285 | for (const name of await readdir(themesPath)) {
286 | const data = await readFile(path.join(themesPath, name));
287 | themes[name.replace(/\.css$/, "")] = Buffer.from(await minifyCSS(String(data)));
288 | }
289 |
290 | const droppyTheme = await readFile(path.join(paths.mod, "/client/cmtheme.css"));
291 | themes.droppy = Buffer.from(await minifyCSS(String(droppyTheme)));
292 |
293 | return themes;
294 | }
295 |
296 | async function readModes() {
297 | const modes = {};
298 |
299 | // parse meta.js from CM for supported modes
300 | const js = await readFile(path.join(paths.mod, "/node_modules/codemirror/mode/meta.js"));
301 |
302 | // Extract modes from CodeMirror
303 | const sandbox = {CodeMirror: {}};
304 | vm.runInNewContext(js, sandbox);
305 |
306 | for (const entry of sandbox.CodeMirror.modeInfo) {
307 | if (entry.mode !== "null") modes[entry.mode] = null;
308 | }
309 |
310 | for (const name of Object.keys(modes)) {
311 | const data = await readFile(path.join(modesPath, name, `${name}.js`));
312 | modes[name] = Buffer.from(await minifyJS(String(data)));
313 | }
314 |
315 | return modes;
316 | }
317 |
318 | async function readLibs() {
319 | const lib = {};
320 |
321 | for (const [dest, files] of Object.entries(libs)) {
322 | lib[dest] = Buffer.concat(await Promise.all(files.map(file => {
323 | return readFile(path.join(paths.mod, file));
324 | })));
325 | }
326 |
327 | // Prefix hardcoded Photoswipe urls
328 | lib["ps.css"] = Buffer.from(String(lib["ps.css"]).replace(/url\(/gm, "url(!/res/lib/"));
329 |
330 | if (minify) {
331 | for (const [file, data] of (Object.entries(lib))) {
332 | if (/\.js$/.test(file)) {
333 | lib[file] = Buffer.from(await minifyJS(String(data)));
334 | } else if (/\.css$/.test(file)) {
335 | lib[file] = Buffer.from(await minifyCSS(String(data)));
336 | }
337 | }
338 | }
339 |
340 | return lib;
341 | }
342 |
343 | async function minifyJS(js) {
344 | if (!minify) return js;
345 | const min = await terser.minify(js, opts.terser);
346 | if (min.error) {
347 | log.error(min.error);
348 | process.exit(1);
349 | }
350 | return min.code;
351 | }
352 |
353 | async function minifyCSS(css) {
354 | if (!minify) return css;
355 | return cleanCSS.minify(String(css)).styles;
356 | }
357 |
358 | function templates() {
359 | const prefix = "(function(){var template=Handlebars.template," +
360 | "templates=Handlebars.templates=Handlebars.templates||{};";
361 | const suffix = "Handlebars.partials=Handlebars.templates})();";
362 |
363 | return prefix + fs.readdirSync(paths.templates).map(file => {
364 | const p = path.join(paths.templates, file);
365 | const name = file.replace(/\..+$/, "");
366 | let html = htmlMinifier.minify(fs.readFileSync(p, "utf8"), opts.htmlMinifier);
367 |
368 | // remove whitespace around {{fragments}}
369 | html = html.replace(/(>|^|}}) ({{|<|$)/g, "$1$2");
370 |
371 | // trim whitespace inside {{fragments}}
372 | html = html.replace(/({{2,})([\s\S\n]*?)(}{2,})/gm, (_, p1, p2, p3) => {
373 | return p1 + p2.replace(/\n/gm, " ").replace(/ {2,}/gm, " ").trim() + p3;
374 | }).trim();
375 |
376 | // remove {{!-- comments --}}
377 | html = html.replace(/{{![\s\S]+?..}}/, "");
378 |
379 | const compiled = handlebars.precompile(html, {data: false});
380 | return `templates['${name}']=template(${compiled});`;
381 | }).join("") + suffix;
382 | }
383 |
384 | resources.compileJS = async function() {
385 | let js = "";
386 | resources.files.js.forEach(file => {
387 | js += `${fs.readFileSync(path.join(paths.mod, file), "utf8")};`;
388 | });
389 |
390 | // Add templates
391 | js = js.replace("/* {{ templates }} */", templates());
392 |
393 | // Minify
394 | js = await minifyJS(js);
395 |
396 | return {
397 | data: Buffer.from(js),
398 | etag: etag(js),
399 | mime: utils.contentType("js"),
400 | };
401 | };
402 |
403 | resources.compileCSS = async function() {
404 | let css = "";
405 | resources.files.css.forEach(file => {
406 | css += `${fs.readFileSync(path.join(paths.mod, file), "utf8")}\n`;
407 | });
408 |
409 | // Vendor prefixes and minify
410 | css = await minifyCSS(postcss([autoprefixer(opts.autoprefixer)]).process(css).css);
411 |
412 | return {
413 | data: Buffer.from(css),
414 | etag: etag(css),
415 | mime: utils.contentType("css"),
416 | };
417 | };
418 |
419 | resources.compileHTML = async function(res) {
420 | let html = fs.readFileSync(path.join(paths.mod, "client/index.html"), "utf8");
421 | html = html.replace("", svg());
422 |
423 | let auth = html.replace("{{type}}", "a");
424 | auth = minify ? htmlMinifier.minify(auth, opts.htmlMinifier) : auth;
425 | res["auth.html"] = {data: Buffer.from(auth), etag: etag(auth), mime: utils.contentType("html")};
426 |
427 | let first = html.replace("{{type}}", "f");
428 | first = minify ? htmlMinifier.minify(first, opts.htmlMinifier) : first;
429 | res["first.html"] = {data: Buffer.from(first), etag: etag(first), mime: utils.contentType("html")};
430 |
431 | let main = html.replace("{{type}}", "m");
432 | main = minify ? htmlMinifier.minify(main, opts.htmlMinifier) : main;
433 | res["main.html"] = {data: Buffer.from(main), etag: etag(main), mime: utils.contentType("html")};
434 | return res;
435 | };
436 |
437 | async function compileAll() {
438 | let res = {};
439 |
440 | res["client.js"] = await resources.compileJS();
441 | res["style.css"] = await resources.compileCSS();
442 | res = await resources.compileHTML(res);
443 |
444 | // Read misc files
445 | for (const file of resources.files.other) {
446 | const name = path.basename(file);
447 | const fullPath = path.join(paths.mod, file);
448 | const data = fs.readFileSync(fullPath);
449 | res[name] = {data, etag: etag(data), mime: utils.contentType(name)};
450 | }
451 |
452 | return res;
453 | }
454 |
--------------------------------------------------------------------------------
/server/svg.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const svgstore = require("svgstore");
4 | const fs = require("fs");
5 | const path = require("path");
6 | const paths = require("./paths.js").get();
7 |
8 | module.exports = function svg() {
9 | const sprites = svgstore({
10 | svgAttrs: {
11 | style: "display: none",
12 | },
13 | });
14 |
15 | fs.readdirSync(paths.svg).forEach(file => {
16 | sprites.add(`i-${file.replace(/\.svg/, "")}`, fs.readFileSync(path.join(paths.svg, file)));
17 | });
18 |
19 | return sprites.toString({inline: true});
20 | };
21 |
--------------------------------------------------------------------------------
/server/utils.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const utils = module.exports = {};
4 | const cd = require("content-disposition");
5 | const crypto = require("crypto");
6 | const escapeStringRegexp = require("escape-string-regexp");
7 | const ext = require("file-extension");
8 | const fs = require("fs");
9 | const isbinaryfile = require("isbinaryfile");
10 | const mimeTypes = require("mime-types");
11 | const mv = require("mv");
12 | const path = require("path");
13 | const util = require("util");
14 | const validate = require("valid-filename");
15 | const {mkdir, stat, lstat, copyFile, readdir, access} = require("fs").promises;
16 |
17 | const paths = require("./paths.js").get();
18 |
19 | const forceBinaryTypes = [
20 | "pdf",
21 | "ps",
22 | "eps",
23 | "ai",
24 | ];
25 |
26 | const overrideMimeTypes = {
27 | "video/x-matroska": "video/webm",
28 | };
29 |
30 | utils.mkdir = async function(dir, cb) {
31 | for (const d of (Array.isArray(dir) ? dir : [dir])) {
32 | await mkdir(d, {mode: "755", recursive: true});
33 | }
34 | cb();
35 | };
36 |
37 | utils.rm = function(p, cb) {
38 | fs.rmdir(p, {recursive: true}, cb);
39 | };
40 |
41 | utils.move = function(src, dst, cb) {
42 | mv(src, dst, err => {
43 | if (cb) cb(err);
44 | });
45 | };
46 |
47 | utils.copyFile = function(src, dst, cb) {
48 | let cbCalled = false;
49 | const read = fs.createReadStream(src);
50 | const write = fs.createWriteStream(dst);
51 |
52 | function done(err) {
53 | if (cbCalled) return;
54 | cbCalled = true;
55 | if (cb) cb(err);
56 | }
57 |
58 | read.on("error", done);
59 | write.on("error", done);
60 | write.on("close", done);
61 | read.pipe(write);
62 | };
63 |
64 | utils.copyDir = async (src, dest) => {
65 | await mkdir(dest);
66 |
67 | for (const file of await readdir(src)) {
68 | if ((await lstat(path.join(src, file))).isFile()) {
69 | await copyFile(path.join(src, file), path.join(dest, file));
70 | } else {
71 | await utils.copyDir(path.join(src, file), path.join(dest, file));
72 | }
73 | }
74 | };
75 |
76 | // Get a pseudo-random n-character lowercase string.
77 | utils.getLink = function(links, length) {
78 | const linkChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789";
79 |
80 | let link = "";
81 | do {
82 | while (link.length < length) {
83 | link += linkChars.charAt(Math.floor(Math.random() * linkChars.length));
84 | }
85 | } while (links[link]); // In case the RNG generates an existing link, go again
86 |
87 | return link;
88 | };
89 |
90 | utils.pretty = function(data) {
91 | return util.inspect(data, {colors: true})
92 | .replace(/^\s+/gm, " ").replace(/\s+$/gm, "")
93 | .replace(/[\r\n]+/gm, "");
94 | };
95 |
96 | utils.getNewPath = async function(origPath, callback) {
97 | let stats;
98 | try {
99 | stats = await stat(origPath);
100 | } catch {
101 | return callback(origPath);
102 | }
103 |
104 | let filename = path.basename(origPath);
105 | const dirname = path.dirname(origPath);
106 | let extension = "";
107 |
108 | if (filename.includes(".") && stats.isFile()) {
109 | extension = filename.substring(filename.lastIndexOf("."));
110 | filename = filename.substring(0, filename.lastIndexOf("."));
111 | }
112 |
113 | if (!/-\d+$/.test(filename)) filename += "-1";
114 |
115 | let canCreate = false;
116 | while (!canCreate) {
117 | const num = parseInt(filename.substring(filename.lastIndexOf("-") + 1));
118 | filename = filename.substring(0, filename.lastIndexOf("-") + 1) + (num + 1);
119 | try {
120 | await access(path.join(dirname, filename + extension));
121 | } catch {
122 | canCreate = true;
123 | }
124 | }
125 |
126 | callback(path.join(dirname, filename + extension));
127 | };
128 |
129 | utils.normalizePath = function(p) {
130 | return p.replace(/[\\|/]+/g, "/");
131 | };
132 |
133 | utils.addFilesPath = function(p) {
134 | return p === "/" ? paths.files : path.join(`${paths.files}/${p}`);
135 | };
136 |
137 | utils.removeFilesPath = function(p) {
138 | if (p.length > paths.files.length) {
139 | return utils.normalizePath(p.substring(paths.files.length));
140 | } else if (p === paths.files) {
141 | return "/";
142 | }
143 | };
144 |
145 | utils.sanitizePathsInString = function(str) {
146 | return (str || "").replace(new RegExp(escapeStringRegexp(paths.files), "g"), "");
147 | };
148 |
149 | utils.isPathSane = function(p, isURL) {
150 | if (isURL) {
151 | // Navigating up/down the tree
152 | if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(p)) {
153 | return false;
154 | }
155 | // Invalid URL path characters
156 | if (!/^[a-zA-Z0-9-._~:/?#[\]@!$&'()*+,;=%]+$/.test(p)) {
157 | return false;
158 | }
159 | return true;
160 | } else {
161 | return p.split(/[\\/]/gm).every(name => {
162 | if (name === "." || name === "..") return false;
163 | if (!name) return true;
164 | return validate(name); // will reject invalid filenames on Windows
165 | });
166 | }
167 | };
168 |
169 | utils.isBinary = async function(p) {
170 | if (forceBinaryTypes.includes(ext(p))) {
171 | return true;
172 | }
173 |
174 | return isbinaryfile.isBinaryFile(p);
175 | };
176 |
177 | utils.contentType = function(p) {
178 | const type = mimeTypes.lookup(p);
179 | if (overrideMimeTypes[type]) return overrideMimeTypes[type];
180 |
181 | if (type) {
182 | const charset = mimeTypes.charsets.lookup(type);
183 | return type + (charset ? `; charset=${charset}` : "");
184 | } else {
185 | try {
186 | return isbinaryfile.isBinaryFileSync(p) ? "application/octet-stream" : "text/plain";
187 | } catch {
188 | return "application/octet-stream";
189 | }
190 | }
191 | };
192 |
193 | utils.getDispo = function(fileName, download) {
194 | return cd(path.basename(fileName), {type: download ? "attachment" : "inline"});
195 | };
196 |
197 | utils.createSid = function() {
198 | return crypto.randomBytes(64).toString("base64").substring(0, 48);
199 | };
200 |
201 | utils.readJsonBody = function(req) {
202 | return new Promise(((resolve, reject) => {
203 | try {
204 | if (req.body) {
205 | // This is needed if the express application is using body-parser
206 | if (typeof req.body === "object") {
207 | resolve(req.body);
208 | } else {
209 | resolve(JSON.parse(req.body));
210 | }
211 | } else {
212 | let body = [];
213 | req.on("data", chunk => {
214 | body.push(chunk);
215 | }).on("end", () => {
216 | body = String(Buffer.concat(body));
217 | resolve(JSON.parse(body));
218 | });
219 | }
220 | } catch (err) {
221 | reject(err);
222 | }
223 | }));
224 | };
225 |
226 | utils.countOccurences = function(string, search) {
227 | let num = 0, pos = 0;
228 | while (true) {
229 | pos = string.indexOf(search, pos);
230 | if (pos >= 0) {
231 | num += 1;
232 | pos += search.length;
233 | } else break;
234 | }
235 | return num;
236 | };
237 |
238 | utils.formatBytes = function(num) {
239 | if (num < 1) return `${num} B`;
240 | const units = ["B", "kB", "MB", "GB", "TB", "PB"];
241 | const exp = Math.min(Math.floor(Math.log(num) / Math.log(1000)), units.length - 1);
242 | return `${(num / (1000 ** exp)).toPrecision(3)} ${units[exp]}`;
243 | };
244 |
245 | // TODO: https://tools.ietf.org/html/rfc7239
246 | utils.ip = function(req) {
247 | return req.headers && req.headers["x-forwarded-for"] &&
248 | req.headers["x-forwarded-for"].split(",")[0].trim() ||
249 | req.headers && req.headers["x-real-ip"] ||
250 | req.connection && req.connection.remoteAddress ||
251 | req.connection && req.connection.socket && req.connection.socket.remoteAddress ||
252 | req.addr || // custom cached property
253 | req.remoteAddress && req.remoteAddress;
254 | };
255 |
256 | utils.port = function(req) {
257 | return req.headers && req.headers["x-real-port"] ||
258 | req.connection && req.connection.remotePort ||
259 | req.connection && req.connection.socket && req.connection.socket.remotePort ||
260 | req.port || // custom cached property
261 | req.remotePort && req.remotePort;
262 | };
263 |
264 | function strcmp(a, b) {
265 | return a > b ? 1 : a < b ? -1 : 0;
266 | }
267 |
268 | utils.naturalSort = function(a, b) {
269 | const x = [], y = [];
270 | a.replace(/(\d+)|(\D+)/g, (_, a, b) => { x.push([a || 0, b]); });
271 | b.replace(/(\d+)|(\D+)/g, (_, a, b) => { y.push([a || 0, b]); });
272 | while (x.length && y.length) {
273 | const xx = x.shift();
274 | const yy = y.shift();
275 | const nn = (xx[0] - yy[0]) || strcmp(xx[1], yy[1]);
276 | if (nn) return nn;
277 | }
278 | if (x.length) return -1;
279 | if (y.length) return 1;
280 | return 0;
281 | };
282 |
283 | utils.extensionRe = function(arr) {
284 | arr = arr.map(ext => {
285 | return escapeStringRegexp(ext);
286 | });
287 | return new RegExp(`\\.(${arr.join("|")})$`, "i");
288 | };
289 |
290 | utils.readFile = function(p, cb) {
291 | if (typeof p !== "string") return cb(null);
292 | fs.stat(p, (_, stats) => {
293 | if (stats && stats.isFile()) {
294 | fs.readFile(p, (err, data) => {
295 | if (err) return cb(err);
296 | cb(null, String(data));
297 | });
298 | } else {
299 | cb(null);
300 | }
301 | });
302 | };
303 |
304 | utils.arrify = function(val) {
305 | return Array.isArray(val) ? val : [val];
306 | };
307 |
308 | utils.addUploadTempExt = path => path.replace(/(\/?[^/]+)/, (_, p1) => `${p1}.droppy-upload`);
309 | utils.removeUploadTempExt = path => path.replace(/(^\/?[^/]+)(\.droppy-upload)/, (_, p1) => p1);
310 | utils.rootname = path => path.split("/").find(p => !!p);
311 |
--------------------------------------------------------------------------------