Scanning and loading dirs

Signed-off-by: James Ketrenos <james_git@ketrenos.com>
This commit is contained in:
James Ketr 2018-01-15 20:31:13 -08:00
parent 92c64c67d6
commit 1e24e5a934
11 changed files with 720 additions and 0 deletions

BIN
frontend/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

55
frontend/ie9-block.js Normal file
View File

@ -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 = "<div style='margin:5em;text-align:center;font-size:30pt;'>The Board Explorer requires Chrome, Firefox, or Internet Explorer 10 or higher.</div>";
});
}
blockLoading();
}
})();

31
frontend/index.html Normal file
View File

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, minimum-scale=1, initial-scale=1, user-scalable=yes">
<title>Photos.Ketr</title>
<meta name="description" content="Self hosted photo management">
<!-- This is replaced by server/app.js when providing index.html to
clients -->
<script>'<base href="BASEPATH">';</script>
<link rel="icon" href="assets/favicon.ico" type="image/x-icon">
<!-- See https://goo.gl/OOhYW5 -->
<link rel="manifest" href="manifest.json">
<script src="bower_components/webcomponentsjs/webcomponents-lite.js"></script>
<link rel="import" href="bower_components/iron-flex-layout/iron-flex-layout-classes.html">
<link rel="import" href="bower_components/app-layout/app-toolbar/app-toolbar.html">
<style is="custom-style" include="iron-flex iron-positioning"></style>
<link rel="import" href="src/ketr-photos/ketr-photos.html">
</head>
<body class="fullbleed vertical layout">
<script src="ie9-block.js"></script>
<ketr-photos class="fit"></ketr-photos>
</body>
</html>

View File

@ -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];
});
})();

View File

@ -0,0 +1,208 @@
<!doctype html>
<html>
<head>
<link rel="import" href="../../bower_components/polymer/polymer.html">
<link rel="import" href="../../bower_components/app-layout/app-header/app-header.html">
<link rel="import" href="../../bower_components/app-layout/app-header-layout/app-header-layout.html">
<link rel="import" href="../../bower_components/app-layout/app-toolbar/app-toolbar.html">
<link rel="import" href="../../bower_components/app-route/app-location.html">
<link rel="import" href="../../bower_components/iron-icon/iron-icon.html">
<link rel="import" href="../../bower_components/iron-icons/iron-icons.html">
<link rel="import" href="../../bower_components/iron-pages/iron-pages.html">
<link rel="import" href="../../bower_components/paper-button/paper-button.html">
<link rel="import" href="../../bower_components/paper-dialog/paper-dialog.html">
<link rel="import" href="../../bower_components/paper-dialog-scrollable/paper-dialog-scrollable.html">
<link rel="import" href="../../bower_components/paper-input/paper-input.html">
<link rel="import" href="../../bower_components/paper-spinner/paper-spinner.html">
<link rel="import" href="../../bower_components/paper-toast/paper-toast.html">
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css?family=Droid+Sans+Mono" />
<script src="fetch.js"></script>
<style>
body,* {
font-family: Helvetica Neue,Helvetica,Arial,sans-serif;
}
b,strong {
font-family: Helvetica Neue,Helvetica,Arial,sans-serif;
}
</style>
</head>
<dom-module id="ketr-photos">
<template>
<style is="custom-style" include="iron-flex iron-flex-alignment iron-positioning">
:host {
}
app-toolbar {
background-color: rgba(64, 0, 64, 0.5);
color: white;
}
#toast[error] {
--paper-toast-background-color: red;
--paper-toast-color: white;
}
.thumbnails > div {
margin: 0.5em;
width: 200px;
height: 200px;
background-repeat: no-repeat;
background-size: cover;
background-position: 50% 50%;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
}
.folders > div {
margin: 0.5em;
width: 200px;
height: 200px;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
}
.thumbnails > div:hover,
.folders > div:hover {
box-shadow: 0px 4px 2px -2px #ddd;
text-decoration: underline;
}
</style>
<app-location route="{{route}}"></app-location>
<app-header-layout fullbleed has-scrolling-region>
<app-header slot="header" fixed>
<paper-spinner active$="[[loading]]" class="thin"></paper-spinner>
<div>[[path]]</div>
</app-header>
<div class="thumbnails layout horizontal wrap">
<template is="dom-repeat" items="[[photos.files]]">
<div style$="background-image:url([[item.filepath]])" on-tap="_imageTap" info="[[item]]"></div>
</template>
</div>
<div class="folders layout horizontal wrap">
<template is="dom-repeat" items="[[photos.paths]]">
<div info="[[item]]" on-tap="_pathTap" >[[item.filepath]]</div>
</template>
</div>
</app-header-layout>
<paper-toast id="toast"></paper-toast>
</template>
<script>
document.addEventListener("WebComponentsReady", function() {
"use strict";
Polymer({
is: "ketr-photos",
properties: {
"loading": Boolean,
"photos": Array
},
observers: [
],
_imageTap: function(event) {
window.open(event.model.item.filepath, "image");
},
_pathTap: function(event) {
window.location.href = event.model.item.filepath;
},
_loadPhotos: function() {
if (this.loading == true) {
return;
}
this.loading = true;
window.fetch("api/v1/photos", function(error, xhr) {
this.loading = false;
if (error) {
console.error(JSON.stringify(error, null, 2));
return;
}
let photos;
try {
photos = JSON.parse(xhr.responseText);
} catch (___) {
this.$.toast.text = "Unable to load/parse photo list.";
this.$.toast.setAttribute("error", true);
this.$.toast.updateStyles();
this.$.toast.show();
console.error("Unable to parse photos");
return;
}
var base = document.querySelector("base");
if (base) {
base = new URL(base.href).pathname;
var parts = new RegExp("http.*" + base.replace(/\//, "\\/") + "(.*)").exec(window.location.href);
if (parts && parts.length == 2) {
this.base = "./" + parts[1];
} else {
this.base = "";
}
} else {
this.base = "";
}
function findPath(path, item) {
if (path.indexOf(item.filepath) != 0) {
return false;
}
if (path == item.filepath || path == item.filepath + "/") {
return item;
}
for (var i = 0; i < item.paths.length; i++) {
var tmp = findPath(path, item.paths[i]);
if (tmp) {
return tmp;
}
}
return false;
}
if (this.base != "") {
this.photos = findPath(this.base, photos) || photos;
} else {
this.photos = photos;
}
}.bind(this));
},
ready: function() {
window.addEventListener("hashchange", function(event) {
this.hash = event.newURL.replace(/^[^#]*/, "");
}.bind(this), false);
/* Hash changes due to anchor clicks aren't firing the 'hashchange'
* event... possibly due to app-location? */
window.setInterval(function() {
if (this.hash != window.location.hash) {
this.hash = window.location.hash;
}
}.bind(this), 100);
this._loadPhotos();
}
});
});
</script>
</dom-module>
</html>

110
server/app.js Normal file
View File

@ -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);
});

30
server/console-line.js Normal file
View File

@ -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);
};
})();
});
}

