diff --git a/frontend/assets/favicon.ico b/frontend/assets/favicon.ico
new file mode 100644
index 0000000..ddf3b88
Binary files /dev/null and b/frontend/assets/favicon.ico differ
diff --git a/frontend/ie9-block.js b/frontend/ie9-block.js
new file mode 100644
index 0000000..4119770
--- /dev/null
+++ b/frontend/ie9-block.js
@@ -0,0 +1,55 @@
+/**
+@license
+Copyright (C) 2017 Intel Corporation
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+/* Do not populate the global namespace */
+(function() {
+ "use strict";
+
+ function getIEVersion() {
+ var agent = navigator.userAgent.toLowerCase(),
+ tokenIndex = agent.indexOf('msie');
+ return (tokenIndex != -1) ? parseInt(agent.substring(tokenIndex)) : false;
+ }
+
+ /* If the User Agent is IE9 or older, block loading in the browser */
+ if (getIEVersion() && getIEVersion() <= 9) {
+ console.log("IE9 or older detected.")
+
+ /* Start a recurring timeout that keeps checking for document.body and
+ * once found, set the body to a "Browser not supported..." message */
+ function blockLoading() {
+ if (!document.body) {
+ setTimeout(blockLoading, 10);
+ return;
+ }
+
+ while (document.body.firstChild) {
+ document.body.removeChild(document.body.firstChild);
+ }
+
+ /* Try and stop loading of any objects by replacing the DOM immediately */
+ document.body.innerHTML = "The Board Explorer requires Chrome, Firefox, or Internet Explorer 10 or higher.";
+
+ /* Polymer and other agents may still be running and DOM stamping, so
+ * wait for DOMContentLoaded to complete, then re-blank the DOM */
+ document.addEventListener('DOMContentLoaded', function() {
+ document.body.innerHTML = "
The Board Explorer requires Chrome, Firefox, or Internet Explorer 10 or higher.
";
+ });
+ }
+
+ blockLoading();
+ }
+})();
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..9467ecc
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ Photos.Ketr
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/ketr-photos/fetch.js b/frontend/src/ketr-photos/fetch.js
new file mode 100755
index 0000000..849d6d7
--- /dev/null
+++ b/frontend/src/ketr-photos/fetch.js
@@ -0,0 +1,72 @@
+"use strict";
+(function() {
+ /*
+ * Async resource GET
+ *
+ * path: path to a file on the domain serving this script
+ * callback: function with signature function(response)
+ *
+ * response is:
+ * instanceof String --- sucess. response contains content
+ * instanceof Error --- error. Reason in response.message
+ */
+ function fetch(path, callback, headers, method, params) {
+ var xhr = new XMLHttpRequest();
+ method = method || 'GET';
+ // xhr.timeout = 15000;
+ if (callback) {
+ xhr.ontimeout = function() {
+ callback("Request timed out for path " + path, xhr);
+ };
+
+ xhr.onreadystatechange = function() {
+ if (this.readyState != 4) {
+ return;
+ }
+
+ if (this.status >= 400) {
+ return callback(this.responseText, this);
+ }
+
+ if (this.status == 200) {
+ return callback(undefined, this);
+ }
+ };
+
+ xhr.onerror = function(err) {
+ callback(
+ "Unable to load request. This can occur if you try and REFRESH the authentication page.",
+ this);
+ }
+ }
+
+ xhr.open(method, path, true);
+
+ var hasType = false;
+ if (headers && typeof headers === 'object') {
+ Array.prototype.forEach.call(Object.getOwnPropertyNames(headers), function(header) {
+ if (/content-type/i.exec(header)) {
+ hasType = headers[header];
+ }
+ xhr.setRequestHeader(header, headers[header]);
+ });
+ }
+
+ if (params && typeof params === 'object') {
+ if (!hasType) {
+ xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
+ }
+ params = JSON.stringify(params);
+ }
+
+ xhr.send(params);
+ }
+
+ var exports = {
+ fetch: fetch
+ };
+
+ Object.getOwnPropertyNames(exports).forEach(function(name) {
+ window[name] = exports[name];
+ });
+})();
diff --git a/frontend/src/ketr-photos/ketr-photos.html b/frontend/src/ketr-photos/ketr-photos.html
new file mode 100755
index 0000000..4f9619a
--- /dev/null
+++ b/frontend/src/ketr-photos/ketr-photos.html
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ [[path]]
+
+
+
+
+ [[item.filepath]]
+
+
+
+
+
+
+
+
+
+
diff --git a/server/app.js b/server/app.js
new file mode 100644
index 0000000..b33a750
--- /dev/null
+++ b/server/app.js
@@ -0,0 +1,110 @@
+"use strict";
+
+console.log("Loading photos.ketr");
+
+const express = require("express"),
+ morgan = require("morgan"),
+ bodyParser = require("body-parser"),
+ config = require("config"),
+ scanner = require("./scanner"),
+ db = require("./db");
+
+require("./console-line.js"); /* Monkey-patch console.log with line numbers */
+
+const picturesPath = config.get("picturesPath"),
+ serverConfig = config.get("server");
+
+let basePath = config.get("basePath");
+
+basePath = "/" + basePath.replace(/^\/+/, "").replace(/\/+$/, "") + "/";
+
+console.log("Loading pictures out of: " + picturesPath);
+console.log("Hosting server from: " + basePath);
+
+const app = express();
+
+app.set("basePath", basePath);
+
+/* App is behind an nginx proxy which we trust, so use the remote address
+ * set in the headers */
+app.set("trust proxy", true);
+
+/* Handle static files first so excessive logging doesn't occur */
+app.use(basePath, express.static("frontend", { index: false }));
+
+app.use(basePath, express.static(".", { index: false }));
+
+app.use(morgan("common"));
+
+app.use(bodyParser.urlencoded({
+ extended: false
+}));
+
+/* body-parser does not support text/*, so add support for that here */
+app.use(function(req, res, next){
+ if (!req.is('text/*')) {
+ return next();
+ }
+ req.setEncoding('utf8');
+ let text = '';
+ req.on('data', function(chunk) {
+ text += chunk;
+ });
+ req.on('end', function() {
+ req.text = text;
+ next();
+ });
+});
+
+app.use(basePath + "api/v1/photos", require("./routes/photos"));
+
+/* Declare the "catch all" index route last; the final route is a 404 dynamic router */
+app.use(basePath, require("./routes/index"));
+
+app.use(function(err, req, res, next) {
+ res.status(err.status || 500).json({
+ message: err.message,
+ error: {}
+ });
+});
+
+/**
+ * Create HTTP server and listen for new connections
+ */
+app.set("port", serverConfig.port);
+
+const server = require("http").createServer(app);
+
+Promise.all([
+ db, scanner.then(function(files) { app.set("files", files); })
+]).then(function() {
+ console.log("Done scanning. Opening server.");
+ server.listen(serverConfig.port);
+}).catch(function(error) {
+ console.error(error);
+ process.exit(-1);
+});
+
+server.on("error", function(error) {
+ if (error.syscall !== "listen") {
+ throw error;
+ }
+
+ // handle specific listen errors with friendly messages
+ switch (error.code) {
+ case "EACCES":
+ console.error(serverConfig.port + " requires elevated privileges");
+ process.exit(1);
+ break;
+ case "EADDRINUSE":
+ console.error(serverConfig.port + " is already in use");
+ process.exit(1);
+ break;
+ default:
+ throw error;
+ }
+});
+
+server.on("listening", function() {
+ console.log("Listening on " + serverConfig.port);
+});
diff --git a/server/console-line.js b/server/console-line.js
new file mode 100644
index 0000000..fb47e05
--- /dev/null
+++ b/server/console-line.js
@@ -0,0 +1,30 @@
+/* monkey-patch console.log to prefix with file/line-number */
+if (process.env.LOG_LINE) {
+ let cwd = process.cwd(),
+ cwdRe = new RegExp("^[^/]*" + cwd.replace("/", "\\/") + "\/([^:]*:[0-9]*).*$");
+ [ "log", "warn", "error" ].forEach(function(method) {
+ console[method] = (function () {
+ let orig = console[method];
+ return function () {
+ function getErrorObject() {
+ try {
+ throw Error('');
+ } catch (err) {
+ return err;
+ }
+ }
+
+ let err = getErrorObject(),
+ caller_line = err.stack.split("\n")[4],
+ args = [caller_line.replace(cwdRe, "$1 -")];
+
+ /* arguments.unshift() doesn't exist... */
+ for (var i = 0; i < arguments.length; i++) {
+ args.push(arguments[i]);
+ }
+
+ orig.apply(this, args);
+ };
+ })();
+ });
+}
diff --git a/server/db/index.js b/server/db/index.js
new file mode 100644
index 0000000..b09d272
--- /dev/null
+++ b/server/db/index.js
@@ -0,0 +1,64 @@
+/**
+ * * Copyright (c) 2016, Intel Corporation.
+ *
+ * This program is licensed under the terms and conditions of the
+ * Apache License, version 2.0. The full text of the Apache License is at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+"use strict";
+
+/**
+ * This class will instantiate the ORM, load in the models, call the method
+ * to create db connections, test the connection, then create the tables and
+ * relationships if not present
+ */
+const fs = require('fs'),
+ path = require('path'),
+ Sequelize = require('sequelize'),
+ config = require('config');
+
+function init() {
+ const db = {
+ sequelize: new Sequelize(config.get("db.host"), config.get("db.options"))
+ };
+
+ return new Promise(function(resolve, reject) {
+ console.log("DB initialization beginning. DB access will block.");
+ let models = [];
+
+ /* Load models */
+ fs.readdirSync(__dirname).forEach(function (file) {
+ if (file == "." || file == ".." || file == "index.js") {
+ return;
+ }
+ let model = db.sequelize.import(path.join(__dirname, file));
+ db[model.name] = model;
+ models.push(model);
+ });
+
+ /* After all the models are loaded, associate any that need it */
+ models.forEach(function (model) {
+ if (model.associate) {
+ model.associate(db);
+ }
+ });
+
+ return db.sequelize.authenticate().then(function () {
+ console.log("Connection established successfully with DB.");
+ return db.sequelize.sync({
+ force: false
+ }).then(function () {
+ console.log("DB relationships successfully mapped. DB access unblocked.");
+ return resolve(db);
+ });
+ }).catch(function (error) {
+ console.log("ERROR: Failed to authenticate with DB");
+ console.log("ERROR: " + JSON.stringify(config.get("db"), null, 2));
+ console.log(error);
+ return reject(error);
+ });
+ });
+}
+
+module.exports = init();
diff --git a/server/routes/index.js b/server/routes/index.js
new file mode 100755
index 0000000..d335938
--- /dev/null
+++ b/server/routes/index.js
@@ -0,0 +1,55 @@
+"use strict";
+
+const express = require("express"),
+ fs = require("fs"),
+ url = require("url"),
+ config = require("config");
+
+const router = express.Router();
+
+/* List of filename extensions we know are "potential" file extensions for
+* assets we don"t want to return "index.html" for */
+const extensions = [
+ "html", "js", "css", "eot", "gif", "ico", "jpeg", "jpg", "mp4",
+ "md", "ttf", "txt", "woff", "woff2", "yml", "svg"
+];
+
+/* Build the extension match RegExp from the list of extensions */
+const extensionMatch = new RegExp("^.*?(" + extensions.join("|") + ")$", "i");
+
+/* To handle dynamic routes, we return index.html to every request that
+ * gets this far -- so this needs to be the last route.
+ *
+ * However, that introduces site development problems when assets are
+ * referenced which don't yet exist (due to bugs, or sequence of adds) --
+ * the server would return HTML content instead of the 404.
+ *
+ * So, check to see if the requested path is for an asset with a recognized
+ * file extension.
+ *
+ * If so, 404 because the asset isn't there. otherwise assume it is a
+ * dynamic client side route and *then* return index.html.
+ */
+router.get("/*", function(req, res/*, next*/) {
+ const parts = url.parse(req.url),
+ basePath = req.app.get("basePath");
+
+ if (req.url == "/" || !extensionMatch.exec(parts.pathname)) {
+ /* Replace in index.html with
+ * the basePath */
+ const index = fs.readFileSync("frontend/index.html", "utf8");
+
+ res.send(index.replace(
+ /