droppy
Version:
Self-hosted file storage
454 lines (391 loc) • 13 kB
JavaScript
;
const resources = module.exports = {};
const etag = require("etag");
const fs = require("fs");
const jb = require("json-buffer");
const path = require("path");
const vm = require("vm");
const {constants, gzip, brotliCompress} = require("zlib");
const {stat, mkdir, readdir, readFile, writeFile} = require("fs").promises;
const {promisify} = require("util");
const log = require("./log.js");
const paths = require("./paths.js").get();
const utils = require("./utils.js");
const themesPath = path.join(paths.mod, "/node_modules/codemirror/theme");
const modesPath = path.join(paths.mod, "/node_modules/codemirror/mode");
const cachePath = path.join(paths.mod, "dist", "cache.json");
const gzipEncode = (data) => promisify(gzip)(data, {level: constants.Z_BEST_COMPRESSION});
const brotliEncode = (data) => promisify(brotliCompress)(data, {[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY});
let minify;
const opts = {
terser: {
mangle: true,
compress: {
booleans: true,
collapse_vars: true,
conditionals: true,
comparisons: true,
dead_code: true,
keep_fargs: false,
drop_debugger: true,
evaluate: true,
hoist_funs: true,
if_return: true,
negate_iife: true,
join_vars: true,
loops: true,
properties: true,
reduce_vars: true,
sequences: true,
toplevel: true,
unsafe: true,
unsafe_proto: true,
unused: true,
},
},
cleanCSS: {
level: {
1: {
specialComments: 0,
},
2: {
all: false,
mergeMedia: true,
removeDuplicateMediaBlocks: true,
removeDuplicateRules: true,
},
},
rebase: false,
},
autoprefixer: {
cascade: false,
},
htmlMinifier: {
caseSensitive: true,
collapseBooleanAttributes: true,
collapseInlineTagWhitespace: true,
collapseWhitespace: true,
customAttrSurround: [[/{{#.+?}}/, /{{\/.+?}}/]],
decodeEntities: true,
ignoreCustomComments: [],
ignoreCustomFragments: [/{{[\s\S]*?}}/],
includeAutoGeneratedTags: false,
minifyCSS: {
specialComments: 0,
rebase: false,
},
removeAttributeQuotes: true,
removeComments: true,
removeOptionalTags: true,
removeRedundantAttributes: true,
removeTagWhitespace: true,
}
};
let autoprefixer, cleanCSS, postcss, terser, htmlMinifier, svg, handlebars;
try {
autoprefixer = require("autoprefixer");
cleanCSS = new (require("clean-css"))(opts.cleanCSS);
handlebars = require("handlebars");
htmlMinifier = require("html-minifier");
postcss = require("postcss");
terser = require("terser");
svg = require("./svg.js");
} catch {}
resources.files = {
css: [
"client/style.css",
"client/sprites.css",
"client/tooltips.css",
],
js: [
"node_modules/handlebars/dist/handlebars.runtime.min.js",
"node_modules/file-extension/file-extension.js",
"node_modules/screenfull/dist/screenfull.js",
"node_modules/mousetrap/mousetrap.min.js",
"node_modules/uppie/uppie.js",
"client/jquery-custom.min.js",
"client/client.js",
],
other: [
"client/images/logo.svg",
"client/images/logo32.png",
"client/images/logo120.png",
"client/images/logo128.png",
"client/images/logo152.png",
"client/images/logo180.png",
"client/images/logo192.png",
"client/images/sprites.png",
]
};
// On-demand loadable libs. Will be available as !/res/lib/[prop]
const libs = {
// plyr
"plyr.js": ["node_modules/plyr/dist/plyr.polyfilled.min.js"],
"plyr.css": ["node_modules/plyr/dist/plyr.css"],
"plyr.svg": ["node_modules/plyr/dist/plyr.svg"],
"blank.mp4": ["node_modules/plyr/dist/blank.mp4"],
// codemirror
"cm.js": [
"node_modules/codemirror/lib/codemirror.js",
"node_modules/codemirror/mode/meta.js",
"node_modules/codemirror/addon/comment/comment.js",
"node_modules/codemirror/addon/mode/overlay.js",
"node_modules/codemirror/addon/dialog/dialog.js",
"node_modules/codemirror/addon/selection/active-line.js",
"node_modules/codemirror/addon/selection/mark-selection.js",
"node_modules/codemirror/addon/search/searchcursor.js",
"node_modules/codemirror/addon/edit/matchbrackets.js",
"node_modules/codemirror/addon/search/search.js",
"node_modules/codemirror/keymap/sublime.js"
],
"cm.css": ["node_modules/codemirror/lib/codemirror.css"],
// photoswipe
"ps.js": [
"node_modules/photoswipe/dist/photoswipe.min.js",
"node_modules/photoswipe/dist/photoswipe-ui-default.min.js",
],
"ps.css": [
"node_modules/photoswipe/dist/photoswipe.css",
"node_modules/photoswipe/dist/default-skin/default-skin.css",
],
// photoswipe skin files included by their CSS
"default-skin.png": ["node_modules/photoswipe/dist/default-skin/default-skin.png"],
"default-skin.svg": ["node_modules/photoswipe/dist/default-skin/default-skin.svg"],
"pdf.js": ["node_modules/pdfjs-dist/build/pdf.js"],
"pdf.worker.js": ["node_modules/pdfjs-dist/build/pdf.worker.js"],
};
resources.load = function(dev, cb) {
minify = !dev;
if (dev) return compile(false, cb);
fs.readFile(cachePath, (err, data) => {
if (err) {
log.info(err.code, " ", cachePath, ", ", "building cache ...");
return compile(true, cb);
}
try {
cb(null, jb.parse(data));
} catch (err2) {
log.error(err2);
compile(false, cb);
}
});
};
resources.build = function(cb) {
isCacheFresh(fresh => {
if (fresh) {
fs.readFile(cachePath, (err, data) => {
if (err) return compile(true, cb);
try {
jb.parse(data);
cb(null);
} catch {
compile(true, cb);
}
});
} else {
minify = true;
compile(true, cb);
}
});
};
async function isCacheFresh(cb) {
let stats;
try {
stats = await stat(cachePath);
} catch {
return cb(false);
}
const files = [];
for (const type of Object.keys(resources.files)) {
resources.files[type].forEach(file => {
files.push(path.join(paths.mod, file));
});
}
for (const file of Object.keys(libs)) {
if (typeof libs[file] === "string") {
files.push(path.join(paths.mod, libs[file]));
} else {
libs[file].forEach(file => {
files.push(path.join(paths.mod, file));
});
}
}
const fileStats = await Promise.all(files.map(file => stat(file)));
const times = fileStats.map(stat => stat.mtime.getTime());
cb(stats.mtime.getTime() >= Math.max(...times));
}
async function compile(write, cb) {
if (!autoprefixer) {
return cb(new Error("Missing devDependencies to compile resource cache, " +
"please reinstall or run `npm install --only=dev` inside the project directory"));
}
const cache = {res: {}, themes: {}, modes: {}, lib: {}};
cache.res = await compileAll();
for (const [theme, data] of Object.entries(await readThemes())) {
cache.themes[theme] = {
data,
etag: etag(data),
mime: utils.contentType("css"),
};
}
for (const [mode, data] of Object.entries(await readModes())) {
cache.modes[mode] = {
data,
etag: etag(data),
mime: utils.contentType("js"),
};
}
for (const [file, data] of Object.entries(await readLibs())) {
cache.lib[file] = {
data,
etag: etag(data),
mime: utils.contentType(file),
};
}
for (const entries of Object.values(cache)) {
await Promise.all(Object.values(entries).map(async props => {
props.gzip = await gzipEncode(props.data);
props.brotli = await brotliEncode(props.data);
}));
}
if (write) {
await mkdir(path.dirname(cachePath), {recursive: true});
await writeFile(cachePath, jb.stringify(cache));
}
cb(null, cache);
}
async function readThemes() {
const themes = {};
for (const name of await readdir(themesPath)) {
const data = await readFile(path.join(themesPath, name));
themes[name.replace(/\.css$/, "")] = Buffer.from(await minifyCSS(String(data)));
}
const droppyTheme = await readFile(path.join(paths.mod, "/client/cmtheme.css"));
themes.droppy = Buffer.from(await minifyCSS(String(droppyTheme)));
return themes;
}
async function readModes() {
const modes = {};
// parse meta.js from CM for supported modes
const js = await readFile(path.join(paths.mod, "/node_modules/codemirror/mode/meta.js"));
// Extract modes from CodeMirror
const sandbox = {CodeMirror: {}};
vm.runInNewContext(js, sandbox);
for (const entry of sandbox.CodeMirror.modeInfo) {
if (entry.mode !== "null") modes[entry.mode] = null;
}
for (const name of Object.keys(modes)) {
const data = await readFile(path.join(modesPath, name, `${name}.js`));
modes[name] = Buffer.from(await minifyJS(String(data)));
}
return modes;
}
async function readLibs() {
const lib = {};
for (const [dest, files] of Object.entries(libs)) {
lib[dest] = Buffer.concat(await Promise.all(files.map(file => {
return readFile(path.join(paths.mod, file));
})));
}
// Prefix hardcoded Photoswipe urls
lib["ps.css"] = Buffer.from(String(lib["ps.css"]).replace(/url\(/gm, "url(!/res/lib/"));
if (minify) {
for (const [file, data] of (Object.entries(lib))) {
if (/\.js$/.test(file)) {
lib[file] = Buffer.from(await minifyJS(String(data)));
} else if (/\.css$/.test(file)) {
lib[file] = Buffer.from(await minifyCSS(String(data)));
}
}
}
return lib;
}
async function minifyJS(js) {
if (!minify) return js;
const min = await terser.minify(js, opts.terser);
if (min.error) {
log.error(min.error);
process.exit(1);
}
return min.code;
}
async function minifyCSS(css) {
if (!minify) return css;
return cleanCSS.minify(String(css)).styles;
}
function templates() {
const prefix = "(function(){var template=Handlebars.template," +
"templates=Handlebars.templates=Handlebars.templates||{};";
const suffix = "Handlebars.partials=Handlebars.templates})();";
return prefix + fs.readdirSync(paths.templates).map(file => {
const p = path.join(paths.templates, file);
const name = file.replace(/\..+$/, "");
let html = htmlMinifier.minify(fs.readFileSync(p, "utf8"), opts.htmlMinifier);
// remove whitespace around {{fragments}}
html = html.replace(/(>|^|}}) ({{|<|$)/g, "$1$2");
// trim whitespace inside {{fragments}}
html = html.replace(/({{2,})([\s\S\n]*?)(}{2,})/gm, (_, p1, p2, p3) => {
return p1 + p2.replace(/\n/gm, " ").replace(/ {2,}/gm, " ").trim() + p3;
}).trim();
// remove {{!-- comments --}}
html = html.replace(/{{![\s\S]+?..}}/, "");
const compiled = handlebars.precompile(html, {data: false});
return `templates['${name}']=template(${compiled});`;
}).join("") + suffix;
}
resources.compileJS = async function() {
let js = "";
resources.files.js.forEach(file => {
js += `${fs.readFileSync(path.join(paths.mod, file), "utf8")};`;
});
// Add templates
js = js.replace("/* {{ templates }} */", templates());
// Minify
js = await minifyJS(js);
return {
data: Buffer.from(js),
etag: etag(js),
mime: utils.contentType("js"),
};
};
resources.compileCSS = async function() {
let css = "";
resources.files.css.forEach(file => {
css += `${fs.readFileSync(path.join(paths.mod, file), "utf8")}\n`;
});
// Vendor prefixes and minify
css = await minifyCSS(postcss([autoprefixer(opts.autoprefixer)]).process(css).css);
return {
data: Buffer.from(css),
etag: etag(css),
mime: utils.contentType("css"),
};
};
resources.compileHTML = async function(res) {
let html = fs.readFileSync(path.join(paths.mod, "client/index.html"), "utf8");
html = html.replace("<!-- {{svg}} -->", svg());
let auth = html.replace("{{type}}", "a");
auth = minify ? htmlMinifier.minify(auth, opts.htmlMinifier) : auth;
res["auth.html"] = {data: Buffer.from(auth), etag: etag(auth), mime: utils.contentType("html")};
let first = html.replace("{{type}}", "f");
first = minify ? htmlMinifier.minify(first, opts.htmlMinifier) : first;
res["first.html"] = {data: Buffer.from(first), etag: etag(first), mime: utils.contentType("html")};
let main = html.replace("{{type}}", "m");
main = minify ? htmlMinifier.minify(main, opts.htmlMinifier) : main;
res["main.html"] = {data: Buffer.from(main), etag: etag(main), mime: utils.contentType("html")};
return res;
};
async function compileAll() {
let res = {};
res["client.js"] = await resources.compileJS();
res["style.css"] = await resources.compileCSS();
res = await resources.compileHTML(res);
// Read misc files
for (const file of resources.files.other) {
const name = path.basename(file);
const fullPath = path.join(paths.mod, file);
const data = fs.readFileSync(fullPath);
res[name] = {data, etag: etag(data), mime: utils.contentType(name)};
}
return res;
}