64
server/db/index.js Normal file
View File

@ -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();

55
server/routes/index.js Executable file
View File

@ -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 <script>'<base href="/BASEPATH/">';</script> in index.html with
* the basePath */
const index = fs.readFileSync("frontend/index.html", "utf8");
res.send(index.replace(
/<script>'<base href="BASEPATH">';<\/script>/,
"<base href='" + basePath + "'>"));
return;
}
console.log("Page not found: " + req.url);
return res.status(404).json({
message: "Page not found",
status: 404
});
});
module.exports = router;

14
server/routes/photos.js Normal file
View File

@ -0,0 +1,14 @@
"use strict";
const express = require("express"),
fs = require("fs"),
url = require("url"),
config = require("config");
const router = express.Router();
router.get("/", function(req, res/*, next*/) {
return res.status(200).json(req.app.get("files"));
});
module.exports = router;

81
server/scanner.js Normal file
View File

@ -0,0 +1,81 @@
"use strict";
const db = require("sequelize"),
Promise = require("bluebird"),
fs = require("fs"),
config = require("config");
let scanning = 0;
const picturesPath = config.get("picturesPath");
function scanFile(path, stats) {
return new Promise(function(resolve, reject) {
const meta = { filepath: path };
console.log("Scanning file: " + path);
meta.created = stats.ctime;
return resolve(meta);
});
}
function scanDir(path) {
return new Promise(function(resolve, reject) {
const meta = { filepath: path, files: [], paths: [] };
console.log("Scanning path " + path);
fs.readdir(path, function(err, files) {
if (err) {
console.warn(" Could not readdir " + path);
return resolve(null);
}
scanning++;
return Promise.map(files, function(file) {
let filepath = path + "/" + file;
return new Promise(function(resolve, reject) {
fs.stat(filepath, function(err, stats) {
if (err) {
console.warn("Could not stat " + filepath);
return resolve(null);
}
if (stats.isDirectory()) {
return scanDir(filepath, stats).then(function(entry) {
if (entry && (entry.files.length || entry.paths.length)) {
meta.paths.push(entry);
}
return resolve(entry);
});
}
/* stats.isFile() */
return scanFile(filepath, stats).then(function(entry) {
if (entry) {
meta.files.push(entry);
}
return resolve(entry);
});
});
});
}, {
concurrency: 10
}).then(function() {
scanning--;
if (scanning == 0) {
const endStamp = Date.now();
console.log("Scanning completed in " + Math.round(((endStamp - startStamp))) + "ms.");
}
}).then(function() {
return resolve(meta);
});
});
});
}
const startStamp = Date.now();
module.exports = scanDir(picturesPath);