2 Commits ad62ddd6ab ... 0fbb3ad45d

Author SHA1 Message Date
  Kristian Vos 0fbb3ad45d chore: update copyright year 1 month ago
  Kristian Vos 349858e8f9 feat: add ability to restrict site to logged-in users 1 month ago

+ 1 - 0
.wiki/Configuration.md

@@ -124,6 +124,7 @@ For more information on configuration files please refer to the
 | `primaryColor` | Primary color of the application, in hex format. |
 | `registrationDisabled` | If set to `true`, users can't register accounts. |
 | `sendDataRequestEmails` | If `true` all admin users will be sent an email if a data request is received. Requires mail to be enabled and configured. |
+| `restrictToUsers` | If `true` only logged-in users will be able to visit user profiles, see news, see stations on the homepage or enter stations (even public stations) - any interactive thing except logging in/registering, and some public config info (site name, experimental features enabled, footer mail/oidc/password enabled, account removal message, etc.) |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
 | `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
 | `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capture all jobs specified in `debug.captureJobs`. |

+ 1 - 0
backend/config/default.json

@@ -109,6 +109,7 @@
 	"shortcutOverrides": {},
 	"registrationDisabled": false,
 	"sendDataRequestEmails": true,
+	"restrictToUsers": false,
 	"skipConfigVersionCheck": false,
 	"skipDbDocumentsVersionCheck": false,
 	"debug": {

+ 5 - 4
backend/logic/actions/activities.js

@@ -1,6 +1,7 @@
 import async from "async";
 
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -37,7 +38,7 @@ export default {
 	 * @param {string} userId - the id of the user in question
 	 * @param {Function} cb - callback
 	 */
-	async length(session, userId, cb) {
+	length: isLoginSometimesRequired(async function length(session, userId, cb) {
 		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
 
 		async.waterfall(
@@ -66,7 +67,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets a set of activities
@@ -76,7 +77,7 @@ export default {
 	 * @param {number} offset - how many activities to skip (keeps frontend and backend in sync)
 	 * @param {Function} cb - callback
 	 */
-	async getSet(session, userId, set, offset, cb) {
+	getSet: isLoginSometimesRequired(async function getSet(session, userId, set, offset, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
 
@@ -121,7 +122,7 @@ export default {
 				return cb({ status: "success", data: { activities } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Hides an activity for a user

+ 3 - 2
backend/logic/actions/apis.js

@@ -3,6 +3,7 @@ import async from "async";
 import axios from "axios";
 
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
@@ -314,7 +315,7 @@ export default {
 	 * @param {string} room - the room to join
 	 * @param {Function} cb - callback
 	 */
-	joinRoom(session, room, cb) {
+	joinRoom: isLoginSometimesRequired(function joinRoom(session, room, cb) {
 		const roomName = room.split(".")[0];
 		// const roomId = room.split(".")[1];
 		const rooms = {
@@ -352,7 +353,7 @@ export default {
 				.then(() => join("success"))
 				.catch(err => join("error", err));
 		else join("error", "Room not found");
-	},
+	}),
 
 	/**
 	 * Leaves a room

+ 3 - 2
backend/logic/actions/media.js

@@ -1,6 +1,7 @@
 import async from "async";
 
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
@@ -706,7 +707,7 @@ export default {
 	 * @param cb
 	 */
 
-	async getRatings(session, mediaSource, cb) {
+	getRatings: isLoginSometimesRequired(async function getRatings(session, mediaSource, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -744,7 +745,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets user's own ratings

+ 8 - 6
backend/logic/actions/news.js

@@ -1,5 +1,6 @@
 import async from "async";
 
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
@@ -166,7 +167,7 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
-	async getPublished(session, cb) {
+	getPublished: isLoginSometimesRequired(async function getPublished(session, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		async.waterfall(
 			[
@@ -186,7 +187,7 @@ export default {
 				return cb({ status: "success", data: { news } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets a news item by id
@@ -194,7 +195,7 @@ export default {
 	 * @param {string} newsId - the news item id
 	 * @param {Function} cb - gets called with the result
 	 */
-	async getNewsFromId(session, newsId, cb) {
+	getNewsFromId: isLoginSometimesRequired(async function getNewsFromId(session, newsId, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 
 		async.waterfall(
@@ -215,7 +216,8 @@ export default {
 				return cb({ status: "success", data: { news } });
 			}
 		);
-	},
+	}),
+
 	/**
 	 * Creates a news item
 	 * @param {object} session - the session object automatically added by the websocket
@@ -257,7 +259,7 @@ export default {
 	 * @param {boolean} newUser - whether the user requesting the newest news is a new user
 	 * @param {Function} cb - gets called with the result
 	 */
-	async newest(session, newUser, cb) {
+	newest: isLoginSometimesRequired(async function newest(session, newUser, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		const query = { status: "published" };
 		if (newUser) query.showToNewUsers = true;
@@ -274,7 +276,7 @@ export default {
 				return cb({ status: "success", data: { news } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Removes a news item

+ 7 - 6
backend/logic/actions/playlists.js

@@ -2,6 +2,7 @@ import async from "async";
 import config from "config";
 
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
@@ -629,7 +630,7 @@ export default {
 	 * @param {string} userId - the user id in question
 	 * @param {Function} cb - gets called with the result
 	 */
-	indexForUser: async function indexForUser(session, userId, cb) {
+	indexForUser: isLoginSometimesRequired(async function indexForUser(session, userId, cb) {
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
@@ -698,7 +699,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets all playlists for the user requesting it
@@ -896,7 +897,7 @@ export default {
 	 * @param {string} playlistId - the id of the playlist we are getting
 	 * @param {Function} cb - gets called with the result
 	 */
-	getPlaylist: function getPlaylist(session, playlistId, cb) {
+	getPlaylist: isLoginSometimesRequired(function getPlaylist(session, playlistId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -937,7 +938,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets a playlist from station id
@@ -946,7 +947,7 @@ export default {
 	 * @param {string} includeSongs - include songs
 	 * @param {Function} cb - gets called with the result
 	 */
-	getPlaylistForStation: function getPlaylist(session, stationId, includeSongs, cb) {
+	getPlaylistForStation: isLoginSometimesRequired(function getPlaylist(session, stationId, includeSongs, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -987,7 +988,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Shuffles songs in a private playlist

+ 90 - 87
backend/logic/actions/stations.js

@@ -4,6 +4,7 @@ import config from "config";
 
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -359,7 +360,7 @@ export default {
 	 * @param {boolean} adminFilter - whether to filter out stations admins do not own
 	 * @param {Function} cb - callback
 	 */
-	async index(session, adminFilter, cb) {
+	index: isLoginSometimesRequired(async function index(session, adminFilter, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 
 		async.waterfall(
@@ -454,7 +455,7 @@ export default {
 				return cb({ status: "success", data: { stations, favorited } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets stations, used in the admin stations page by the AdvancedTable component
@@ -564,7 +565,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	getStationForActivity(session, stationId, cb) {
+	getStationForActivity: isLoginSometimesRequired(function getStationForActivity(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -599,7 +600,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Verifies that a station exists from its name
@@ -607,7 +608,7 @@ export default {
 	 * @param {string} stationName - the station name
 	 * @param {Function} cb - callback
 	 */
-	existsByName(session, stationName, cb) {
+	existsByName: isLoginSometimesRequired(function existsByName(session, stationName, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -643,7 +644,7 @@ export default {
 				return cb({ status: "success", data: { exists } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Verifies that a station exists from its id
@@ -651,7 +652,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	existsById(session, stationId, cb) {
+	existsById: isLoginSometimesRequired(function existsById(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -687,7 +688,7 @@ export default {
 				return cb({ status: "success", data: { exists } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets the official playlist for a station
@@ -695,7 +696,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	getPlaylist(session, stationId, cb) {
+	getPlaylist: isLoginSometimesRequired(function getPlaylist(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -769,7 +770,7 @@ export default {
 				return cb({ status: "success", data: { songs: playlist.songs } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Joins the station by its name
@@ -777,7 +778,7 @@ export default {
 	 * @param {string} stationIdentifier - the station name or station id
 	 * @param {Function} cb - callback
 	 */
-	async join(session, stationIdentifier, cb) {
+	join: isLoginSometimesRequired(async function join(session, stationIdentifier, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 		async.waterfall(
 			[
@@ -897,7 +898,7 @@ export default {
 				return cb({ status: "success", data });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets a station by id
@@ -905,7 +906,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	getStationById(session, stationId, cb) {
+	getStationById: isLoginSometimesRequired(function getStationById(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -986,7 +987,7 @@ export default {
 				return cb({ status: "success", data: { station: data } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets station history
@@ -994,7 +995,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	getHistory(session, stationId, cb) {
+	getHistory: isLoginSometimesRequired(function getHistory(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1054,80 +1055,82 @@ export default {
 				return cb({ status: "success", data: { history } });
 			}
 		);
-	},
-
-	getStationAutofillPlaylistsById(session, stationId, cb) {
-		async.waterfall(
-			[
-				next => {
-					StationsModule.runJob("GET_STATION", { stationId }, this)
-						.then(station => {
-							next(null, station);
-						})
-						.catch(next);
-				},
-
-				(station, next) => {
-					if (!station) return next("Station not found.");
-					return StationsModule.runJob(
-						"CAN_USER_VIEW_STATION",
-						{
-							station,
-							userId: session.userId
-						},
-						this
-					)
-						.then(canView => {
-							if (!canView) next("Not allowed to get station.");
-							else next(null, station);
-						})
-						.catch(err => next(err));
-				},
+	}),
 
-				(station, next) => {
-					const playlists = [];
+	getStationAutofillPlaylistsById: isLoginSometimesRequired(
+		function getStationAutofillPlaylistsById(session, stationId, cb) {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
 
-					async.eachLimit(
-						station.autofill.playlists,
-						1,
-						(playlistId, next) => {
-							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-								.then(playlist => {
-									playlists.push(playlist);
-									next();
-								})
-								.catch(() => {
-									playlists.push(null);
-									next();
-								});
-						},
-						err => {
-							next(err, playlists);
-						}
-					);
-				}
-			],
-			async (err, playlists) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					(station, next) => {
+						if (!station) return next("Station not found.");
+						return StationsModule.runJob(
+							"CAN_USER_VIEW_STATION",
+							{
+								station,
+								userId: session.userId
+							},
+							this
+						)
+							.then(canView => {
+								if (!canView) next("Not allowed to get station.");
+								else next(null, station);
+							})
+							.catch(err => next(err));
+					},
+
+					(station, next) => {
+						const playlists = [];
+
+						async.eachLimit(
+							station.autofill.playlists,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										playlists.push(playlist);
+										next();
+									})
+									.catch(() => {
+										playlists.push(null);
+										next();
+									});
+							},
+							err => {
+								next(err, playlists);
+							}
+						);
+					}
+				],
+				async (err, playlists) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"GET_STATION_AUTOFILL_PLAYLISTS_BY_ID",
+							`Getting station "${stationId}"'s autofilling playlists failed. "${err}"`
+						);
+						return cb({ status: "error", message: err });
+					}
 					this.log(
-						"ERROR",
+						"SUCCESS",
 						"GET_STATION_AUTOFILL_PLAYLISTS_BY_ID",
-						`Getting station "${stationId}"'s autofilling playlists failed. "${err}"`
+						`Got station "${stationId}"'s autofilling playlists successfully.`
 					);
-					return cb({ status: "error", message: err });
+					return cb({ status: "success", data: { playlists } });
 				}
-				this.log(
-					"SUCCESS",
-					"GET_STATION_AUTOFILL_PLAYLISTS_BY_ID",
-					`Got station "${stationId}"'s autofilling playlists successfully.`
-				);
-				return cb({ status: "success", data: { playlists } });
-			}
-		);
-	},
+			);
+		}
+	),
 
-	getStationBlacklistById(session, stationId, cb) {
+	getStationBlacklistById: isLoginSometimesRequired(function getStationBlacklistById(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1196,7 +1199,7 @@ export default {
 				return cb({ status: "success", data: { playlists } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Toggle votes to skip a station
@@ -1331,7 +1334,7 @@ export default {
 	 * @param {string} stationId - id of station to leave
 	 * @param {Function} cb - callback
 	 */
-	leave(session, stationId, cb) {
+	leave: isLoginSometimesRequired(function leave(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1366,7 +1369,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Updates a station's settings
@@ -2034,7 +2037,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	getQueue(session, stationId, cb) {
+	getQueue: isLoginSometimesRequired(function getQueue(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -2079,7 +2082,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Reposition a song in station queue

+ 3 - 2
backend/logic/actions/users.js

@@ -6,6 +6,7 @@ import mongoose from "mongoose";
 import bcrypt from "bcrypt";
 import sha256 from "sha256";
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
@@ -984,7 +985,7 @@ export default {
 	 * @param {string} identifier - the ObjectId or username of the user we are trying to find
 	 * @param {Function} cb - gets called with the result
 	 */
-	getBasicUser: async function getBasicUser(session, identifier, cb) {
+	getBasicUser: isLoginSometimesRequired(async function getBasicUser(session, identifier, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
@@ -1025,7 +1026,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets a list of long jobs, including onprogress events when those long jobs have progress

+ 50 - 0
backend/logic/hooks/loginSometimesRequired.js

@@ -0,0 +1,50 @@
+import async from "async";
+import config from "config";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const CacheModule = moduleManager.modules.cache;
+const UtilsModule = moduleManager.modules.utils;
+
+// This is for actions that are only restricted to logged-in users if restrictToUsers config option is true
+export default destination =>
+	function loginSometimesRequired(session, ...args) {
+		const cb = args[args.length - 1];
+
+		async.waterfall(
+			[
+				next => {
+					if (!config.get("restrictToUsers")) next(true);
+					else if (!session || !session.sessionId) next("Login required.");
+					else next();
+				},
+
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "sessions",
+							key: session.sessionId
+						},
+						this
+					)
+						.then(session => next(null, session))
+						.catch(next);
+				},
+				(session, next) => {
+					if (!session || !session.userId) return next("Login required.");
+					return next();
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("LOGIN_SOMETIMES_REQUIRED", `User failed to pass login required check.`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("LOGIN_SOMETIMES_REQUIRED", `User "${session.userId}" passed login required check.`);
+				return destination.apply(this, [session].concat(args));
+			}
+		);
+	};

+ 1 - 1
frontend/src/components/MainFooter.vue

@@ -25,7 +25,7 @@ const getLink = title =>
 		<div class="container">
 			<div class="footer-content">
 				<div id="footer-copyright">
-					<p>© Copyright Musare 2015 - 2024</p>
+					<p>© Copyright Musare 2015 - 2025</p>
 				</div>
 				<router-link id="footer-logo" to="/">
 					<img

+ 2 - 2
frontend/src/index.html

@@ -1,4 +1,4 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="en">
 	<head>
 		<title>{{ title }}</title>
@@ -19,7 +19,7 @@
 		/>
 		<meta
 			name="copyright"
-			content="© Copyright Musare 2015-2024 All Right Reserved"
+			content="© Copyright Musare 2015-2025 All Right Reserved"
 		/>
 
 		<link