| "host": "localhost", | "host": "localhost", | ||||
| "user": "dbuser", | "user": "dbuser", | ||||
| "password": "dbpass", | "password": "dbpass", | ||||
| "database": "mimg" | |||||
| "database": "spus" | |||||
| }, | }, | ||||
| "use_https": false, | "use_https": false, | ||||
| "https": { | "https": { | ||||
| "session_timeout": 1800000, | "session_timeout": 1800000, | ||||
| "dir": { | "dir": { | ||||
| "imgs": "imgs" | "imgs": "imgs" | ||||
| } | |||||
| }, | |||||
| "debug": false | |||||
| } | } |
| var formidable = require("formidable"); | var formidable = require("formidable"); | ||||
| var crypto = require("crypto"); | var crypto = require("crypto"); | ||||
| var preprocess = require("./preprocess.js"); | |||||
| var sessions = {}; | var sessions = {}; | ||||
| function templatify(str, args) { | |||||
| function templatify(str, args, ctx) { | |||||
| str = preprocess(str, { | |||||
| session: ctx.session, | |||||
| template: function(key) { | |||||
| return ctx.template(key); | |||||
| } | |||||
| }); | |||||
| if (args == undefined) | if (args == undefined) | ||||
| return str; | return str; | ||||
| setTimeout(function() { | setTimeout(function() { | ||||
| delete sessions[key]; | delete sessions[key]; | ||||
| }, this.conf.session_timeout); | }, this.conf.session_timeout); | ||||
| this.session = sessions[key]; | |||||
| } | } | ||||
| } | } | ||||
| module.exports.prototype = { | module.exports.prototype = { | ||||
| end: function(str) { | end: function(str) { | ||||
| if (this.statusCode) | |||||
| this.res.writeHead(this.statusCode); | |||||
| this.res.end(str); | this.res.end(str); | ||||
| }, | }, | ||||
| obj = obj || {}; | obj = obj || {}; | ||||
| obj.success = true; | obj.success = true; | ||||
| this.res.setHeader("Content-Type", "application/json"); | |||||
| this.end(JSON.stringify(obj)); | this.end(JSON.stringify(obj)); | ||||
| }, | }, | ||||
| fail: function(err) { | fail: function(err) { | ||||
| console.log("Sending error to client:"); | |||||
| console.trace(err); | |||||
| this.res.setHeader("Content-Type", "application/json"); | |||||
| obj = {}; | obj = {}; | ||||
| obj.success = false; | obj.success = false; | ||||
| obj.error = err.toString(); | obj.error = err.toString(); | ||||
| if (!str) | if (!str) | ||||
| throw new Error("No such template: "+name); | throw new Error("No such template: "+name); | ||||
| return templatify(str, args); | |||||
| return templatify(str, args, this); | |||||
| }, | }, | ||||
| view: function(name, args) { | view: function(name, args) { | ||||
| if (!str) | if (!str) | ||||
| throw new Error("No such view: "+name); | throw new Error("No such view: "+name); | ||||
| return templatify(str, args); | |||||
| return templatify(str, args, this); | |||||
| }, | }, | ||||
| getPostData: function(cb) { | getPostData: function(cb) { | ||||
| cb(null, data, files); | cb(null, data, files); | ||||
| }.bind(this)); | }.bind(this)); | ||||
| }, | |||||
| setStatus: function(code) { | |||||
| this.statusCode = code; | |||||
| } | } | ||||
| } | } |
| var fs = require("fs"); | var fs = require("fs"); | ||||
| var minify = require("./minify"); | |||||
| var minify = require("./minify.js"); | |||||
| var preprocess = require("./preprocess.js"); | |||||
| var globalRegex = /{{([^}]+)#([^}]+)}}/g; | |||||
| var localRegex = /{{([^}]+)#([^}]+)}}/; | |||||
| var cache = {}; | |||||
| module.exports = function load(path, conf) { | module.exports = function load(path, conf) { | ||||
| var html = fs.readFileSync(path, "utf8"); | var html = fs.readFileSync(path, "utf8"); | ||||
| var placeholders = html.match(globalRegex); | |||||
| if (!placeholders) | |||||
| return minify.html(html); | |||||
| placeholders.forEach(function(p) { | |||||
| var parts = html.match(localRegex); | |||||
| var s = parts[0]; | |||||
| var ns = parts[1]; | |||||
| var key = parts[2]; | |||||
| switch (ns) { | |||||
| case "conf": | |||||
| html = html.replace(s, conf[key]); | |||||
| break; | |||||
| case "template": | |||||
| html = html.replace(s, load("templates/"+key+".html", conf)); | |||||
| break; | |||||
| var env = { | |||||
| conf: conf.web, | |||||
| template: function(key) { | |||||
| var str = load("templates/"+key+".html", conf); | |||||
| return str; | |||||
| } | } | ||||
| }); | |||||
| } | |||||
| html = preprocess(html, env); | |||||
| return minify.html(html); | |||||
| if (conf.minify) | |||||
| return minify.html(html); | |||||
| else | |||||
| return html; | |||||
| } | } |
| var templates = {}; | var templates = {}; | ||||
| fs.readdirSync("templates").forEach(function(f) { | fs.readdirSync("templates").forEach(function(f) { | ||||
| var name = f.replace(/\.html$/, ""); | var name = f.replace(/\.html$/, ""); | ||||
| res.templates[name] = includeHtml("templates/"+f, conf.web); | |||||
| res.templates[name] = includeHtml("templates/"+f, conf); | |||||
| }); | }); | ||||
| //Prepare all views | //Prepare all views | ||||
| var views = {}; | var views = {}; | ||||
| fs.readdirSync("views").forEach(function(f) { | fs.readdirSync("views").forEach(function(f) { | ||||
| var name = f.replace(/\.html$/, ""); | var name = f.replace(/\.html$/, ""); | ||||
| res.views[name] = includeHtml("views/"+f, conf.web); | |||||
| res.views[name] = includeHtml("views/"+f, conf); | |||||
| }); | }); | ||||
| return res; | return res; |
| var valueRegex = "([a-zA-Z0-9_\\-]+)#([a-zA-Z0-9_\\-]+)"; | |||||
| var regexStr = | |||||
| "{{"+ //{{ | |||||
| valueRegex+ //foo#bar - $1#$2 | |||||
| "(?:"+ //<optional> | |||||
| " \\? "+ //? | |||||
| valueRegex+ //foo#bar - $3#$4 | |||||
| " : "+ //: | |||||
| valueRegex+ //foo#bar - $5#$6 | |||||
| ")?"+ //</optional> | |||||
| "}}"; //}} | |||||
| var localRegex = new RegExp(regexStr); | |||||
| var globalRegex = new RegExp(regexStr, "g"); | |||||
| function getVal(ns, key, env) { | |||||
| var n = env[ns]; | |||||
| if (typeof n === "function") | |||||
| return n(key); | |||||
| else if (n) | |||||
| return n[key]; | |||||
| else | |||||
| throw new Error("Namespace "+ns+" doesn't exist."); | |||||
| } | |||||
| module.exports = function(str, env) { | |||||
| var placeholders = str.match(globalRegex); | |||||
| if (!placeholders) | |||||
| return str; | |||||
| placeholders.forEach(function(p) { | |||||
| var parts = p.match(localRegex); | |||||
| var s = parts[0]; | |||||
| //Ternary | |||||
| if (parts[6]) { | |||||
| try { | |||||
| var cond = getVal(parts[1], parts[2], env); | |||||
| var val1 = getVal(parts[3], parts[4], env); | |||||
| var val2 = getVal(parts[5], parts[6], env); | |||||
| } catch (err) { | |||||
| return; | |||||
| } | |||||
| if (cond === true) | |||||
| str = str.replace(s, val1); | |||||
| else | |||||
| str = str.replace(s, val2); | |||||
| } | |||||
| //Direct value | |||||
| else { | |||||
| try { | |||||
| var val = getVal(parts[1], parts[2], env); | |||||
| } catch (err) { | |||||
| return; | |||||
| } | |||||
| if (val !== undefined && val !== null) | |||||
| str = str.replace(s, val); | |||||
| } | |||||
| }); | |||||
| return str; | |||||
| } |
| "formidable": "^1.0.17", | "formidable": "^1.0.17", | ||||
| "html-minifier": "^0.7.2", | "html-minifier": "^0.7.2", | ||||
| "pg": "^4.4.0", | "pg": "^4.4.0", | ||||
| "scrypt": "^4.0.7", | |||||
| "uglify-js": "^2.4.24", | "uglify-js": "^2.4.24", | ||||
| "uglifycss": "0.0.15" | "uglifycss": "0.0.15" | ||||
| } | } |
| var conf = JSON.parse(fs.readFileSync("./conf.json")); | var conf = JSON.parse(fs.readFileSync("./conf.json")); | ||||
| var sql = fs.readFileSync("sql/reset.sql", "utf8"); | |||||
| var sql = fs.readFileSync("scripts/sql/reset.sql", "utf8"); | |||||
| var client = new pg.Client( | |||||
| "postgres://"+ | |||||
| conf.db.user+":"+ | |||||
| conf.db.pass+"@"+ | |||||
| conf.db.host+"/"+ | |||||
| conf.db.database | |||||
| ); | |||||
| var client = new pg.Client(conf.db); | |||||
| function deleteFiles(dir) { | |||||
| fs.readdirSync(dir).forEach(function(f) { | |||||
| fs.unlinkSync(dir+"/"+f); | |||||
| }); | |||||
| } | |||||
| client.connect(function(err) { | client.connect(function(err) { | ||||
| if (err) { | if (err) { | ||||
| } else { | } else { | ||||
| console.log("Database reset."); | console.log("Database reset."); | ||||
| } | } | ||||
| deleteFiles(conf.dir.imgs); | |||||
| process.exit(); | process.exit(); | ||||
| }); | }); | ||||
| }); | }); |
| var conf = JSON.parse(fs.readFileSync("./conf.json")); | var conf = JSON.parse(fs.readFileSync("./conf.json")); | ||||
| var sql = fs.readFileSync("sql/setup.sql", "utf8"); | |||||
| var sql = fs.readFileSync("scripts/sql/setup.sql", "utf8"); | |||||
| var client = new pg.Client( | |||||
| "postgres://"+ | |||||
| conf.db.user+":"+ | |||||
| conf.db.pass+"@"+ | |||||
| conf.db.host+"/"+ | |||||
| conf.db.database | |||||
| ); | |||||
| var client = new pg.Client(conf.db); | |||||
| client.connect(function(err) { | client.connect(function(err) { | ||||
| if (err) { | if (err) { |
| CREATE TABLE users ( | CREATE TABLE users ( | ||||
| id SERIAL PRIMARY KEY, | id SERIAL PRIMARY KEY, | ||||
| username VARCHAR(64) UNIQUE NOT NULL, | username VARCHAR(64) UNIQUE NOT NULL, | ||||
| pass_hash CHAR(128) NOT NULL, | |||||
| pass_hash VARCHAR(256) NOT NULL, | |||||
| date_created TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW() | date_created TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW() | ||||
| ); | ); | ||||
| CREATE TABLE collections ( | CREATE TABLE collections ( | ||||
| id SERIAL PRIMARY KEY, | id SERIAL PRIMARY KEY, | ||||
| name VARCHAR(64), | name VARCHAR(64), | ||||
| date_created TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), | |||||
| user_id INTEGER REFERENCES users(id) ON DELETE CASCADE | user_id INTEGER REFERENCES users(id) ON DELETE CASCADE | ||||
| ); | ); |
| "/i": "i/index.node.js", | "/i": "i/index.node.js", | ||||
| //API files | //API files | ||||
| "/api/template": "api/template.node.js", | |||||
| "/api/image_create": "api/image_create.node.js", | "/api/image_create": "api/image_create.node.js", | ||||
| "/api/collection_create": "api/collection_create.node.js" | |||||
| "/api/collection_create": "api/collection_create.node.js", | |||||
| "/api/account_create": "api/account_create.node.js", | |||||
| "/api/account_login": "api/account_login.node.js" | |||||
| } | } | ||||
| var loaded = loader.load(endpoints, conf); | var loaded = loader.load(endpoints, conf); | ||||
| function onRequest(req, res) { | function onRequest(req, res) { | ||||
| console.log("Request for "+req.url); | console.log("Request for "+req.url); | ||||
| var ctx = new Context({ | |||||
| req: req, | |||||
| res: res, | |||||
| templates: loaded.templates, | |||||
| views: loaded.views, | |||||
| db: db, | |||||
| conf: conf | |||||
| }); | |||||
| var ep = loaded.endpoints[req.url.split("?")[0]]; | var ep = loaded.endpoints[req.url.split("?")[0]]; | ||||
| //If the file doesn't exist, we 404. | //If the file doesn't exist, we 404. | ||||
| if (!ep) { | if (!ep) { | ||||
| ep = loaded.endpoints["/404"]; | ep = loaded.endpoints["/404"]; | ||||
| res.writeHead(404); | |||||
| ctx.setStatus(404); | |||||
| } | } | ||||
| //Execute if it's a .node.js, or just respond with the contents of the file | //Execute if it's a .node.js, or just respond with the contents of the file | ||||
| if (typeof ep == "function") { | if (typeof ep == "function") { | ||||
| ep(new Context({ | |||||
| req: req, | |||||
| res: res, | |||||
| templates: loaded.templates, | |||||
| views: loaded.views, | |||||
| db: db, | |||||
| conf: conf | |||||
| })); | |||||
| ep(ctx); | |||||
| } else { | } else { | ||||
| res.end(ep); | |||||
| ctx.end(ep); | |||||
| } | } | ||||
| } | } | ||||
| }); | }); | ||||
| //We don't want to crash even if something throws an uncaught exception. | //We don't want to crash even if something throws an uncaught exception. | ||||
| var d = domain.create(); | |||||
| d.on("error", function(err) { | |||||
| console.trace(err); | |||||
| }); | |||||
| process.on("uncaughtException", function(err) { | |||||
| console.trace(err); | |||||
| }); | |||||
| if (!conf.debug) { | |||||
| var d = domain.create(); | |||||
| d.on("error", function(err) { | |||||
| console.trace(err); | |||||
| }); | |||||
| process.on("uncaughtException", function(err) { | |||||
| console.trace(err); | |||||
| }); | |||||
| } |
| <div class="navbar-inner"> | <div class="navbar-inner"> | ||||
| <div class="container-fluid"> | <div class="container-fluid"> | ||||
| <a class="navbar-brand" href="/">{{conf#title}}</a> | <a class="navbar-brand" href="/">{{conf#title}}</a> | ||||
| <ul class="nav navbar-nav navbar-right"> | |||||
| {{profile}} | |||||
| <ul id="navbar-profile-container" class="nav navbar-nav navbar-right"> | |||||
| {{session#loggedIn ? template#navbar-loggedin : template#navbar-login}} | |||||
| </ul> | </ul> | ||||
| </div> | </div> | ||||
| </div> | </div> |
| <li> | |||||
| <a href="/profile?{{session#userId}}">{{session#username}}</a> | |||||
| </li> |
| <form id="login-form"> | <form id="login-form"> | ||||
| <div class="form-group"> | <div class="form-group"> | ||||
| <label>Username<br> | <label>Username<br> | ||||
| <input type="text"> | |||||
| <input type="text" id="login-username"> | |||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <div class="form-group"> | <div class="form-group"> | ||||
| <label>Password<br> | <label>Password<br> | ||||
| <input type="password"> | |||||
| <input type="password" id="login-password"> | |||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <div class="submit-container"> | <div class="submit-container"> | ||||
| <button type="submit" class="btn btn-default">Log In</button> | <button type="submit" class="btn btn-default">Log In</button> | ||||
| <a class="btn btn-default register" href="/register">Register</a> | |||||
| </div> | </div> | ||||
| </form> | </form> | ||||
| </li></ul> | </li></ul> |
| var scrypt = require("scrypt"); | |||||
| module.exports = function(ctx) { | |||||
| ctx.getPostData(function(err, data) { | |||||
| if (err) | |||||
| return ctx.fail(err); | |||||
| if (!data.username || !data.password) | |||||
| return ctx.fail("You must provide a username and a password."); | |||||
| var params = scrypt.params(1); | |||||
| scrypt.hash(new Buffer(data.password), params, function(err, hash) { | |||||
| if (err) | |||||
| return ctx.fail(err); | |||||
| ctx.db.query( | |||||
| "INSERT INTO users (username, pass_hash) "+ | |||||
| "VALUES ($1, $2 )"+ | |||||
| "RETURNING id", | |||||
| [data.username, hash.toString("hex")], | |||||
| queryCallback | |||||
| ); | |||||
| }); | |||||
| }); | |||||
| function queryCallback(err, res) { | |||||
| if (err) | |||||
| return ctx.fail(err); | |||||
| ctx.session.loggedIn = true; | |||||
| ctx.session.userId = res.rows[0].id; | |||||
| ctx.session.username = ctx.postData.username; | |||||
| ctx.succeed({ | |||||
| id: res.rows[0].id | |||||
| }); | |||||
| } | |||||
| } |
| var scrypt = require("scrypt"); | |||||
| module.exports = function(ctx) { | |||||
| ctx.getPostData(function(err, data) { | |||||
| if (err) | |||||
| return ctx.fail(err); | |||||
| if (!data.username || !data.password) | |||||
| return ctx.fail("You must provide a username and a password."); | |||||
| ctx.db.query( | |||||
| "SELECT id, username, pass_hash "+ | |||||
| "FROM users "+ | |||||
| "WHERE username=$1", | |||||
| [data.username], | |||||
| queryCallback | |||||
| ); | |||||
| }); | |||||
| function queryCallback(err, res) { | |||||
| if (err) | |||||
| return ctx.fail(err); | |||||
| var user = res.rows[0]; | |||||
| ctx.session.loggedIn = true; | |||||
| ctx.session.userId = user.id; | |||||
| ctx.session.username = user.username; | |||||
| if (!user) | |||||
| return ctx.fail("Wrong username or password."); | |||||
| scrypt.verify( | |||||
| new Buffer(user.pass_hash, "hex"), | |||||
| new Buffer(ctx.postData.data.password), | |||||
| function(err, success) { | |||||
| if (success) { | |||||
| ctx.succeed({ | |||||
| id: user.id | |||||
| }) | |||||
| } else { | |||||
| ctx.fail("Wrong username or password."); | |||||
| } | |||||
| } | |||||
| ); | |||||
| } | |||||
| } |
| module.exports = function(ctx) { | module.exports = function(ctx) { | ||||
| ctx.getPostData(function(err, data) { | ctx.getPostData(function(err, data) { | ||||
| if (err) return ctx.fail(err); | |||||
| if (err) | |||||
| return ctx.fail(err); | |||||
| ctx.db.query( | ctx.db.query( | ||||
| "INSERT INTO collections (name) "+ | "INSERT INTO collections (name) "+ | ||||
| }); | }); | ||||
| function queryCallback(err, res) { | function queryCallback(err, res) { | ||||
| if (err) return ctx.fail(err); | |||||
| if (err) | |||||
| return ctx.fail(err); | |||||
| ctx.session.collectionId = res.rows[0].id; | ctx.session.collectionId = res.rows[0].id; | ||||
| module.exports = function(ctx) { | |||||
| var name = ctx.req.url.split("?")[1]; | |||||
| if (!name) | |||||
| return ctx.fail("You must supply a template name."); | |||||
| ctx.getPostData(function(err, data) { | |||||
| if (err) | |||||
| return ctx.fail(err); | |||||
| try { | |||||
| ctx.succeed({ | |||||
| html: ctx.template(name, data) | |||||
| }); | |||||
| } catch (err) { | |||||
| ctx.fail(err); | |||||
| } | |||||
| }); | |||||
| } |
| #login-dropdown .submit-container { | #login-dropdown .submit-container { | ||||
| text-align: right; | text-align: right; | ||||
| } | } | ||||
| #login-dropdown .submit-container .btn { | |||||
| width: 75px; | |||||
| } | |||||
| #notify-box { | #notify-box { | ||||
| transition: max-height 0.2s; | transition: max-height 0.2s; | ||||
| border: 1px solid #E7E7E7; | border: 1px solid #E7E7E7; | ||||
| overflow: hidden; | overflow: hidden; | ||||
| line-height: 48px; | |||||
| position: fixed; | position: fixed; | ||||
| bottom: 0px; | bottom: 0px; | ||||
| width: 100%; | width: 100%; | ||||
| box-sizing: content-box; | |||||
| } | } | ||||
| #notify-box .close { | #notify-box .close { | ||||
| } | } | ||||
| #notify-box .title, | #notify-box .title, | ||||
| #notify-box .body { | #notify-box .body { | ||||
| line-height: 48px; | |||||
| margin-left: 12px; | margin-left: 12px; | ||||
| width: calc(100% - 42px); | width: calc(100% - 42px); | ||||
| margin-top: 16px; | |||||
| } | } | ||||
| #notify-box .title { | #notify-box .title { | ||||
| font-weight: bold; | font-weight: bold; | ||||
| } | } | ||||
| #notify-box.active { | #notify-box.active { | ||||
| min-height: 48px; | |||||
| max-height: 48px; | max-height: 48px; | ||||
| } | } | ||||
| #notify-box.active:hover { | #notify-box.active:hover { |
| $("#notify-box").on("mouseenter", function() { | $("#notify-box").on("mouseenter", function() { | ||||
| clearTimeout(util.notify.timeout); | clearTimeout(util.notify.timeout); | ||||
| }); | }); | ||||
| $("#login-form").on("submit", function(evt) { | |||||
| evt.stopPropagation(); | |||||
| evt.preventDefault(); | |||||
| util.notify("Feature Not Implemented", "This feature is not implemented."); | |||||
| }); | |||||
| }); | }); | ||||
| util.error = function(body) { | util.error = function(body) { | ||||
| util.notify("An error occurred.", body); | |||||
| util.notify("Error: "+body); | |||||
| } | } | ||||
| util.htmlEntities = function(str) { | util.htmlEntities = function(str) { | ||||
| }).done(function(res) { | }).done(function(res) { | ||||
| console.log("response from "+name+":"); | console.log("response from "+name+":"); | ||||
| console.log(res); | console.log(res); | ||||
| var obj = JSON.parse(res); | |||||
| if (obj.success) | |||||
| cb(null, obj); | |||||
| if (res.success) | |||||
| cb(null, res); | |||||
| else | else | ||||
| cb(obj.error); | |||||
| cb(res.error); | |||||
| }); | }); | ||||
| } | } | ||||
| n -= 1; | n -= 1; | ||||
| } | } | ||||
| } | } | ||||
| window.display = {}; | |||||
| window.display.loggedIn = function() { | |||||
| util.api("template?navbar-loggedin", {}, function(err, res) { | |||||
| if (err) | |||||
| return util.error(err); | |||||
| $("#navbar-profile-container").html(res.html); | |||||
| }); | |||||
| } | |||||
| $(document).ready(function() { | |||||
| $("#login-form").on("submit", function(evt) { | |||||
| evt.stopPropagation(); | |||||
| evt.preventDefault(); | |||||
| var username = $("#login-username").val(); | |||||
| var password = $("#login-password").val(); | |||||
| util.api("account_login", { | |||||
| username: username, | |||||
| password: password | |||||
| }, function(err, res) { | |||||
| if (err) | |||||
| util.error(err); | |||||
| else | |||||
| display.loggedIn(); | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| })(); | })(); |
| module.exports = function(ctx) { | module.exports = function(ctx) { | ||||
| ctx.end(ctx.view("index", { | |||||
| profile: ctx.template("navbar-profile-login") | |||||
| })); | |||||
| ctx.end(ctx.view("index")); | |||||
| } | } |
| util.api("collection_create", { | util.api("collection_create", { | ||||
| name: "New Collection" | name: "New Collection" | ||||
| }, function(err, res) { | }, function(err, res) { | ||||
| if (err) return util.error(err); | |||||
| if (err) | |||||
| return util.error(err); | |||||
| var collectionId = res.id; | var collectionId = res.id; | ||||
| collectionId: collectionId, | collectionId: collectionId, | ||||
| file: f | file: f | ||||
| }, function(err, res) { | }, function(err, res) { | ||||
| if (err) return util.error(err); | |||||
| if (err) | |||||
| return util.error(err); | |||||
| a(); | a(); | ||||
| }, getXhr); | }, getXhr); |
| }); | }); | ||||
| ctx.end(ctx.view("view", { | ctx.end(ctx.view("view", { | ||||
| profile: ctx.template("navbar-profile-login"), | |||||
| images: images | images: images | ||||
| })); | })); | ||||
| } | } |