1
0
Fork 0
pull/17/head
Ambrose Chua 2021-05-07 22:42:20 +08:00
parent 8672207a2c
commit cff8e4445e
4 changed files with 428 additions and 420 deletions

View File

@ -13,4 +13,6 @@ RUN cd /usr/local/share/file-manager \
VOLUME /data VOLUME /data
WORKDIR /data WORKDIR /data
ENV NODE_ENV=production
CMD ["node", "/usr/local/share/file-manager/index.js"] CMD ["node", "/usr/local/share/file-manager/index.js"]

View File

@ -7,5 +7,6 @@ RUN cd /usr/local/share/file-manager \
USER ambrose USER ambrose
ENV SHELL="zsh -l" ENV SHELL="zsh -l"
ENV NODE_ENV=production
CMD ["node", "/usr/local/share/file-manager/index.js"] CMD ["node", "/usr/local/share/file-manager/index.js"]

841
index.js
View File

@ -30,40 +30,40 @@ let http = app.listen(process.env.PORT || 8080);
app.set("views", path.join(__dirname, "views")); app.set("views", path.join(__dirname, "views"));
app.engine("handlebars", hbs({ app.engine("handlebars", hbs({
partialsDir: path.join(__dirname, "views", "partials"), partialsDir: path.join(__dirname, "views", "partials"),
layoutsDir: path.join(__dirname, "views", "layouts"), layoutsDir: path.join(__dirname, "views", "layouts"),
defaultLayout: "main", defaultLayout: "main",
helpers: { helpers: {
either: (a, b, options) => { either: (a, b, options) => {
if (a || b) { if (a || b) {
return options.fn(this); return options.fn(this);
} }
}, },
filesize: filesize, filesize: filesize,
octicon: (i, options) => { octicon: (i, options) => {
if (!octicons[i]) { if (!octicons[i]) {
return new handlebars.SafeString(octicons.question.toSVG()); return new handlebars.SafeString(octicons.question.toSVG());
} }
return new handlebars.SafeString(octicons[i].toSVG()); return new handlebars.SafeString(octicons[i].toSVG());
}, },
eachpath: (path, options) => { eachpath: (path, options) => {
if (typeof path != "string") { if (typeof path != "string") {
return ""; return "";
} }
let out = ""; let out = "";
path = path.split("/"); path = path.split("/");
path.splice(path.length - 1, 1); path.splice(path.length - 1, 1);
path.unshift(""); path.unshift("");
path.forEach((folder, index) => { path.forEach((folder, index) => {
out += options.fn({ out += options.fn({
name: folder + "/", name: folder + "/",
path: "/" + path.slice(1, index + 1).join("/"), path: "/" + path.slice(1, index + 1).join("/"),
current: index === path.length - 1 current: index === path.length - 1
}); });
}); });
return out; return out;
}, },
} }
})); }));
app.set("view engine", "handlebars"); app.set("view engine", "handlebars");
@ -77,7 +77,7 @@ app.use("/@assets/xterm-addon-attach", express.static(path.join(__dirname, "node
app.use("/@assets/xterm-addon-fit", express.static(path.join(__dirname, "node_modules/xterm-addon-fit"))); app.use("/@assets/xterm-addon-fit", express.static(path.join(__dirname, "node_modules/xterm-addon-fit")));
app.use(session({ app.use(session({
secret: "meowmeow" secret: "meowmeow"
})); }));
app.use(flash()); app.use(flash());
app.use(busboy()); app.use(busboy());
@ -88,392 +88,392 @@ app.use(bodyparser.urlencoded());
const KEY = process.env.KEY ? base32.decode(process.env.KEY.replace(/ /g, "")) : null; const KEY = process.env.KEY ? base32.decode(process.env.KEY.replace(/ /g, "")) : null;
app.get("/@logout", (req, res) => { app.get("/@logout", (req, res) => {
if (KEY) { if (KEY) {
req.session.login = false; req.session.login = false;
req.flash("success", "Signed out."); req.flash("success", "Signed out.");
res.redirect("/@login"); res.redirect("/@login");
return return
} }
req.flash("error", "You were never logged in..."); req.flash("error", "You were never logged in...");
res.redirect("back"); res.redirect("back");
}); });
app.get("/@login", (req, res) => { app.get("/@login", (req, res) => {
res.render("login", flashify(req, {})); res.render("login", flashify(req, {}));
}); });
app.post("/@login", (req, res) => { app.post("/@login", (req, res) => {
let pass = notp.totp.verify(req.body.token.replace(" ", ""), KEY); let pass = notp.totp.verify(req.body.token.replace(" ", ""), KEY);
console.log(pass, req.body.token.replace(" ", "")); console.log(pass, req.body.token.replace(" ", ""));
if (pass) { if (pass) {
req.session.login = true; req.session.login = true;
res.redirect("/"); res.redirect("/");
return; return;
} }
req.flash("error", "Bad token."); req.flash("error", "Bad token.");
res.redirect("/@login"); res.redirect("/@login");
}); });
app.use((req, res, next) => { app.use((req, res, next) => {
if (!KEY) { if (!KEY) {
return next(); return next();
} }
if (req.session.login === true) { if (req.session.login === true) {
return next(); return next();
} }
req.flash("error", "Please sign in."); req.flash("error", "Please sign in.");
res.redirect("/@login"); res.redirect("/@login");
}); });
function relative(...paths) { function relative(...paths) {
return paths.reduce((a, b) => path.join(a, b), process.cwd()); return paths.reduce((a, b) => path.join(a, b), process.cwd());
} }
function flashify(req, obj) { function flashify(req, obj) {
let error = req.flash("error"); let error = req.flash("error");
if (error && error.length > 0) { if (error && error.length > 0) {
if (!obj.errors) { if (!obj.errors) {
obj.errors = []; obj.errors = [];
} }
obj.errors.push(error); obj.errors.push(error);
} }
let success = req.flash("success"); let success = req.flash("success");
if (success && success.length > 0) { if (success && success.length > 0) {
if (!obj.successes) { if (!obj.successes) {
obj.successes = []; obj.successes = [];
} }
obj.successes.push(success); obj.successes.push(success);
} }
obj.isloginenabled = !!KEY; obj.isloginenabled = !!KEY;
return obj; return obj;
} }
app.all("/*", (req, res, next) => { app.all("/*", (req, res, next) => {
res.filename = req.params[0]; res.filename = req.params[0];
let fileExists = new Promise((resolve, reject) => { let fileExists = new Promise((resolve, reject) => {
// check if file exists // check if file exists
fs.stat(relative(res.filename), (err, stats) => { fs.stat(relative(res.filename), (err, stats) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
return resolve(stats); return resolve(stats);
}); });
}); });
fileExists.then((stats) => { fileExists.then((stats) => {
res.stats = stats; res.stats = stats;
next(); next();
}).catch((err) => { }).catch((err) => {
res.stats = { error: err }; res.stats = { error: err };
next(); next();
}); });
}); });
// currently unused // currently unused
app.put("/*", (req, res) => { app.put("/*", (req, res) => {
if (res.stats.error) { if (res.stats.error) {
req.busboy.on("file", (key, file, filename) => { req.busboy.on("file", (key, file, filename) => {
if (key == "file") { if (key == "file") {
let save = fs.createWriteStream(relative(res.filename)); let save = fs.createWriteStream(relative(res.filename));
file.pipe(save); file.pipe(save);
save.on("close", () => { save.on("close", () => {
res.flash("success", "File saved. "); res.flash("success", "File saved. ");
res.redirect("back"); res.redirect("back");
}); });
save.on("error", (err) => { save.on("error", (err) => {
res.flash("error", err.toString()); res.flash("error", err.toString());
res.redirect("back"); res.redirect("back");
}); });
} }
}); });
req.busboy.on("field", (key, value) => { req.busboy.on("field", (key, value) => {
}); });
req.pipe(req.busboy); req.pipe(req.busboy);
} }
else { else {
req.flash("error", "File exists, cannot overwrite. "); req.flash("error", "File exists, cannot overwrite. ");
res.redirect("back"); res.redirect("back");
} }
}); });
app.post("/*@upload", (req, res) => { app.post("/*@upload", (req, res) => {
res.filename = req.params[0]; res.filename = req.params[0];
let buff = null; let buff = null;
let saveas = null; let saveas = null;
req.busboy.on("file", (key, stream, filename) => { req.busboy.on("file", (key, stream, filename) => {
if (key == "file") { if (key == "file") {
let buffs = []; let buffs = [];
stream.on("data", (d) => { stream.on("data", (d) => {
buffs.push(d); buffs.push(d);
}); });
stream.on("end", () => { stream.on("end", () => {
buff = Buffer.concat(buffs); buff = Buffer.concat(buffs);
buffs = null; buffs = null;
}); });
} }
}); });
req.busboy.on("field", (key, value) => { req.busboy.on("field", (key, value) => {
if (key == "saveas") { if (key == "saveas") {
saveas = value; saveas = value;
} }
}); });
req.busboy.on("finish", () => { req.busboy.on("finish", () => {
if (!buff || !saveas) { if (!buff || !saveas) {
return res.status(400).end(); return res.status(400).end();
} }
let fileExists = new Promise((resolve, reject) => { let fileExists = new Promise((resolve, reject) => {
// check if file exists // check if file exists
fs.stat(relative(res.filename, saveas), (err, stats) => { fs.stat(relative(res.filename, saveas), (err, stats) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
return resolve(stats); return resolve(stats);
}); });
}); });
fileExists.then((stats) => { fileExists.then((stats) => {
req.flash("error", "File exists, cannot overwrite. "); req.flash("error", "File exists, cannot overwrite. ");
res.redirect("back"); res.redirect("back");
}).catch((err) => { }).catch((err) => {
const saveName = relative(res.filename, saveas); const saveName = relative(res.filename, saveas);
console.log("saving file to " + saveName); console.log("saving file to " + saveName);
let save = fs.createWriteStream(saveName); let save = fs.createWriteStream(saveName);
save.on("close", () => { save.on("close", () => {
if (res.headersSent) { if (res.headersSent) {
return; return;
} }
if (buff.length === 0) { if (buff.length === 0) {
req.flash("success", "File saved. Warning: empty file."); req.flash("success", "File saved. Warning: empty file.");
} }
else { else {
buff = null; buff = null;
req.flash("success", "File saved. "); req.flash("success", "File saved. ");
} }
res.redirect("back"); res.redirect("back");
}); });
save.on("error", (err) => { save.on("error", (err) => {
req.flash("error", err.toString()); req.flash("error", err.toString());
res.redirect("back"); res.redirect("back");
}); });
save.write(buff); save.write(buff);
save.end(); save.end();
}); });
}); });
req.pipe(req.busboy); req.pipe(req.busboy);
}); });
app.post("/*@mkdir", (req, res) => { app.post("/*@mkdir", (req, res) => {
res.filename = req.params[0]; res.filename = req.params[0];
let folder = req.body.folder; let folder = req.body.folder;
if (!folder || folder.length < 1) { if (!folder || folder.length < 1) {
return res.status(400).end(); return res.status(400).end();
} }
let fileExists = new Promise((resolve, reject) => { let fileExists = new Promise((resolve, reject) => {
// Check if file exists // Check if file exists
fs.stat(relative(res.filename, folder), (err, stats) => { fs.stat(relative(res.filename, folder), (err, stats) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
return resolve(stats); return resolve(stats);
}); });
}); });
fileExists.then((stats) => { fileExists.then((stats) => {
req.flash("error", "Folder exists, cannot overwrite. "); req.flash("error", "Folder exists, cannot overwrite. ");
res.redirect("back"); res.redirect("back");
}).catch((err) => { }).catch((err) => {
fs.mkdir(relative(res.filename, folder), (err) => { fs.mkdir(relative(res.filename, folder), (err) => {
if (err) { if (err) {
req.flash("error", err.toString()); req.flash("error", err.toString());
res.redirect("back"); res.redirect("back");
return; return;
} }
req.flash("success", "Folder created. "); req.flash("success", "Folder created. ");
res.redirect("back"); res.redirect("back");
}); });
}); });
}); });
app.post("/*@delete", (req, res) => { app.post("/*@delete", (req, res) => {
res.filename = req.params[0]; res.filename = req.params[0];
let files = JSON.parse(req.body.files); let files = JSON.parse(req.body.files);
if (!files || !files.map) { if (!files || !files.map) {
req.flash("error", "No files selected."); req.flash("error", "No files selected.");
res.redirect("back"); res.redirect("back");
return; // res.status(400).end(); return; // res.status(400).end();
} }
let promises = files.map(f => { let promises = files.map(f => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.stat(relative(res.filename, f), (err, stats) => { fs.stat(relative(res.filename, f), (err, stats) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
resolve({ resolve({
name: f, name: f,
isdirectory: stats.isDirectory(), isdirectory: stats.isDirectory(),
isfile: stats.isFile() isfile: stats.isFile()
}); });
}); });
}); });
}); });
Promise.all(promises).then((files) => { Promise.all(promises).then((files) => {
let promises = files.map(f => { let promises = files.map(f => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let op = null; let op = null;
if (f.isdirectory) { if (f.isdirectory) {
op = (dir, cb) => rimraf(dir, { op = (dir, cb) => rimraf(dir, {
glob: false glob: false
}, cb); }, cb);
} }
else if (f.isfile) { else if (f.isfile) {
op = fs.unlink; op = fs.unlink;
} }
if (op) { if (op) {
op(relative(res.filename, f.name), (err) => { op(relative(res.filename, f.name), (err) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
resolve(); resolve();
}); });
} }
}); });
}); });
Promise.all(promises).then(() => { Promise.all(promises).then(() => {
req.flash("success", "Files deleted. "); req.flash("success", "Files deleted. ");
res.redirect("back"); res.redirect("back");
}).catch((err) => { }).catch((err) => {
req.flash("error", "Unable to delete some files: " + err); req.flash("error", "Unable to delete some files: " + err);
res.redirect("back"); res.redirect("back");
}); });
}).catch((err) => { }).catch((err) => {
req.flash("error", err.toString()); req.flash("error", err.toString());
res.redirect("back"); res.redirect("back");
}); });
}); });
app.get("/*@download", (req, res) => { app.get("/*@download", (req, res) => {
res.filename = req.params[0]; res.filename = req.params[0];
let files = null; let files = null;
try { try {
files = JSON.parse(req.query.files); files = JSON.parse(req.query.files);
} catch (e) {} } catch (e) {}
if (!files || !files.map) { if (!files || !files.map) {
req.flash("error", "No files selected."); req.flash("error", "No files selected.");
res.redirect("back"); res.redirect("back");
return; // res.status(400).end(); return; // res.status(400).end();
} }
let promises = files.map(f => { let promises = files.map(f => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.stat(relative(res.filename, f), (err, stats) => { fs.stat(relative(res.filename, f), (err, stats) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
resolve({ resolve({
name: f, name: f,
isdirectory: stats.isDirectory(), isdirectory: stats.isDirectory(),
isfile: stats.isFile() isfile: stats.isFile()
}); });
}); });
}); });
}); });
Promise.all(promises).then((files) => { Promise.all(promises).then((files) => {
let zip = archiver("zip", {}); let zip = archiver("zip", {});
zip.on("error", function(err) { zip.on("error", function(err) {
res.status(500).send({ res.status(500).send({
error: err.message error: err.message
}); });
}); });
files.filter(f => f.isfile).forEach((f) => { files.filter(f => f.isfile).forEach((f) => {
zip.file(relative(res.filename, f.name), { name: f.name }); zip.file(relative(res.filename, f.name), { name: f.name });
}); });
files.filter(f => f.isdirectory).forEach((f) => { files.filter(f => f.isdirectory).forEach((f) => {
zip.directory(relative(res.filename, f.name), f.name); zip.directory(relative(res.filename, f.name), f.name);
}); });
res.attachment("Archive.zip"); res.attachment("Archive.zip");
zip.pipe(res); zip.pipe(res);
zip.finalize(); zip.finalize();
}).catch((err) => { }).catch((err) => {
console.log(err); console.log(err);
req.flash("error", err.toString()); req.flash("error", err.toString());
res.redirect("back"); res.redirect("back");
}); });
}); });
const shellable = process.env.SHELL != "false" && process.env.SHELL; const shellable = process.env.SHELL != "false" && process.env.SHELL;
const cmdable = process.env.CMD != "false" && process.env.CMD; const cmdable = process.env.CMD != "false" && process.env.CMD;
if (shellable || cmdable) { if (shellable || cmdable) {
const shellArgs = process.env.SHELL.split(" "); const shellArgs = process.env.SHELL.split(" ");
const exec = process.env.SHELL == "login" ? "/usr/bin/env" : shellArgs[0]; const exec = process.env.SHELL == "login" ? "/usr/bin/env" : shellArgs[0];
const args = process.env.SHELL == "login" ? ["login"] : shellArgs.slice(1); const args = process.env.SHELL == "login" ? ["login"] : shellArgs.slice(1);
const child_process = require("child_process"); const child_process = require("child_process");
app.post("/*@cmd", (req, res) => { app.post("/*@cmd", (req, res) => {
res.filename = req.params[0]; res.filename = req.params[0];
let cmd = req.body.cmd; let cmd = req.body.cmd;
if (!cmd || cmd.length < 1) { if (!cmd || cmd.length < 1) {
return res.status(400).end(); return res.status(400).end();
} }
child_process.exec(cmd, { child_process.exec(cmd, {
cwd: relative(res.filename), cwd: relative(res.filename),
timeout: 60 * 1000, timeout: 60 * 1000,
}, (err, stdout, stderr) => { }, (err, stdout, stderr) => {
if (err) { if (err) {
req.flash("error", "Command failed due to non-zero exit code"); req.flash("error", "Command failed due to non-zero exit code");
} }
res.render("cmd", flashify(req, { res.render("cmd", flashify(req, {
path: res.filename, path: res.filename,
cmd: cmd, cmd: cmd,
stdout: stdout, stdout: stdout,
stderr: stderr, stderr: stderr,
})); }));
}); });
}); });
const pty = require("node-pty"); const pty = require("node-pty");
const WebSocket = require("ws"); const WebSocket = require("ws");
app.get("/*@shell", (req, res) => { app.get("/*@shell", (req, res) => {
res.filename = req.params[0]; res.filename = req.params[0];
res.render("shell", flashify(req, { res.render("shell", flashify(req, {
path: res.filename, path: res.filename,
})); }));
}); });
const ws = new WebSocket.Server({ server: http }); const ws = new WebSocket.Server({ server: http });
ws.on("connection", (socket, request) => { ws.on("connection", (socket, request) => {
console.log(request.url); console.log(request.url);
const { path } = querystring.parse(request.url.split("?")[1]); const { path } = querystring.parse(request.url.split("?")[1]);
let cwd = relative(path); let cwd = relative(path);
let term = pty.spawn(exec, args, { let term = pty.spawn(exec, args, {
name: "xterm-256color", name: "xterm-256color",
cols: 80, cols: 80,
rows: 30, rows: 30,
cwd: cwd, cwd: cwd,
}); });
console.log("pid " + term.pid + " shell " + process.env.SHELL + " started in " + cwd); console.log("pid " + term.pid + " shell " + process.env.SHELL + " started in " + cwd);
term.on("data", (data) => { term.on("data", (data) => {
socket.send(data, { binary: true }); socket.send(data, { binary: true });
}); });
term.on("exit", (code) => { term.on("exit", (code) => {
console.log("pid " + term.pid + " ended") console.log("pid " + term.pid + " ended")
socket.close(); socket.close();
}); });
socket.on("message", (data) => { socket.on("message", (data) => {
// special messages should decode to Buffers // special messages should decode to Buffers
if (Buffer.isBuffer(data)) { if (Buffer.isBuffer(data)) {
switch (data.readUInt16BE(0)) { switch (data.readUInt16BE(0)) {
@ -482,12 +482,12 @@ if (shellable || cmdable) {
return; return;
} }
} }
term.write(data); term.write(data);
}); });
socket.on("close", () => { socket.on("close", () => {
term.end(); term.end();
}); });
}); });
} }
const EXT_IMAGES = [".jpg", ".jpeg", ".png", ".webp", ".svg", ".gif", ".tiff"]; const EXT_IMAGES = [".jpg", ".jpeg", ".png", ".webp", ".svg", ".gif", ".tiff"];
@ -501,82 +501,83 @@ function isimage(f) {
} }
app.get("/*", (req, res) => { app.get("/*", (req, res) => {
if (res.stats.error) { if (res.stats.error) {
res.render("list", flashify(req, { res.render("list", flashify(req, {
shellable: shellable, shellable: shellable,
cmdable: cmdable, cmdable: cmdable,
path: res.filename, path: res.filename,
errors: [ errors: [
res.stats.error res.stats.error
] ]
})); }));
} }
else if (res.stats.isDirectory()) { else if (res.stats.isDirectory()) {
if (!req.url.endsWith("/")) { if (!req.url.endsWith("/")) {
return res.redirect(req.url + "/"); return res.redirect(req.url + "/");
} }
let readDir = new Promise((resolve, reject) => { let readDir = new Promise((resolve, reject) => {
fs.readdir(relative(res.filename), (err, filenames) => { fs.readdir(relative(res.filename), (err, filenames) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
return resolve(filenames); return resolve(filenames);
}); });
}); });
readDir.then((filenames) => { readDir.then((filenames) => {
let promises = filenames.map(f => { const promises = filenames.map(f => new Promise((resolve, reject) => {
return new Promise((resolve, reject) => { fs.stat(relative(res.filename, f), (err, stats) => {
fs.stat(relative(res.filename, f), (err, stats) => { if (err) {
if (err) { return resolve({
return reject(err); name: f,
} error: err
resolve({ });
name: f, }
isdirectory: stats.isDirectory(), resolve({
issmallimage: isimage(f) && stats.size < 1024 * 10, name: f,
size: stats.size isdirectory: stats.isDirectory(),
}); issmallimage: isimage(f) && stats.size < 1024 * 10,
}); size: stats.size
}); });
}); });
}));
Promise.all(promises).then((files) => { Promise.all(promises).then((files) => {
res.render("list", flashify(req, { res.render("list", flashify(req, {
shellable: shellable, shellable: shellable,
cmdable: cmdable, cmdable: cmdable,
path: res.filename, path: res.filename,
files: files, files: files,
})); }));
}).catch((err) => { }).catch((err) => {
res.render("list", flashify(req, { res.render("list", flashify(req, {
shellable: shellable, shellable: shellable,
cmdable: cmdable, cmdable: cmdable,
path: res.filename, path: res.filename,
errors: [ errors: [
err err
] ]
})); }));
}); });
}).catch((err) => { }).catch((err) => {
res.render("list", flashify(req, { res.render("list", flashify(req, {
shellable: shellable, shellable: shellable,
cmdable: cmdable, cmdable: cmdable,
path: res.filename, path: res.filename,
errors: [ errors: [
err err
] ]
})); }));
}); });
} }
else if (res.stats.isFile()) { else if (res.stats.isFile()) {
res.sendFile(relative(res.filename), { res.sendFile(relative(res.filename), {
headers: { headers: {
"Content-Security-Policy": "default-src 'self'; script-src 'none'; sandbox" "Content-Security-Policy": "default-src 'self'; script-src 'none'; sandbox"
}, },
dotfiles: "allow" dotfiles: "allow"
}); });
} }
}); });

View File

@ -23,10 +23,14 @@
<span class="form-check-label d-flex align-items-start justify-content-between"> <span class="form-check-label d-flex align-items-start justify-content-between">
{{#if isdirectory}} {{#if isdirectory}}
<a href="./{{name}}/" class="name">{{name}}/</a> <a href="./{{name}}/" class="name">{{name}}/</a>
{{else}}
{{#if error}}
<a href="./{{name}}/" class="name" title="{{error}}">{{name}}/</a>
{{else}} {{else}}
<a href="./{{name}}" class="name">{{name}}</a> <a href="./{{name}}" class="name">{{name}}</a>
<span class="badge rounded-pill bg-secondary badge-alignment">{{filesize size}}</span> <span class="badge rounded-pill bg-secondary badge-alignment">{{filesize size}}</span>
{{/if}} {{/if}}
{{/if}}
</span> </span>
{{#if issmallimage}} {{#if issmallimage}}
<img src="./{{name}}" class="mt-2" style="max-height: 6em; max-width: 100%;"> <img src="./{{name}}" class="mt-2" style="max-height: 6em; max-width: 100%;">