diff --git a/core/bbs.js b/core/bbs.js index d1ea390f..b0f64838 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -76,9 +76,6 @@ function bbsMain() { return callback(err); }); }, - function listenConnections(callback) { - return startListening(callback); - } ], function complete(err) { // note this is escaped: @@ -113,6 +110,12 @@ function shutdownSystem() { } callback(null); }, + function stopListeningServers(callback) { + return require('./listening_server.js').shutdown( () => { + // :TODO: log err + return callback(null); // ignore err + }); + }, function stopEventScheduler(callback) { if(initServices.eventScheduler) { return initServices.eventScheduler.shutdown( () => { @@ -122,6 +125,12 @@ function shutdownSystem() { return callback(null); } }, + function stopFileAreaWeb(callback) { + require('./file_area_web.js').startup(err => { + // :TODO: Log me if err + return callback(null); + }); + }, function stopMsgNetwork(callback) { require('./msg_network.js').shutdown(callback); } @@ -222,6 +231,12 @@ function initialize(cb) { function readyMessageNetworkSupport(callback) { return require('./msg_network.js').startup(callback); }, + function listenConnections(callback) { + return require('./listening_server.js').startup(callback); + }, + function readyFileAreaWeb(callback) { + return require('./file_area_web.js').startup(callback); + }, function readyEventScheduler(callback) { const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule; EventSchedulerModule.loadAndStart( (err, modInst) => { @@ -235,118 +250,3 @@ function initialize(cb) { } ); } - -function startListening(cb) { - if(!conf.config.loginServers) { - // :TODO: Log error ... output to stderr as well. We can do it all with the logger - return cb(new Error('No login servers configured')); - } - - const moduleUtil = require('./module_util.js'); // late load so we get Config - - moduleUtil.loadModulesForCategory('loginServers', (err, module) => { - if(err) { - if('EENIGMODDISABLED' === err.code) { - logger.log.debug(err.message); - } else { - logger.log.info( { err : err }, 'Failed loading module'); - } - return; - } - - const port = parseInt(module.runtime.config.port); - if(isNaN(port)) { - logger.log.error( { port : module.runtime.config.port, server : module.moduleInfo.name }, 'Cannot load server (Invalid port)'); - return; - } - - const moduleInst = new module.getModule(); - let server; - try { - server = moduleInst.createServer(); - } catch(e) { - logger.log.warn(e, 'Exception caught creating server!'); - return; - } - - // :TODO: handle maxConnections, e.g. conf.maxConnections - - server.on('client', function newClient(client, clientSock) { - // - // Start tracking the client. We'll assign it an ID which is - // just the index in our connections array. - // - if(_.isUndefined(client.session)) { - client.session = {}; - } - - client.session.serverName = module.moduleInfo.name; - client.session.isSecure = module.moduleInfo.isSecure || false; - - clientConns.addNewClient(client, clientSock); - - client.on('ready', function clientReady(readyOptions) { - - client.startIdleMonitor(); - - // Go to module -- use default error handler - prepareClient(client, function clientPrepared() { - require('./connect.js').connectEntry(client, readyOptions.firstMenu); - }); - }); - - client.on('end', function onClientEnd() { - clientConns.removeClient(client); - }); - - client.on('error', function onClientError(err) { - logger.log.info({ clientId : client.session.id }, 'Connection error: %s' % err.message); - }); - - client.on('close', function onClientClose(hadError) { - const logFunc = hadError ? logger.log.info : logger.log.debug; - logFunc( { clientId : client.session.id }, 'Connection closed'); - - clientConns.removeClient(client); - }); - - client.on('idle timeout', function idleTimeout() { - client.log.info('User idle timeout expired'); - - client.menuStack.goto('idleLogoff', function goMenuRes(err) { - if(err) { - // likely just doesn't exist - client.term.write('\nIdle timeout expired. Goodbye!\n'); - client.end(); - } - }); - }); - }); - - server.on('error', function serverErr(err) { - logger.log.info(err); // 'close' should be handled after - }); - - server.listen(port); - - logger.log.info( - { server : module.moduleInfo.name, port : port }, 'Listening for connections'); - }, err => { - cb(err); - }); -} - -function prepareClient(client, cb) { - const theme = require('./theme.js'); - - // :TODO: it feels like this should go somewhere else... and be a bit more elegant. - - if('*' === conf.config.preLoginTheme) { - client.user.properties.theme_id = theme.getRandomTheme() || ''; - } else { - client.user.properties.theme_id = conf.config.preLoginTheme; - } - - theme.setClientTheme(client, client.user.properties.theme_id); - return cb(null); // note: currently useless to use cb here - but this may change...again... -} \ No newline at end of file diff --git a/core/client_connections.js b/core/client_connections.js index e4a5c5ff..2b3c1714 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -12,6 +12,7 @@ exports.getActiveConnections = getActiveConnections; exports.getActiveNodeList = getActiveNodeList; exports.addNewClient = addNewClient; exports.removeClient = removeClient; +exports.getConnectionByUserId = getConnectionByUserId; const clientConnections = []; exports.clientConnections = clientConnections; @@ -93,3 +94,7 @@ function removeClient(client) { ); } } + +function getConnectionByUserId(userId) { + return getActiveConnections().find( ac => userId === ac.user.userId ); +} \ No newline at end of file diff --git a/core/config.js b/core/config.js index 27721b0c..929cd5a9 100644 --- a/core/config.js +++ b/core/config.js @@ -211,6 +211,23 @@ function getDefaultConfig() { } }, + contentServers : { + web : { + domain : 'another-fine-enigma-bbs.org', + + http : { + enabled : false, + port : 8080, + }, + https : { + enabled : false, + port : 8443, + certPem : paths.join(__dirname, './../misc/https_cert.pem'), + keyPem : paths.join(__dirname, './../misc/https_cert_key.pem'), + } + } + }, + archives : { archivers : { '7Zip' : { @@ -362,6 +379,12 @@ function getDefaultConfig() { // :TODO: DD/MMM/YY, DD/MMMM/YY, DD/MMM/YYYY, etc. ], + web : { + path : '/f/', + routePath : '/f/[a-zA-Z0-9]+$', + expireMinutes : 1440, // 1 day + }, + areas: { message_attachment : { name : 'Message attachments', diff --git a/core/config_cache.js b/core/config_cache.js index 41bdef16..fa80c19d 100644 --- a/core/config_cache.js +++ b/core/config_cache.js @@ -40,11 +40,11 @@ function ConfigCache() { this.gaze.on('changed', function fileChanged(filePath) { assert(filePath in self.cache); - Log.info( { filePath : filePath }, 'Configuration file changed; recaching'); + Log.info( { path : filePath }, 'Configuration file changed; re-caching'); self.reCacheConfigFromFile(filePath, function reCached(err) { if(err) { - Log.error( { error : err, filePath : filePath } , 'Error recaching HJSON config'); + Log.error( { error : err.message, path : filePath } , 'Failed re-caching configuration'); } else { self.emit('recached', filePath); } diff --git a/core/database.js b/core/database.js index 608c263a..e5496ce4 100644 --- a/core/database.js +++ b/core/database.js @@ -333,5 +333,12 @@ const DB_INIT_TABLE = { UNIQUE(hash_tag_id, file_id) );` ); + + dbs.file.run( + `CREATE TABLE IF NOT EXISTS file_web_serve ( + hash_id VARCHAR NOT NULL PRIMARY KEY, + expire_timestamp DATETIME NOT NULL + );` + ); } }; \ No newline at end of file diff --git a/core/download_queue.js b/core/download_queue.js index 6b909643..7554e885 100644 --- a/core/download_queue.js +++ b/core/download_queue.js @@ -4,22 +4,22 @@ const FileEntry = require('./file_entry.js'); module.exports = class DownloadQueue { - constructor(user) { - this.user = user; + constructor(client) { + this.client = client; - this.user.downloadQueue = this.user.downloadQueue || []; + this.loadFromProperty(client); } toggle(fileEntry) { if(this.isQueued(fileEntry)) { - this.user.downloadQueue = this.user.downloadQueue.filter(e => fileEntry.fileId !== e.fileId); + this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => fileEntry.fileId !== e.fileId); } else { this.add(fileEntry); } } add(fileEntry) { - this.user.downloadQueue.push({ + this.client.user.downloadQueue.push({ fileId : fileEntry.fileId, areaTag : fileEntry.areaTag, fileName : fileEntry.fileName, @@ -32,16 +32,18 @@ module.exports = class DownloadQueue { entryOrId = entryOrId.fileId; } - return this.user.downloadQueue.find(e => entryOrId === e.fileId) ? true : false; + return this.client.user.downloadQueue.find(e => entryOrId === e.fileId) ? true : false; } - toProperty() { return JSON.stringify(this.user.downloadQueue); } + toProperty() { return JSON.stringify(this.client.user.downloadQueue); } loadFromProperty(prop) { try { - this.user.downloadQueue = JSON.parse(prop); + this.client.user.downloadQueue = JSON.parse(prop); } catch(e) { - this.user.log.error( { error : e.message, property : prop }, 'Failed parsing download queue property'); + this.client.user.downloadQueue = []; + + this.client.log.error( { error : e.message, property : prop }, 'Failed parsing download queue property'); } } }; diff --git a/core/file_area.js b/core/file_area.js index 46cb0016..bd5cc336 100644 --- a/core/file_area.js +++ b/core/file_area.js @@ -21,8 +21,9 @@ const iconv = require('iconv-lite'); exports.getAvailableFileAreas = getAvailableFileAreas; exports.getSortedAvailableFileAreas = getSortedAvailableFileAreas; -exports.getDefaultFileAreaTag = getDefaultFileAreaTag; +exports.getDefaultFileAreaTag = getDefaultFileAreaTag; exports.getFileAreaByTag = getFileAreaByTag; +exports.getFileEntryPath = getFileEntryPath; exports.changeFileAreaWithOptions = changeFileAreaWithOptions; //exports.addOrUpdateFileEntry = addOrUpdateFileEntry; exports.scanFileAreaForChanges = scanFileAreaForChanges; @@ -46,15 +47,7 @@ function getAvailableFileAreas(client, options) { } function getSortedAvailableFileAreas(client, options) { - const areas = _.map(getAvailableFileAreas(client, options), (v, k) => { - const areaInfo = { - areaTag : k, - area : v - }; - - return areaInfo; - }); - + const areas = _.map(getAvailableFileAreas(client, options), v => v); sortAreasOrConfs(areas, 'area'); return areas; } @@ -124,6 +117,13 @@ function getAreaStorageDirectory(areaInfo) { return paths.join(Config.fileBase.areaStoragePrefix, areaInfo.storageDir || ''); } +function getFileEntryPath(fileEntry) { + const areaInfo = getFileAreaByTag(fileEntry.areaTag); + if(areaInfo) { + return paths.join(areaInfo.storageDirectory, fileEntry.fileName); + } +} + function getExistingFileEntriesBySha1(sha1, cb) { const entries = []; diff --git a/core/file_area_web.js b/core/file_area_web.js new file mode 100644 index 00000000..3975390b --- /dev/null +++ b/core/file_area_web.js @@ -0,0 +1,230 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Config = require('./config.js').config; +const FileDb = require('./database.js').dbs.file; +const getISOTimestampString = require('./database.js').getISOTimestampString; +const FileEntry = require('./file_entry.js'); +const getServer = require('./listening_server.js').getServer; +const Errors = require('./enig_error.js').Errors; + +// deps +const hashids = require('hashids'); +const moment = require('moment'); +const paths = require('path'); +const async = require('async'); +const fs = require('fs'); +const mimeTypes = require('mime-types'); + +const WEB_SERVER_PACKAGE_NAME = 'codes.l33t.enigma.web.server'; + + /* + :TODO: + * Load temp download URLs @ startup & set expire timers via scheduler. + * At creation, set expire timer via scheduler + * + */ + +class FileAreaWebAccess { + constructor() { + this.hashids = new hashids(Config.general.boardName); + } + + startup(cb) { + const self = this; + + async.series( + [ + function initFromDb(callback) { + // :TODO: Init from DB & register expiration timers + return callback(null); + }, + function addWebRoute(callback) { + const webServer = getServer(WEB_SERVER_PACKAGE_NAME); + if(!webServer) { + return callback(Errors.DoesNotExist(`Server with package name "${WEB_SERVER_PACKAGE_NAME}" does not exist`)); + } + + const routeAdded = webServer.instance.addRoute({ + method : 'GET', + path : '/f/[a-zA-Z0-9]+$', // :TODO: allow this to be configurable + handler : self.routeWebRequest.bind(self), + }); + + return callback(routeAdded ? null : Errors.General('Failed adding route')); + } + ], + err => { + return cb(err); + } + ); + } + + shutdown(cb) { + return cb(null); + } + + load(cb) { + return cb(null); // :TODO: Load from db + } + + loadServedHashId(hashId, cb) { + FileDb.get( + `SELECT expire_timestamp FROM + file_web_serve + WHERE hash_id = ?`, + [ hashId ], + (err, result) => { + if(err) { + return cb(err); + } + + const decoded = this.hashids.decode(hashId); + if(!result || 2 !== decoded.length) { + return cb(Errors.Invalid('Invalid or unknown hash ID')); + } + + return cb( + null, + { + hashId : hashId, + userId : decoded[0], + fileId : decoded[1], + expireTimestamp : moment(result.expire_timestamp), + } + ); + } + ); + } + + getHashId(client, fileEntry) { + // + // Hashid is a unique combination of userId & fileId + // + return this.hashids.encode(client.user.userId, fileEntry.fileId); + } + + buildTempDownloadLink(client, fileEntry, hashId) { + hashId = hashId || this.getHashId(client, fileEntry); + + // + // Create a URL such as + // https://l33t.codes:44512/f/qFdxyZr + // + // :TODO: build from config + + // + // Prefer HTTPS over HTTP. Be explicit about the port + // only if required. + // + let schema; + let port; + if(Config.contentServers.web.https.enabled) { + schema = 'https://'; + port = (443 === Config.contentServers.web.https.port) ? + '' : + `:${Config.contentServers.web.https.port}`; + } else { + schema = 'http://'; + port = (80 === Config.contentServers.web.http.port) ? + '' : + `:${Config.contentServers.web.http.port}`; + } + + return `${schema}${Config.contentServers.web.domain}${port}${Config.fileBase.web.path}${hashId}`; + } + + getExistingTempDownloadServeItem(client, fileEntry, cb) { + const hashId = this.getHashId(client, fileEntry); + this.loadServedHashId(hashId, (err, servedItem) => { + if(err) { + return cb(err); + } + + servedItem.url = this.buildTempDownloadLink(client, fileEntry); + + return cb(null, servedItem); + }); + } + + createAndServeTempDownload(client, fileEntry, options, cb) { + const hashId = this.getHashId(client, fileEntry); + const url = this.buildTempDownloadLink(client, fileEntry, hashId); + options.expireTime = options.expireTime || moment().add(2, 'days'); + + // add/update rec with hash id and (latest) timestamp + FileDb.run( + `REPLACE INTO file_web_serve (hash_id, expire_timestamp) + VALUES (?, ?);`, + [ hashId, getISOTimestampString(options.expireTime) ], + err => { + if(err) { + return cb(err); + } + + // :TODO: setup tracking of expiration time so we can clean up the entry + + return cb(null, url); + } + ); + } + + fileNotFound(resp) { + resp.writeHead(404, { 'Content-Type' : 'text/html' } ); + + // :TODO: allow custom 404 + return resp.end('Not found'); + } + + routeWebRequest(req, resp) { + const hashId = paths.basename(req.url); + + this.loadServedHashId(hashId, (err, servedItem) => { + + if(err) { + return this.fileNotFound(resp); + } + + const fileEntry = new FileEntry(); + fileEntry.load(servedItem.fileId, err => { + if(err) { + return this.fileNotFound(resp); + } + + const filePath = fileEntry.filePath; + if(!filePath) { + return this.fileNotFound(resp); + } + + fs.stat(filePath, (err, stats) => { + if(err) { + return this.fileNotFound(resp); + } + + resp.on('close', () => { + // connection closed *before* the response was fully sent + // :TODO: Log and such + }); + + resp.on('finish', () => { + // transfer completed fully + // :TODO: we need to update the users stats - bytes xferred, credit stuff, etc. + }); + + const headers = { + 'Content-Type' : mimeTypes.contentType(paths.extname(filePath)) || mimeTypes.contentType('.bin'), + 'Content-Length' : stats.size, + 'Content-Disposition' : `attachment; filename="${fileEntry.fileName}"`, + }; + + const readStream = fs.createReadStream(filePath); + resp.writeHead(200, headers); + return readStream.pipe(resp); + }); + }); + }); + } +} + +module.exports = new FileAreaWebAccess(); \ No newline at end of file diff --git a/core/file_base_filter.js b/core/file_base_filter.js new file mode 100644 index 00000000..10628747 --- /dev/null +++ b/core/file_base_filter.js @@ -0,0 +1,88 @@ +/* jslint node: true */ +'use strict'; + +const _ = require('lodash'); +const uuids = require('node-uuid'); + +module.exports = class FileBaseFilters { + constructor(client) { + this.client = client; + + this.load(); + } + + static get OrderByValues() { + return [ 'ascending', 'descending' ]; + } + + static get SortByValues() { + return [ + 'upload_timestamp', + 'upload_by_username', + 'dl_count', + 'user_rating', + 'est_release_year', + 'byte_size', + ]; + } + + toArray() { + return _.map(this.filters, (filter, uuid) => { + return Object.assign( { uuid : uuid }, filter ); + }); + } + + get(filterUuid) { + return this.filters[filterUuid]; + } + + add(filterInfo) { + const filterUuid = uuids.v4(); + + filterInfo.tags = this.cleanTags(filterInfo.tags); + + this.filters[filterUuid] = filterInfo; + + return filterUuid; + } + + remove(filterUuid) { + delete this.filters[filterUuid]; + } + + load(prop) { + prop = prop || this.client.user.properties.file_base_filters; + + try { + this.filters = JSON.parse(prop); + } catch(e) { + this.filters = {}; + + this.client.log.error( { error : e.message, property : prop }, 'Failed parsing file base filters property' ); + } + } + + persist(cb) { + return this.client.user.persistProperty('file_base_filters', JSON.stringify(this.filters), cb); + } + + cleanTags(tags) { + return tags.toLowerCase().replace(/,?\s+|\,/g, ' ').trim(); + } + + setActive(filterUuid) { + const activeFilter = this.get(filterUuid); + + if(activeFilter) { + this.activeFilter = activeFilter; + this.client.user.persistProperty('file_base_filter_active_uuid', filterUuid); + return true; + } + + return false; + } + + static getActiveFilter(client) { + return new FileBaseFilters(client).get(client.user.properties.file_base_filter_active_uuid); + } +}; diff --git a/core/file_entry.js b/core/file_entry.js index dd656442..17d2995f 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -3,11 +3,13 @@ const fileDb = require('./database.js').dbs.file; const Errors = require('./enig_error.js').Errors; -const getISOTimestampString = require('./database.js').getISOTimestampString; +const getISOTimestampString = require('./database.js').getISOTimestampString; +const Config = require('./config.js').config; // deps const async = require('async'); const _ = require('lodash'); +const paths = require('path'); const FILE_TABLE_MEMBERS = [ 'file_id', 'area_tag', 'file_sha1', 'file_name', @@ -130,6 +132,17 @@ module.exports = class FileEntry { ); } + get filePath() { + const areaInfo = Config.fileAreas.areas[this.areaTag]; + if(areaInfo) { + return paths.join( + Config.fileBase.areaStoragePrefix, + areaInfo.storageDir || '', + this.fileName + ); + } + } + static persistMetaValue(fileId, name, value, cb) { fileDb.run( `REPLACE INTO file_meta (file_id, meta_name, meta_value) diff --git a/core/listening_server.js b/core/listening_server.js new file mode 100644 index 00000000..e1009338 --- /dev/null +++ b/core/listening_server.js @@ -0,0 +1,65 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const logger = require('./logger.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +const listeningServers = {}; // packageName -> info + +exports.startup = startup; +exports.shutdown = shutdown; +exports.getServer = getServer; + +function startup(cb) { + return startListening(cb); +} + +function shutdown(cb) { + return cb(null); +} + +function getServer(packageName) { + return listeningServers[packageName]; +} + +function startListening(cb) { + const moduleUtil = require('./module_util.js'); // late load so we get Config + + async.each( [ 'login', 'content' ], (category, next) => { + moduleUtil.loadModulesForCategory(`${category}Servers`, (err, module) => { + // :TODO: use enig error here! + if(err) { + if('EENIGMODDISABLED' === err.code) { + logger.log.debug(err.message); + } else { + logger.log.info( { err : err }, 'Failed loading module'); + } + return; + } + + const moduleInst = new module.getModule(); + try { + moduleInst.createServer(); + if(!moduleInst.listen()) { + throw new Error('Failed listening'); + } + + listeningServers[module.moduleInfo.packageName] = { + instance : moduleInst, + info : module.moduleInfo, + }; + + } catch(e) { + logger.log.error(e, 'Exception caught creating server!'); + } + }, err => { + return next(err); + }); + }, err => { + return cb(err); + }); +} diff --git a/core/login_server_module.js b/core/login_server_module.js new file mode 100644 index 00000000..4f003982 --- /dev/null +++ b/core/login_server_module.js @@ -0,0 +1,87 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const conf = require('./config.js'); +const logger = require('./logger.js'); +const ServerModule = require('./server_module.js').ServerModule; +const clientConns = require('./client_connections.js'); + +// deps +const _ = require('lodash'); + +module.exports = class LoginServerModule extends ServerModule { + constructor() { + super(); + } + + // :TODO: we need to max connections -- e.g. from config 'maxConnections' + + prepareClient(client, cb) { + const theme = require('./theme.js'); + + // + // Choose initial theme before we have user context + // + if('*' === conf.config.preLoginTheme) { + client.user.properties.theme_id = theme.getRandomTheme() || ''; + } else { + client.user.properties.theme_id = conf.config.preLoginTheme; + } + + theme.setClientTheme(client, client.user.properties.theme_id); + return cb(null); // note: currently useless to use cb here - but this may change...again... + } + + handleNewClient(client, clientSock, modInfo) { + // + // Start tracking the client. We'll assign it an ID which is + // just the index in our connections array. + // + if(_.isUndefined(client.session)) { + client.session = {}; + } + + client.session.serverName = modInfo.name; + client.session.isSecure = modInfo.isSecure || false; + + clientConns.addNewClient(client, clientSock); + + client.on('ready', readyOptions => { + + client.startIdleMonitor(); + + // Go to module -- use default error handler + this.prepareClient(client, () => { + require('./connect.js').connectEntry(client, readyOptions.firstMenu); + }); + }); + + client.on('end', () => { + clientConns.removeClient(client); + }); + + client.on('error', err => { + logger.log.info({ clientId : client.session.id }, 'Connection error: %s' % err.message); + }); + + client.on('close', err => { + const logFunc = err ? logger.log.info : logger.log.debug; + logFunc( { clientId : client.session.id }, 'Connection closed'); + + clientConns.removeClient(client); + }); + + client.on('idle timeout', () => { + client.log.info('User idle timeout expired'); + + client.menuStack.goto('idleLogoff', err => { + if(err) { + // likely just doesn't exist + client.term.write('\nIdle timeout expired. Goodbye!\n'); + client.end(); + } + }); + }); + } +}; diff --git a/core/module_util.js b/core/module_util.js index 651a2a8a..9834fe5a 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -59,9 +59,6 @@ function loadModuleEx(options, cb) { return cb(new Error('Invalid or missing "getModule" method for module!')); } - // Ref configuration, if any, for convience to the module - mod.runtime = { config : modConfig }; - return cb(null, mod); } @@ -89,7 +86,7 @@ function loadModulesForCategory(category, iterator, complete) { }); async.each(jsModules, (file, next) => { - loadModule(paths.basename(file, '.js'), category, (err, mod) => { ++ loadModule(paths.basename(file, '.js'), category, (err, mod) => { iterator(err, mod); return next(); }); diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 985225bb..aadd824f 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -8,6 +8,7 @@ const getMessageAreaByTag = require('./message_area.js').getMessageAreaByTag; const getMessageConferenceByTag = require('./message_area.js').getMessageConferenceByTag; const clientConnections = require('./client_connections.js'); const StatLog = require('./stat_log.js'); +const FileBaseFilters = require('./file_base_filter.js'); // deps const packageJson = require('../package.json'); @@ -80,6 +81,10 @@ function getPredefinedMCIValue(client, code) { ND : function connectedNode() { return client.node.toString(); }, IP : function clientIpAddress() { return client.address().address; }, ST : function serverName() { return client.session.serverName; }, + FN : function activeFileBaseFilterName() { + const activeFilter = FileBaseFilters.getActiveFilter(client); + return activeFilter ? activeFilter.name : ''; + }, MS : function accountCreated() { return moment(client.user.properties.account_created).format(client.currentTheme.helpers.getDateFormat()); }, CS : function currentStatus() { return client.currentStatus; }, diff --git a/core/servers/content/web.js b/core/servers/content/web.js new file mode 100644 index 00000000..6a184284 --- /dev/null +++ b/core/servers/content/web.js @@ -0,0 +1,123 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Log = require('../../logger.js').log; +const ServerModule = require('../../server_module.js').ServerModule; +const Config = require('../../config.js').config; + +// deps +const http = require('http'); +const https = require('https'); +const _ = require('lodash'); +const fs = require('fs'); + +const ModuleInfo = exports.moduleInfo = { + name : 'Web', + desc : 'Web Server', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.web.server', +}; + +class Route { + constructor(route) { + Object.assign(this, route); + + if(this.method) { + this.method = this.method.toUpperCase(); + } + + try { + this.pathRegExp = new RegExp(this.path); + } catch(e) { + Log.debug( { route : route }, 'Invalid regular expression for route path' ); + } + } + + isValid() { + return ( + this.pathRegExp instanceof RegExp && + ( -1 !== [ 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', ].indexOf(this.method) ) || + !_.isFunction(this.handler) + ); + } + + matchesRequest(req) { return req.method === this.method && this.pathRegExp.test(req.url); } + + getRouteKey() { return `${this.method}:${this.path}`; } +} + +exports.getModule = class WebServerModule extends ServerModule { + constructor() { + super(); + + this.enableHttp = Config.contentServers.web.http.enabled || true; + this.enableHttps = Config.contentServers.web.https.enabled || false; + + this.routes = {}; + } + + createServer() { + if(this.enableHttp) { + this.httpServer = http.createServer( (req, resp) => this.routeRequest(req, resp) ); + } + + if(this.enableHttps) { + const options = { + cert : fs.readFileSync(Config.contentServers.web.https.certPem), + key : fs.readFileSync(Config.contentServers.web.https.keyPem), + }; + + // additional options + Object.assign(options, Config.contentServers.web.https.options || {} ); + + this.httpsServer = https.createServer(options, this.routeRequest); + } + } + + listen() { + let ok = true; + + [ 'http', 'https' ].forEach(service => { + const name = `${service}Server`; + if(this[name]) { + const port = parseInt(Config.contentServers.web[service].port); + if(isNaN(port)) { + ok = false; + return Log.warn( { port : Config.contentServers.web[service].port, server : ModuleInfo.name }, `Invalid port (${service})` ); + } + return this[name].listen(port); + } + }); + + return ok; + } + + addRoute(route) { + route = new Route(route); + + if(!route.isValid()) { + Log( { route : route }, 'Cannot add route: missing or invalid required members' ); + return false; + } + + const routeKey = route.getRouteKey(); + if(routeKey in this.routes) { + Log( { route : route }, 'Cannot add route: duplicate method/path combination exists' ); + return false; + } + + this.routes[routeKey] = route; + return true; + } + + routeRequest(req, resp) { + const route = _.find(this.routes, r => r.matchesRequest(req) ); + return route ? route.handler(req, resp) : this.accessDenied(resp); + } + + accessDenied(resp) { + resp.writeHead(401, { 'Content-Type' : 'text/html' } ); + return resp.end('Access denied'); + } +} \ No newline at end of file diff --git a/core/servers/login/ssh.js b/core/servers/login/ssh.js index 1abd1e84..51a2da2f 100644 --- a/core/servers/login/ssh.js +++ b/core/servers/login/ssh.js @@ -2,14 +2,14 @@ 'use strict'; // ENiGMA½ -const Config = require('../../config.js').config; -const baseClient = require('../../client.js'); -const Log = require('../../logger.js').log; -const ServerModule = require('../../server_module.js').ServerModule; -const userLogin = require('../../user_login.js').userLogin; -const enigVersion = require('../../../package.json').version; -const theme = require('../../theme.js'); -const stringFormat = require('../../string_format.js'); +const Config = require('../../config.js').config; +const baseClient = require('../../client.js'); +const Log = require('../../logger.js').log; +const LoginServerModule = require('../../login_server_module.js'); +const userLogin = require('../../user_login.js').userLogin; +const enigVersion = require('../../../package.json').version; +const theme = require('../../theme.js'); +const stringFormat = require('../../string_format.js'); // deps const ssh2 = require('ssh2'); @@ -18,15 +18,14 @@ const util = require('util'); const _ = require('lodash'); const assert = require('assert'); -exports.moduleInfo = { +const ModuleInfo = exports.moduleInfo = { name : 'SSH', desc : 'SSH Server', author : 'NuSkooler', isSecure : true, + packageName : 'codes.l33t.enigma.ssh.server', }; -exports.getModule = SSHServerModule; - function SSHClient(clientConn) { baseClient.Client.apply(this, arguments); @@ -226,40 +225,45 @@ util.inherits(SSHClient, baseClient.Client); SSHClient.ValidAuthMethods = [ 'password', 'keyboard-interactive' ]; -function SSHServerModule() { - ServerModule.call(this); -} +exports.getModule = class SSHServerModule extends LoginServerModule { + constructor() { + super(); + } -util.inherits(SSHServerModule, ServerModule); + createServer() { + const serverConf = { + hostKeys : [ + { + key : fs.readFileSync(Config.loginServers.ssh.privateKeyPem), + passphrase : Config.loginServers.ssh.privateKeyPass, + } + ], + ident : 'enigma-bbs-' + enigVersion + '-srv', + + // Note that sending 'banner' breaks at least EtherTerm! + debug : (sshDebugLine) => { + if(true === Config.loginServers.ssh.traceConnections) { + Log.trace(`SSH: ${sshDebugLine}`); + } + }, + }; -SSHServerModule.prototype.createServer = function() { - SSHServerModule.super_.prototype.createServer.call(this); + this.server = ssh2.Server(serverConf); + this.server.on('connection', (conn, info) => { + Log.info(info, 'New SSH connection'); + this.handleNewClient(new SSHClient(conn), conn._sock, ModuleInfo); + }); + } - const serverConf = { - hostKeys : [ - { - key : fs.readFileSync(Config.loginServers.ssh.privateKeyPem), - passphrase : Config.loginServers.ssh.privateKeyPass, - } - ], - ident : 'enigma-bbs-' + enigVersion + '-srv', - - // Note that sending 'banner' breaks at least EtherTerm! - debug : (sshDebugLine) => { - if(true === Config.loginServers.ssh.traceConnections) { - Log.trace(`SSH: ${sshDebugLine}`); - } - }, - }; + listen() { + const port = parseInt(Config.loginServers.ssh.port); + if(isNaN(port)) { + Log.error( { server : ModuleInfo.name, port : Config.loginServers.ssh.port }, 'Cannot load server (invalid port)' ); + return false; + } - const server = ssh2.Server(serverConf); - server.on('connection', function onConnection(conn, info) { - Log.info(info, 'New SSH connection'); - - const client = new SSHClient(conn); - - this.emit('client', client, conn._sock); - }); - - return server; + this.server.listen(port); + Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' ); + return true; + } }; diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js index 7573e62e..406e28a5 100644 --- a/core/servers/login/telnet.js +++ b/core/servers/login/telnet.js @@ -2,10 +2,10 @@ 'use strict'; // ENiGMA½ -const baseClient = require('../../client.js'); -const Log = require('../../logger.js').log; -const ServerModule = require('../../server_module.js').ServerModule; -const Config = require('../../config.js').config; +const baseClient = require('../../client.js'); +const Log = require('../../logger.js').log; +const LoginServerModule = require('../../login_server_module.js'); +const Config = require('../../config.js').config; // deps const net = require('net'); @@ -16,16 +16,14 @@ const util = require('util'); //var debug = require('debug')('telnet'); -exports.moduleInfo = { +const ModuleInfo = exports.moduleInfo = { name : 'Telnet', desc : 'Telnet Server', author : 'NuSkooler', isSecure : false, + packageName : 'codes.l33t.enigma.telnet.server', }; -exports.getModule = TelnetServerModule; - - // // Telnet Protocol Resources // * http://pcmicro.com/netfoss/telnet.html @@ -767,22 +765,34 @@ Object.keys(OPTIONS).forEach(function(name) { }); }); -function TelnetServerModule() { - ServerModule.call(this); -} +exports.getModule = class TelnetServerModule extends LoginServerModule { + constructor() { + super(); + } -util.inherits(TelnetServerModule, ServerModule); + createServer() { + this.server = net.createServer( sock => { + const client = new TelnetClient(sock, sock); -TelnetServerModule.prototype.createServer = function() { - TelnetServerModule.super_.prototype.createServer.call(this); + client.banner(); - const server = net.createServer( (sock) => { - const client = new TelnetClient(sock, sock); - - client.banner(); + this.handleNewClient(client, sock, ModuleInfo); + }); - server.emit('client', client, sock); - }); + this.server.on('error', err => { + Log.info( { error : err.message }, 'Telnet server error'); + }); + } - return server; + listen() { + const port = parseInt(Config.loginServers.telnet.port); + if(isNaN(port)) { + Log.error( { server : ModuleInfo.name, port : Config.loginServers.telnet.port }, 'Cannot load server (invalid port)' ); + return false; + } + + this.server.listen(port); + Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' ); + return true; + } }; diff --git a/core/user_login.js b/core/user_login.js index b122b372..84d20a9d 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -4,7 +4,6 @@ // ENiGMA½ const setClientTheme = require('./theme.js').setClientTheme; const clientConnections = require('./client_connections.js').clientConnections; -const userDb = require('./database.js').dbs.user; const StatLog = require('./stat_log.js'); const logger = require('./logger.js'); @@ -21,66 +20,64 @@ function userLogin(client, username, password, cb) { // :TODO: if username exists, record failed login attempt to properties // :TODO: check Config max failed logon attempts/etc. - set err.maxAttempts = true - cb(err); - } else { - const now = new Date(); - const user = client.user; - - // - // Ensure this user is not already logged in. - // Loop through active connections -- which includes the current -- - // and check for matching user ID. If the count is > 1, disallow. - // - var existingClientConnection; - clientConnections.forEach(function connEntry(cc) { - if(cc.user !== user && cc.user.userId === user.userId) { - existingClientConnection = cc; - } - }); - - if(existingClientConnection) { - client.log.info( { - existingClientId : existingClientConnection.session.id, - username : user.username, - userId : user.userId }, - 'Already logged in' - ); - - var existingConnError = new Error('Already logged in as supplied user'); - existingConnError.existingConn = true; - - return cb(existingClientConnection); - } - - - // update client logger with addition of username - client.log = logger.log.child( { clientId : client.log.fields.clientId, username : user.username }); - client.log.info('Successful login'); - - async.parallel( - [ - function setTheme(callback) { - setClientTheme(client, user.properties.theme_id); - callback(null); - }, - function updateSystemLoginCount(callback) { - StatLog.incrementSystemStat('login_count', 1, callback); - }, - function recordLastLogin(callback) { - StatLog.setUserStat(user, 'last_login_timestamp', StatLog.now, callback); - }, - function updateUserLoginCount(callback) { - StatLog.incrementUserStat(user, 'login_count', 1, callback); - }, - function recordLoginHistory(callback) { - const LOGIN_HISTORY_MAX = 200; // history of up to last 200 callers - StatLog.appendSystemLogEntry('user_login_history', user.userId, LOGIN_HISTORY_MAX, StatLog.KeepType.Max, callback); - } - ], - function complete(err) { - cb(err); - } - ); + return cb(err); } + const user = client.user; + + // + // Ensure this user is not already logged in. + // Loop through active connections -- which includes the current -- + // and check for matching user ID. If the count is > 1, disallow. + // + let existingClientConnection = + clientConnections.forEach(function connEntry(cc) { + if(cc.user !== user && cc.user.userId === user.userId) { + existingClientConnection = cc; + } + }); + + if(existingClientConnection) { + client.log.info( { + existingClientId : existingClientConnection.session.id, + username : user.username, + userId : user.userId }, + 'Already logged in' + ); + + var existingConnError = new Error('Already logged in as supplied user'); + existingConnError.existingConn = true; + + return cb(existingClientConnection); + } + + + // update client logger with addition of username + client.log = logger.log.child( { clientId : client.log.fields.clientId, username : user.username }); + client.log.info('Successful login'); + + async.parallel( + [ + function setTheme(callback) { + setClientTheme(client, user.properties.theme_id); + callback(null); + }, + function updateSystemLoginCount(callback) { + StatLog.incrementSystemStat('login_count', 1, callback); + }, + function recordLastLogin(callback) { + StatLog.setUserStat(user, 'last_login_timestamp', StatLog.now, callback); + }, + function updateUserLoginCount(callback) { + StatLog.incrementUserStat(user, 'login_count', 1, callback); + }, + function recordLoginHistory(callback) { + const LOGIN_HISTORY_MAX = 200; // history of up to last 200 callers + StatLog.appendSystemLogEntry('user_login_history', user.userId, LOGIN_HISTORY_MAX, StatLog.KeepType.Max, callback); + } + ], + function complete(err) { + cb(err); + } + ); }); } \ No newline at end of file diff --git a/core/view_controller.js b/core/view_controller.js index cf90de3e..37f41fad 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -6,7 +6,6 @@ var MCIViewFactory = require('./mci_view_factory.js').MCIViewFactory; var menuUtil = require('./menu_util.js'); var asset = require('./asset.js'); var ansi = require('./ansi_term.js'); -const Log = require('./logger.js'); // deps var events = require('events'); @@ -449,6 +448,12 @@ ViewController.prototype.setFocus = function(focused) { this.setViewFocusWithEvents(this.focusedView, focused); }; +ViewController.prototype.resetInitialFocus = function() { + if(this.formInitialFocusId) { + return this.switchFocus(this.formInitialFocusId); + } +} + ViewController.prototype.switchFocus = function(id) { // // Perform focus switching validation now @@ -618,7 +623,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) { var self = this; var formIdKey = options.formId ? options.formId.toString() : '0'; - var initialFocusId = 1; // default to first + this.formInitialFocusId = 1; // default to first var formConfig; // :TODO: honor options.withoutForm @@ -671,7 +676,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) { function applyViewConfiguration(callback) { if(_.isObject(formConfig)) { self.applyViewConfig(formConfig, function configApplied(err, info) { - initialFocusId = info.initialFocusId; + self.formInitialFocusId = info.initialFocusId; callback(err); }); } else { @@ -746,12 +751,12 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) { callback(null); }, function drawAllViews(callback) { - self.redrawAll(initialFocusId); + self.redrawAll(self.formInitialFocusId); callback(null); }, function setInitialViewFocus(callback) { - if(initialFocusId) { - self.switchFocus(initialFocusId); + if(self.formInitialFocusId) { + self.switchFocus(self.formInitialFocusId); } callback(null); } @@ -794,7 +799,7 @@ ViewController.prototype.getFormData = function(key) { } */ - var formData = { + const formData = { id : this.formId, submitId : this.focusedView.id, value : {}, @@ -804,6 +809,26 @@ ViewController.prototype.getFormData = function(key) { formData.key = key; } + let viewData; + _.each(this.views, view => { + try { + // don't fill forms with static, non user-editable data data + if(!view.acceptsInput) { + return; + } + + viewData = view.getData(); + if(_.isUndefined(viewData)) { + return; + } + + formData.value[ view.submitArgName ? view.submitArgName : view.id ] = viewData; + } catch(e) { + this.client.log.error( { error : e.message }, 'Exception caught gathering form data' ); + } + }); + /* + var viewData; var view; for(var id in this.views) { @@ -820,10 +845,10 @@ ViewController.prototype.getFormData = function(key) { } catch(e) { this.client.log.error(e); // :TODO: Log better ;) } - } + }*/ return formData; -} +}; /* ViewController.prototype.formatMenuArgs = function(args) { diff --git a/mods/file_area_filter_edit.js b/mods/file_area_filter_edit.js new file mode 100644 index 00000000..7dfc3143 --- /dev/null +++ b/mods/file_area_filter_edit.js @@ -0,0 +1,283 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('../core/menu_module.js').MenuModule; +const ViewController = require('../core/view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('../core/file_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('../core/file_base_filter.js'); +const stringFormat = require('../core/string_format.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'File Area Filter Editor', + desc : 'Module for adding, deleting, and modifying file base filters', + author : 'NuSkooler', +}; + +const MciViewIds = { + editor : { + searchTerms : 1, + tags : 2, + area : 3, + sort : 4, + order : 5, + filterName : 6, + navMenu : 7, + + selectedFilterInfo : 10, // { ...filter object ... } + activeFilterInfo : 11, // { ...filter object ... } + } +}; + +exports.getModule = class FileAreaFilterEdit extends MenuModule { + constructor(options) { + super(options); + + this.filtersArray = new FileBaseFilters(this.client).toArray(); // ordered, such that we can index into them + this.currentFilterIndex = 0; // into |filtersArray| + + this.menuMethods = { + saveFilter : (formData, extraArgs, cb) => { + return this.saveCurrentFilter(formData, cb); + + }, + prevFilter : (formData, extraArgs, cb) => { + this.currentFilterIndex -= 1; + if(this.currentFilterIndex < 0) { + this.currentFilterIndex = this.filtersArray.length - 1; + } + this.loadDataForFilter(this.currentFilterIndex); + return cb(null); + }, + nextFilter : (formData, extraArgs, cb) => { + this.currentFilterIndex += 1; + if(this.currentFilterIndex >= this.filtersArray.length) { + this.currentFilterIndex = 0; + } + this.loadDataForFilter(this.currentFilterIndex); + return cb(null); + }, + makeFilterActive : (formData, extraArgs, cb) => { + const filters = new FileBaseFilters(this.client); + filters.setActive(this.filtersArray[this.currentFilterIndex].uuid); + + this.updateActiveLabel(); + + // :TODO: Need to update %FN somehow + return cb(null); + }, + newFilter : (formData, extraArgs, cb) => { + this.currentFilterIndex = this.filtersArray.length; // next avail slot + this.clearForm(true); // true=reset focus + return cb(null); + }, + deleteFilter : (formData, extraArgs, cb) => { + const filterUuid = this.filtersArray[this.currentFilterIndex].uuid; + this.filtersArray.splice(this.currentFilterIndex, 1); // remove selected entry + + // remove from stored properties + const filters = new FileBaseFilters(this.client); + filters.remove(filterUuid); + filters.persist( () => { + + // + // If the item was also the active filter, we need to make a new one active + // + if(filterUuid === this.client.user.properties.file_base_filter_active_uuid) { + const newActive = this.filtersArray[this.currentFilterIndex]; + if(newActive) { + filters.setActive(newActive.uuid); + } else { + // nothing to set active to + // :TODO: is this what we want? + this.client.user.properties.file_base_filter_active_uuid = 'none'; + } + } + + // update UI + if(this.filtersArray.length > 0) { + this.loadDataForFilter(this.currentFilterIndex); + } else { + this.clearForm(true); // true=reset focus + } + return cb(null); + }); + } + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.addViewController( 'editor', new ViewController( { client : this.client } ) ); + + async.series( + [ + function loadFromConfig(callback) { + return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); + }, + function populateAreas(callback) { + self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); + + const areasView = vc.getView(MciViewIds.editor.area); + if(areasView) { + areasView.setItems( self.availAreas.map( a => a.name ) ); + } + + self.updateActiveLabel(); + self.loadDataForFilter(self.currentFilterIndex); + self.viewControllers.editor.resetInitialFocus(); + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + getCurrentFilter() { + return this.filtersArray[this.currentFilterIndex]; + } + + setText(mciId, text) { + const view = this.viewControllers.editor.getView(mciId); + if(view) { + view.setText(text); + } + } + + updateActiveLabel() { + const activeFilter = FileBaseFilters.getActiveFilter(this.client); + if(activeFilter) { + const activeFormat = this.menuConfig.config.activeFormat || '{name}'; + this.setText(MciViewIds.editor.activeFilterInfo, stringFormat(activeFormat, activeFilter)); + } + } + + setFocusItemIndex(mciId, index) { + const view = this.viewControllers.editor.getView(mciId); + if(view) { + view.setFocusItemIndex(index); + } + } + + clearForm(setFocus) { + [ MciViewIds.editor.searchTerms, MciViewIds.editor.tags, MciViewIds.editor.filterName ].forEach(mciId => { + this.setText(mciId, ''); + }); + + [ MciViewIds.editor.area, MciViewIds.editor.order, MciViewIds.editor.sort ].forEach(mciId => { + this.setFocusItemIndex(mciId, 0); + }); + + if(setFocus) { + this.viewControllers.editor.resetInitialFocus(); + } + } + + getSelectedAreaTag(index) { + if(0 === index) { + return ''; // -ALL- + } + const area = this.availAreas[index]; + if(!area) { + return ''; + } + return area.areaTag; + } + + getOrderBy(index) { + return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0]; + } + + setAreaIndexFromCurrentFilter() { + let index; + const filter = this.getCurrentFilter(); + if(filter) { + // special treatment: areaTag saved as blank ("") if -ALL- + index = (filter.areaTag && this.availAreas.findIndex(area => filter.areaTag === area.areaTag)) || 0; + } else { + index = 0; + } + this.setFocusItemIndex(MciViewIds.editor.area, index); + } + + setOrderByFromCurrentFilter() { + let index; + const filter = this.getCurrentFilter(); + if(filter) { + index = FileBaseFilters.OrderByValues.findIndex( ob => filter.order === ob ) || 0; + } else { + index = 0; + } + this.setFocusItemIndex(MciViewIds.editor.order, index); + } + + setSortByFromCurrentFilter() { + let index; + const filter = this.getCurrentFilter(); + if(filter) { + index = FileBaseFilters.SortByValues.findIndex( sb => filter.sort === sb ) || 0; + } else { + index = 0; + } + this.setFocusItemIndex(MciViewIds.editor.sort, index); + } + + getSortBy(index) { + return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0]; + } + + setFilterValuesFromFormData(filter, formData) { + filter.name = formData.value.name; + filter.areaTag = this.getSelectedAreaTag(formData.value.areaIndex); + filter.terms = formData.value.searchTerms; + filter.tags = formData.value.tags; + filter.order = this.getOrderBy(formData.value.orderByIndex); + filter.sort = this.getSortBy(formData.value.sortByIndex); + } + + saveCurrentFilter(formData, cb) { + const filters = new FileBaseFilters(this.client); + const selectedFilter = this.filtersArray[this.currentFilterIndex]; + if(selectedFilter) { + // *update* currently selected filter + this.setFilterValuesFromFormData(selectedFilter, formData); + } else { + // add a new entry; note that UUID will be generated + const newFilter = {}; + this.setFilterValuesFromFormData(newFilter, formData); + + // set current to what we just saved + newFilter.uuid = filters.add(newFilter); + + // add to our array (at current index position) + this.filtersArray[this.currentFilterIndex] = newFilter; + } + + return filters.persist(cb); + } + + loadDataForFilter(filterIndex) { + const filter = this.filtersArray[filterIndex]; + if(filter) { + this.setText(MciViewIds.editor.searchTerms, filter.terms); + this.setText(MciViewIds.editor.tags, filter.tags); + this.setText(MciViewIds.editor.filterName, filter.name); + + this.setAreaIndexFromCurrentFilter(); + this.setSortByFromCurrentFilter(); + this.setOrderByFromCurrentFilter(); + } + } +}; diff --git a/mods/file_area_list.js b/mods/file_area_list.js index eb83708a..193021da 100644 --- a/mods/file_area_list.js +++ b/mods/file_area_list.js @@ -13,6 +13,7 @@ const Errors = require('../core/enig_error.js').Errors; const ArchiveUtil = require('../core/archive_util.js'); const Config = require('../core/config.js').config; const DownloadQueue = require('../core/download_queue.js'); +const FileAreaWeb = require('../core/file_area_web.js'); // deps const async = require('async'); @@ -43,7 +44,6 @@ const MciViewIds = { browse : { desc : 1, navMenu : 2, - queueToggle : 3, // active queue toggle indicator - others avail in customs as {isQueued} // 10+ = customs }, details : { @@ -74,7 +74,7 @@ exports.getModule = class FileAreaList extends MenuModule { this.filterCriteria = options.extraArgs.filterCriteria; } - this.dlQueue = new DownloadQueue(this.client.user); + this.dlQueue = new DownloadQueue(this.client); this.filterCriteria = this.filterCriteria || { // :TODO: set area tag - all in current area by default @@ -112,6 +112,9 @@ exports.getModule = class FileAreaList extends MenuModule { this.updateQueueIndicator(); return cb(null); }, + showWebDownloadLink : (formData, extraArgs, cb) => { + return this.fetchAndDisplayWebDownloadLink(cb); + }, }; } @@ -141,7 +144,7 @@ exports.getModule = class FileAreaList extends MenuModule { ); } - populateCurrentEntryInfo() { + populateCurrentEntryInfo(cb) { const config = this.menuConfig.config; const currEntry = this.currentFileEntry; @@ -163,6 +166,8 @@ exports.getModule = class FileAreaList extends MenuModule { uploadTimestamp : moment(currEntry.uploadTimestamp).format(uploadTimestampFormat), hashTags : Array.from(currEntry.hashTags).join(hashTagsSep), isQueued : this.dlQueue.isQueued(this.currentFileEntry) ? isQueuedIndicator : isNotQueuedIndicator, + webDlLink : '', // :TODO: fetch web any existing web d/l link + webDlExpire : '', // :TODO: fetch web d/l link expire time }; // @@ -194,9 +199,27 @@ exports.getModule = class FileAreaList extends MenuModule { if(entryInfo.userRating < 5) { entryInfo.userRatingString += new Array( (5 - entryInfo.userRating) + 1).join(userRatingUnticked); } + + FileAreaWeb.getExistingTempDownloadServeItem(this.client, this.currentFileEntry, (err, serveItem) => { + if(err) { + entryInfo.webDlLink = config.webDlLinkNeedsGenerated || 'Not yet generated'; + entryInfo.webDlExpire = ''; + } else { + const webDlExpireTimeFormat = config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + entryInfo.webDlLink = serveItem.url; + entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); + } + + return cb(null); + }); } populateCustomLabels(category, startId) { + return this.updateCustomLabelsWithFilter(category, startId); + } + + updateCustomLabelsWithFilter(category, startId, filter) { let textView; let customMciId = startId; const config = this.menuConfig.config; @@ -205,7 +228,7 @@ exports.getModule = class FileAreaList extends MenuModule { const key = `${category}InfoFormat${customMciId}`; const format = config[key]; - if(format) { + if(format && (!filter || filter.find(f => format.indexOf(f) > - 1))) { textView.setText(stringFormat(format, this.currentFileEntry.entryInfo)); } @@ -295,8 +318,11 @@ exports.getModule = class FileAreaList extends MenuModule { self.currentFileEntry = new FileEntry(); self.currentFileEntry.load( self.fileList[ self.fileListPosition ], err => { - self.populateCurrentEntryInfo(); - return callback(err); + if(err) { + return callback(err); + } + + return self.populateCurrentEntryInfo(callback); }); }, function populateViews(callback) { @@ -360,8 +386,63 @@ exports.getModule = class FileAreaList extends MenuModule { } ); } + + fetchAndDisplayWebDownloadLink(cb) { + const self = this; + + async.series( + [ + function generateLinkIfNeeded(callback) { + + if(self.currentFileEntry.webDlExpireTime < moment()) { + return callback(null); + } + + const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); + + FileAreaWeb.createAndServeTempDownload( + self.client, + self.currentFileEntry, + { expireTime : expireTime }, + (err, url) => { + if(err) { + return callback(err); + } + + self.currentFileEntry.webDlExpireTime = expireTime; + + const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + self.currentFileEntry.entryInfo.webDlLink = url; + self.currentFileEntry.entryInfo.webDlExpire = expireTime.format(webDlExpireTimeFormat); + + return callback(null); + } + ); + }, + function updateActiveViews(callback) { + self.updateCustomLabelsWithFilter( 'browse', 10, [ '{webDlLink}', '{webDlExpire}' ] ); + return callback(null); + } + ], + err => { + return cb(err); + } + ); + } updateQueueIndicator() { + const isQueuedIndicator = this.menuConfig.config.isQueuedIndicator || 'Y'; + const isNotQueuedIndicator = this.menuConfig.config.isNotQueuedIndicator || 'N'; + + this.currentFileEntry.entryInfo.isQueued = stringFormat( + this.dlQueue.isQueued(this.currentFileEntry) ? + isQueuedIndicator : + isNotQueuedIndicator + ); + + this.updateCustomLabelsWithFilter( 'browse', 10, [ '{isQueued}' ] ); + /* const indicatorView = this.viewControllers.browse.getView(MciViewIds.browse.queueToggle); if(indicatorView) { @@ -374,7 +455,7 @@ exports.getModule = class FileAreaList extends MenuModule { isNotQueuedIndicator ) ); - } + }*/ } cacheArchiveEntries(cb) { diff --git a/package.json b/package.json index d35884eb..0c4c175e 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "ptyw.js": "NuSkooler/ptyw.js", "sqlite3": "^3.1.1", "ssh2": "^0.5.1", - "temp": "^0.8.3" + "temp": "^0.8.3", + "hashids" : "^1.1.1", + "mime-types" : "^2.1.12" }, "engines": { "node": ">=4.2.0"