added a bunch of stuff

This commit is contained in:
renzynx 2022-12-30 12:05:39 +07:00
parent d419d86199
commit 2523dcd57e
29 changed files with 561 additions and 91 deletions

8
.github/README.md vendored
View File

@ -2,16 +2,16 @@
- Invite only registration or disable registration completely.
- Upload files, images, and videos.
- Manage users, roles.
- Manage users role.
- Limit user quota.
- Discord Embed customizer.
- Download and delete files.
- File preview.
- Download and delete files from the dashboard.
- Downloadable upload config for ShareX, Flameshot.
- A view page for each file.
- S3 support (AWS, DigitalOcean, etc.).
- Easy installation with docker.
- Easy to use admin panel.
- Email verification.
# Installing
@ -63,6 +63,7 @@ docker compose pull && docker compose up -d
- `pm2` globally installed
- `yarn` globally installed
- `caddy` installed
- `ffmpeg` installed
### Backend Installation
@ -125,6 +126,7 @@ Then go through the installation steps again.
### Domain name and SSL configuration
If you don't have caddy installed already
<br>
[Click Here](https://caddyserver.com/docs/install)
Copy and paste the following into your terminal:

4
.gitignore vendored
View File

@ -1 +1,3 @@
docker-compose.yml
docker-compose.yml
db_data
redis_data

View File

@ -12,4 +12,6 @@
/.yarn/*
!/.yarn/releases
!/.yarn/plugins
/test
/test
.env
.env.example

18
api/Dockerfile.dev Normal file
View File

@ -0,0 +1,18 @@
FROM node:lts-buster-slim AS development
ENV NODE_ENV development
RUN apt-get update && apt-get install --no-install-recommends -y \
openssl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
RUN yarn install --immutable
COPY . .
CMD [ "yarn", "start:dev" ]

3
api/env.d.ts vendored
View File

@ -19,5 +19,8 @@ declare namespace NodeJS {
S3_REGION: string;
CDN_URL: string;
UPLOADER: "local" | "s3";
// optional env vars
COOKIE_NAME?: string;
UPLOAD_DIR?: string;
}
}

View File

@ -37,6 +37,7 @@
"cuid": "^2.1.8",
"express-session": "^1.17.3",
"fast-folder-size": "^1.7.1",
"ffmpeg-static": "^5.1.0",
"hbs": "^4.2.0",
"helmet": "^6.0.0",
"ioredis": "^5.2.4",

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "files" ADD COLUMN "cover" TEXT;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `cover` on the `files` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "files" DROP COLUMN "cover";

View File

@ -15,6 +15,7 @@ import { RedisService } from "modules/redis/redis.service";
import { ThrottlerModule } from "@nestjs/throttler";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerBehindProxyGuard } from "modules/root/root.guard";
import { S3Service } from "modules/s3/s3.service";
@Module({
imports: [
@ -32,6 +33,7 @@ import { ThrottlerBehindProxyGuard } from "modules/root/root.guard";
providers: [
RootService,
PrismaService,
S3Service,
RedisService,
{
provide: APP_GUARD,

View File

@ -1,26 +1,67 @@
import { uploadDir } from "./constants";
import { promises as fs } from "fs";
import { CronJob } from "cron";
import { join } from "path";
import { Logger } from "@nestjs/common";
import { exec } from "child_process";
const cleanUp = async () => {
// find files start with tmp_ that are older than 24 hours
const tmpFiles = (await fs.readdir(uploadDir))
.filter((file) => file.startsWith("tmp_"))
.filter(async (file) => {
const { birthtime } = await fs.stat(join(uploadDir, file));
return Date.now() - birthtime.getTime() > 1000 * 60 * 60 * 24;
});
const currentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const job = new CronJob(
// every 24 hours at 12 AM
"0 0 * * *",
async () => {
console.log("Cleaning up tmp files...");
for (const file of tmpFiles) {
await fs.unlink(join(uploadDir, file));
const startTime = Date.now();
const logger = new Logger("CronJob");
const isWindows = process.platform === "win32";
if (isWindows) {
// check if forfiles.exe exists
try {
await fs.access("C:\\Windows\\System32\\forfiles.exe");
} catch (error) {
logger.error("forfiles.exe not found, aborting job");
logger.log(`Finished job in ${Date.now() - startTime}ms`);
return;
}
const { stdout } = await exec(
`forfiles /p ${uploadDir} /s /m tmp_* /D -1 /c "cmd /c del @path"`
);
stdout.on("data", (data) => {
logger.log(data);
});
logger.log(`Finished job in ${Date.now() - startTime}ms`);
return;
}
const isUnix =
process.platform === "linux" ||
process.platform === "darwin" ||
process.platform === "freebsd" ||
process.platform === "openbsd";
if (isUnix) {
const { stdout } = await exec(
`find ${uploadDir} -type f -name "tmp_*" -mtime +1 -exec rm -f {} \\;`
);
stdout.on("data", (data) => {
logger.log(data);
});
logger.log(`Finished job in ${Date.now() - startTime}ms`);
return;
} else {
logger.log("OS not supported");
logger.log(`Finished job in ${Date.now() - startTime}ms`);
return;
}
},
null,
@ -28,6 +69,12 @@ const cleanUp = async () => {
currentTimeZone
);
const supportedOS = ["win32", "linux", "darwin", "freebsd", "openbsd"];
if (!supportedOS.includes(process.platform)) {
return;
}
job.start();
};

View File

@ -5,14 +5,11 @@ export enum ROUTES {
USERS = "users",
UPLOAD = "upload",
DELETE = "delete",
STATISTICS = "statistics",
}
export const rootDir = join(__dirname, "..", "..");
export const uploadDir = join(rootDir, "uploads");
export const thumbnailDir = join(rootDir, "public");
export const uploadDir = process.env.UPLOAD_DIR ?? join(rootDir, "uploads");
export const logsDir = join(rootDir, "logs");
export const tmpDir = join(rootDir, "tmp");
export const COOKIE_NAME = process.env.COOKIE_NAME ?? "auth";
export const INVITE_PREFIX = "invite:";
export const FORGOT_PASSWORD_PREFIX = "forgot-password:";

View File

@ -16,6 +16,7 @@ export interface UserResponse {
export interface findUserOptions {
byId?: boolean;
withPassword?: boolean;
withFiles?: boolean;
totalUsed?: boolean;
}

View File

@ -53,7 +53,9 @@ async function bootstrap() {
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
secure:
process.env.NODE_ENV === "production" &&
process.env.USE_SSL === "true",
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
sameSite: "lax",

View File

@ -8,7 +8,7 @@ import { Request, Response } from "express";
import fastFolderSize from "fast-folder-size";
import { createReadStream, existsSync } from "fs";
import { stat } from "fs/promises";
import { thumbnailDir, uploadDir } from "lib/constants";
import { uploadDir } from "lib/constants";
import { CustomSession } from "lib/types";
import {
formatBytes,
@ -17,13 +17,17 @@ import {
lookUp,
} from "lib/utils";
import { PrismaService } from "modules/prisma/prisma.service";
import { S3Service } from "modules/s3/s3.service";
import { join } from "path";
import { promisify } from "util";
@Injectable()
export class RootService {
private logger = new Logger(RootService.name);
constructor(private readonly prismaService: PrismaService) {}
constructor(
private readonly prismaService: PrismaService,
private readonly s3Service: S3Service
) {}
downloadFile(filename: string, res: Response) {
if (!filename) {
@ -88,6 +92,7 @@ export class RootService {
const isAudio = lookUp(file.filename).includes("audio");
const cannotDisplay = !isImage && !isVideo && !isAudio;
const timezone = new Date().getTimezoneOffset() / 60;
let albumCover = null;
if (process.env.UPLOADER === "s3") {
baseUrl =
@ -95,21 +100,37 @@ export class RootService {
"https://" + process.env.BUCKET_NAME + process.env.S3_ENDPOINT;
oembed = `${baseUrl}/${slug}.json`;
url = `${baseUrl}/${slug}.${ext}`;
await this.s3Service
.createInstance()
.getObject(process.env.S3_BUCKET_NAME, `${slug}.jpg`, (error) => {
if (error) {
albumCover = null;
} else {
albumCover = `${baseUrl}/${slug}.jpg`;
}
});
} else {
baseUrl = `${protocol}://${req.headers.host}`;
oembed = `${baseUrl}/${slug}.json`;
url = `${baseUrl}/${slug}.${ext}`;
if (existsSync(join(uploadDir, `${slug}.jpg`))) {
albumCover = `${baseUrl}/${slug}.jpg`;
} else {
albumCover = null;
}
}
const {
user: { embed_settings },
} = file;
const enabled = embed_settings?.enabled;
return {
oembed,
url,
title: embed_settings.enabled ? embed_settings?.title : null,
description: embed_settings.enabled ? embed_settings?.description : null,
title: enabled ? embed_settings?.title : null,
description: enabled ? embed_settings?.description : null,
color: embed_settings?.color ?? generateRandomHexColor(),
ogType: isVideo ? "video.other" : isImage ? "image" : "website",
urlType: isVideo ? "video" : isAudio ? "audio" : "image",
@ -118,13 +139,14 @@ export class RootService {
slug: file.slug + "." + file.filename.split(".").pop(),
size: formatBytes(file.size),
username: file.user.username,
embed_enabled: embed_settings?.enabled,
embed_enabled: enabled,
views: vw,
timestamp: formatDate(file.createdAt) + ` (UTC${timezone})`,
isVideo,
isImage,
isAudio,
cannotDisplay,
albumCover,
};
}

View File

@ -36,6 +36,10 @@ export class S3Service {
});
}
createInstance() {
return this.s3;
}
createOEmbedJSON(oembed: Partial<EmbedSettings> & { filename: string }) {
const { author_name, author_url, provider_name, provider_url, filename } =
oembed;

View File

@ -14,6 +14,8 @@ import { generateRandomString, lookUp } from "lib/utils";
import { PrismaService } from "modules/prisma/prisma.service";
import { join } from "path";
import md5 from "md5";
import { exec } from "node:child_process";
import ffmpegPath from "ffmpeg-static";
@Injectable()
export class UploadService {
@ -206,7 +208,7 @@ export class UploadService {
}
const name = decodeURIComponent(req.headers["x-file-name"] as string);
const size = req.headers["x-file-size"] as string;
let size = req.headers["x-file-size"] as string;
const currentChunk = req.headers["x-current-chunk"] as string;
const totalChunks = req.headers["x-total-chunks"] as string;
@ -253,17 +255,37 @@ export class UploadService {
const { embed_settings } = user;
if (
mimetype.includes("image") &&
embed_settings &&
embed_settings.enabled
) {
if (mimetype.includes("image") && embed_settings?.enabled) {
this.createOEmbedJSON({
filename: name,
...embed_settings,
});
}
if (mimetype.includes("audio")) {
// get album cover if exists
const { stderr } = await exec(
`${ffmpegPath} -i ${join(
uploadDir,
`${slug}.${ext}`
)} -an -vcodec copy ${join(uploadDir, `${slug}.jpg`)}`
);
if (stderr) {
return;
}
if (
embed_settings?.enabled &&
existsSync(join(uploadDir, `${slug}.jpg`))
) {
this.createOEmbedJSON({
filename: name,
...embed_settings,
});
}
}
await this.prismaService.file.create({
data: {
userId: user.id,

View File

@ -32,7 +32,7 @@ export class UsersController {
@SkipThrottle(false)
@UseGuards(AuthGuard)
@Post("verify/send")
async sendVerifyMail(@Request() req: ERequest) {
sendVerifyMail(@Request() req: ERequest) {
return this.usersService.sendVerifyEmail(
(req.session as CustomSession).userId
);
@ -40,32 +40,32 @@ export class UsersController {
@UseGuards(AuthGuard)
@Post("verify")
async verifyEmail(@Body() { token }: { token: string }) {
verifyEmail(@Body() { token }: { token: string }) {
return this.usersService.verifyEmail(token);
}
@SkipThrottle(false)
@Post("forgot-password")
async forgotPassword(@Body() { email }: { email: string }) {
forgotPassword(@Body() { email }: { email: string }) {
return this.usersService.sendForgotPasswordEmail(email);
}
@SkipThrottle(false)
@Post("check-token")
async checkToken(@Body() { token }: { token: string }) {
checkToken(@Body() { token }: { token: string }) {
return this.usersService.checkToken(token);
}
@UseFilters(new HttpExceptionFilter())
@UsePipes(new ValidationPipe({ transform: true }))
@Post("reset-password")
async resetPassword(@Body() { token, password }: ResetPasswordDTO) {
resetPassword(@Body() { token, password }: ResetPasswordDTO) {
return this.usersService.resetPassword(token, password);
}
@UseGuards(AuthGuard)
@Get("files")
async getFiles(
getFiles(
@Request() req: ERequest,
@Query("skip") skip: string,
@Query("take") take: string,
@ -90,10 +90,7 @@ export class UsersController {
@UseFilters(new HttpExceptionFilter())
@UsePipes(new ValidationPipe({ transform: true }))
@Put("embed-settings")
async updateEmbedSettings(
@Request() req: ERequest,
@Body() body: EmbedSettingDTO
) {
updateEmbedSettings(@Request() req: ERequest, @Body() body: EmbedSettingDTO) {
return this.usersService.setEmbedSettings(
body,
(req.session as CustomSession).userId
@ -102,7 +99,7 @@ export class UsersController {
@UseGuards(AuthGuard)
@Get("embed-settings")
async getEmbedSettings(@Request() req: ERequest) {
getEmbedSettings(@Request() req: ERequest) {
return this.usersService.getEmbedSettings(
(req.session as CustomSession).userId
);
@ -113,7 +110,7 @@ export class UsersController {
@UsePipes(new ValidationPipe({ transform: true }))
@UseFilters(new HttpExceptionFilter())
@Put("change-password")
async changePassword(
changePassword(
@Request() req: ERequest,
@Body()
{ password, newPassword }: ChangePasswordDTO
@ -130,7 +127,7 @@ export class UsersController {
@UsePipes(new ValidationPipe({ transform: true }))
@UseFilters(new HttpExceptionFilter())
@Put("change-username")
async changeUsername(
changeUsername(
@Body()
{ username, newUsername }: ChangeUsernameDTO
) {
@ -141,7 +138,7 @@ export class UsersController {
@Throttle(1, 300)
@UseGuards(AuthGuard)
@Put("regenerate-api-key")
async regnerateApiKey(@Request() req: ERequest) {
regnerateApiKey(@Request() req: ERequest) {
return this.usersService.regenerateApiKey(
(req.session as CustomSession).userId
);
@ -149,9 +146,15 @@ export class UsersController {
@UseGuards(AuthGuard)
@Delete("delete-account")
async deleteAccount(@Request() req: ERequest) {
deleteAccount(@Request() req: ERequest) {
return this.usersService.deleteAccount(
(req.session as CustomSession).userId
);
}
@UseGuards(AuthGuard)
@Delete("wipe-files")
wipeFiles(@Request() req: ERequest) {
return this.usersService.wipeFiles((req.session as CustomSession).userId);
}
}

View File

@ -5,7 +5,7 @@ import {
Logger,
UnauthorizedException,
} from "@nestjs/common";
import { EmbedSettings, User } from "@prisma/client";
import { EmbedSettings, File, User } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2";
import { MailService } from "../mail/mail.service";
@ -29,9 +29,10 @@ import {
FORGOT_PASSWORD_PREFIX,
INVITE_PREFIX,
rootDir,
uploadDir,
} from "lib/constants";
import { join } from "path";
import { readFile } from "fs/promises";
import { readFile, unlink } from "fs/promises";
import { EmbedSettingDTO } from "./dto/EmbedSettingsDTO";
import cuid from "cuid";
@ -49,12 +50,13 @@ export class UsersService implements IUserService {
async findUser(
username_or_email: string,
{ byId, withPassword, totalUsed }: findUserOptions = {
{ byId, withPassword, totalUsed, withFiles }: findUserOptions = {
byId: false,
withPassword: false,
totalUsed: false,
withFiles: false,
}
): Promise<User | null> {
): Promise<(User & { files?: File[] }) | null> {
if (!username_or_email) {
throw new BadRequestException("Invalid request");
}
@ -68,6 +70,9 @@ export class UsersService implements IUserService {
where: {
id: username_or_email,
},
include: {
files: withFiles,
},
});
if (totalUsed) {
const tmp = await this.prisma.file.aggregate({
@ -87,6 +92,7 @@ export class UsersService implements IUserService {
where: username_or_email.includes("@")
? { email: username_or_email }
: { username: username_or_email },
include: { files: withFiles },
});
if (totalUsed) {
@ -558,8 +564,31 @@ export class UsersService implements IUserService {
return true;
}
async wipeFiles(id: string) {
const files = await this.prisma.file.findMany({ where: { userId: id } });
const promises = files.map((file) => {
const ext = file.filename.split(".").pop();
const filename = `${file.slug}.${ext}`;
if (file.mimetype.includes("audio")) {
return Promise.all([
unlink(join(uploadDir, filename)),
unlink(join(uploadDir, `${file.slug}.jpg`)),
]);
}
return unlink(join(uploadDir, filename));
});
await Promise.all([
this.prisma.file.deleteMany({ where: { userId: id } }),
...promises,
]).catch(() => {});
return true;
}
async deleteAccount(id: string) {
const user = await this.findUser(id, { byId: true });
const user = await this.findUser(id, { byId: true, withFiles: true });
if (!user) {
throw new UnauthorizedException("not authorized");
@ -573,9 +602,11 @@ export class UsersService implements IUserService {
throw new BadRequestException("You cannot delete root account");
}
await this.prisma.user.delete({ where: { id } });
const wiped = await this.wipeFiles(id);
return true;
await this.prisma.user.delete({ where: { id } }).catch(() => {});
return wiped;
}
async sendForgotPasswordEmail(email: string) {
@ -590,7 +621,12 @@ export class UsersService implements IUserService {
});
}
const isEmail = /\S+@\S+\.\S+/.test(email);
// RFC 5322 Official Standard
const emailRegex = new RegExp(
/(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/g
);
const isEmail = emailRegex.test(email);
if (!isEmail) {
throw new BadRequestException({

View File

@ -86,6 +86,14 @@
</video>
{{/if}}
{{#if isAudio}}
{{#if albumCover}}
<img
decoding="async"
src="{{albumCover}}"
class="card-img-top mb-3"
alt="Album Cover"
/>
{{/if}}
<audio controls loop preload="metadata">
<source src="{{url}}" type="{{mimetype}}" />
Your browser does&apos;t support this audio type

View File

@ -491,6 +491,18 @@ __metadata:
languageName: node
linkType: hard
"@derhuerst/http-basic@npm:^8.2.0":
version: 8.2.4
resolution: "@derhuerst/http-basic@npm:8.2.4"
dependencies:
caseless: ^0.12.0
concat-stream: ^2.0.0
http-response-object: ^3.0.1
parse-cache-control: ^1.0.1
checksum: dfb2f30c23fb907988d1c34318fa74c54dcd3c3ba6b4b0e64cdb584d03303ad212dd3b3874328a9367d7282a232976acbd33a20bb9c7a6ea20752e879459253b
languageName: node
linkType: hard
"@eslint/eslintrc@npm:^1.3.3":
version: 1.3.3
resolution: "@eslint/eslintrc@npm:1.3.3"
@ -1478,6 +1490,13 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^10.0.3":
version: 10.17.60
resolution: "@types/node@npm:10.17.60"
checksum: 2cdb3a77d071ba8513e5e8306fa64bf50e3c3302390feeaeff1fd325dd25c8441369715dfc8e3701011a72fed5958c7dfa94eb9239a81b3c286caa4d97db6eef
languageName: node
linkType: hard
"@types/node@npm:^16.0.0":
version: 16.18.3
resolution: "@types/node@npm:16.18.3"
@ -2120,6 +2139,7 @@ __metadata:
eslint-plugin-prettier: ^4.0.0
express-session: ^1.17.3
fast-folder-size: ^1.7.1
ffmpeg-static: ^5.1.0
hbs: ^4.2.0
helmet: ^6.0.0
ioredis: ^5.2.4
@ -2721,6 +2741,13 @@ __metadata:
languageName: node
linkType: hard
"caseless@npm:^0.12.0":
version: 0.12.0
resolution: "caseless@npm:0.12.0"
checksum: b43bd4c440aa1e8ee6baefee8063b4850fd0d7b378f6aabc796c9ec8cb26d27fb30b46885350777d9bd079c5256c0e1329ad0dc7c2817e0bb466810ebb353751
languageName: node
linkType: hard
"chainsaw@npm:~0.1.0":
version: 0.1.0
resolution: "chainsaw@npm:0.1.0"
@ -3035,6 +3062,18 @@ __metadata:
languageName: node
linkType: hard
"concat-stream@npm:^2.0.0":
version: 2.0.0
resolution: "concat-stream@npm:2.0.0"
dependencies:
buffer-from: ^1.0.0
inherits: ^2.0.3
readable-stream: ^3.0.2
typedarray: ^0.0.6
checksum: d7f75d48f0ecd356c1545d87e22f57b488172811b1181d96021c7c4b14ab8855f5313280263dca44bb06e5222f274d047da3e290a38841ef87b59719bde967c7
languageName: node
linkType: hard
"connect-redis@npm:^6.1.3":
version: 6.1.3
resolution: "connect-redis@npm:6.1.3"
@ -4011,6 +4050,18 @@ __metadata:
languageName: node
linkType: hard
"ffmpeg-static@npm:^5.1.0":
version: 5.1.0
resolution: "ffmpeg-static@npm:5.1.0"
dependencies:
"@derhuerst/http-basic": ^8.2.0
env-paths: ^2.2.0
https-proxy-agent: ^5.0.0
progress: ^2.0.3
checksum: 0e27d671a0be1f585ef03e48c2af7c2be14f4e61470ffa02e3b8919551243ee854028a898dfcd16cdf1e3c01916f3c5e9938f42cbc7e877d7dd80d566867db8b
languageName: node
linkType: hard
"figures@npm:^3.0.0":
version: 3.2.0
resolution: "figures@npm:3.2.0"
@ -4593,6 +4644,15 @@ __metadata:
languageName: node
linkType: hard
"http-response-object@npm:^3.0.1":
version: 3.0.2
resolution: "http-response-object@npm:3.0.2"
dependencies:
"@types/node": ^10.0.3
checksum: 6cbdcb4ce7b27c9158a131b772c903ed54add2ba831e29cc165e91c3969fa6f8105ddf924aac5b954b534ad15a1ae697b693331b2be5281ee24d79aae20c3264
languageName: node
linkType: hard
"https-proxy-agent@npm:^5.0.0":
version: 5.0.1
resolution: "https-proxy-agent@npm:5.0.1"
@ -6473,6 +6533,13 @@ __metadata:
languageName: node
linkType: hard
"parse-cache-control@npm:^1.0.1":
version: 1.0.1
resolution: "parse-cache-control@npm:1.0.1"
checksum: 5a70868792124eb07c2dd07a78fcb824102e972e908254e9e59ce59a4796c51705ff28196d2b20d3b7353d14e9f98e65ed0e4eda9be072cc99b5297dc0466fee
languageName: node
linkType: hard
"parse-json@npm:^5.0.0, parse-json@npm:^5.2.0":
version: 5.2.0
resolution: "parse-json@npm:5.2.0"
@ -6647,6 +6714,13 @@ __metadata:
languageName: node
linkType: hard
"progress@npm:^2.0.3":
version: 2.0.3
resolution: "progress@npm:2.0.3"
checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7
languageName: node
linkType: hard
"promise-inflight@npm:^1.0.1":
version: 1.0.1
resolution: "promise-inflight@npm:1.0.1"
@ -6824,7 +6898,7 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:2 || 3, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0":
"readable-stream@npm:2 || 3, readable-stream@npm:^3.0.2, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0":
version: 3.6.0
resolution: "readable-stream@npm:3.6.0"
dependencies:

74
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,74 @@
version: '3.9'
services:
db:
image: postgres:latest
restart: always
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: postgres
volumes:
- db_data:/var/lib/postgresql/data
redis:
image: redis:latest
restart: always
volumes:
- redis_data:/data
api:
build:
context: ./api
dockerfile: Dockerfile.dev
restart: always
volumes:
- ./api:/app
ports:
- 3000:3000
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres?schema=public&connect_timeout=300
- REDIS_URL=redis://redis:6379
- CORS_ORIGIN=http://localhost:5000
- SESSION_SECRET=
- USE_MAIL=true
- MAIL_HOST=
- MAIL_PORT=
- MAIL_USER=
- MAIL_PASS=
- MAIL_FROM=Bliss <noreply@amog-us.club>
# s3 or local
- UPLOADER=local
# if you use s3 set these
- S3_ENDPOINT=
- S3_ACCESS_KEY_ID=
- S3_SECRET_ACCESS_KEY=
- S3_BUCKET_NAME=
- S3_REGION=
# optional
- COOKIE_NAME=
- UPLOAD_DIR=
depends_on:
- db
- redis
web:
build:
context: ./web
dockerfile: Dockerfile.dev
restart: always
volumes:
- ./web:/app
ports:
- 5000:5000
depends_on:
- api
environment:
- NEXT_PUBLIC_API_URL=http://localhost:3000
volumes:
db_data:
redis_data:

View File

@ -43,14 +43,24 @@ services:
- MAIL_PASS=
# e.g. ServiceName <noreply@domain.com>
- MAIL_FROM=
# should be local or s3, s3 doesn't work yet
- UPLOADER=local
# set to "true" if you are going to use a reverse proxy
- USE_PROXY=false
# set to "true" if you are going to use SSL
- USE_SSL=false
# port for the container to listen on
- PORT=3000
# should be local or s3
- UPLOADER=local
# s3 config (only needed if you are using s3)
- S3_ENDPOINT=
- S3_ACCESS_KEY_ID=
- S3_SECRET_ACCESS_KEY=
- S3_BUCKET_NAME=
- S3_REGION=
# optional
- COOKIE_NAME=
ports:
# 👇 Change this to whatever port you want
- 8080:3000

18
web/Dockerfile.dev Normal file
View File

@ -0,0 +1,18 @@
FROM node:lts-buster-slim AS development
RUN apt-get update && apt-get install --no-install-recommends -y \
openssl \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV development
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
RUN yarn install --immutable
COPY . .
CMD [ "yarn", "dev" ]

View File

@ -1,5 +1,5 @@
import { API_URL } from '@lib/constants';
import { IFile } from '@lib/types';
import { FileResponse, IFile } from '@lib/types';
import { formatDate } from '@lib/utils';
import {
Box,
@ -7,26 +7,20 @@ import {
Flex,
Grid,
Group,
// Paper,
Image,
Stack,
Text,
} from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconCheck, IconX } from '@tabler/icons';
import {
QueryObserverResult,
RefetchOptions,
RefetchQueryFilters,
} from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FC, useCallback } from 'react';
const PreviewCard: FC<{
file: IFile;
refetch: <TPageData>(
options?: (RefetchOptions & RefetchQueryFilters<TPageData>) | undefined
) => Promise<QueryObserverResult<any, unknown>>;
}> = ({ file, refetch }) => {
}> = ({ file }) => {
const queryClient = useQueryClient();
const fileURL = `${API_URL}/${file.slug}.${file.filename.split('.').pop()}`;
const deleteFile = useCallback(() => {
return axios
@ -39,9 +33,25 @@ const PreviewCard: FC<{
message: data,
icon: <IconCheck />,
});
refetch({ queryKey: ['files'], exact: true });
queryClient.setQueryData<FileResponse>(['files'], (oldData) => {
if (!oldData) return { files: [], totalFiles: 0, totalPages: 0 };
return {
files: oldData?.files.filter((f: IFile) => f.id !== file.id),
totalFiles: oldData?.totalFiles - 1,
totalPages: oldData?.totalPages,
};
});
})
.catch(() => {
.catch((err) => {
if (err.response.status === 429) {
showNotification({
title: 'Delete File',
color: 'red',
message: 'You are deleting files too fast.',
icon: <IconX />,
});
return;
}
showNotification({
title: 'Delete File',
color: 'red',
@ -49,7 +59,7 @@ const PreviewCard: FC<{
icon: <IconX />,
});
});
}, [file.id, refetch]);
}, [file.id, queryClient]);
return (
<Box>
@ -62,7 +72,6 @@ const PreviewCard: FC<{
border: `1px solid ${theme.colors.dark[4]}`,
borderRadius: theme.radius.md,
boxShadow: theme.shadows.sm,
maxHeight: 500,
msOverflowStyle: 'none',
scrollbarWidth: 'thin',
scrollbarColor: `${theme.colors.dark[5]} ${theme.colors.dark[7]}`,
@ -113,9 +122,17 @@ const PreviewCard: FC<{
<source src={fileURL} />
</video>
) : file.mimetype.includes('audio') ? (
<audio controls loop preload="metadata">
<source src={fileURL} />
</audio>
<Stack align="center">
<Image
width={300}
height={300}
src={`${API_URL}/${file.slug}.jpg`}
alt="Album Cover"
/>
<audio controls loop preload="metadata">
<source src={fileURL} />
</audio>
</Stack>
) : (
<Text align="center">
This file type is not supported for preview.

View File

@ -15,15 +15,15 @@ import {
} from '@mantine/core';
import { useDebouncedState } from '@mantine/hooks';
import dynamic from 'next/dynamic';
import { Suspense, useEffect, useMemo, useState } from 'react';
const PreviewCard = dynamic(() => import('./PreviewCard'));
import { Suspense, useMemo, useState, memo } from 'react';
const PreviewCard = memo(dynamic(() => import('./PreviewCard')));
const FileViewer = () => {
const [value, setValue] = useDebouncedState('', 200);
const [page, setPage] = useState(1);
const [limit, setLimit] = useState<number | 'all'>(15);
const [sort, setSort] = useState<'newest' | 'oldest'>('newest');
const { data, isFetching, error, refetch, isLoading } = useGetUserFiles({
const { data, isFetching, error, isLoading } = useGetUserFiles({
currentPage: page,
skip: limit !== 'all' ? limit * (page - 1) : 0,
take: limit !== 'all' ? limit : 'all',
@ -31,19 +31,15 @@ const FileViewer = () => {
search: value,
});
useEffect(() => {
refetch({ queryKey: ['files'], exact: true });
}, [page, limit, sort, value, refetch]);
const files = useMemo(() => {
if (data?.files) {
return data.files.map((file: IFile) => (
<PreviewCard refetch={refetch} key={file.id} file={file} />
<PreviewCard key={file.id} file={file} />
));
} else {
return null;
}
}, [data?.files, refetch]);
}, [data?.files]);
if (isLoading) {
return <LoadingPage color="yellow" />;

View File

@ -0,0 +1,91 @@
import { Button, Group, Text, TextInput } from '@mantine/core';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { useMutation } from '@tanstack/react-query';
import axios from 'axios';
import { API_ROUTES, API_URL } from '@lib/constants';
import { showNotification } from '@mantine/notifications';
import { IconCheck, IconExclamationMark } from '@tabler/icons';
const Modal = dynamic(() => import('@mantine/core').then((mod) => mod.Modal));
const WipeFiles = () => {
const [opened, setOpened] = useState(false);
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const { mutateAsync, isLoading } = useMutation(['wipe-files'], () =>
axios
.delete(API_URL + API_ROUTES.WIPE_FILES, { withCredentials: true })
.catch((err) => {
throw new Error(err.response.data.message);
})
);
return (
<>
<Modal
opened={opened}
onClose={() => setOpened(false)}
centered
withCloseButton={false}
>
<Text weight="bold" size="lg" color="red" my="xs">
Danger Zone
</Text>
<TextInput
label="Type 'delete' to confirm"
description="This action cannot be undone"
error={error}
value={confirm}
placeholder="delete"
onChange={(e) => {
setError('');
setConfirm(e.currentTarget.value);
}}
/>
<Group position="right" mt="md">
<Button variant="default" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
color="red"
loading={isLoading}
onClick={() => {
if (confirm === 'delete') {
mutateAsync()
.then(() => {
showNotification({
title: 'Success',
message: 'Your files has been deleted sucessfully.',
color: 'green',
icon: <IconCheck />,
autoClose: 5000,
});
})
.catch((err) => {
setError(err.message);
showNotification({
title: 'Error',
message: err.message,
color: 'red',
icon: <IconExclamationMark />,
autoClose: 5000,
});
});
} else {
setError('Please type "delete" to confirm');
}
}}
>
Wipe Files
</Button>
</Group>
</Modal>
<Button onClick={() => setOpened(true)} color="red">
Wipe Files
</Button>
</>
);
};
export default WipeFiles;

View File

@ -21,6 +21,7 @@ import { useSendVerificationEmail, useSignOut } from '@lib/hooks';
import { showNotification } from '@mantine/notifications';
import { profileStyles } from './styles';
import DeleteAccount from './DeleteAccount';
import WipeFiles from './WipeFiles';
const Modal = dynamic(() => import('@mantine/core').then((mod) => mod.Modal));
const ChangePasswordForm = dynamic(() => import('./ChangePasswordForm'));
@ -150,7 +151,11 @@ const ProfilePage = () => {
<Divider mt="xl" mb="xl" />
<CredentialsForm username={user?.username!} />
<Divider mt="xl" mb="xl" />
<DeleteAccount />
<Group spacing="xl">
<DeleteAccount />
<Divider orientation="vertical" />
<WipeFiles />
</Group>
</Box>
</Paper>
);

View File

@ -31,6 +31,7 @@ export enum API_ROUTES {
FORGOT_PASSWORD = '/users/forgot-password',
RESET_PASSWORD = '/users/reset-password',
DELETE_ACCOUNT = '/users/delete-account',
WIPE_FILES = '/users/wipe-files',
CHECK_TOKEN = '/users/check-token',
SEND_VERIFICATION_EMAIL = '/users/verify/send',
VERIFY_EMAIL = '/users/verify',

View File

@ -16,8 +16,8 @@ export const useGetUserFiles = ({
sort?: string;
search?: string;
}) => {
const { data, isLoading, error, isFetching, refetch } = useQuery(
['files'],
const { data, isLoading, error, isFetching } = useQuery(
['files', skip, take, sort, search, currentPage],
() =>
axios
.get<FileResponse>(
@ -28,7 +28,9 @@ export const useGetUserFiles = ({
withCredentials: true,
}
)
.then((res) => res.data)
.then((res) => {
return res.data;
})
.catch((error) => {
throw new Error(error.response.data.message);
}),
@ -39,5 +41,5 @@ export const useGetUserFiles = ({
}
);
return { data, isLoading, isFetching, error, refetch };
return { data, isLoading, isFetching, error };
};