APIModule.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. import config from "config";
  2. import { Types, isObjectIdOrHexString } from "mongoose";
  3. import { IncomingMessage } from "node:http";
  4. import JobContext from "../JobContext";
  5. import BaseModule from "../BaseModule";
  6. import { Jobs, Modules, UniqueMethods } from "../types/Modules";
  7. import WebSocket from "../WebSocket";
  8. import { UserRole } from "../schemas/user";
  9. import { StationType } from "../schemas/station";
  10. import permissions from "../permissions";
  11. import Job from "../Job";
  12. import { Models } from "../types/Models";
  13. export default class APIModule extends BaseModule {
  14. private _subscriptions: Record<string, Set<string>>;
  15. /**
  16. * API Module
  17. */
  18. public constructor() {
  19. super("api");
  20. this._dependentModules = ["data", "events", "websocket"];
  21. this._subscriptions = {};
  22. }
  23. /**
  24. * startup - Startup api module
  25. */
  26. public override async startup() {
  27. await super.startup();
  28. await super._started();
  29. }
  30. /**
  31. * shutdown - Shutdown api module
  32. */
  33. public override async shutdown() {
  34. await super.shutdown();
  35. await this._removeAllSubscriptions();
  36. await super._stopped();
  37. }
  38. /**
  39. * runJob - Run a job
  40. */
  41. public async runJob<
  42. ModuleNameType extends keyof Jobs & keyof Modules,
  43. JobNameType extends keyof Jobs[ModuleNameType] &
  44. keyof Omit<Modules[ModuleNameType], keyof BaseModule>,
  45. PayloadType extends "payload" extends keyof Jobs[ModuleNameType][JobNameType]
  46. ? Jobs[ModuleNameType][JobNameType]["payload"] extends undefined
  47. ? Record<string, never>
  48. : Jobs[ModuleNameType][JobNameType]["payload"]
  49. : Record<string, never>,
  50. ReturnType = "returns" extends keyof Jobs[ModuleNameType][JobNameType]
  51. ? Jobs[ModuleNameType][JobNameType]["returns"]
  52. : never
  53. >(
  54. context: JobContext,
  55. {
  56. moduleName,
  57. jobName,
  58. payload,
  59. sessionId,
  60. socketId
  61. }: {
  62. moduleName: ModuleNameType;
  63. jobName: JobNameType;
  64. payload: PayloadType;
  65. sessionId?: string;
  66. socketId?: string;
  67. }
  68. ): Promise<ReturnType> {
  69. let session;
  70. if (sessionId) {
  71. const Session = await context.getModel("session");
  72. session = await Session.findByIdAndUpdate(sessionId, {
  73. updatedAt: Date.now()
  74. });
  75. }
  76. return context.executeJob(moduleName, jobName, payload, {
  77. session,
  78. socketId
  79. });
  80. }
  81. /**
  82. * getCookieValueFromHeader - Get value of a cookie from cookie header string
  83. */
  84. private _getCookieValueFromHeader(cookieName: string, header: string) {
  85. const cookie = header
  86. .split("; ")
  87. .find(
  88. cookie =>
  89. cookie.substring(0, cookie.indexOf("=")) === cookieName
  90. );
  91. return cookie?.substring(cookie.indexOf("=") + 1, cookie.length);
  92. }
  93. /**
  94. * prepareWebsocket - Prepare websocket connection
  95. */
  96. public async prepareWebsocket(
  97. context: JobContext,
  98. { socket, request }: { socket: WebSocket; request: IncomingMessage }
  99. ) {
  100. const socketId = request.headers["sec-websocket-key"];
  101. socket.setSocketId(socketId);
  102. let sessionId = request.headers.cookie
  103. ? this._getCookieValueFromHeader(
  104. config.get<string>("cookie"),
  105. request.headers.cookie
  106. )
  107. : undefined;
  108. if (sessionId && isObjectIdOrHexString(sessionId))
  109. socket.setSessionId(sessionId);
  110. else sessionId = undefined;
  111. let user;
  112. if (sessionId) {
  113. const Session = await context.getModel("session");
  114. const session = await Session.findByIdAndUpdate(sessionId, {
  115. updatedAt: Date.now()
  116. });
  117. if (session) {
  118. context.setSession(session);
  119. user = await context.getUser().catch(() => undefined);
  120. }
  121. }
  122. socket.on("close", async () => {
  123. if (socketId)
  124. await this._jobQueue.runJob("api", "unsubscribeAll", {
  125. socketId
  126. });
  127. });
  128. return {
  129. config: {
  130. cookie: config.get("cookie"),
  131. sitename: config.get("sitename"),
  132. recaptcha: {
  133. enabled: config.get("apis.recaptcha.enabled"),
  134. key: config.get("apis.recaptcha.key")
  135. },
  136. githubAuthentication: config.get("apis.github.enabled"),
  137. messages: config.get("messages"),
  138. christmas: config.get("christmas"),
  139. footerLinks: config.get("footerLinks"),
  140. shortcutOverrides: config.get("shortcutOverrides"),
  141. registrationDisabled: config.get("registrationDisabled"),
  142. mailEnabled: config.get("mail.enabled"),
  143. discogsEnabled: config.get("apis.discogs.enabled"),
  144. experimental: {
  145. changable_listen_mode: config.get(
  146. "experimental.changable_listen_mode"
  147. ),
  148. media_session: config.get("experimental.media_session"),
  149. disable_youtube_search: config.get(
  150. "experimental.disable_youtube_search"
  151. ),
  152. station_history: config.get("experimental.station_history"),
  153. soundcloud: config.get("experimental.soundcloud"),
  154. spotify: config.get("experimental.spotify")
  155. }
  156. },
  157. user: user
  158. ? {
  159. loggedIn: true,
  160. role: user.role,
  161. username: user.username,
  162. email: user.email.address,
  163. userId: user._id
  164. }
  165. : { loggedIn: false }
  166. };
  167. }
  168. public async getUserPermissions(context: JobContext) {
  169. const user = await context.getUser().catch(() => null);
  170. if (!user) return {};
  171. const roles: UserRole[] = [user.role];
  172. let rolePermissions: Record<string, boolean> = {};
  173. roles.forEach(role => {
  174. if (permissions[role])
  175. rolePermissions = { ...rolePermissions, ...permissions[role] };
  176. });
  177. return rolePermissions;
  178. }
  179. public async getUserModelPermissions(
  180. context: JobContext,
  181. {
  182. modelName,
  183. modelId
  184. }: { modelName: keyof Models; modelId?: Types.ObjectId }
  185. ) {
  186. const user = await context.getUser().catch(() => null);
  187. const permissions = await context.getUserPermissions();
  188. const Model = await context.getModel(modelName);
  189. if (!Model) throw new Error("Model not found");
  190. const model = modelId ? await Model.findById(modelId) : null;
  191. if (modelId && !model) throw new Error("Model not found");
  192. const jobs = await Promise.all(
  193. Object.keys(this._moduleManager.getModule("data")?.getJobs() ?? {})
  194. .filter(
  195. jobName =>
  196. jobName.startsWith(modelName.toString()) &&
  197. (modelId ? true : !jobName.endsWith("ById"))
  198. )
  199. .map(async jobName => {
  200. jobName = `data.${jobName}`;
  201. let hasPermission = permissions[jobName];
  202. if (!hasPermission && modelId)
  203. hasPermission =
  204. permissions[`${jobName}.*`] ||
  205. permissions[`${jobName}.${modelId}`];
  206. if (hasPermission) return [jobName, true];
  207. const [, shortJobName] =
  208. new RegExp(`^data.${modelName}.([A-z]+)`).exec(
  209. jobName
  210. ) ?? [];
  211. const schemaOptions = (Model.schema.get("jobConfig") ?? {})[
  212. shortJobName
  213. ];
  214. let options = schemaOptions?.hasPermission ?? [];
  215. if (!Array.isArray(options)) options = [options];
  216. hasPermission = await options.reduce(
  217. async (previous, option) => {
  218. if (await previous) return true;
  219. if (option === "loggedIn" && user) return true;
  220. if (option === "owner" && user && model) {
  221. let ownerAttribute;
  222. if (model.schema.path("createdBy"))
  223. ownerAttribute = "createdBy";
  224. else if (model.schema.path("owner"))
  225. ownerAttribute = "owner";
  226. if (ownerAttribute)
  227. return (
  228. model[ownerAttribute].toString() ===
  229. user._id.toString()
  230. );
  231. }
  232. if (typeof option === "boolean") return option;
  233. if (typeof option === "function")
  234. return option(model, user);
  235. return false;
  236. },
  237. Promise.resolve(false)
  238. );
  239. return [jobName, !!hasPermission];
  240. })
  241. );
  242. return Object.fromEntries(jobs);
  243. }
  244. private async _subscriptionCallback(channel: string, value?: any) {
  245. const promises = [];
  246. for await (const socketId of this._subscriptions[channel].values()) {
  247. promises.push(
  248. this._jobQueue.runJob("websocket", "dispatch", {
  249. socketId,
  250. channel,
  251. value
  252. })
  253. );
  254. }
  255. await Promise.all(promises);
  256. }
  257. public async subscribe(
  258. context: JobContext,
  259. payload: { channel: string; socketId?: string }
  260. ) {
  261. // TODO: assert perm to join by socketId
  262. // TODO: Prevent socketId payload from outside backend
  263. const { channel } = payload;
  264. const socketId = payload.socketId ?? context.getSocketId();
  265. if (!socketId) throw new Error("No socketId specified");
  266. if (!this._subscriptions[channel])
  267. this._subscriptions[channel] = new Set();
  268. if (this._subscriptions[channel].has(socketId)) return;
  269. this._subscriptions[channel].add(socketId);
  270. if (this._subscriptions[channel].size === 1)
  271. await context.executeJob("events", "subscribe", {
  272. type: "event",
  273. channel,
  274. callback: value => this._subscriptionCallback(channel, value)
  275. });
  276. }
  277. public async unsubscribe(
  278. context: JobContext,
  279. payload: { channel: string; socketId: string }
  280. ) {
  281. const { channel } = payload;
  282. const socketId = payload.socketId ?? context.getSocketId();
  283. if (!socketId) throw new Error("No socketId specified");
  284. if (
  285. !(
  286. this._subscriptions[channel] &&
  287. this._subscriptions[channel].has(socketId)
  288. )
  289. )
  290. return;
  291. this._subscriptions[channel].delete(socketId);
  292. if (this._subscriptions[channel].size === 0)
  293. await context.executeJob("events", "unsubscribe", {
  294. type: "event",
  295. channel,
  296. callback: value => this._subscriptionCallback(channel, value)
  297. });
  298. }
  299. public async unsubscribeAll(
  300. context: JobContext,
  301. payload: { socketId: string }
  302. ) {
  303. const socketId = payload.socketId ?? context.getSocketId();
  304. if (!socketId) throw new Error("No socketId specified");
  305. await Promise.all(
  306. Object.entries(this._subscriptions)
  307. .filter(([, socketIds]) => socketIds.has(socketId))
  308. .map(([channel]) =>
  309. context.executeJob("api", "unsubscribe", {
  310. socketId,
  311. channel
  312. })
  313. )
  314. );
  315. }
  316. private async _removeAllSubscriptions() {
  317. await Promise.all(
  318. Object.entries(this._subscriptions).map(
  319. async ([channel, socketIds]) => {
  320. const promises = [];
  321. for await (const socketId of socketIds.values()) {
  322. promises.push(
  323. new Job("unsubscribe", "api", {
  324. socketId,
  325. channel
  326. }).execute()
  327. );
  328. }
  329. return Promise.all(promises);
  330. }
  331. )
  332. );
  333. }
  334. }
  335. export type APIModuleJobs = {
  336. [Property in keyof UniqueMethods<APIModule>]: {
  337. payload: Parameters<UniqueMethods<APIModule>[Property]>[1];
  338. returns: Awaited<ReturnType<UniqueMethods<APIModule>[Property]>>;
  339. };
  340. };