Bladeren bron

Merge branch 'polishing' of https://github.com/Musare/MusareNode into polishing

Kristian Vos 3 jaren geleden
bovenliggende
commit
3eefc38b5f

+ 2 - 1
backend/config/template.json

@@ -9,6 +9,7 @@
 	"registrationDisabled": true,
 	"hideAutomaticallyRequestedSongs": false,
     "hideAnonymousSongs": false,
+	"sendDataRequestEmails": true,
 	"fancyConsole": true,
 	"apis": {
 		"youtube": {
@@ -91,5 +92,5 @@
 			]
 		}
 	},
-	"configVersion": 5
+	"configVersion": 6
 }

+ 1 - 1
backend/index.js

@@ -3,7 +3,7 @@ import "./loadEnvVariables.js";
 import util from "util";
 import config from "config";
 
-const REQUIRED_CONFIG_VERSION = 5;
+const REQUIRED_CONFIG_VERSION = 6;
 
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {

+ 102 - 0
backend/logic/actions/dataRequests.js

@@ -0,0 +1,102 @@
+import async from "async";
+
+import { isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+
+CacheModule.runJob("SUB", {
+	channel: "dataRequest.resolve",
+	cb: dataRequestId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.users",
+			args: ["event:admin.dataRequests.resolved", { data: { dataRequestId } }]
+		});
+	}
+});
+
+export default {
+	/**
+	 * Gets all unresolved data requests
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: isAdminRequired(async function index(session, cb) {
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					dataRequestModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next);
+				}
+			],
+			async (err, requests) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "DATA_REQUESTS_INDEX", `Indexing data requests failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "DATA_REQUESTS_INDEX", `Indexing data requests successful.`, false);
+
+				return cb({ status: "success", data: { requests } });
+			}
+		);
+	}),
+
+	/**
+	 * Resolves a data request
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} dataRequestId - the id of the data request to resolve
+	 * @param {Function} cb - gets called with the result
+	 */
+	resolve: isAdminRequired(async function update(session, dataRequestId, cb) {
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!dataRequestId || typeof dataRequestId !== "string")
+						return next("Please provide a data request id.");
+					return next();
+				},
+
+				next => {
+					dataRequestModel.updateOne({ _id: dataRequestId }, { resolved: true }, { upsert: true }, err =>
+						next(err)
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"DATA_REQUESTS_RESOLVE",
+						`Resolving data request ${dataRequestId} failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "dataRequest.resolve", value: dataRequestId });
+
+				this.log(
+					"SUCCESS",
+					"DATA_REQUESTS_RESOLVE",
+					`Resolving data request "${dataRequestId}" successful for user ${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully resolved data request."
+				});
+			}
+		);
+	})
+};

+ 2 - 0
backend/logic/actions/index.js

@@ -3,6 +3,7 @@ import songs from "./songs";
 import stations from "./stations";
 import playlists from "./playlists";
 import users from "./users";
+import dataRequests from "./dataRequests";
 import activities from "./activities";
 import reports from "./reports";
 import news from "./news";
