diff --git a/README.md b/README.md index 9d15d234..4d820d0c 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ *Inspired by the larger sites that are now charging for core functionality, Scholarsome intends to be a drop-in replacement for any study workflow.* -https://scholarsome.com - ![](https://img.shields.io/badge/-Join%20our%20Discord-white?logo=Discord&logoColor=blue) ![](https://img.shields.io/github/license/hwgilbert16/scholarsome?color=blue) ![](https://img.shields.io/badge/contributions-welcome-orange) @@ -61,13 +59,11 @@ You can read more about our design philosophy here. +Scholarsome can be hosted yourself on any system. For those wishing to self-host Scholarsome, documentation for installation can be found here. ## Development -For development purposes, Scholarsome is required to be installed outside the standard container-based system that is used for production installs. Documentation for development can be found here. +For development purposes, Scholarsome is required to be installed outside the standard container-based system that is used for production installs. Documentation for development can be found here. While we use many technologies, some of our most prominent are: diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 07d59095..7fde688e 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -21,6 +21,8 @@ import { TokenRefreshMiddleware } from "./providers/token-refresh.middleware"; import { ConvertingModule } from "./converting/converting.module"; import { StorageModule } from "./providers/storage/storage.module"; import { FoldersModule } from "./folders/folders.module"; +import { ScheduleModule } from "@nestjs/schedule"; +import { TasksService } from "./providers/tasks.service"; @Module({ imports: [ @@ -30,7 +32,7 @@ import { FoldersModule } from "./folders/folders.module"; cacheControl: true, maxAge: 31536000 }, - exclude: ["/api/(.*)", "/handbook/(.*)"] + exclude: ["/api/(.*)", "/handbook/(.*)", "/sitemaps/(.*)", "/sitemap.xml"] }), ServeStaticModule.forRoot({ rootPath: join(__dirname, "..", "docs"), @@ -40,13 +42,31 @@ import { FoldersModule } from "./folders/folders.module"; maxAge: 31536000 } }), + ServeStaticModule.forRoot({ + rootPath: join(__dirname, "..", "..", "sitemaps"), + serveRoot: "/sitemaps", + serveStaticOptions: { + index: false, + cacheControl: true, + maxAge: 0 + } + }), + ServeStaticModule.forRoot({ + rootPath: join(__dirname, "..", "..", "sitemaps", "sitemap.xml"), + serveRoot: "/sitemap.xml", + serveStaticOptions: { + index: false, + cacheControl: true, + maxAge: 0 + } + }), ConfigModule.forRoot({ isGlobal: true }), RedisModule.forRootAsync({ inject: [ConfigService], useFactory: (configService: ConfigService) => ({ - defaultOptions: { + commonOptions: { host: configService.get("REDIS_HOST"), port: configService.get("REDIS_PORT"), username: configService.get("REDIS_USERNAME"), @@ -64,6 +84,7 @@ import { FoldersModule } from "./folders/folders.module"; ] }) }), + ScheduleModule.forRoot(), AuthModule, DatabaseModule, SetsModule, @@ -86,7 +107,7 @@ import { FoldersModule } from "./folders/folders.module"; FoldersModule ], controllers: [], - providers: [], + providers: [TasksService], exports: [JwtModule] }) export class AppModule implements NestModule { diff --git a/apps/api/src/app/cards/cards.service.ts b/apps/api/src/app/cards/cards.service.ts index cec7b180..6918ea87 100644 --- a/apps/api/src/app/cards/cards.service.ts +++ b/apps/api/src/app/cards/cards.service.ts @@ -60,7 +60,7 @@ export class CardsService { await this.storageService.getInstance() .putFile("media/sets/" + fileName, decoded); - side = side.replace(source, "/api/sets/" + setId + "/media/" + fileName); + side = side.replace(source, "/api/sets/" + setId + "/media/" + name); } } else return false; diff --git a/apps/api/src/app/cards/dto/createCard.dto.ts b/apps/api/src/app/cards/dto/createCard.dto.ts index afaf7ea4..392d166c 100644 --- a/apps/api/src/app/cards/dto/createCard.dto.ts +++ b/apps/api/src/app/cards/dto/createCard.dto.ts @@ -1,58 +1,59 @@ -import { IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator"; +import { + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUUID, + Max, + Min, +} from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { Transform, TransformFnParams } from "class-transformer"; import * as sanitizeHtml from "sanitize-html"; +import { sanitizationConfig } from "../../shared/sanitization/sanitization-config"; export class CreateCardDto { @ApiProperty({ description: "The ID of the set that the card will belong to", example: "27758237-5f57-4f6c-b483-6161056dad76", minLength: 36, - maxLength: 36 + maxLength: 36, }) @IsUUID("4") @IsNotEmpty() - setId: string; + setId: string; @ApiProperty({ description: "The index of the card in the set", example: 0, minimum: 0, - maximum: 2147483647 + maximum: 2147483647, }) @IsNumber() @Min(0) @Max(2147483647) @IsOptional() - index: number; + index: number; @ApiProperty({ - description: "The front or \"term\" of the card", + description: 'The front or "term" of the card', example: "The definition of the card", - maxLength: 65535 }) @IsString() @IsNotEmpty() - @MaxLength(65535) - @Transform((params: TransformFnParams) => sanitizeHtml(params.value, { - allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: { "img": ["src", "width", "height"], "span": ["style"] }, - allowedSchemes: ["data"] - })) - term: string; + @Transform((params: TransformFnParams) => + sanitizeHtml(params.value, sanitizationConfig) + ) + term: string; @ApiProperty({ - description: "The back or \"definition\" of the card", + description: 'The back or "definition" of the card', example: "The definition of the card", - maxLength: 65535 }) @IsString() @IsNotEmpty() - @MaxLength(65535) - @Transform((params: TransformFnParams) => sanitizeHtml(params.value, { - allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: { "img": ["src", "width", "height"], "span": ["style"] }, - allowedSchemes: ["data"] - })) - definition: string; + @Transform((params: TransformFnParams) => + sanitizeHtml(params.value, sanitizationConfig) + ) + definition: string; } diff --git a/apps/api/src/app/cards/dto/updateCard.dto.ts b/apps/api/src/app/cards/dto/updateCard.dto.ts index d83a8c15..26b17abc 100644 --- a/apps/api/src/app/cards/dto/updateCard.dto.ts +++ b/apps/api/src/app/cards/dto/updateCard.dto.ts @@ -1,7 +1,15 @@ -import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from "class-validator"; +import { + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { Transform, TransformFnParams } from "class-transformer"; import * as sanitizeHtml from "sanitize-html"; +import { sanitizationConfig } from "../../shared/sanitization/sanitization-config"; export class UpdateCardDto { @ApiProperty({ @@ -9,46 +17,38 @@ export class UpdateCardDto { example: 0, required: false, minimum: 0, - maximum: 2147483647 + maximum: 2147483647, }) @IsNumber() @IsOptional() @Min(0) @Max(2147483647) @IsNotEmpty() - index?: number; + index?: number; @ApiProperty({ - description: "The front or \"term\" of the card", + description: 'The front or "term" of the card', example: "The definition of the card", - maxLength: 65535, - required: false + required: false, }) @IsString() @IsOptional() @IsNotEmpty() - @MaxLength(65535) - @Transform((params: TransformFnParams) => sanitizeHtml(params.value, { - allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: { "img": ["src", "width", "height"], "span": ["style"] }, - allowedSchemes: ["data"] - })) - term?: string; + @Transform((params: TransformFnParams) => + sanitizeHtml(params.value, sanitizationConfig) + ) + term?: string; @ApiProperty({ - description: "The back or \"definition\" of the card", + description: 'The back or "definition" of the card', example: "The definition of the card", - maxLength: 65535, - required: false + required: false, }) @IsString() @IsOptional() @IsNotEmpty() - @MaxLength(65535) - @Transform((params: TransformFnParams) => sanitizeHtml(params.value, { - allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: { "img": ["src", "width", "height"], "span": ["style"] }, - allowedSchemes: ["data"] - })) - definition?: string; + @Transform((params: TransformFnParams) => + sanitizeHtml(params.value, sanitizationConfig) + ) + definition?: string; } diff --git a/apps/api/src/app/folders/folders.controller.ts b/apps/api/src/app/folders/folders.controller.ts index 5aefcbad..d9d09a48 100644 --- a/apps/api/src/app/folders/folders.controller.ts +++ b/apps/api/src/app/folders/folders.controller.ts @@ -217,8 +217,6 @@ export class FoldersController { @UseGuards(AuthenticatedGuard) @Post() async createFolder(@Body(HtmlDecodePipe) body: CreateFolderDto, @Request() req: ExpressRequest): Promise> { - console.log(body); - const user = await this.authService.getUserInfo(req); if (!user) { throw new UnauthorizedException({ diff --git a/apps/api/src/app/folders/folders.service.ts b/apps/api/src/app/folders/folders.service.ts index 26375827..0a5b80a4 100644 --- a/apps/api/src/app/folders/folders.service.ts +++ b/apps/api/src/app/folders/folders.service.ts @@ -39,6 +39,24 @@ export class FoldersService { return folder.author.id === user.id; } + /** + * Queries the database for every public folder's ID and when they were last modified + * Used for sitemap generation + * + * @returns Array of all folder IDs and when they were last updated + */ + async getSitemapFolderInfo(): Promise<{ id: string, updatedAt: Date }[]> { + return this.prisma.folder.findMany({ + where: { + private: false + }, + select: { + id: true, + updatedAt: true + } + }); + } + /** * Queries the database for a unique folder * diff --git a/apps/api/src/app/media/media.controller.ts b/apps/api/src/app/media/media.controller.ts index 223a085b..5633cc1f 100644 --- a/apps/api/src/app/media/media.controller.ts +++ b/apps/api/src/app/media/media.controller.ts @@ -1,7 +1,8 @@ import { BadRequestException, Body, - Controller, Delete, + Controller, + Delete, Get, NotFoundException, Param, @@ -56,24 +57,39 @@ export class MediaController { type: ErrorResponse }) @Get(["sets/:setId/media/:file", "media/sets/:setId/:file"]) - async getSetFile(@Param() params: SetIdAndFileParam, @Request() req: ExpressRequest, @Res({ passthrough: true }) res: Response) { + async getSetFile( + @Param() params: SetIdAndFileParam, + @Request() req: ExpressRequest, + @Res({ passthrough: true }) res: Response + ) { const set = await this.setsService.set({ id: params.setId }); - if (!set) throw new NotFoundException({ status: "fail", message: "Set not found" }); + if (!set) { + throw new NotFoundException({ status: "fail", message: "Set not found" }); + } if (set.private) { const userCookie = await this.authService.getUserInfo(req); if (!userCookie || set.authorId !== userCookie.id) { - throw new UnauthorizedException({ status: "fail", message: "Invalid authentication to access the requested resource" }); + throw new UnauthorizedException({ + status: "fail", + message: "Invalid authentication to access the requested resource" + }); } } - const file = await this.storageService.getInstance() + const file = await this.storageService + .getInstance() .getFile("media/sets/" + params.setId + "/" + params.file); - if (!file) throw new NotFoundException({ status: "fail", message: "Media not found" }); + if (!file) { + throw new NotFoundException({ + status: "fail", + message: "Media not found" + }); + } res.writeHead(200, { "Content-Type": "image/" + params.file.split(".").pop() @@ -86,7 +102,8 @@ export class MediaController { @ApiTags("Sets") @ApiOperation({ summary: "Get a set media file", - description: "Retrieves a media file that is attached to a set. Deprecated URL - see route above for correct URL.", + description: + "Retrieves a media file that is attached to a set. Deprecated URL - see route above for correct URL.", deprecated: true }) @ApiOkResponse({ @@ -131,12 +148,23 @@ export class MediaController { @Query("height") height: string ) { const userCookie = await this.authService.getUserInfo(req); - if (!userCookie) throw new UnauthorizedException({ status: "fail", message: "Invalid authentication to access the requested resource" }); + if (!userCookie) { + throw new UnauthorizedException({ + status: "fail", + message: "Invalid authentication to access the requested resource" + }); + } - const file = await this.storageService.getInstance() + const file = await this.storageService + .getInstance() .getFile("media/avatars/" + userCookie.id + ".jpeg"); - if (!file) throw new NotFoundException({ status: "fail", message: "Media not found" }); + if (!file) { + throw new NotFoundException({ + status: "fail", + message: "Media not found" + }); + } res.writeHead(200, { "Content-Type": "image/jpeg" @@ -186,10 +214,16 @@ export class MediaController { @Query("width") width: string, @Query("height") height: string ) { - const file = await this.storageService.getInstance() + const file = await this.storageService + .getInstance() .getFile("media/avatars/" + params.userId + ".jpeg"); - if (!file) throw new NotFoundException({ status: "fail", message: "Media not found" }); + if (!file) { + throw new NotFoundException({ + status: "fail", + message: "Media not found" + }); + } res.writeHead(200, { "Content-Type": "image/jpeg" @@ -223,11 +257,20 @@ export class MediaController { type: ErrorResponse }) @Post("user/me/avatar") - async setMyAvatar(@Body() setAvatarDto: SetAvatarDto, @Request() req: ExpressRequest, @UploadedFile() file: Express.Multer.File) { + async setMyAvatar( + @Body() setAvatarDto: SetAvatarDto, + @Request() req: ExpressRequest, + @UploadedFile() file: Express.Multer.File + ) { if (!file) throw new BadRequestException(); const userCookie = await this.authService.getUserInfo(req); - if (!userCookie) throw new UnauthorizedException({ status: "fail", message: "Invalid authentication to access the requested resource" }); + if (!userCookie) { + throw new UnauthorizedException({ + status: "fail", + message: "Invalid authentication to access the requested resource" + }); + } const avatar = await sharp(file.buffer) .jpeg({ progressive: true, force: true, quality: 80 }) @@ -235,7 +278,8 @@ export class MediaController { .flatten({ background: "#ffffff" }) .toBuffer(); - await this.storageService.getInstance() + await this.storageService + .getInstance() .putFile("media/avatars/" + userCookie.id + ".jpeg", avatar); return { @@ -259,9 +303,15 @@ export class MediaController { @Delete("user/me/avatar") async deleteAvatar(@Request() req: ExpressRequest) { const userCookie = await this.authService.getUserInfo(req); - if (!userCookie) throw new UnauthorizedException({ status: "fail", message: "Invalid authentication to access the requested resource" }); + if (!userCookie) { + throw new UnauthorizedException({ + status: "fail", + message: "Invalid authentication to access the requested resource" + }); + } - await this.storageService.getInstance() + await this.storageService + .getInstance() .deleteFile("media/avatars/" + userCookie.id + ".jpeg"); return { diff --git a/apps/api/src/app/providers/missing-sitemap.middleware.ts b/apps/api/src/app/providers/missing-sitemap.middleware.ts new file mode 100644 index 00000000..f10d7122 --- /dev/null +++ b/apps/api/src/app/providers/missing-sitemap.middleware.ts @@ -0,0 +1,25 @@ +import { NextFunction, Request, Response } from "express"; +import * as fs from "fs"; +import { join } from "path"; +import { HttpStatus } from "@nestjs/common"; + +export function missingSitemapMiddleware(req: Request, res: Response, next: NextFunction) { + if (req.url.startsWith("/sitemaps")) { + const urlParams = req.url.split("/"); + + // if the last param is an empty string, then we know the file doesn't exist + const exists = + urlParams[urlParams.length - 1] !== "" ? fs.existsSync(join("./dist/sitemaps", urlParams[urlParams.length - 1])) : false; + + if (!exists) { + res.status(HttpStatus.NOT_FOUND).json({ + statusCode: HttpStatus.NOT_FOUND, + message: "Not Found" + }); + + res.end(); + } + } + + next(); +} diff --git a/apps/api/src/app/providers/no-index.middleware.ts b/apps/api/src/app/providers/no-index.middleware.ts new file mode 100644 index 00000000..db644578 --- /dev/null +++ b/apps/api/src/app/providers/no-index.middleware.ts @@ -0,0 +1,9 @@ +import { Request, Response, NextFunction } from "express"; + +export function noIndexMiddleware(req: Request, res: Response, next: NextFunction) { + if (req.path.startsWith("/api") || process.env.NODE_ENV !== "public") { + res.setHeader("X-Robots-Tag", "noindex"); + } + + next(); +} diff --git a/apps/api/src/app/providers/storage/interfaces/storage-provider.interface.ts b/apps/api/src/app/providers/storage/interfaces/storage-provider.interface.ts index e7e3bff6..6bc493f0 100644 --- a/apps/api/src/app/providers/storage/interfaces/storage-provider.interface.ts +++ b/apps/api/src/app/providers/storage/interfaces/storage-provider.interface.ts @@ -8,9 +8,9 @@ export interface StorageProvider { * * @throws {Error} A file does not exist. * - * @returns File content in bytes. + * @returns File content in bytes or null if file does not exist. */ - getFile(path: string): Promise; + getFile(path: string): Promise; /** * Puts a file. diff --git a/apps/api/src/app/providers/storage/provider/local.storage.ts b/apps/api/src/app/providers/storage/provider/local.storage.ts index 5908af79..7f49f516 100644 --- a/apps/api/src/app/providers/storage/provider/local.storage.ts +++ b/apps/api/src/app/providers/storage/provider/local.storage.ts @@ -11,7 +11,7 @@ export class LocalStorageProvider implements StorageProvider { this.localStorageDir = configService.get("STORAGE_LOCAL_DIR"); } - public async getFile(path: string): Promise { + public async getFile(path: string): Promise { const filePath = nodePath.join(this.localStorageDir, path); if (!fs.existsSync(filePath)) return null; @@ -72,12 +72,6 @@ export class LocalStorageProvider implements StorageProvider { throw new Error(`the path provided is not a directory: "${dirPath}"`); } - for (const entity of await fs.promises.readdir(dirPath)) { - if (await this.isDirectory(nodePath.join(dirPath, entity))) { - throw new Error(`directory "${dirPath}" contains subdirectories.`); - } - } - await fs.promises.rm(dirPath, { recursive: true, force: true }); } diff --git a/apps/api/src/app/providers/storage/provider/s3.storage.ts b/apps/api/src/app/providers/storage/provider/s3.storage.ts index c9de88b3..6dc8832a 100644 --- a/apps/api/src/app/providers/storage/provider/s3.storage.ts +++ b/apps/api/src/app/providers/storage/provider/s3.storage.ts @@ -20,11 +20,17 @@ export class S3StorageProvider implements StorageProvider { this.bucket = configService.get("S3_STORAGE_BUCKET"); } - public async getFile(path: string): Promise { - const file = await this.s3.getObject({ - Key: path, - Bucket: this.bucket - }); + public async getFile(path: string): Promise { + let file; + + try { + file = await this.s3.getObject({ + Key: path, + Bucket: this.bucket + }); + } catch (_) { + return null; + } const content = await file.Body.transformToByteArray(); diff --git a/apps/api/src/app/providers/tasks.service.ts b/apps/api/src/app/providers/tasks.service.ts new file mode 100644 index 00000000..61402e9e --- /dev/null +++ b/apps/api/src/app/providers/tasks.service.ts @@ -0,0 +1,166 @@ +import { Injectable } from "@nestjs/common"; +import { Cron } from "@nestjs/schedule"; +import { SetsService } from "../sets/sets.service"; +import { create } from "xmlbuilder2"; +import { ConfigService } from "@nestjs/config"; +import * as fs from "fs"; +import { UsersService } from "../users/users.service"; +import { FoldersService } from "../folders/folders.service"; +import { CronExpression } from "@nestjs/schedule"; + +@Injectable() +export class TasksService { + constructor( + private readonly setsService: SetsService, + private readonly usersService: UsersService, + private readonly foldersService: FoldersService, + private readonly configService: ConfigService + ) {} + + /** + * Generates a sitemap to save to the filesystem + */ + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async generateSitemap() { + // sitemaps are only generated when NODE_ENV is set to public + if (this.configService.get("NODE_ENV") !== "public") return; + + let htmlPrefix = ""; + + const date = new Date().toISOString(); + + if ( + process.env.SSL_KEY_BASE64 && + process.env.SSL_KEY_BASE64.length > 0 && + process.env.SSL_CERT_BASE64 && + process.env.SSL_CERT_BASE64.length > 0 + ) { + htmlPrefix = "https"; + } else htmlPrefix = "http"; + + /* + Generate the root sitemap.xml, that links to the others + */ + + const sitemapIndex = + create({ version: "1.0", encoding: "UTF-8" }); + + const sitemapIndexRoot = sitemapIndex + .ele("sitemapindex") + .att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"); + + sitemapIndexRoot + .ele("sitemap") + .ele("loc") + .txt(`${htmlPrefix}://${this.configService.get("HOST")}/sitemaps/sets.xml`) + .up() + .ele("lastmod") + .txt(date); + + sitemapIndexRoot + .ele("sitemap") + .ele("loc") + .txt(`${htmlPrefix}://${this.configService.get("HOST")}/sitemaps/folders.xml`) + .up() + .ele("lastmod") + .txt(date); + + sitemapIndexRoot + .ele("sitemap") + .ele("loc") + .txt(`${htmlPrefix}://${this.configService.get("HOST")}/sitemaps/users.xml`) + .up() + .ele("lastmod") + .txt(date); + + if (!fs.existsSync("./dist/sitemaps")) { + fs.mkdirSync("./dist/sitemaps"); + } + + fs.writeFileSync("./dist/sitemaps/sitemap.xml", sitemapIndex.end({ prettyPrint: true })); + + /* + Generate the sets sitemap + */ + + const sets = await this.setsService.getSitemapSetInfo(); + + const setsSitemap = create({ version: "1.0", encoding: "UTF-8" }) + .ele("urlset") + .att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"); + + for (const set of sets) { + setsSitemap + .ele("url") + .ele("loc") + .txt(`${htmlPrefix}://${this.configService.get("HOST")}/study-set/${set.id}`) + .up() + .ele("lastmod") + .txt(set.updatedAt.toISOString()) + .up() + .ele("changefreq") + .txt("Daily") + .up() + .ele("priority") + .txt("1"); + } + + fs.writeFileSync("./dist/sitemaps/sets.xml", setsSitemap.end({ prettyPrint: true })); + + /* + Generate the users sitemap + */ + + const users = await this.usersService.getSitemapUserInfo(); + + const usersSitemap = create({ version: "1.0", encoding: "UTF-8" }) + .ele("urlset") + .att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"); + + for (const user of users) { + usersSitemap + .ele("url") + .ele("loc") + .txt(`${htmlPrefix}://${this.configService.get("HOST")}/profile/${user.id}`) + .up() + .ele("lastmod") + .txt(user.updatedAt.toISOString()) + .up() + .ele("changefreq") + .txt("Daily") + .up() + .ele("priority") + .txt("1"); + } + + fs.writeFileSync("./dist/sitemaps/users.xml", usersSitemap.end({ prettyPrint: true })); + + /* + Generate the folders sitemap + */ + + const folders = await this.foldersService.getSitemapFolderInfo(); + + const foldersSitemap = create({ version: "1.0", encoding: "UTF-8" }) + .ele("urlset") + .att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"); + + for (const folder of folders) { + foldersSitemap + .ele("url") + .ele("loc") + .txt(`${htmlPrefix}://${this.configService.get("HOST")}/folder/${folder.id}`) + .up() + .ele("lastmod") + .txt(folder.updatedAt.toISOString()) + .up() + .ele("changefreq") + .txt("Daily") + .up() + .ele("priority") + .txt("1"); + } + + fs.writeFileSync("./dist/sitemaps/folders.xml", foldersSitemap.end({ prettyPrint: true })); + } +} diff --git a/apps/api/src/app/sets/sets.service.ts b/apps/api/src/app/sets/sets.service.ts index d3436428..4a8d1fc9 100644 --- a/apps/api/src/app/sets/sets.service.ts +++ b/apps/api/src/app/sets/sets.service.ts @@ -85,6 +85,24 @@ export class SetsService { await Promise.all(updates); } + /** + * Queries the database for every public set's ID and when they were last modified + * Used for sitemap generation + * + * @returns Array of all set IDs and when they were last updated + */ + async getSitemapSetInfo(): Promise<{ id: string, updatedAt: Date }[]> { + return this.prisma.set.findMany({ + where: { + private: false + }, + select: { + id: true, + updatedAt: true + } + }); + } + /** * Queries the database for a unique set * diff --git a/apps/api/src/app/sets/validator/card.validator.ts b/apps/api/src/app/sets/validator/card.validator.ts index b1436099..d333ab10 100644 --- a/apps/api/src/app/sets/validator/card.validator.ts +++ b/apps/api/src/app/sets/validator/card.validator.ts @@ -1,48 +1,41 @@ -import { IsNotEmpty, IsNumber, IsString, Max, MaxLength, Min } from "class-validator"; +import { IsNotEmpty, IsNumber, IsString, Max, Min } from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { Transform, TransformFnParams } from "class-transformer"; import * as sanitizeHtml from "sanitize-html"; +import { sanitizationConfig } from "../../shared/sanitization/sanitization-config"; export class CardValidator { @ApiProperty({ description: "The index of the card in the set", example: 0, minimum: 0, - maximum: 2147483647 + maximum: 2147483647, }) @IsNumber() @Min(0) @Max(2147483647) @IsNotEmpty() - index: number; + index: number; @ApiProperty({ - description: "The front or \"term\" of the card", + description: 'The front or "term" of the card', example: "The term of the card", - maxLength: 65535 }) @IsString() @IsNotEmpty() - @MaxLength(65535) - @Transform((params: TransformFnParams) => sanitizeHtml(params.value, { - allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: { "img": ["src", "width", "height"], "span": ["style"] }, - allowedSchemes: ["data"] - })) - term: string; + @Transform((params: TransformFnParams) => + sanitizeHtml(params.value, sanitizationConfig) + ) + term: string; @ApiProperty({ - description: "The back or \"definition\" of the card", + description: 'The back or "definition" of the card', example: "The definition of the card", - maxLength: 65535 }) @IsString() @IsNotEmpty() - @MaxLength(65535) - @Transform((params: TransformFnParams) => sanitizeHtml(params.value, { - allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: { "img": ["src", "width", "height"], "span": ["style"] }, - allowedSchemes: ["data"] - })) - definition: string; + @Transform((params: TransformFnParams) => + sanitizeHtml(params.value, sanitizationConfig) + ) + definition: string; } diff --git a/apps/api/src/app/sets/validator/cardWithId.validator.ts b/apps/api/src/app/sets/validator/cardWithId.validator.ts index 006fa22f..95ea285d 100644 --- a/apps/api/src/app/sets/validator/cardWithId.validator.ts +++ b/apps/api/src/app/sets/validator/cardWithId.validator.ts @@ -1,7 +1,15 @@ -import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from "class-validator"; +import { + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; import { Transform, TransformFnParams } from "class-transformer"; import * as sanitizeHtml from "sanitize-html"; import { ApiProperty } from "@nestjs/swagger"; +import { sanitizationConfig } from "../../shared/sanitization/sanitization-config"; export class CardWithIdValidator { /* @@ -11,50 +19,42 @@ export class CardWithIdValidator { description: "The ID of the card", example: "27758237-5f57-4f6c-b483-6161056dad76", maxLength: 36, - minLength: 36 + minLength: 36, }) @IsOptional() - id: string; + id?: string; @ApiProperty({ description: "The index of the card in the set", example: 0, minimum: 0, - maximum: 2147483647 + maximum: 2147483647, }) @IsNumber() @Min(0) @Max(2147483647) @IsNotEmpty() - index: number; + index: number; @ApiProperty({ - description: "The front or \"term\" of the card", + description: 'The front or "term" of the card', example: "The term of the card", - maxLength: 65535 }) @IsString() @IsNotEmpty() - @MaxLength(65535) - @Transform((params: TransformFnParams) => sanitizeHtml(params.value, { - allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: { "img": ["src", "width", "height"], "span": ["style"] }, - allowedSchemes: ["data"] - })) - term: string; + @Transform((params: TransformFnParams) => + sanitizeHtml(params.value, sanitizationConfig) + ) + term: string; @ApiProperty({ - description: "The back or \"definition\" of the card", + description: 'The back or "definition" of the card', example: "The definition of the card", - maxLength: 65535 }) @IsString() @IsNotEmpty() - @MaxLength(65535) - @Transform((params: TransformFnParams) => sanitizeHtml(params.value, { - allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: { "img": ["src", "width", "height"], "span": ["style"] }, - allowedSchemes: ["data"] - })) - definition: string; + @Transform((params: TransformFnParams) => + sanitizeHtml(params.value, sanitizationConfig) + ) + definition: string; } diff --git a/apps/api/src/app/shared/sanitization/sanitization-config.ts b/apps/api/src/app/shared/sanitization/sanitization-config.ts new file mode 100644 index 00000000..92842ccf --- /dev/null +++ b/apps/api/src/app/shared/sanitization/sanitization-config.ts @@ -0,0 +1,125 @@ +import * as sanitizeHtml from "sanitize-html"; + +const allowedElements = [ + // HTML Elements + "div", + "span", + "style", + "class", + "id", + + // MathML Elements + "math", + "mi", + "mn", + "mo", + "ms", + "mtext", + "mspace", + "mover", + "munder", + "munderover", + "msup", + "msub", + "msubsup", + "mfrac", + "mroot", + "msqrt", + "mtable", + "mtr", + "mtd", + "mlabeledtr", + "merror", + "mpadded", + "mphantom", + "mfenced", + "menclose", + "semantics", + "annotation", + "annotation-xml", + + // SVG Elements + "svg", + "g", + "path", + "line", + "circle", + "rect", + "polygon", + "polyline", + "ellipse", + "text", + "tspan", + "textPath", + "defs", + "marker", + "pattern", + "clippath", + "mask", + "desc", + "title", + "use", + "symbol", +]; + +const allowedAttributes = [ + // Common Attributes + "id", + "class", + "style", + "href", + "x", + "y", + "cx", + "cy", + "r", + "rx", + "ry", + "d", + "fill", + "stroke", + "stroke-width", + "transform", + "width", + "height", + "xlink:href", + "viewBox", + "xmlns", + "xmlns:xlink", + + // MathML Specific Attributes + "mathvariant", + "mathsize", + "mathcolor", + "display", + "dir", + "xlink:type", + "xlink:href", + + // SVG Specific Attributes + "preserveAspectRatio", + "marker-start", + "marker-mid", + "marker-end", + "patternUnits", + "patternContentUnits", + "patternTransform", + "maskUnits", + "maskContentUnits", + "clipPathUnits", + + // Dataset attributes + "data-*", +]; + +export const sanitizationConfig = { + allowedTags: sanitizeHtml.defaults.allowedTags.concat( + ["img"], + allowedElements + ), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + "*": allowedAttributes, + }, + allowedSchemes: ["data"], +}; diff --git a/apps/api/src/app/users/users.service.ts b/apps/api/src/app/users/users.service.ts index 71f2ce71..f2d5aaf0 100644 --- a/apps/api/src/app/users/users.service.ts +++ b/apps/api/src/app/users/users.service.ts @@ -9,6 +9,21 @@ export class UsersService { private readonly prisma: PrismaService ) {} + /** + * Queries the database for every user ID and when they were last modified + * Used for sitemap generation + * + * @returns Array of all user IDs and when they were last updated + */ + async getSitemapUserInfo(): Promise<{ id: string, updatedAt: Date }[]> { + return this.prisma.user.findMany({ + select: { + id: true, + updatedAt: true + } + }); + } + /** * Queries the database for a unique user * diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 3c9ce804..8bb4b94b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -12,6 +12,8 @@ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import * as fs from "fs"; import { LoggerFactory } from "./app/shared/logger.factory"; import helmet from "helmet"; +import { missingSitemapMiddleware } from "./app/providers/missing-sitemap.middleware"; +import { noIndexMiddleware } from "./app/providers/no-index.middleware"; async function bootstrap() { const validation = envSchema @@ -28,7 +30,7 @@ async function bootstrap() { const server = express(); const app = await NestFactory.create(AppModule, new ExpressAdapter(server), { - bufferLogs: process.env.NODE_ENV === "production" + bufferLogs: process.env.NODE_ENV !== "development" }); app.enableCors(); @@ -48,11 +50,12 @@ async function bootstrap() { app.use(helmet({ contentSecurityPolicy: { directives: { - "script-src": ["'self'", "'unsafe-eval'", "'unsafe-inline'", "blob:", "https://www.gstatic.com", "https://www.google.com", "https://googletagmanager.com"], - "img-src": ["'self'", "blob:", "data:", "https://cdn.redoc.ly"], + "script-src": ["'self'", "'unsafe-eval'", "'unsafe-inline'", "blob:", "https://www.gstatic.com", "https://www.google.com", "https://www.googletagmanager.com", "https://www.google-analytics.com", "https://ssl.google-analytics.com"], + "img-src": ["'self'", "blob:", "data:", "https://cdn.redoc.ly", "https://www.google-analytics.com"], "script-src-attr": ["'unsafe-inline'"], - "default-src": ["'self'", "https://api.github.com/", "https://google-analytics.com"], - "style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com/"] + "default-src": ["'self'", "https://api.github.com", "https://google-analytics.com"], + "style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com/"], + "connect-src": ["'self'", "https://www.google-analytics.com", "https://api.github.com"] } } })); @@ -69,7 +72,7 @@ async function bootstrap() { new ValidationPipe({ whitelist: true, transform: true, - disableErrorMessages: process.env.NODE_ENV === "production" + disableErrorMessages: process.env.NODE_ENV === "production" || process.env.NODE_ENV === "public" }) ); @@ -80,6 +83,11 @@ async function bootstrap() { app.use(express.json({ limit: "30mb" })); app.use(express.urlencoded({ limit: "30mb", extended: true })); + // these middleware functions need to run before the serve static module, + // therefore they are functional middleware instead of being class-based + app.use(missingSitemapMiddleware); + app.use(noIndexMiddleware); + if ( process.env.SSL_KEY_BASE64 && process.env.SSL_KEY_BASE64.length > 0 && diff --git a/apps/docs/docs/installation/installing.md b/apps/docs/docs/installation/installing.md index 7d82348a..24d23d04 100644 --- a/apps/docs/docs/installation/installing.md +++ b/apps/docs/docs/installation/installing.md @@ -66,31 +66,41 @@ Additionally, if you are using S3 as your storage medium, you will need to fill If the SMTP fields are left blank, users will not have to verify their emails. Most installations do not need to enforce email verification, unless you are planning to expose Scholarsome to other users. ::: +:::info +Scholarsome has three separate modes: `production`, `public`, and `development`. + +**Production mode is the recommended mode** for selfhosted installations where low volume of users will be using the application. In this mode, features intended for a public-facing install (e.g. the standard landing page, sitemaps, etc) are disabled. + +Public mode is intended for installations where large volumes of users will be using the application, and all features will be enabled. + +Development mode is to be used if you are contributing to Scholarsome's development on a local system. +::: +
Docker Compose Environment Variables -| Variable Name | Description | -|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| NODE_ENV | **Required.** Declares whether the application is running in development or production. Recommended to be set to `production`. | -| DATABASE_PASSWORD | **Required.** Internal password for databases. Select something strong, as you will not need to know this. | -| JWT_SECRET | **Required.** String used to encrypt cookies and other sensitive items. Select something strong, as you will not need to know this. | -| HTTP_PORT | **Required.** Port that Scholarsome with be accessible through. Recommended to be set to 80. If using SSL, set to 80, as another server will be spawned with port 443. | -| HOST | **Required.** The Domain that Scholarsome will be running on. **Do not include HTTP.** | -| STORAGE_TYPE | **Required.** The method that Scholarsome will store media files, either `local` or `s3`. If set to local, Scholarsome will store media files locally. | -| SMTP_HOST | Host to access the SMTP server. | -| SMTP_PORT | Port to access the SMTP server. | -| SMTP_USERNAME | Username to access the SMTP server. | -| SMTP_PASSWORD | Password to access the SMTP server. | -| SSL_KEY_BASE64 | Base64 encoded SSL public key. | -| SSL_CERT_BASE64 | Base64 encoded SSL certificate. | -| SCHOLARSOME_RECAPTCHA_SITE | reCAPTCHA site key. | -| SCHOLARSOME_RECAPTCHA_SECRET | reCAPTCHA secret key. | -| SCHOLARSOME_HEAD_SCRIPTS_BASE64 | Base64 encoded HTML of any scripts that should be included in the head tag for every page. | -| S3_STORAGE_ENDPOINT | Required if storing files in S3. The endpoint of the S3 service. | -| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. Access key for the S3 service. | -| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. Secret key for the S3 service. | -| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. Region for the S3 service. | -| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. The name of the bucket being used in S3 to store media files. | +| Variable Name | Description | +|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| NODE_ENV | **Required.** Declares whether the application is running in `development`, `production`, or `public` mode. Recommended to be set to `production` for selfhosted installs. | +| DATABASE_PASSWORD | **Required.** Internal password for databases. Select something strong, as you will not need to know this. | +| JWT_SECRET | **Required.** String used to encrypt cookies and other sensitive items. Select something strong, as you will not need to know this. | +| HTTP_PORT | **Required.** Port that Scholarsome with be accessible through. Recommended to be set to 80. If using SSL, set to 80, as another server will be spawned with port 443. | +| HOST | **Required.** The Domain that Scholarsome will be running on. **Do not include HTTP.** | +| STORAGE_TYPE | **Required.** The method that Scholarsome will store media files, either `local` or `s3`. If set to local, Scholarsome will store media files locally. | +| SMTP_HOST | Host to access the SMTP server. | +| SMTP_PORT | Port to access the SMTP server. | +| SMTP_USERNAME | Username to access the SMTP server. | +| SMTP_PASSWORD | Password to access the SMTP server. | +| SSL_KEY_BASE64 | Base64 encoded SSL public key. | +| SSL_CERT_BASE64 | Base64 encoded SSL certificate. | +| SCHOLARSOME_RECAPTCHA_SITE | reCAPTCHA site key. | +| SCHOLARSOME_RECAPTCHA_SECRET | reCAPTCHA secret key. | +| SCHOLARSOME_HEAD_SCRIPTS_BASE64 | Base64 encoded HTML of any scripts that should be included in the head tag for every page. | +| S3_STORAGE_ENDPOINT | Required if storing files in S3. The endpoint of the S3 service. | +| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. Access key for the S3 service. | +| S3_STORAGE_SECRET_KEY | Required if storing files in S3. Secret key for the S3 service. | +| S3_STORAGE_REGION | Required if storing files in S3. Region for the S3 service. | +| S3_STORAGE_BUCKET | Required if storing files in S3. The name of the bucket being used in S3 to store media files. |
@@ -134,39 +144,49 @@ Expand the dropdown below, it lists Scholarsome's environment variables. These a Ensure that you provide a filepath for the `STORAGE_LOCAL_DIR` variable if using local media storage, or provide S3 authentication details if using S3 as your storage medium. :::info -If the SMTP fields are left blank, users will be verified by default. Most installations do not need to enforce email verification. +If the SMTP fields are left blank, users will not have to verify their emails. Most installations do not need to enforce email verification, unless you are planning to expose Scholarsome to other users. +::: + +:::info +Scholarsome has three separate modes: `production`, `public`, and `development`. + +**Production mode is the recommended mode** for selfhosted installations where low volume of users will be using the application. In this mode, features intended for a public-facing install (e.g. the standard landing page, sitemaps, etc) are disabled. + +Public mode is intended for installations where large volumes of users will be using the application, and all features will be enabled. + +Development mode is to be used if you are contributing to Scholarsome's development on a local system. :::
Docker Environment Variables -| Variable Name | Description | -|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| NODE_ENV | **Required.** Declares whether the application is running in development or production. Recommended to be set to `production`. | -| DATABASE_URL | **Required.** Connection string to the MySQL database. The format should be as follows: `mysql://(username):(password)@(host):(port)/(database)` | -| JWT_SECRET | **Required.** String used to encrypt cookies and other sensitive items. Select something strong, as you will not need to know this. | -| HTTP_PORT | **Required.** Port that Scholarsome with be accessible through. Recommended to be set to 80. If using SSL, set to 80, as another server will be spawned with port 443. | -| HOST | **Required.** The domain that Scholarsome will be running on. **Do not include HTTP.** | -| STORAGE_TYPE | **Required.** The method that Scholarsome will store media files, either `local` or `s3`. If set to local, Scholarsome will store media files locally. | -| REDIS_HOST | **Required.** Host used to access the Redis database. | -| REDIS_PORT | **Required.** Port used to access the Redis database. | -| REDIS_USERNAME | **Required.** Username used to access the Redis database. | -| REDIS_PASSWORD | **Required.** Password used to access the Redis database. | -| SMTP_HOST | Host to access the SMTP server. | -| SMTP_PORT | Port to access the SMTP server. | -| SMTP_USERNAME | Username to access the SMTP server. | -| SMTP_PASSWORD | Password to access the SMTP server. | -| SSL_KEY_BASE64 | Base64 encoded SSL public key. | -| SSL_CERT_BASE64 | Base64 encoded SSL certificate. | -| SCHOLARSOME_RECAPTCHA_SITE | reCAPTCHA site key. | -| SCHOLARSOME_RECAPTCHA_SECRET | reCAPTCHA secret key. | -| SCHOLARSOME_HEAD_SCRIPTS_BASE64 | Base64 encoded HTML of any scripts that should be included in the head tag for every page. | -| STORAGE_LOCAL_DIR | Required if storing files locally. The absolute filepath pointing to the directory where Scholarsome should store media files. | -| S3_STORAGE_ENDPOINT | Required if storing files in S3. The endpoint of the S3 service. | -| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. Access key for the S3 service. | -| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. Secret key for the S3 service. | -| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. Region for the S3 service. | -| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. The name of the bucket being used in S3 to store media files. | +| Variable Name | Description | +|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| NODE_ENV | **Required.** Declares whether the application is running in `development`, `production`, or `public` mode. Recommended to be set to `production` for selfhosted installs. | +| DATABASE_URL | **Required.** Connection string to the MySQL database. The format should be as follows: `mysql://(username):(password)@(host):(port)/(database)` | +| JWT_SECRET | **Required.** String used to encrypt cookies and other sensitive items. Select something strong, as you will not need to know this. | +| HTTP_PORT | **Required.** Port that Scholarsome with be accessible through. Recommended to be set to 80. If using SSL, set to 80, as another server will be spawned with port 443. | +| HOST | **Required.** The domain that Scholarsome will be running on. **Do not include HTTP.** | +| STORAGE_TYPE | **Required.** The method that Scholarsome will store media files, either `local` or `s3`. If set to local, Scholarsome will store media files locally. | +| REDIS_HOST | **Required.** Host used to access the Redis database. | +| REDIS_PORT | **Required.** Port used to access the Redis database. | +| REDIS_USERNAME | **Required.** Username used to access the Redis database. | +| REDIS_PASSWORD | **Required.** Password used to access the Redis database. | +| SMTP_HOST | Host to access the SMTP server. | +| SMTP_PORT | Port to access the SMTP server. | +| SMTP_USERNAME | Username to access the SMTP server. | +| SMTP_PASSWORD | Password to access the SMTP server. | +| SSL_KEY_BASE64 | Base64 encoded SSL public key. | +| SSL_CERT_BASE64 | Base64 encoded SSL certificate. | +| SCHOLARSOME_RECAPTCHA_SITE | reCAPTCHA site key. | +| SCHOLARSOME_RECAPTCHA_SECRET | reCAPTCHA secret key. | +| SCHOLARSOME_HEAD_SCRIPTS_BASE64 | Base64 encoded HTML of any scripts that should be included in the head tag for every page. | +| STORAGE_LOCAL_DIR | Required if storing files locally. The absolute filepath pointing to the directory where Scholarsome should store media files. | +| S3_STORAGE_ENDPOINT | Required if storing files in S3. The endpoint of the S3 service. | +| S3_STORAGE_ACCESS_KEY | Required if storing files in S3. Access key for the S3 service. | +| S3_STORAGE_SECRET_KEY | Required if storing files in S3. Secret key for the S3 service. | +| S3_STORAGE_REGION | Required if storing files in S3. Region for the S3 service. | +| S3_STORAGE_BUCKET | Required if storing files in S3. The name of the bucket being used in S3 to store media files. |
diff --git a/apps/front/proxy.conf.json b/apps/front/proxy.conf.json index 3a5bb724..e135a453 100644 --- a/apps/front/proxy.conf.json +++ b/apps/front/proxy.conf.json @@ -7,5 +7,15 @@ "target": "http://localhost:3000", "secure": false, "changeOrigin": true + }, + "/sitemaps": { + "target": "http://localhost:3333", + "secure": false, + "changeOrigin": true + }, + "/sitemap.xml": { + "target": "http://localhost:3333", + "secure": false, + "changeOrigin": true } } diff --git a/apps/front/src/app/header/forgot-password-modal/forgot-password-modal.component.html b/apps/front/src/app/header/forgot-password-modal/forgot-password-modal.component.html index e9a2f8fa..466dff29 100644 --- a/apps/front/src/app/header/forgot-password-modal/forgot-password-modal.component.html +++ b/apps/front/src/app/header/forgot-password-modal/forgot-password-modal.component.html @@ -1,7 +1,7 @@ diff --git a/apps/front/src/app/header/forgot-password-modal/forgot-password-modal.component.ts b/apps/front/src/app/header/forgot-password-modal/forgot-password-modal.component.ts index 03bbe866..85568934 100644 --- a/apps/front/src/app/header/forgot-password-modal/forgot-password-modal.component.ts +++ b/apps/front/src/app/header/forgot-password-modal/forgot-password-modal.component.ts @@ -3,6 +3,8 @@ import { NgForm } from "@angular/forms"; import { AuthService } from "../../auth/auth.service"; import { BsModalRef, BsModalService } from "ngx-bootstrap/modal"; import { ApiResponseOptions } from "@scholarsome/shared"; +import { Router } from "@angular/router"; +import { ModalService } from "../../shared/modal.service"; @Component({ selector: "scholarsome-forgot-password-modal", @@ -12,7 +14,9 @@ import { ApiResponseOptions } from "@scholarsome/shared"; export class ForgotPasswordModalComponent { constructor( private readonly authService: AuthService, - private readonly bsModalService: BsModalService + private readonly router: Router, + private readonly bsModalService: BsModalService, + public readonly modalService: ModalService ) { this.bsModalService.onHide.subscribe(() => { this.response = null; @@ -25,11 +29,17 @@ export class ForgotPasswordModalComponent { protected clicked = false; protected response: ApiResponseOptions | null; + protected publicAppEnv = false; + protected onLandingPage = false; + protected readonly ApiResponseOptions = ApiResponseOptions; protected modalRef?: BsModalRef; public open(): BsModalRef { - this.modalRef = this.bsModalService.show(this.modal); + this.publicAppEnv = process.env["NG_APP_ENV"] === "public"; + this.onLandingPage = this.router.url === "/"; + + this.modalRef = this.bsModalService.show(this.modal, { ignoreBackdropClick: !this.publicAppEnv }); return this.modalRef; } diff --git a/apps/front/src/app/header/login-modal/login-modal.component.html b/apps/front/src/app/header/login-modal/login-modal.component.html index eba9bccd..3e0f9eee 100644 --- a/apps/front/src/app/header/login-modal/login-modal.component.html +++ b/apps/front/src/app/header/login-modal/login-modal.component.html @@ -1,7 +1,19 @@ @@ -20,7 +32,7 @@ Forgot? -
This site is protected by reCAPTCHA and the Google +
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
diff --git a/apps/front/src/app/header/login-modal/login-modal.component.ts b/apps/front/src/app/header/login-modal/login-modal.component.ts index d409996b..f78ea60d 100644 --- a/apps/front/src/app/header/login-modal/login-modal.component.ts +++ b/apps/front/src/app/header/login-modal/login-modal.component.ts @@ -31,12 +31,22 @@ export class LoginModalComponent { protected response: ApiResponseOptions | null; protected clicked = false; + protected publicAppEnv = false; + protected onLandingPage = false; + protected recaptchaEnabled = false; + protected appUrl = ""; + protected modalRef?: BsModalRef; protected readonly ApiResponseOptions = ApiResponseOptions; public open(): BsModalRef { - this.modalRef = this.bsModalService.show(this.modal); + this.publicAppEnv = process.env["NG_APP_ENV"] === "public"; + this.onLandingPage = this.router.url === "/"; + this.recaptchaEnabled === !process.env["SCHOLARSOME_RECAPTCHA_SECRET"] || !process.env["SCHOLARSOME_RECAPTCHA_SITE"]; + this.appUrl = window.location.host; + + this.modalRef = this.bsModalService.show(this.modal, { ignoreBackdropClick: !this.publicAppEnv && this.onLandingPage }); return this.modalRef; } diff --git a/apps/front/src/app/header/register-modal/register-modal.component.html b/apps/front/src/app/header/register-modal/register-modal.component.html index 221ffff2..69c24e44 100644 --- a/apps/front/src/app/header/register-modal/register-modal.component.html +++ b/apps/front/src/app/header/register-modal/register-modal.component.html @@ -1,7 +1,17 @@ @@ -21,10 +31,10 @@
-
By clicking sign up, I agree to Scholarsome's +
By clicking sign up, I agree to Scholarsome's Terms of Service and Privacy Policy.
-
This site is protected by reCAPTCHA and the Google +
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
diff --git a/apps/front/src/app/header/register-modal/register-modal.component.ts b/apps/front/src/app/header/register-modal/register-modal.component.ts index 030a3685..f8f71477 100644 --- a/apps/front/src/app/header/register-modal/register-modal.component.ts +++ b/apps/front/src/app/header/register-modal/register-modal.component.ts @@ -30,12 +30,22 @@ export class RegisterModalComponent { protected response: ApiResponseOptions | null; protected clicked = false; + protected publicAppEnv = false; + protected onLandingPage = false; + protected recaptchaEnabled = false; + protected appUrl = ""; + protected modalRef?: BsModalRef; protected readonly ApiResponseOptions = ApiResponseOptions; public open(): BsModalRef { - this.modalRef = this.bsModalService.show(this.modal); + this.publicAppEnv = process.env["NG_APP_ENV"] === "public"; + this.onLandingPage = this.router.url === "/"; + this.recaptchaEnabled === !process.env["SCHOLARSOME_RECAPTCHA_SECRET"] || !process.env["SCHOLARSOME_RECAPTCHA_SITE"]; + this.appUrl = window.location.host; + + this.modalRef = this.bsModalService.show(this.modal, { ignoreBackdropClick: !this.publicAppEnv && this.onLandingPage }); return this.modalRef; } diff --git a/apps/front/src/app/landing/landing.component.html b/apps/front/src/app/landing/landing.component.html index 33bd3c4b..12024ef7 100644 --- a/apps/front/src/app/landing/landing.component.html +++ b/apps/front/src/app/landing/landing.component.html @@ -1,4 +1,4 @@ -