diff --git a/server/app.js b/server/app.js index 5fb7811..8781c2d 100755 --- a/server/app.js +++ b/server/app.js @@ -13,6 +13,7 @@ const express = require("express"), bodyParser = require("body-parser"), config = require("config"), session = require('express-session'), + hb = require("handlebars"), SQLiteStore = require('connect-sqlite3')(session), scanner = require("./scanner"); @@ -21,8 +22,14 @@ require("./console-line.js"); /* Monkey-patch console.log with line numbers */ const picturesPath = config.get("picturesPath").replace(/\/$/, ""), serverConfig = config.get("server"); +config.get("admin.mail"); +config.get("smtp.host"); +config.get("smtp.sender"); + let basePath = config.get("basePath"); +let photoDB = null, userDB = null + basePath = "/" + basePath.replace(/^\/+/, "").replace(/\/+$/, "") + "/"; if (basePath == "//") { basePath = "/"; @@ -70,7 +77,123 @@ app.use(session({ const index = require("./routes/index"); +const transporter = require("nodemailer").createTransport({ + host: config.get("smtp.host"), + pool: true, + port: config.has("smtp.port") ? config.get("smtp.port") : 25 +}); + +const templates = { + "html": [ + "

The user {{displayName}} has verified their email address ({{mail}}).

", + "", + "

They indicated they know:

", + "
{{notes}}
", + "", + "

To authenticate:

", + "

echo 'UPDATE users SET authenticated=1 WHERE id={{id}};' | sqlite3 users.db

", + "", + "

Sincerely,
", + "James

" + ].join("\n"), + "text": [ + "The user {{displayName}} has verified their email address ({{mail}}).", + "", + "They indicated they know:", + "{{notes}}", + "", + "To authenticate:", + "echo 'UPDATE users SET authenticated=1 WHERE id={{id}};' | sqlite3 users.db", + "", + "Sincerely,", + "James" + ].join("\n") +}; + /* Allow loading of the app w/out being logged in */ +app.use(basePath, function(req, res, next) { + let match = req.url.match(/^\/([0-9a-f]+)$/); + if (!match) { + return next(); + } + + let key = match[1]; + return userDB.sequelize.query("SELECT * FROM authentications WHERE key=:key", { + replacements: { + key: key + }, + type: userDB.sequelize.QueryTypes.SELECT + }).then(function(results) { + let token; + if (results.length == 0) { + console.log("Invalid key. Ignoring."); + return next(); + } + + token = results[0]; + + console.log("Matched token: " + JSON.stringify(token, null, 2)); + switch (token.type) { + case "account-setup": + return userDB.sequelize.query("UPDATE users SET mailVerified=1 WHERE id=:userId", { + replacements: token + }).then(function() { + return userDB.sequelize.query("DELETE FROM authentications WHERE key=:key", { + replacements: token + }); + }).then(function() { + return userDB.sequelize.query("SELECT * FROM users WHERE id=:userId", { + replacements: token, + type: userDB.sequelize.QueryTypes.SELECT + }).then(function(results) { + if (results.length == 0) { + throw "DB mis-match between authentications and users table"; + } + let user = results[0], + envelope = { + to: config.get("admin.mail"), + from: config.get("smtp.sender"), + subject: "VERIFIED: Account'" + user.displayName + "'", + cc: "", + bcc: "", + text: hb.compile(templates.text)(user), + html: hb.compile(templates.html)(user) + }; + + return new Promise(function (resolve, reject) { + let attempts = 10; + + function send(envelope) { + /* Rate limit to ten per second */ + transporter.sendMail(envelope, function (error, info) { + if (!error) { + console.log('Message sent: ' + info.response); + return resolve(); + } + + if (attempts == 0) { + console.log("Error sending email: ", error) + return reject(error); + } + + attempts--; + console.log("Unable to send mail. Trying again in 100ms (" + attempts + " attempts remain): ", error); + setTimeout(send.bind(undefined, envelope), 100); + }); + } + + send(envelope); + }); + }).then(function() { + return res.redirect(308, basePath); + }); + }); + } + + return next(); + }); +}); + app.use(basePath, index); app.use(basePath + "api/v1/users", require("./routes/users")); @@ -125,11 +248,16 @@ app.set("port", serverConfig.port); const server = require("http").createServer(app); -require("./db/photos").then(function(photoDB) { +require("./db/photos").then(function(db) { + photoDB = db; +}).then(function() { + return require("./db/users").then(function(db) { + userDB = db; + }); +}).then(function() { console.log("DB connected. Opening server."); server.listen(serverConfig.port); - return photoDB; -}).then(function(photoDB) { +}).then(function() { console.log("Scanning."); scanner.init(photoDB); return scanner.scan(); diff --git a/server/db/users.js b/server/db/users.js index 6a92c9c..74a109e 100644 --- a/server/db/users.js +++ b/server/db/users.js @@ -38,6 +38,7 @@ function init() { authToken: Sequelize.STRING, authDate: Sequelize.DATE, authenticated: Sequelize.BOOLEAN, + mailVerified: Sequelize.BOOLEAN, mail: Sequelize.STRING, memberSince: Sequelize.DATE, password: Sequelize.STRING, /* SHA hash of user supplied password for !isLDAP users */ diff --git a/server/routes/users.js b/server/routes/users.js index eb8fd08..dfeb5ac 100755 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -37,21 +37,25 @@ router.get("/", function(req, res/*, next*/) { const templates = { "html": [ - "

Dear {{name}},

", + "

Dear {{username}},

", "", - "

Welcome to HTML {{username}}.

", + "

Welcome to ketrenos.com. Before you can access the system, you must authenticate", + "your email address ({{mail}}).

", "", - "

Your secret is: {{secret}}.

", + "

To do so, simply access this link, which contains your authentication ", + "token: {{secret}}

", "", "

Sincerely,

", "

James

" ].join("\n"), "text": [ - "Dear {{name}},", + "Dear {{username}},", "", - "Welcome to TEXT {{username}}.", + "Welcome to ketrenos.com. Before you can access the system, you must authenticate", + "your email address ({{mail}}).", "", - "Your secret is: {{secret}}.", + "To do so, simply access this link, which contains your authentication ", + "token: {{url}}{{secret}}", "", "Sincerely,", "James" @@ -75,7 +79,7 @@ function ldapPromise(username, password) { router.post("/create", function(req, res) { let who = req.query.w || req.body.w || "", password = req.query.p || req.body.p || "", - name = req.query.n || req.body.n || username, + name = req.query.n || req.body.n || "", mail = req.query.m || req.body.m; if (!who || !password || !mail || !name) { @@ -93,13 +97,21 @@ router.post("/create", function(req, res) { return res.status(400).send("Email address already used."); } - let re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, - secret = "magic cookie"; + let re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; if (!re.exec(mail)) { console.log("Invalid email address: " + mail); return res.status(400).send("Invalid email address."); } - + }).then(function() { + return new Promise(function(resolve, reject) { + crypto.randomBytes(16, function(error, buffer) { + if (error) { + return reject(error); + } + return resolve(buffer.toString('hex')); + }); + }); + }).then(function(secret) { return userDB.sequelize.query("INSERT INTO users " + "(uid,displayName,password,mail,memberSince,authenticated,notes) " + "VALUES(:username,:name,:password,:mail,CURRENT_TIMESTAMP,0,:notes)", { @@ -122,17 +134,18 @@ router.post("/create", function(req, res) { console.log(error); throw error; }); - }).spread(function(results, metadata) { + }).then(function() { let data = { username: name, mail: mail, - secret: secret + secret: secret, + url: req.protocol + "://" + req.hostname + req.app.get("basePath") }, envelope = { to: mail, from: config.get("smtp.sender"), - subject: "Request to create account for " + name, + subject: "Request to ketrenos.com create account for '" + name + "'", cc: "", - bcc: "", + bcc: config.get("admin.mail"), text: hb.compile(templates.text)(data), html: hb.compile(templates.html)(data) }; @@ -208,7 +221,7 @@ router.post("/login", function(req, res) { }); }).then(function(user) { if (!user) { - console.log(username + " not found."); + console.log(username + " not found (or invalid password.)"); req.session.user = {}; return res.status(401).send("Invalid login credentials"); } diff --git a/server/scanner.js b/server/scanner.js index 678595b..d75bde4 100755 --- a/server/scanner.js +++ b/server/scanner.js @@ -11,7 +11,7 @@ let photoDB = null; const picturesPath = config.get("picturesPath").replace(/\/$/, "") + "/"; -let processQueue = [], triedClean = [], lastScan = new Date("1900-01-01"); +let processQueue = [], triedClean = [], lastScan = new Date("1800-01-01"); //const rawExtension = /\.(nef|orf)$/i, extensions = [ "jpg", "jpeg", "png", "gif", "nef", "orf" ]; @@ -574,7 +574,8 @@ function findOrCreateDBAlbum(transaction, album) { } function findOrUpdateDBAsset(transaction, asset) { - let query = "SELECT id,DATETIME(scanned) AS scanned,DATETIME(modified) AS modified FROM photos WHERE albumId=:albumId AND filename=:filename"; + let query = "SELECT id,DATETIME(scanned) AS scanned,DATETIME(modified) AS modified FROM photos " + + "WHERE albumId=:albumId AND filename=:filename"; if (!asset.album || !asset.album.id) { let error = "Asset being processed without an album"; console.error(error); @@ -651,111 +652,106 @@ function doScan() { return Promise.resolve("scanning"); } - return photoDB.sequelize.query("SELECT max(scanned) AS scanned FROM photos", { - type: photoDB.sequelize.QueryTypes.SELECT - }).then(function(results) { - if (results[0].scanned == null) { - lastScan = new Date("1800-01-01"); - } else { - lastScan = new Date(results[0].scanned); - } - console.log("Updating any asset newer than " + moment(lastScan).format()); - }).then(function() { - return scanDir(null, picturesPath).spread(function(albums, assets) { - console.log("Found " + assets.length + " assets in " + albums.length + " albums after " + - ((Date.now() - now) / 1000) + "s"); - /* One at a time, in series, as the album[] array has parents first, then descendants. - * Operating in parallel could result in a child being searched for prior to the parent */ - now = Date.now(); + return scanDir(null, picturesPath).spread(function(albums, assets) { + console.log("Found " + assets.length + " assets in " + albums.length + " albums after " + + ((Date.now() - now) / 1000) + "s"); + /* One at a time, in series, as the album[] array has parents first, then descendants. + * Operating in parallel could result in a child being searched for prior to the parent */ + now = Date.now(); - let toProcess = albums.length, lastMessage = moment(); - return photoDB.sequelize.transaction(function(transaction) { - return Promise.mapSeries(albums, function(album) { - return findOrCreateDBAlbum(transaction, album).then(function() { - toProcess--; - if (moment().add(-5, 'seconds') > lastMessage) { - console.log("Albums to be created in DB: " + toProcess); - lastMessage = moment(); - } - }); - }); - }).then(function() { - console.log("Processed " + albums.length + " album DB entries in " + - ((Date.now() - now) / 1000) + "s"); - now = Date.now(); - - console.log(assets.length + " assets remaining to be verified/updated. ETA N/A"); - - let processed = 0, start = Date.now(), last = 0, updateScanned = [], newEntries = 0; - return photoDB.sequelize.transaction(function(transaction) { - return Promise.map(assets, function(asset) { - return new Promise(function(resolve, reject) { - /* If both mtime and ctime of the asset are older than the lastScan, - * skip this asset. */ - if (lastScan != null && asset.stats.mtime < lastScan && asset.stats.ctime < lastScan) { - updateScanned.push(asset.id); - return resolve(asset); - } - - return findOrUpdateDBAsset(transaction, asset).then(function(asset) { - if (!asset.scanned) { - newEntries++; - } - console.log(typeof asset.scanned); - if (!asset.scanned || asset.scanned < asset.stats.mtime || !asset.modified) { - console.log("Process asset: " + asset.scanned); - needsProcessing.push(asset); - } else { - console.log("Update scanned tag: " + asset.scanned); - updateScanned.push(asset.id); - } - return asset; - }).then(function(asset) { - return resolve(asset); - }).catch(function(error) { - return reject(error); - }); - }).then(function(asset) { - processed++; - - let elapsed = Date.now() - start; - if (elapsed < 5000) { - return asset; - } - - let remaining = assets.length - processed, eta = Math.ceil((elapsed / 1000) * remaining / (processed - last)); - console.log(remaining + " assets remaining be verified/updated " + - "(" + newEntries + " new entries, " + (processed - newEntries) + " up-to-date so far). ETA " + eta + "s"); - last = processed; - start = Date.now(); - }); - }, { - concurrency: 10 - }); - }).then(function() { - if (updateScanned.length) { - return photoDB.sequelize.query("UPDATE photos SET scanned=CURRENT_TIMESTAMP WHERE id IN (:ids)", { - replacements: { - ids: updateScanned - } - }).then(function() { - console.log("Updated scan date of " + updateScanned.length + " assets"); - updateScanned = []; - }); + let toProcess = albums.length, lastMessage = moment(); + return photoDB.sequelize.transaction(function(transaction) { + return Promise.mapSeries(albums, function(album) { + return findOrCreateDBAlbum(transaction, album).then(function() { + toProcess--; + if (moment().add(-5, 'seconds') > lastMessage) { + console.log("Albums to be created in DB: " + toProcess); + lastMessage = moment(); } - }).then(function() { - console.log(newEntries + " assets are new. " + (needsProcessing.length - newEntries) + " assets have been modified."); - console.log(needsProcessing.length + " assets need HASH computed. " + (assets.length - needsProcessing.length) + " need no update.");; - processBlock(needsProcessing); - needsProcessing = []; - }).then(function() { - console.log("Scanned " + assets.length + " asset DB entries in " + - ((Date.now() - now) / 1000) + "s"); - assets = []; }); }); }).then(function() { - console.log("Total time to initialize DB and all scans: " + ((Date.now() - initialized) / 1000) + "s"); + console.log("Processed " + albums.length + " album DB entries in " + + ((Date.now() - now) / 1000) + "s"); + now = Date.now(); + + console.log(assets.length + " assets remaining to be verified/updated. ETA N/A"); + + let processed = 0, start = Date.now(), last = 0, updateScanned = [], newEntries = 0; + return photoDB.sequelize.transaction(function(transaction) { + return Promise.map(assets, function(asset) { + return new Promise(function(resolve, reject) { + /* If both mtime and ctime of the asset are older than the lastScan, skip it + * Can only do this after a full scan has occurred */ + if (lastScan != null && asset.stats.mtime < lastScan && asset.stats.ctime < lastScan) { + return resolve(asset); + } + + return findOrUpdateDBAsset(transaction, asset).then(function(asset) { + if (!asset.scanned) { + newEntries++; + } + if (!asset.scanned || asset.scanned < asset.stats.mtime || !asset.modified) { + needsProcessing.push(asset); + } else { + updateScanned.push(asset.id); + } + return asset; + }).then(function(asset) { + return resolve(asset); + }).catch(function(error) { + return reject(error); + }); + }).then(function(asset) { + processed++; + + let elapsed = Date.now() - start; + if (elapsed < 5000) { + return asset; + } + + let remaining = assets.length - processed, eta = Math.ceil((elapsed / 1000) * remaining / (processed - last)); + console.log(remaining + " assets remaining be verified/updated " + + "(" + newEntries + " new entries, " + (processed - newEntries) + " up-to-date so far). ETA " + eta + "s"); + last = processed; + start = Date.now(); + }); + }, { + concurrency: 10 + }); + }).then(function() { + if (updateScanned.length) { + return photoDB.sequelize.query("UPDATE photos SET scanned=CURRENT_TIMESTAMP WHERE id IN (:ids)", { + replacements: { + ids: updateScanned + } + }).then(function() { + console.log("Updated scan date of " + updateScanned.length + " assets"); + updateScanned = []; + }); + } + }).then(function() { + console.log(newEntries + " assets are new. " + (needsProcessing.length - newEntries) + " assets have been modified."); + console.log(needsProcessing.length + " assets need HASH computed. " + (assets.length - needsProcessing.length) + " need no update.");; + processBlock(needsProcessing); + needsProcessing = []; + }).then(function() { + console.log("Scanned " + assets.length + " asset DB entries in " + + ((Date.now() - now) / 1000) + "s"); + assets = []; + }); + }); + }).then(function() { + console.log("Total time to initialize DB and all scans: " + ((Date.now() - initialized) / 1000) + "s"); + return photoDB.sequelize.query("SELECT max(scanned) AS scanned FROM photos", { + type: photoDB.sequelize.QueryTypes.SELECT + }).then(function(results) { + if (results[0].scanned == null) { + lastScan = new Date("1800-01-01"); + } else { + lastScan = new Date(results[0].scanned); + } + console.log("Updating any asset newer than " + moment(lastScan).format()); }); }).then(function() { scanning = false;