@@ -15,6 +16,7 @@ export default {
 	stations,
 	playlists,
 	users,
+	dataRequests,
 	activities,
 	reports,
 	news,

+ 32 - 10
backend/logic/actions/users.js

@@ -215,10 +215,13 @@ export default {
 	 */
 	remove: isLoginRequired(async function remove(session, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
 
+		const dataRequestEmail = await MailModule.runJob("GET_SCHEMA", { schemaName: "dataRequest" }, this);
+
 		async.waterfall(
 			[
 				// activities related to the user
@@ -267,6 +270,33 @@ export default {
 				// user object
 				(res, next) => {
 					userModel.deleteMany({ _id: session.userId }, next);
+				},
+
+				// request data removal for user
+				(res, next) => {
+					dataRequestModel.create({ userId: session.userId, type: "remove" }, next);
+				},
+
+				(request, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.users",
+						args: ["event:admin.dataRequests.created", { data: { request } }]
+					});
+
+					return next();
+				},
+
+				next => userModel.find({ role: "admin" }, next),
+
+				// send email to all admins of a data removal request
+				(users, next) => {
+					if (!config.get("sendDataRequestEmails")) return next();
+					if (users.length === 0) return next();
+
+					const to = [];
+					users.forEach(user => to.push(user.email.address));
+
+					return dataRequestEmail(to, session.userId, "remove", err => next(err));
 				}
 			],
 			async err => {
@@ -349,9 +379,7 @@ export default {
 						},
 						this
 					)
-						.then(() => {
-							next(null, sessionId);
-						})
+						.then(() => next(null, sessionId))
 						.catch(next);
 				}
 			],
@@ -392,13 +420,7 @@ export default {
 		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
 
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const verifyEmailSchema = await MailModule.runJob(
-			"GET_SCHEMA",
-			{
-				schemaName: "verifyEmail"
-			},
-			this
-		);
+		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
 
 		async.waterfall(
 			[

+ 0 - 2
backend/logic/app.js

@@ -374,8 +374,6 @@ class _AppModule extends CoreClass {
 						}
 					],
 					async (err, userId) => {
-						console.log(err, userId);
-
 						if (err && err !== true) {
 							err = await UtilsModule.runJob("GET_ERROR", {
 								error: err

+ 3 - 0
backend/logic/db/index.js

@@ -66,6 +66,7 @@ class _DBModule extends CoreClass {
 						queueSong: {},
 						station: {},
 						user: {},
+						dataRequest: {},
 						activity: {},
 						playlist: {},
 						news: {},
@@ -85,6 +86,7 @@ class _DBModule extends CoreClass {
 					await importSchema("queueSong");
 					await importSchema("station");
 					await importSchema("user");
+					await importSchema("dataRequest");
 					await importSchema("activity");
 					await importSchema("playlist");
 					await importSchema("news");
@@ -96,6 +98,7 @@ class _DBModule extends CoreClass {
 						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),

+ 7 - 0
backend/logic/db/schemas/dataRequest.js

@@ -0,0 +1,7 @@
+export default {
+	userId: { type: String, required: true },
+	createdAt: { type: Date, default: Date.now, required: true },
+	type: { type: String, required: true, enum: ["remove"] },
+	resolved: { type: Boolean, default: false },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 3 - 1
backend/logic/mail/index.js

@@ -28,7 +28,8 @@ class _MailModule extends CoreClass {
 		this.schemas = {
 			verifyEmail: await importSchema("verifyEmail"),
 			resetPasswordRequest: await importSchema("resetPasswordRequest"),
-			passwordRequest: await importSchema("passwordRequest")
+			passwordRequest: await importSchema("passwordRequest"),
+			dataRequest: await importSchema("dataRequest")
 		};
 
 		this.enabled = config.get("smtp.enabled");
@@ -55,6 +56,7 @@ class _MailModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	SEND_MAIL(payload) {
+		// console.log(payload);
 		return new Promise((resolve, reject) => {
 			if (MailModule.enabled)
 				return MailModule.transporter

+ 34 - 0
backend/logic/mail/schemas/dataRequest.js

@@ -0,0 +1,34 @@
+import config from "config";
+
+import mail from "../index";
+
+/**
+ * Sends an email to all admins that a user has submitted a data request
+ *
+ * @param {string} to - an array of email addresses of admins
+ * @param {string} userId - the id of the user the data request is for
+ * @param {string} type - the type of data request e.g. remove
+ * @param {Function} cb - gets called when an error occurred or when the operation was successful
+ */
+export default (to, userId, type, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: `Data Request - ${type}`,
+		html: `
+				Hello,
+				<br>
+				<br>
+				User ${userId} has requested to ${type} the data for their account on Musare.
+				<br>
+				<br>
+				This request can be viewed and resolved in the <a href="${config.get(
+					"domain"
+				)}/admin/users">Users tab of the admin page</a>. Note: All admins will be sent the same message.
+			`
+	};
+
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
+};

+ 0 - 3
backend/logic/mail/schemas/verifyEmail.js

@@ -1,7 +1,4 @@
 import config from "config";
-
-// const moduleManager = require('../../../index');
-
 import mail from "../index";
 
 /**

+ 5 - 2
frontend/dist/config/template.json

@@ -15,9 +15,12 @@
 	"siteSettings": {
 		"logo_white": "/assets/white_wordmark.png",
 		"logo_blue": "/assets/blue_wordmark.png",
-		"siteName": "Musare",
+		"sitename": "Musare",
 		"github": "https://github.com/Musare/MusareNode"
 	},
+	"messages": {
+		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
+	},
 	"skipConfigVersionCheck": false,
-	"configVersion": 2
+	"configVersion": 3
 }

+ 2 - 2
frontend/src/components/layout/MainHeader.vue

@@ -4,7 +4,7 @@
 			<router-link v-if="!hideLogo" class="nav-item is-brand" to="/">
 				<img
 					:src="`${this.siteSettings.logo_white}`"
-					:alt="`${this.siteSettings.siteName}` || `Musare`"
+					:alt="`${this.siteSettings.sitename}` || `Musare`"
 				/>
 			</router-link>
 		</div>
@@ -71,7 +71,7 @@ export default {
 			frontendDomain: "",
 			siteSettings: {
 				logo: "",
-				siteName: ""
+				sitename: ""
 			}
 		};
 	},

+ 15 - 5
frontend/src/components/modals/RemoveAccount.vue

@@ -130,6 +130,7 @@
 				<div class="content-box-inputs">
 					<a
 						class="button is-github"
+						@click="relinkGithub()"
 						:href="`${apiDomain}/auth/github/link`"
 					>
 						<div class="icon">
@@ -154,7 +155,7 @@
 			>
 				<h2 class="content-box-title">Remove your account</h2>
 				<p class="content-box-description">
-					There is no going back after confirming account removal.
+					{{ accountRemovalMessage }}
 				</p>
 
 				<div class="content-box-inputs">
@@ -182,8 +183,10 @@ export default {
 	components: { Modal, Confirm },
 	data() {
 		return {
+			name: "RemoveAccount",
 			step: "confirm-identity",
 			apiDomain: "",
+			accountRemovalMessage: "",
 			password: {
 				value: "",
 				visible: false
@@ -197,6 +200,7 @@ export default {
 	}),
 	async mounted() {
 		this.apiDomain = await lofig.get("apiDomain");
+		this.accountRemovalMessage = await lofig.get("messages.accountRemoval");
 	},
 	methods: {
 		togglePasswordVisibility() {
@@ -227,14 +231,20 @@ export default {
 							`Your GitHub account isn't linked. Please re-link your account and try again.`
 						);
 						this.step = "relink-github";
-						localStorage.setItem(
-							"github_redirect",
-							window.location.pathname + window.location.search
-						);
 					}
 				} else new Toast(res.message);
 			});
 		},
+		relinkGithub() {
+			localStorage.setItem(
+				"github_redirect",
+				`${window.location.pathname + window.location.search}${
+					!this.$route.query.removeAccount
+						? "&removeAccount=relinked-github"
+						: ""
+				}`
+			);
+		},
 		remove() {
 			return this.socket.dispatch("users.remove", res => {
 				if (res.status === "success") {

+ 1 - 1
frontend/src/main.js

@@ -8,7 +8,7 @@ import store from "./store";
 
 import App from "./App.vue";
 
-const REQUIRED_CONFIG_VERSION = 2;
+const REQUIRED_CONFIG_VERSION = 3;
 
 const handleMetadata = attrs => {
 	document.title = `Musare | ${attrs.title}`;

+ 57 - 0
frontend/src/pages/Admin/tabs/Users.vue

@@ -2,6 +2,40 @@
 	<div>
 		<metadata title="Admin | Users" />
 		<div class="container">
+			<h2 v-if="dataRequests.length > 0">Data Requests</h2>
+
+			<table class="table is-striped" v-if="dataRequests.length > 0">
+				<thead>
+					<tr>
+						<td>User ID</td>
+						<td>Request Type</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(request, index) in dataRequests" :key="index">
+						<td>{{ request.userId }}</td>
+						<td>
+							{{
+								request.type === "remove"
+									? "Remove all associated data"
+									: request.type
+							}}
+						</td>
+						<td>
+							<button
+								class="button is-primary"
+								@click="resolveDataRequest(request._id)"
+							>
+								Resolve
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+
+			<h2>Users</h2>
+
 			<table class="table is-striped">
 				<thead>
 					<tr>
@@ -67,6 +101,7 @@
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
+import Toast from "toasters";
 
 import ProfilePicture from "@/components/ProfilePicture.vue";
 import ws from "@/ws";
@@ -79,6 +114,7 @@ export default {
 	data() {
 		return {
 			editingUserId: "",
+			dataRequests: [],
 			users: []
 		};
 	},
@@ -111,7 +147,28 @@ export default {
 					}
 				}
 			});
+
+			this.socket.dispatch("dataRequests.index", res => {
+				if (res.status === "success")
+					this.dataRequests = res.data.requests;
+			});
+
 			this.socket.dispatch("apis.joinAdminRoom", "users", () => {});
+
+			this.socket.on("event:admin.dataRequests.created", res =>
+				this.dataRequests.push(res.data.request)
+			);
+
+			this.socket.on("event:admin.dataRequests.resolved", res => {
+				this.dataRequests = this.dataRequests.filter(
+					request => request._id !== res.data.dataRequestId
+				);
+			});
+		},
+		resolveDataRequest(id) {
+			this.socket.dispatch("dataRequests.resolve", id, res => {
+				if (res.status === "success") new Toast(res.message);
+			});
 		},
 		...mapActions("modalVisibility", ["openModal"])
 	}

+ 3 - 3
frontend/src/pages/Home.vue

@@ -15,7 +15,7 @@
 						<img
 							class="logo"
 							src="/assets/white_wordmark.png"
-							:alt="`${this.siteName}` || `Musare`"
+							:alt="`${this.sitename}` || `Musare`"
 						/>
 						<div v-if="!loggedIn" class="buttons">
 							<button
@@ -464,7 +464,7 @@ export default {
 			stations: [],
 			favoriteStations: [],
 			searchQuery: "",
-			siteName: "Musare",
+			sitename: "Musare",
 			orderOfFavoriteStations: [],
 			drag: false
 		};
@@ -512,7 +512,7 @@ export default {
 		}
 	},
 	async mounted() {
-		this.siteName = await lofig.get("siteSettings.siteName");
+		this.sitename = await lofig.get("siteSettings.sitename");
 
 		if (this.socket.readyState === 1) this.init();
 		ws.onConnect(() => this.init());

+ 16 - 0
frontend/src/pages/Settings/tabs/Account.vue

@@ -171,6 +171,22 @@ export default {
 			}
 		}
 	},
+	mounted() {
+		if (
+			this.$route.query.removeAccount === "relinked-github" &&
+			!localStorage.getItem("github_redirect")
+		) {
+			this.openModal("removeAccount");
+
+			setTimeout(() => {
+				const modal = this.$parent.$children.find(
+					child => child.name === "RemoveAccount"
+				);
+
+				modal.confirmGithubLink();
+			}, 50);
+		}
+	},
 	methods: {
 		onInput(inputName) {
 			this.validation[inputName].entered = true;