| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624 | import config from "config";import mongoose from "mongoose";import bluebird from "bluebird";import async from "async";import CoreClass from "../../core";const REQUIRED_DOCUMENT_VERSIONS = {	activity: 4,	news: 3,	playlist: 7,	punishment: 1,	queueSong: 1,	report: 7,	song: 10,	station: 10,	user: 4,	youtubeApiRequest: 1,	youtubeVideo: [1, 2],	youtubeChannel: 1,	ratings: 2,	importJob: 1,	stationHistory: 2,	soundcloudTrack: 1,	spotifyTrack: 1,	spotifyAlbum: 1,	spotifyArtist: 1,	genericApiRequest: 1};const regex = {	azAZ09_: /^[A-Za-z0-9_]+$/,	az09_: /^[a-z0-9_]+$/,	emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,	ascii: /^[\x00-\x7F]+$/,	name: /^[\p{L}0-9 .'_-]+$/u,	custom: regex => new RegExp(`^[${regex}]+$`)};const isLength = (string, min, max) => !(typeof string !== "string" || string.length < min || string.length > max);mongoose.Promise = bluebird;let DBModule;class _DBModule extends CoreClass {	// eslint-disable-next-line require-jsdoc	constructor() {		super("db");		DBModule = this;	}	/**	 * Initialises the database module	 * @returns {Promise} - returns promise (reject, resolve)	 */	initialize() {		return new Promise((resolve, reject) => {			this.schemas = {};			this.models = {};			const { user, password, host, port, database } = config.get("mongo");			mongoose				.connect(`mongodb://${user}:${password}@${host}:${port}/${database}`, {					useNewUrlParser: true,					useUnifiedTopology: true				})				.then(async () => {					this.schemas = {						song: {},						queueSong: {},						station: {},						user: {},						dataRequest: {},						activity: {},						playlist: {},						news: {},						report: {},						punishment: {},						youtubeApiRequest: {},						youtubeVideo: {},						youtubeChannel: {},						ratings: {},						stationHistory: {},						soundcloudTrack: {},						spotifyTrack: {},						spotifyAlbum: {},						spotifyArtist: {},						genericApiRequest: {}					};					const importSchema = schemaName =>						new Promise(resolve => {							import(`./schemas/${schemaName}`).then(schema => {								this.schemas[schemaName] = new mongoose.Schema(schema.default);								return resolve();							});						});					await importSchema("song");					await importSchema("queueSong");					await importSchema("station");					await importSchema("user");					await importSchema("dataRequest");					await importSchema("activity");					await importSchema("playlist");					await importSchema("news");					await importSchema("report");					await importSchema("punishment");					await importSchema("youtubeApiRequest");					await importSchema("youtubeVideo");					await importSchema("youtubeChannel");					await importSchema("ratings");					await importSchema("importJob");					await importSchema("stationHistory");					await importSchema("soundcloudTrack");					await importSchema("spotifyTrack");					await importSchema("spotifyAlbum");					await importSchema("spotifyArtist");					await importSchema("genericApiRequest");					this.models = {						song: mongoose.model("song", this.schemas.song),						queueSong: mongoose.model("queueSong", this.schemas.queueSong),						station: mongoose.model("station", this.schemas.station),						user: mongoose.model("user", this.schemas.user),						dataRequest: mongoose.model("dataRequest", this.schemas.dataRequest),						activity: mongoose.model("activity", this.schemas.activity),						playlist: mongoose.model("playlist", this.schemas.playlist),						news: mongoose.model("news", this.schemas.news),						report: mongoose.model("report", this.schemas.report),						punishment: mongoose.model("punishment", this.schemas.punishment),						youtubeApiRequest: mongoose.model("youtubeApiRequest", this.schemas.youtubeApiRequest),						youtubeVideo: mongoose.model("youtubeVideo", this.schemas.youtubeVideo),						youtubeChannel: mongoose.model("youtubeChannel", this.schemas.youtubeChannel),						ratings: mongoose.model("ratings", this.schemas.ratings),						importJob: mongoose.model("importJob", this.schemas.importJob),						stationHistory: mongoose.model("stationHistory", this.schemas.stationHistory),						soundcloudTrack: mongoose.model("soundcloudTrack", this.schemas.soundcloudTrack),						spotifyTrack: mongoose.model("spotifyTrack", this.schemas.spotifyTrack),						spotifyAlbum: mongoose.model("spotifyAlbum", this.schemas.spotifyAlbum),						spotifyArtist: mongoose.model("spotifyArtist", this.schemas.spotifyArtist),						genericApiRequest: mongoose.model("genericApiRequest", this.schemas.genericApiRequest)					};					mongoose.connection.on("error", err => {						this.log("ERROR", err);					});					mongoose.connection.on("disconnected", () => {						this.log("ERROR", "Disconnected, going to try to reconnect...");						this.setStatus("RECONNECTING");					});					mongoose.connection.on("reconnected", () => {						this.log("INFO", "Reconnected.");						this.setStatus("READY");					});					mongoose.connection.on("reconnectFailed", () => {						this.log("INFO", "Reconnect failed, stopping reconnecting.");						this.setStatus("FAILED");					});					// User					this.schemas.user						.path("username")						.validate(							username =>								isLength(username, 2, 32) &&								regex.custom("a-zA-Z0-9_-").test(username) &&								username.replaceAll(/[_]/g, "").length > 0,							"Invalid username."						);					this.schemas.user.path("email.address").validate(email => {						if (!isLength(email, 3, 254)) return false;						if (email.indexOf("@") !== email.lastIndexOf("@")) return false;						return regex.emailSimple.test(email) && regex.ascii.test(email);					}, "Invalid email.");					this.schemas.user						.path("name")						.validate(							name =>								isLength(name, 1, 64) &&								regex.name.test(name) &&								name.replaceAll(/[ .'_-]/g, "").length > 0,							"Invalid name."						);					// Station					this.schemas.station						.path("name")						.validate(id => isLength(id, 2, 16) && regex.az09_.test(id), "Invalid station name.");					this.schemas.station						.path("displayName")						.validate(							displayName => isLength(displayName, 2, 32) && regex.ascii.test(displayName),							"Invalid display name."						);					this.schemas.station.path("description").validate(description => {						if (!isLength(description, 2, 200)) return false;						const characters = description.split("");						return characters.filter(character => character.charCodeAt(0) === 21328).length === 0;					}, "Invalid display name.");					this.schemas.station.path("owner").validate({						validator: owner =>							new Promise((resolve, reject) => {								this.models.station.countDocuments({ owner }, (err, c) => {									if (err) reject(new Error("A mongo error happened."));									else if (c >= 25) reject(new Error("User already has 25 stations."));									else resolve();								});							}),						message: "User already has 25 stations."					});					this.schemas.station						.path("requests.autorequestLimit")						.validate(function validateRequestsAutorequestLimit(autorequestLimit) {							const { limit } = this.get("requests");							if (autorequestLimit > limit) return false;							return true;						}, "Autorequest limit cannot be higher than the request limit.");					// Song					this.schemas.song.path("mediaSource").validate(mediaSource => {						if (mediaSource.startsWith("youtube:")) return true;						if (mediaSource.startsWith("soundcloud:")) return true;						if (mediaSource.startsWith("spotify:")) return true;						return false;					});					const songTitle = title => isLength(title, 1, 100);					this.schemas.song.path("title").validate(songTitle, "Invalid title.");					this.schemas.song.path("artists").validate(artists => artists.length <= 10, "Invalid artists.");					const songArtists = artists =>						artists.filter(artist => isLength(artist, 1, 64) && artist !== "NONE").length ===						artists.length;					this.schemas.song.path("artists").validate(songArtists, "Invalid artists.");					const songGenres = genres => {						if (genres.length > 16) return false;						return (							genres.filter(genre => isLength(genre, 1, 32) && regex.ascii.test(genre)).length ===							genres.length						);					};					this.schemas.song.path("genres").validate(songGenres, "Invalid genres.");					const songTags = tags =>						tags.filter(tag => /^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/.test(tag))							.length === tags.length;					this.schemas.song.path("tags").validate(songTags, "Invalid tags.");					const songThumbnail = thumbnail => {						if (!isLength(thumbnail, 1, 256)) return false;						if (config.get("url.secure") === true) return thumbnail.startsWith("https://");						return thumbnail.startsWith("http://") || thumbnail.startsWith("https://");					};					this.schemas.song.path("thumbnail").validate(songThumbnail, "Invalid thumbnail.");					// Playlist					this.schemas.playlist						.path("displayName")						.validate(							displayName => isLength(displayName, 1, 64) && regex.ascii.test(displayName),							"Invalid display name."						);					this.schemas.playlist.path("createdBy").validate(createdBy => {						this.models.playlist.countDocuments({ createdBy }, (err, c) => !(err || c >= 100));					}, "Max 100 playlists per user.");					this.schemas.playlist						.path("songs")						.validate(songs => songs.length <= 10000, "Max 10000 songs per playlist.");					// this.schemas.playlist.path("songs").validate(songs => {					// 	if (songs.length === 0) return true;					// 	return songs[0].duration <= 10800;					// }, "Max 3 hours per song.");					this.models.activity.syncIndexes();					this.models.dataRequest.syncIndexes();					this.models.news.syncIndexes();					this.models.playlist.syncIndexes();					this.models.punishment.syncIndexes();					this.models.queueSong.syncIndexes();					this.models.report.syncIndexes();					this.models.song.syncIndexes();					this.models.station.syncIndexes();					this.models.user.syncIndexes();					this.models.youtubeApiRequest.syncIndexes();					this.models.youtubeVideo.syncIndexes();					this.models.youtubeChannel.syncIndexes();					this.models.ratings.syncIndexes();					this.models.importJob.syncIndexes();					this.models.stationHistory.syncIndexes();					this.models.soundcloudTrack.syncIndexes();					this.models.spotifyTrack.syncIndexes();					this.models.spotifyArtist.syncIndexes();					this.models.genericApiRequest.syncIndexes();					if (config.get("skipDbDocumentsVersionCheck")) resolve();					else {						this.runJob("CHECK_DOCUMENT_VERSIONS", {}, null, -1)							.then(() => {								resolve();							})							.catch(err => {								reject(err);							});					}				})				.catch(err => {					this.log("ERROR", err);					reject(err);				});		});	}	/**	 * Checks if all documents have the correct document version	 * @returns {Promise} - returns promise (reject, resolve)	 */	CHECK_DOCUMENT_VERSIONS() {		return new Promise((resolve, reject) => {			async.each(				Object.keys(REQUIRED_DOCUMENT_VERSIONS),				async modelName => {					const model = DBModule.models[modelName];					const requiredDocumentVersion = REQUIRED_DOCUMENT_VERSIONS[modelName];					const count = await model.countDocuments({						documentVersion: {							$nin: Array.isArray(requiredDocumentVersion)								? requiredDocumentVersion								: [requiredDocumentVersion]						}					});					if (count > 0)						throw new Error(							`Collection "${modelName}" has ${count} documents with a wrong document version. Run migration.`						);					if (Array.isArray(requiredDocumentVersion)) {						const count2 = await model.countDocuments({							documentVersion: {								$ne: requiredDocumentVersion[requiredDocumentVersion.length - 1]							}						});						if (count2 > 0)							console.warn(								`Collection "${modelName}" has ${count2} documents with a outdated document version. Run steps manually to update these.`							);					}				},				err => {					if (err) reject(new Error(err));					else resolve();				}			);		});	}	/**	 * Returns a database model	 * @param {object} payload - object containing the payload	 * @param {object} payload.modelName - name of the model to get	 * @returns {Promise} - returns promise (reject, resolve)	 */	GET_MODEL(payload) {		return new Promise(resolve => {			resolve(DBModule.models[payload.modelName]);		});	}	/**	 * Returns a database schema	 * @param {object} payload - object containing the payload	 * @param {object} payload.schemaName - name of the schema to get	 * @returns {Promise} - returns promise (reject, resolve)	 */	GET_SCHEMA(payload) {		return new Promise(resolve => {			resolve(DBModule.schemas[payload.schemaName]);		});	}	/**	 * Gets data	 * @param {object} payload - object containing the payload	 * @param {string} payload.page - the page	 * @param {string} payload.pageSize - the page size	 * @param {string} payload.properties - the properties to return for each song	 * @param {string} payload.sort - the sort object	 * @param {string} payload.queries - the queries array	 * @param {string} payload.operator - the operator for queries	 * @param {string} payload.modelName - the db collection modal name	 * @param {string} payload.blacklistedProperties - the properties that are not allowed to be returned, filtered by or sorted by	 * @param {string} payload.specialProperties - the special properties	 * @param {string} payload.specialQueries - the special queries	 * @returns {Promise} - returns a promise (resolve, reject)	 */	GET_DATA(payload) {		return new Promise((resolve, reject) => {			async.waterfall(				[					// Creates pipeline array					next => next(null, []),					// If a query filter property or sort property is blacklisted, throw error					(pipeline, next) => {						const { sort, queries, blacklistedProperties } = payload;						if (							queries.some(query =>								blacklistedProperties.some(blacklistedProperty =>									blacklistedProperty.startsWith(query.filter.property)								)							)						)							return next("Unable to filter by blacklisted property.");						if (							Object.keys(sort).some(property =>								blacklistedProperties.some(blacklistedProperty =>									blacklistedProperty.startsWith(property)								)							)						)							return next("Unable to sort by blacklisted property.");						return next(null, pipeline);					},					// If a filter or property exists for a special property, add some custom pipeline steps					(pipeline, next) => {						const { properties, queries, specialProperties } = payload;						async.eachLimit(							Object.entries(specialProperties),							1,							([specialProperty, pipelineSteps], next) => {								// Check if a filter with the special property exists								const filterExists =									queries.map(query => query.filter.property).indexOf(specialProperty) !== -1;								// Check if a property with the special property exists								const propertyExists = properties.indexOf(specialProperty) !== -1;								// If no such filter or property exists, skip this function								if (!filterExists && !propertyExists) return next();								// Add the specified pipeline steps into the pipeline								pipeline.push(...pipelineSteps);								return next();							},							err => {								next(err, pipeline);							}						);					},					// Adds the match stage to aggregation pipeline, which is responsible for filtering					(pipeline, next) => {						const { queries, operator, specialQueries, specialFilters } = payload;						let queryError;						const newQueries = queries.flatMap(query => {							const { data, filter, filterType } = query;							const newQuery = {};							if (filterType === "regex") {								newQuery[filter.property] = new RegExp(`${data.slice(1, data.length - 1)}`, "i");							} else if (filterType === "contains") {								newQuery[filter.property] = new RegExp(									`${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,									"i"								);							} else if (filterType === "exact") {								newQuery[filter.property] = data.toString();							} else if (filterType === "datetimeBefore") {								newQuery[filter.property] = { $lte: new Date(data) };							} else if (filterType === "datetimeAfter") {								newQuery[filter.property] = { $gte: new Date(data) };							} else if (filterType === "numberLesserEqual") {								newQuery[filter.property] = { $lte: Number(data) };							} else if (filterType === "numberLesser") {								newQuery[filter.property] = { $lt: Number(data) };							} else if (filterType === "numberGreater") {								newQuery[filter.property] = { $gt: Number(data) };							} else if (filterType === "numberGreaterEqual") {								newQuery[filter.property] = { $gte: Number(data) };							} else if (filterType === "numberEquals") {								newQuery[filter.property] = { $eq: Number(data) };							} else if (filterType === "boolean") {								newQuery[filter.property] = { $eq: !!data };							} else if (filterType === "special") {								pipeline.push(...specialFilters[filter.property](data));								newQuery[filter.property] = { $eq: true };							}							if (specialQueries[filter.property]) {								return specialQueries[filter.property](newQuery);							}							return newQuery;						});						if (queryError) next(queryError);						const queryObject = {};						if (newQueries.length > 0) {							if (operator === "and") queryObject.$and = newQueries;							else if (operator === "or") queryObject.$or = newQueries;							else if (operator === "nor") queryObject.$nor = newQueries;						}						pipeline.push({ $match: queryObject });						next(null, pipeline);					},					// Adds sort stage to aggregation pipeline if there is at least one column being sorted, responsible for sorting data					(pipeline, next) => {						const { sort } = payload;						const newSort = Object.fromEntries(							Object.entries(sort).map(([property, direction]) => [								property,								direction === "ascending" ? 1 : -1							])						);						if (Object.keys(newSort).length > 0) pipeline.push({ $sort: newSort });						next(null, pipeline);					},					// Adds first project stage to aggregation pipeline, responsible for including only the requested properties					(pipeline, next) => {						const { properties } = payload;						pipeline.push({ $project: Object.fromEntries(properties.map(property => [property, 1])) });						next(null, pipeline);					},					// Adds second project stage to aggregation pipeline, responsible for excluding some specific properties					(pipeline, next) => {						const { blacklistedProperties } = payload;						if (blacklistedProperties.length > 0)							pipeline.push({								$project: Object.fromEntries(blacklistedProperties.map(property => [property, 0]))							});						next(null, pipeline);					},					// Adds the facet stage to aggregation pipeline, responsible for returning a total document count, skipping and limitting the documents that will be returned					(pipeline, next) => {						const { page, pageSize } = payload;						pipeline.push({							$facet: {								count: [{ $count: "count" }],								documents: [{ $skip: pageSize * (page - 1) }, { $limit: pageSize }]							}						});						// console.dir(pipeline, { depth: 6 });						next(null, pipeline);					},					(pipeline, next) => {						const { modelName } = payload;						DBModule.runJob("GET_MODEL", { modelName }, this)							.then(model => {								if (!model) return next("Invalid model.");								return next(null, pipeline, model);							})							.catch(err => {								next(err);							});					},					// Executes the aggregation pipeline					(pipeline, model, next) => {						model.aggregate(pipeline).exec((err, result) => {							// console.dir(err);							// console.dir(result, { depth: 6 });							if (err) return next(err);							if (result[0].count.length === 0) return next(null, 0, []);							const { count } = result[0].count[0];							const { documents } = result[0];							// console.log(111, err, result, count, documents[0]);							return next(null, count, documents);						});					}				],				(err, count, documents) => {					if (err && err !== true) return reject(new Error(err));					return resolve({ data: documents, count });				}			);		});	}	/**	 * Checks if a password to be stored in the database has a valid length	 * @param {object} password - the password itself	 * @returns {Promise} - returns promise (reject, resolve)	 */	passwordValid(password) {		return isLength(password, 6, 200);	}}export default new _DBModule();
 |