feat: mikroorm, convert to monorepo

This commit is contained in:
Ryan 2021-10-02 22:17:05 +00:00
parent e72ae7bb57
commit a58fd3ad77
166 changed files with 3515 additions and 2543 deletions

5
.gitignore vendored
View File

@ -37,4 +37,7 @@ dist
/data
# nektos/act secrets file
.secrets
.secrets
packages/web/.next
packages/api/data

12
.pnpmfile.cjs Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
hooks: {
readPackage: (pkg, context) => {
if (pkg.name === "@mikro-orm/cli") {
delete pkg.dependencies["@mikro-orm/core"];
pkg.peerDependencies["@mikro-orm/core"] = pkg.peerDependencies["@mikro-orm/postgresql"];
}
return pkg;
},
},
};

View File

@ -10,5 +10,6 @@
},
"headwind.classRegex": {
"typescriptreact": "className(?:s\\((?:\\s+?)?|=)[\"'`]([A-z0-9-:/ ]+)[\"'`]"
}
},
"cSpell.words": ["xbytes"]
}

View File

@ -75,7 +75,6 @@ There currently isn't an admin interface, only endpoints that let you do some ba
- [ ] Redirects may be broken. Also hosts with no redirect should probably just have it set to the root host, that should allow us to strip some unnecessary code.
- [ ] GIFs should probably be converted to mp4 videos to save space
- Discord is currently blocking this as they handle embedding videos (and gifs) extremely poorly. Unless the url has "mp4" in it it outright won't embed most of the time.
- [ ] Drop `nest-next`, it appears to be unmaintained and has issues with passthrough paths
## discord

View File

@ -1,5 +1,6 @@
# THIS FILE IS FOR DEVELOPMENT ONLY.
# If you want complete examples on how to host micro, see the /examples directory.
# Persistence is not setup for this postgres instance.
version: "3"
services:
postgres:

View File

@ -1,10 +0,0 @@
module.exports = {
async rewrites() {
return [
{
source: "/f/:fileId",
destination: "/file/:fileId",
},
];
},
};

36
package-lock.json generated
View File

@ -1,36 +0,0 @@
{
"name": "micro",
"version": "0.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@prisma/client": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-2.27.0.tgz",
"integrity": "sha512-Sh2b1M8MGbOHbwG1FEqdWTUCrEX3p7gt2e7gpaBWou8yTIJvP1UZ4YlHgpuUcR1q4pEIR/JTZJeQk2l4iDyRBQ==",
"requires": {
"@prisma/engines-version": "2.27.0-43.cdba6ec525e0213cce26f8e4bb23cf556d1479bb"
}
},
"@prisma/engines": {
"version": "2.27.0-43.cdba6ec525e0213cce26f8e4bb23cf556d1479bb",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-2.27.0-43.cdba6ec525e0213cce26f8e4bb23cf556d1479bb.tgz",
"integrity": "sha512-AIbIhAxmd2CHZO5XzQTPrfk+Tp/5eoNoSledOG3yc6Dk97siLvnBuSEv7prggUbedCufDwZLAvwxV4PEw3zOlQ==",
"dev": true
},
"@prisma/engines-version": {
"version": "2.27.0-43.cdba6ec525e0213cce26f8e4bb23cf556d1479bb",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-2.27.0-43.cdba6ec525e0213cce26f8e4bb23cf556d1479bb.tgz",
"integrity": "sha512-pwOsYdzw8+cwKlUrCzasiRh96RhNuJ/QcKr0HwjxxlUWTmbEayDKjqRRz5fsUYIpSv5fW1B3SsbzHOqVtFZ6XQ=="
},
"prisma": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-2.27.0.tgz",
"integrity": "sha512-/3H9C+IPlJmY5KArhfKHMpxKXqcZIBZ+LjM1b5FxvLCGQkq/mRC96SpHcKcLtiYgftNAX13nvlxg+cBw9Dbe8Q==",
"dev": true,
"requires": {
"@prisma/engines": "2.27.0-43.cdba6ec525e0213cce26f8e4bb23cf556d1479bb"
}
}
}
}

View File

@ -6,94 +6,21 @@
"license": "GPL-3.0",
"private": true,
"engine": {
"node": ">=14 <15"
"node": ">=16"
},
"scripts": {
"watch": "cross-env NODE_ENV=development tsup src/main.ts --watch --onSuccess \"node .next/api/main.js\"",
"build:clean": "rimraf .next",
"build:web": "next build",
"build:api": "tsup",
"build": "pnpm build:clean && pnpm build:web && pnpm build:api",
"start": "cross-env NODE_ENV=production node .next/api/main.js",
"lint": "eslint --fix ./src/**/*.{ts,tsx,js}",
"generate": "prisma generate",
"postinstall": "pnpm run generate"
},
"dependencies": {
"@anatine/esbuild-decorators": "^0.2.12",
"@headlessui/react": "^1.3.0",
"@nestjs/common": "^8.0.4",
"@nestjs/core": "^8.0.4",
"@nestjs/jwt": "^8.0.0",
"@nestjs/passport": "^8.0.0",
"@nestjs/platform-fastify": "^8.0.4",
"@nestjs/schedule": "^1.0.0",
"@prisma/client": "^2.27.0",
"@ryanke/venera": "^0.0.2",
"autoprefixer": "^10.3.1",
"bcrypt": "^5.0.1",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"classnames": "^2.3.1",
"content-range": "^2.0.0",
"copy-to-clipboard": "^3.3.1",
"cross-env": "^7.0.3",
"escape-string-regexp": "^4.0.0",
"fastify": "3.19.2",
"fastify-cookie": "^5.3.1",
"fastify-multipart": "^4.0.7",
"file-type": "^16.5.2",
"generate-avatar": "1.4.10",
"http-status-codes": "^2.1.4",
"istextorbinary": "^5.12.0",
"luxon": "^2.0.1",
"mime-types": "^2.1.31",
"ms": "^2.1.3",
"nanoid": "^3.1.23",
"nest-next": "9.3.0-beta.0",
"next": "11.0.1",
"normalize-url": "^6.0.0",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"postcss": "^8.3.6",
"prism-react-renderer": "^1.2.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-feather": "^2.0.9",
"react-infinite-scroll-component": "^6.1.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0",
"sharp": "^0.28.3",
"stream-size": "^0.0.6",
"swr": "^0.5.6",
"tailwindcss": "^2.2.7",
"xbytes": "^1.7.0"
"lint": "eslint --fix ./packages/*/src/**/*.{ts,tsx,js}"
},
"devDependencies": {
"@commitlint/cli": "^13.1.0",
"@commitlint/config-conventional": "^13.1.0",
"@ryanke/eslint-config": "^1.0.1",
"@types/bcrypt": "^5.0.0",
"@types/luxon": "^1.27.1",
"@types/mime-types": "^2.1.0",
"@types/ms": "^0.7.31",
"@types/node": "^14.17.3",
"@types/passport-jwt": "^3.0.6",
"@types/passport-local": "^1.0.34",
"@types/rc": "^1.1.0",
"@types/react": "^17.0.15",
"@types/sharp": "^0.28.4",
"@typescript-eslint/eslint-plugin": "^4.29.3",
"@tsconfig/node16": "^1.0.2",
"@typescript-eslint/eslint-plugin": "^4.31.2",
"eslint": "^7.32.0",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react": "^7.26.0",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-unicorn": "^35.0.0",
"husky": "^7.0.1",
"prettier": "^2.3.2",
"prisma": "^2.27.0",
"rimraf": "^3.0.0",
"tsup": "^4.12.5",
"typescript": "^4.3.5"
"husky": "^7.0.2",
"prettier": "^2.4.1"
}
}

74
packages/api/package.json Normal file
View File

@ -0,0 +1,74 @@
{
"name": "@micro/api",
"version": "0.0.1",
"repository": "https://github.com/sylv/micro.git",
"author": "Ryan <ryan@sylver.me>",
"license": "GPL-3.0",
"private": true,
"main": "./src/types.ts",
"engine": {
"node": ">=16"
},
"scripts": {
"watch": "nodemon --exec \"ts-eager\" ./src/main.ts"
},
"dependencies": {
"@micro/common": "workspace:^0.0.1",
"@mikro-orm/core": "^4.5.9",
"@mikro-orm/nestjs": "^4.3.0",
"@mikro-orm/postgresql": "^4.5.9",
"@nestjs/common": "^8.0.7",
"@nestjs/core": "^8.0.7",
"@nestjs/jwt": "^8.0.0",
"@nestjs/passport": "^8.0.1",
"@nestjs/platform-fastify": "^8.0.7",
"@nestjs/schedule": "^1.0.1",
"@ryanke/venera": "^0.0.2",
"bcrypt": "^5.0.1",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"content-range": "^2.0.2",
"escape-string-regexp": "^4",
"fastify": "^3.21.6",
"fastify-cookie": "^5.3.1",
"fastify-multipart": "^5.0.0",
"file-type": "^16.5.3",
"http-status-codes": "^2.1.4",
"istextorbinary": "^6.0.0",
"luxon": "^2.0.2",
"mime-types": "^2.1.32",
"ms": "^3.0.0-canary.1",
"nanoid": "^3.1.25",
"next": "^11.1.2",
"normalize-url": "^6",
"passport": "^0.5.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"rxjs": "^7.3.0",
"sharp": "^0.29.1",
"stream-size": "^0.0.6",
"xbytes": "^1.7.0"
},
"devDependencies": {
"@mikro-orm/cli": "^4.5.9",
"@mikro-orm/migrations": "^4.5.9",
"@tsconfig/node16": "^1.0.2",
"@types/bcrypt": "^5.0.0",
"@types/luxon": "^2.0.4",
"@types/mime-types": "^2.1.1",
"@types/node": "^16.10.1",
"@types/passport-jwt": "^3.0.6",
"@types/passport-local": "^1.0.34",
"@types/sharp": "^0.29.2",
"nodemon": "^2.0.13",
"ts-eager": "^2.0.2",
"ts-node": "^10.2.1"
},
"mikro-orm": {
"useTsNode": true,
"configPaths": [
"./src/mikro-orm.config.ts",
"./dist/mikro-orm.config.js"
]
}
}

View File

@ -34,6 +34,6 @@ export class MicroHost {
// using pattern.test. we should cache this or create it once during the transform.
const escaped = escapeString(this.key);
const pattern = escaped.replace("\\{\\{username\\}\\}", "(?<username>[a-z0-9-{}]+?)");
return new RegExp(`^(https?:\\/\\/)?${pattern}\\/?$`);
return new RegExp(`^(https?:\\/\\/)?${pattern}\\/?`);
}
}

View File

@ -0,0 +1,14 @@
import { loadConfig } from "@ryanke/venera";
import { plainToClass } from "class-transformer";
import { validateSync } from "class-validator";
import { MicroConfig } from "./classes/MicroConfig";
const data = loadConfig("micro");
const config = plainToClass(MicroConfig, data, { exposeDefaultValues: true });
const errors = validateSync(config);
if (errors.length) throw errors;
if (config.rootHost.wildcard) {
throw new Error(`Root host cannot be a wildcard domain.`);
}
export { config };

View File

@ -1,5 +1,5 @@
import { customAlphabet } from "nanoid";
import blocklist from "../data/blocklist.json";
import blocklist from "../blocklist.json";
// note: changing this will require changes to the file.service.ts regex
export const contentIdLength = 6;

View File

@ -0,0 +1,5 @@
import { randomBytes } from "crypto";
export function generateDeleteKey() {
return randomBytes(16).toString("hex");
}

View File

@ -3,11 +3,8 @@ import { NestFactory, Reflector } from "@nestjs/core";
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
import cookie from "fastify-cookie";
import multipart, { FastifyMultipartOptions } from "fastify-multipart";
import { RenderService } from "nest-next";
import { config } from "./config";
import { errorHandler } from "./helpers/error-handler.helper";
import { RedirectInterceptor } from "./interceptors/redirect.interceptor";
import { AppModule } from "./modules/app.module";
import { HostsGuard } from "./modules/hosts/hosts.guard";
const limits: FastifyMultipartOptions = {
limits: {
@ -24,15 +21,24 @@ async function bootstrap(): Promise<void> {
const logger = new Logger("bootstrap");
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter);
app.useGlobalInterceptors(new ClassSerializerInterceptor(new Reflector(), {}));
app.useGlobalInterceptors(new RedirectInterceptor());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, forbidUnknownValues: true }));
app.useGlobalGuards(new HostsGuard());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
app.register(cookie as any);
app.register(multipart as any, limits);
const service = app.get(RenderService);
service.setErrorHandler(errorHandler);
await app.listen(8080, "0.0.0.0", (error, address) => {
logger.log(`Listing on ${address} (${config.rootHost.url})`);
if (error) throw error;
logger.log(`Listening at ${address}`);
});
}

View File

@ -0,0 +1,12 @@
import { MikroOrmModuleSyncOptions } from "@mikro-orm/nestjs";
import { config } from "./config";
import { File } from "./modules/file/file.entity";
import { Invite } from "./modules/invite/invite.entity";
import { Thumbnail } from "./modules/thumbnail/thumbnail.entity";
import { User } from "./modules/user/user.entity";
export default {
type: "postgresql",
entities: [File, Thumbnail, User, Invite],
clientUrl: config.database,
} as MikroOrmModuleSyncOptions;

View File

@ -7,20 +7,14 @@ import { HostsService } from "./hosts/hosts.service";
export class AppController {
constructor(private hostsService: HostsService) {}
@Get()
@Render("index")
getHome() {
return this.getConfig();
}
@Get("api/config")
@Get("config")
async getConfig() {
const hosts = this.hostsService.getHosts([]);
return {
inquiries: config.inquiries,
uploadLimit: config.uploadLimit,
allowTypes: config.allowTypes,
hosts: classToPlain(hosts) as typeof hosts,
hosts: hosts,
};
}
}

View File

@ -1,45 +1,31 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
import { RenderModule } from "nest-next";
import next from "next";
import { IS_DEV } from "../constants";
import { JWTStrategy } from "../strategies/jwt.strategy";
import { PasswordStrategy } from "../strategies/password.strategy";
import { ScheduleModule } from "@nestjs/schedule";
import { AppController } from "./app.controller";
import { AuthModule } from "./auth/auth.module";
import { DeletionModule } from "./deletion/deletion.module";
import { FileModule } from "./file/file.module";
import { HostsModule } from "./hosts/hosts.module";
import { InviteModule } from "./invite/invite.module";
import { LinkModule } from "./link/link.module";
import { UploadModule } from "./upload/upload.module";
import { StorageModule } from "./storage/storage.module";
import { ThumbnailModule } from "./thumbnail/thumbnail.module";
import { UserModule } from "./user/user.module";
import { ScheduleModule } from "@nestjs/schedule";
import MikroOrmOptions from "../mikro-orm.config";
@Module({
controllers: [AppController],
providers: [],
imports: [
PassportModule,
JWTStrategy,
StorageModule,
HostsModule,
PasswordStrategy,
DeletionModule,
AuthModule,
FileModule,
ThumbnailModule,
InviteModule,
LinkModule,
UploadModule,
UserModule,
MikroOrmModule.forRoot(MikroOrmOptions),
ScheduleModule.forRoot(),
RenderModule.forRootAsync(next({ dev: IS_DEV }), {
passthrough404: true,
viewsDir: null,
}),
],
})
export class AppModule {}

View File

@ -1,10 +1,10 @@
import { Controller, Post, Req, Res, UseGuards } from "@nestjs/common";
import { FastifyReply, FastifyRequest } from "fastify";
import { config } from "../../config";
import { PasswordAuthGuard } from "../../guards/password.guard";
import { JWTPayloadUser } from "../../strategies/jwt.strategy";
import { JWTPayloadUser } from "./strategies/jwt.strategy";
import { AuthService, TokenType } from "./auth.service";
import ms from "ms";
import { PasswordAuthGuard } from "./guards/password.guard";
@Controller()
export class AuthController {
@ -18,7 +18,7 @@ export class AuthController {
constructor(private authService: AuthService) {}
@Post("api/auth/login")
@Post("auth/login")
@UseGuards(PasswordAuthGuard)
async login(@Req() request: FastifyRequest, @Res() reply: FastifyReply) {
const payload: JWTPayloadUser = { name: request.user.username, id: request.user.id, secret: request.user.secret };
@ -32,7 +32,7 @@ export class AuthController {
.send({ ok: true });
}
@Post("api/auth/logout")
@Post("auth/logout")
async logout(@Res() reply: FastifyReply) {
return reply
.setCookie("token", "", {

View File

@ -1,8 +1,8 @@
import { applyDecorators, createParamDecorator, ExecutionContext, SetMetadata, UseGuards } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { Permission } from "../../constants";
import { JWTAuthGuard } from "../../guards/jwt.guard";
import { PermissionGuard } from "../../guards/permission.guard";
import { Permission } from "@micro/common";
import { JWTAuthGuard } from "./guards/jwt.guard";
import { PermissionGuard } from "./guards/permission.guard";
export const RequirePermissions = (...permissions: Permission[]) => {
let aggregate = 0;

View File

@ -1,14 +1,19 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { config } from "../../config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JWTStrategy } from "./strategies/jwt.strategy";
import { PasswordStrategy } from "./strategies/password.strategy";
import { User } from "../user/user.entity";
@Module({
controllers: [AuthController],
providers: [AuthService],
providers: [AuthService, PasswordStrategy, JWTStrategy],
exports: [AuthService],
imports: [
MikroOrmModule.forFeature([User]),
JwtModule.register({
secret: config.secret,
}),

View File

@ -1,8 +1,8 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { FastifyRequest } from "fastify";
import { Permission } from "../constants";
import { UserService } from "../modules/user/user.service";
import { Permission } from "@micro/common";
import { UserService } from "../../user/user.service";
@Injectable()
export class PermissionGuard implements CanActivate {

View File

@ -1,11 +1,13 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import { EntityRepository } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import { ForbiddenException, Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { FastifyRequest } from "fastify";
import { Strategy } from "passport-jwt";
import { config } from "../config";
import { TokenType } from "../modules/auth/auth.service";
import { prisma } from "../prisma";
import { config } from "../../../config";
import { User } from "../../user/user.entity";
import { TokenType } from "../auth.service";
export interface JWTPayloadUser {
id: string;
@ -15,7 +17,7 @@ export interface JWTPayloadUser {
@Injectable()
export class JWTStrategy extends PassportStrategy(Strategy) {
constructor() {
constructor(@InjectRepository(User) private userRepo: EntityRepository<User>) {
super({
audience: TokenType.USER,
ignoreExpiration: false,
@ -30,7 +32,7 @@ export class JWTStrategy extends PassportStrategy(Strategy) {
// but they're convenient so why not keep them, in the future this requirement
// might be removed.
if (!payload.secret) throw new ForbiddenException("Outdated JWT - refresh your sesion.");
const user = await prisma.user.findFirst({ where: { secret: payload.secret } });
const user = await this.userRepo.findOne({ secret: payload.secret });
if (!user) throw new ForbiddenException("Invalid token secret.");
return user;
}

View File

@ -1,16 +1,22 @@
import { EntityRepository } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import bcrypt from "bcrypt";
import { Strategy } from "passport-local";
import { FastifyRequest } from "fastify";
import { prisma } from "../prisma";
import { Strategy } from "passport-local";
import { User } from "../../user/user.entity";
@Injectable()
export class PasswordStrategy extends PassportStrategy(Strategy) {
constructor(@InjectRepository(User) private userRepo: EntityRepository<User>) {
super();
}
async validate(username: string, password: string): Promise<FastifyRequest["user"]> {
const lowerUsername = username.toLowerCase();
const user = await prisma.user.findFirst({
where: { username: lowerUsername },
const user = await this.userRepo.findOne({
username: lowerUsername,
});
if (!user) throw new UnauthorizedException();

View File

@ -1,5 +1,5 @@
import { User } from "@prisma/client";
import { MicroHost } from "../../src/classes/MicroHost";
import { MicroHost } from "../classes/MicroHost";
import { User } from "./user/user.entity";
import "fastify";
declare module "fastify" {

View File

@ -13,73 +13,64 @@ import {
Res,
UseGuards,
} from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { classToPlain } from "class-transformer";
import { FastifyReply, FastifyRequest } from "fastify";
import { MultipartFile } from "fastify-multipart";
import mime from "mime-types";
import { config } from "../../config";
import { JWTAuthGuard } from "../../guards/jwt.guard";
import { isImageScraper } from "../../helpers/is-image-scraper.helper";
import { RenderableReply } from "../../types";
import { UserId } from "../auth/auth.decorators";
import { ContentType, DeletionService } from "../deletion/deletion.service";
import { JWTAuthGuard } from "../auth/guards/jwt.guard";
import { HostsService } from "../hosts/hosts.service";
import { UserService } from "../user/user.service";
import { FileService } from "./file.service";
@Controller()
export class FileController {
constructor(
private fileService: FileService,
private deletionService: DeletionService,
private userService: UserService,
private hostsService: HostsService
) {}
constructor(private fileService: FileService, private userService: UserService, private hostsService: HostsService) {}
@Get(["file/:key", "f/:key"])
async getFilePage(@Res() reply: RenderableReply, @Req() request: FastifyRequest, @Param("key") key: string) {
@Get("file/:key")
async getFile(@Res() reply: FastifyReply, @Param("key") key: string, @Request() request: FastifyRequest) {
const clean = this.fileService.cleanFileKey(key);
const file = await this.getFile(clean.id, request);
const file = await this.fileService.getFile(clean.id, request.host);
if (!file) throw new NotFoundException("File not found.");
if (!this.hostsService.checkHostCanSendFile(file, request.host)) {
throw new NotFoundException("Your file is in another castle.");
throw new ForbiddenException("That file is not available on this host.");
}
if (clean.ext) {
const mimeType = mime.lookup(clean.ext);
if (!mimeType) throw new BadRequestException("Unknown file extension.");
if (file.type !== mimeType) {
throw new BadRequestException("File extension does not match file type.");
}
}
const scraper = isImageScraper(request.headers["user-agent"]);
const directOverride = scraper && (!scraper.types || scraper.types.includes(file.type));
if (clean.ext || directOverride) {
const isDirect = (scraper && (!scraper.types || scraper.types.includes(file.type))) || !!clean.ext;
if (isDirect) {
return this.fileService.sendFile(clean.id, request, reply);
}
return reply.render("file/[fileId]", {
fileId: clean.id,
file: JSON.stringify(file),
});
return reply.send(classToPlain(file));
}
@Get("api/file/:id")
async getFile(@Param("id") id: string, @Request() request: FastifyRequest) {
return this.fileService.getFile(id, request.host);
}
@Delete("api/file/:id")
@Delete("file/:id")
@UseGuards(JWTAuthGuard)
async deleteFile(@Param("id") id: string, @UserId() userId: string) {
await this.fileService.deleteFile(id, userId);
return { deleted: true };
}
@Post("api/file")
@Post("file")
@UseGuards(JWTAuthGuard)
async createFile(@UserId() userId: string, @Req() request: FastifyRequest, @Headers("x-micro-host") hosts = config.rootHost.url) {
const user = await this.userService.getUser(userId);
if (!user) throw new ForbiddenException("Unknown user.");
// todo: invalid type or maybe not because im a dumbass and forgot to leave a comment.
// also an issue in upload.controller.ts, see there for more info with this same issue.
const upload = (await request.file()) as MultipartFile | undefined;
if (!upload) throw new BadRequestException("Missing upload.");
const host = await this.hostsService.resolveHost(hosts, user.tags, true);
const file = await this.fileService.createFile(upload, request, user, host);
const deletionUrl = this.deletionService.createToken(ContentType.FILE, file.id);
return Object.assign(this.fileService.getFileUrls(file), {
delete: deletionUrl,
});
return file;
}
}

View File

@ -0,0 +1,70 @@
import { EMBEDDABLE_IMAGE_TYPES } from "@micro/common";
import { Entity, ManyToOne, OneToOne, PrimaryKey, Property } from "@mikro-orm/core";
import { Expose } from "class-transformer";
import mimeType from "mime-types";
import { generateDeleteKey } from "../../helpers/generate-delete-key.helper";
import { TimestampType } from "../../timestamp.type";
import { Thumbnail } from "../thumbnail/thumbnail.entity";
import { User } from "../user/user.entity";
@Entity({ tableName: "files" })
export class File {
@PrimaryKey()
id!: string;
@Property()
type!: string;
@Property()
size!: number;
@Property()
hash!: string;
@Property({ nullable: true })
host?: string;
@Property({ type: String, lazy: true })
deleteKey = generateDeleteKey();
@Property({ nullable: true })
name?: string;
@OneToOne({ entity: () => Thumbnail, inversedBy: "file", nullable: true, orphanRemoval: true })
thumbnail?: Thumbnail;
@ManyToOne(() => User)
owner!: User;
@Property({ type: TimestampType })
createdAt = new Date();
@Property({ persist: false })
@Expose()
get displayName() {
const extension = mimeType.extension(this.type);
return this.name ? this.name : extension ? `${this.id}.${extension}` : this.id;
}
@Property({ persist: false })
@Expose()
get urls() {
const extension = mimeType.extension(this.type);
const supportsThumbnail = EMBEDDABLE_IMAGE_TYPES.includes(this.type);
const viewUrl = `/f/${this.id}`;
const directUrl = `/f/${this.id}.${extension}`;
const metadataUrl = `/api/file/${this.id}`;
const thumbnailUrl = supportsThumbnail ? `/t/${this.id}` : null;
// todo: this.deleteKey is lazy which means the only time it should be present
// is when the file is created or its explicitly asked for. that said, we should
// still make sure we aren't leaking delete keys by accident.
const deleteUrl = this.deleteKey ? `/f/${this.id}/delete?key=${this.deleteKey}` : null;
return {
view: viewUrl,
direct: directUrl,
metadata: metadataUrl,
thumbnail: thumbnailUrl,
delete: deleteUrl,
};
}
}

View File

@ -1,13 +1,14 @@
import { forwardRef, Module } from "@nestjs/common";
import { DeletionModule } from "../deletion/deletion.module";
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
import { HostsModule } from "../hosts/hosts.module";
import { StorageModule } from "../storage/storage.module";
import { UserModule } from "../user/user.module";
import { FileController } from "./file.controller";
import { File } from "./file.entity";
import { FileService } from "./file.service";
@Module({
imports: [forwardRef(() => DeletionModule), StorageModule, HostsModule, UserModule],
imports: [StorageModule, HostsModule, UserModule, MikroOrmModule.forFeature([File])],
controllers: [FileController],
providers: [FileService],
exports: [FileService],

View File

@ -1,3 +1,5 @@
import { EntityRepository } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import {
BadRequestException,
Injectable,
@ -8,28 +10,29 @@ import {
UnauthorizedException,
} from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { File } from "@prisma/client";
import contentRange from "content-range";
import { FastifyReply, FastifyRequest } from "fastify";
import { Multipart } from "fastify-multipart";
import { DateTime } from "luxon";
import mimeType from "mime-types";
import { PassThrough } from "stream";
import xbytes from "xbytes";
import { MicroHost } from "../../classes/MicroHost";
import { config } from "../../config";
import { EMBEDDABLE_IMAGE_TYPES } from "../../constants";
import { contentIdLength, generateContentId } from "../../helpers/generate-content-id.helper";
import { getStreamType } from "../../helpers/get-stream-type.helper";
import { prisma } from "../../prisma";
import { HostsService } from "../hosts/hosts.service";
import { StorageService } from "../storage/storage.service";
import { File } from "./file.entity";
@Injectable()
export class FileService implements OnApplicationBootstrap {
private static readonly FILE_KEY_REGEX = new RegExp(`^(?<id>.{${contentIdLength}})(?<ext>\\.[A-z0-9]{2,})?$`);
private readonly logger = new Logger(FileService.name);
constructor(private storageService: StorageService, private hostsService: HostsService) {}
constructor(
@InjectRepository(File) private fileRepo: EntityRepository<File>,
private storageService: StorageService,
private hostsService: HostsService
) {}
cleanFileKey(key: string): { id: string; ext?: string } {
const groups = FileService.FILE_KEY_REGEX.exec(key)?.groups;
@ -38,35 +41,30 @@ export class FileService implements OnApplicationBootstrap {
}
async getFile(id: string, host: MicroHost) {
const file = await prisma.file.findFirst({ where: { id } });
if (!file) throw new NotFoundException("Unknown file.");
const file = await this.fileRepo.findOne(id);
if (!file) throw new NotFoundException(`Unknown file "${id}"`);
if (!this.hostsService.checkHostCanSendFile(file, host)) {
throw new NotFoundException("Your file is in another castle.");
}
return Object.assign(file, {
displayName: this.getFileDisplayName(file),
urls: this.getFileUrls(file),
});
return file;
}
async deleteFile(id: string, ownerId: string | null) {
const file = await prisma.file.findFirst({ where: { id } });
const file = await this.fileRepo.findOne(id);
if (!file) throw new NotFoundException();
if (ownerId && file.ownerId !== ownerId) {
if (ownerId && file.owner.id !== ownerId) {
throw new UnauthorizedException("You cannot delete other users files.");
}
const filesWithHash = await prisma.file.count({ where: { hash: file.hash } });
// todo: this should use a subscriber for file delete events, should also do
// the same for thumbnails or something ig
const filesWithHash = await this.fileRepo.count({ hash: file.hash });
if (filesWithHash === 1) {
await this.storageService.delete(file.hash);
}
// await prisma.file.delete({ where: { id: file.id } });
// todo: https://github.com/prisma/prisma/issues/2057
// prisma doesnt support cascade deletes, so we have to have our own migration
// that adds them then call this directly so prisma doesnt think it will fail
await prisma.$executeRaw`DELETE FROM files WHERE id = ${file.id}`;
await this.fileRepo.removeAndFlush(file);
}
async createFile(
@ -92,63 +90,47 @@ export class FileService implements OnApplicationBootstrap {
const fileId = generateContentId();
const { hash, size } = await this.storageService.create(uploadStream);
const file = await prisma.file.create({
data: {
id: fileId,
type: type,
name: multipart.filename,
ownerId: owner.id,
host: host?.key,
hash: hash,
size: size,
},
const file = this.fileRepo.create({
id: fileId,
type: type,
name: multipart.filename,
owner: owner.id,
host: host?.key,
hash: hash,
size: size,
});
await this.fileRepo.persistAndFlush(file);
return file;
}
async sendFile(fileId: string, request: FastifyRequest, reply: FastifyReply) {
const file = await this.getFile(fileId, request.host);
const range = request.headers["content-range"] ? contentRange.parse(request.headers["content-range"]) : null;
const displayName = this.getFileDisplayName(file);
const stream = this.storageService.createReadStream(file.hash, range);
if (range) reply.header("Content-Range", contentRange.format(range));
const type = file.type.startsWith("text") ? `${file.type}; charset=UTF-8` : file.type;
return reply
.header("ETag", `"${file.hash}"`)
.header("Accept-Ranges", "bytes")
.header("Content-Type", file.type)
.header("Content-Type", type)
.header("Content-Length", file.size)
.header("Last-Modified", file.createdAt)
.header("Content-Disposition", `inline; filename="${displayName}"`)
.header("Content-Disposition", `inline; filename="${file.displayName}"`)
.send(stream);
}
getFileDisplayName(file: Pick<File, "type" | "id" | "name">) {
if (!file.id) throw new Error("Missing file ID");
const extension = mimeType.extension(file.type);
return file.name ? file.name : extension ? `${file.id}.${extension}` : file.id;
}
getFileUrls(file: Pick<File, "id" | "type">) {
const extension = mimeType.extension(file.type);
const supportsThumbnail = EMBEDDABLE_IMAGE_TYPES.includes(file.type);
const view = `/f/${file.id}`;
const direct = `/f/${file.id}.${extension}`;
const metadata = `/api/file/${file.id}`;
const thumbnail = supportsThumbnail ? `/t/${file.id}` : null;
return { view, direct, metadata, thumbnail };
}
@Cron(CronExpression.EVERY_HOUR)
async purgeFiles() {
if (!config.purge) return;
const createdBefore = new Date(Date.now() - config.purge.afterTime);
const files = await prisma.file.findMany({
where: {
size: { gte: config.purge.overLimit },
createdAt: {
lte: createdBefore,
},
const files = await this.fileRepo.find({
size: {
$gte: config.purge.overLimit,
},
createdAt: {
$lte: createdBefore,
},
});
@ -167,6 +149,7 @@ export class FileService implements OnApplicationBootstrap {
onApplicationBootstrap() {
if (config.purge) {
const size = xbytes(config.purge.overLimit, { space: false });
// todo: swap out luxon for dayjs
const age = DateTime.local().minus(config.purge.afterTime).toRelative();
this.logger.warn(`Purging files is enabled for files over ${size} uploaded more than ${age}.`);
}

View File

@ -1,11 +1,11 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { classToPlain } from "class-transformer";
import { JWTAuthGuard } from "../../guards/jwt.guard";
import { UserId } from "../auth/auth.decorators";
import { JWTAuthGuard } from "../auth/guards/jwt.guard";
import { UserService } from "../user/user.service";
import { HostsService } from "./hosts.service";
@Controller("api/hosts")
@Controller("hosts")
export class HostsController {
constructor(private userService: UserService, private hostsService: HostsService) {}

View File

@ -0,0 +1,20 @@
import { BadRequestException, CallHandler, CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { config } from "../../config";
@Injectable()
export class HostsGuard implements CanActivate {
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const referer = request.headers["referer"];
if (!referer) {
request.host = config.hosts[0];
return true;
}
const host = config.hosts.find((host) => host.pattern.test(referer));
if (!host) throw new BadRequestException('Invalid "referer" header.');
request.host = host;
return true;
}
}

View File

@ -40,7 +40,7 @@ export class HostsService {
return parsed;
}
checkHostCanSendFile(file: { host: string | null }, host: MicroHost) {
checkHostCanSendFile(file: { host?: string }, host: MicroHost) {
// todo: if host.wildcard, we should check to make sure the file owner
// matches the given username in the request url. so uploads to
// sylver.is-fucking.gay can't be accessed on cyk.is-fucking.gay and vice versa

View File

@ -0,0 +1,25 @@
import { Controller, Get, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
import { Permission } from "@micro/common";
import { RequirePermissions, UserId } from "../auth/auth.decorators";
import { InviteService } from "./invite.service";
import { JWTAuthGuard } from "../auth/guards/jwt.guard";
@Controller()
export class InviteController {
constructor(private inviteService: InviteService) {}
@Get("invite/:id")
async getInvite(@Param("id") inviteId: string) {
const invite = await this.inviteService.get(inviteId);
if (!invite) throw new NotFoundException();
return invite;
}
@Get("invite")
@Post("invite")
@RequirePermissions(Permission.CREATE_INVITE)
@UseGuards(JWTAuthGuard)
async createInvite(@UserId() userId: string) {
return this.inviteService.create(userId, null);
}
}

View File

@ -0,0 +1,38 @@
import { Entity, ManyToOne, OneToOne, PrimaryKey, Property } from "@mikro-orm/core";
import { Expose } from "class-transformer";
import { generateDeleteKey } from "../../helpers/generate-delete-key.helper";
import { TimestampType } from "../../timestamp.type";
import { User } from "../user/user.entity";
@Entity()
export class Invite {
@PrimaryKey({ type: String })
id = generateDeleteKey();
@Property({ nullable: true })
permissions?: number;
@ManyToOne({ entity: () => User, nullable: true })
inviter?: User;
@OneToOne({ entity: () => User, nullable: true })
invited?: User;
@Property({ type: TimestampType })
createdAt = new Date();
@Property({ type: TimestampType, nullable: true })
expiresAt?: Date;
@Property({ persist: false })
@Expose()
get expired() {
return this.expiresAt && this.expiresAt.getTime() < Date.now();
}
@Property({ persist: false })
@Expose()
get url() {
return `/invite/${this.id}`;
}
}

View File

@ -1,11 +1,14 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { forwardRef, Module } from "@nestjs/common";
import { User } from "../user/user.entity";
import { AuthModule } from "../auth/auth.module";
import { UserModule } from "../user/user.module";
import { InviteController } from "./invite.controller";
import { InviteService } from "./invite.service";
import { Invite } from "./invite.entity";
@Module({
imports: [forwardRef(() => UserModule), AuthModule],
imports: [forwardRef(() => UserModule), AuthModule, MikroOrmModule.forFeature([User, Invite])],
controllers: [InviteController],
providers: [InviteService],
exports: [InviteService],

View File

@ -0,0 +1,54 @@
import { Permission } from "@micro/common";
import { EntityRepository } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common";
import { config } from "../../config";
import { User } from "../user/user.entity";
import { Invite } from "./invite.entity";
export interface JWTPayloadInvite {
id: string;
inviter?: string;
permissions?: number;
}
@Injectable()
export class InviteService implements OnApplicationBootstrap {
private readonly logger = new Logger(InviteService.name);
constructor(
@InjectRepository(User) private userRepo: EntityRepository<User>,
@InjectRepository(Invite) private inviteRepo: EntityRepository<Invite>
) {}
async create(inviterId: string | null, permissions: Permission | null) {
const invite = this.inviteRepo.create({
inviter: inviterId,
permissions: permissions,
});
await this.inviteRepo.persistAndFlush(invite);
return invite;
}
async get(inviteId: string) {
return this.inviteRepo.findOne(inviteId);
}
async consume(invite: Invite) {
this.inviteRepo.remove(invite);
await this.inviteRepo.flush();
}
async onApplicationBootstrap() {
const users = await this.userRepo.count();
if (users >= 1) return;
const existing = await this.inviteRepo.findOne({ inviter: null, permissions: Permission.ADMINISTRATOR });
if (existing) {
this.logger.log(`Go to ${config.rootHost.url}${existing.url} to create the first account.`);
return;
}
const invite = await this.create(null, Permission.ADMINISTRATOR);
this.logger.log(`Go to ${config.rootHost.url}${invite.url} to create the first account.`);
}
}

View File

@ -8,7 +8,7 @@ import getSizeTransform from "stream-size";
import { promisify } from "util";
import { ExifTransformer } from "../../classes/ExifTransformer";
import { config } from "../../config";
import { isObject } from "../../helpers/is-object.helper";
import { isObject } from "../../../../common/src/helpers/is-object.helper";
const pipeline = promisify(stream.pipeline);

View File

@ -7,13 +7,7 @@ import { ThumbnailService } from "./thumbnail.service";
export class ThumbnailController {
constructor(private fileService: FileService, private thumbnailService: ThumbnailService) {}
@Get("t/:key")
async getThumbnailPage(@Param("key") key: string, @Req() request: FastifyRequest, @Res() reply: FastifyReply) {
const clean = this.fileService.cleanFileKey(key);
return this.thumbnailService.sendThumbnail(clean.id, request, reply);
}
@Get("api/thumbnail/:id")
@Get("thumbnail/:id")
async getThumbnail(@Param("id") id: string) {
return this.thumbnailService.getThumbnail(id);
}

View File

@ -0,0 +1,24 @@
import { BlobType, Entity, OneToOne, PrimaryKey, Property } from "@mikro-orm/core";
import { TimestampType } from "../../timestamp.type";
import { File } from "../file/file.entity";
@Entity({ tableName: "thumbnails" })
export class Thumbnail {
@PrimaryKey()
id!: string;
@Property()
size!: number;
@Property()
duration!: number;
@Property({ type: BlobType, lazy: true })
data!: Buffer;
@OneToOne({ entity: () => File, mappedBy: "thumbnail" })
file!: File;
@Property({ type: TimestampType })
createdAt = new Date();
}

View File

@ -3,9 +3,11 @@ import { FileModule } from "../file/file.module";
import { StorageModule } from "../storage/storage.module";
import { ThumbnailController } from "./thumbnail.controller";
import { ThumbnailService } from "./thumbnail.service";
import { Thumbnail } from "./thumbnail.entity";
import { MikroOrmModule } from "@mikro-orm/nestjs";
@Module({
imports: [StorageModule, FileModule],
imports: [StorageModule, FileModule, MikroOrmModule.forFeature([Thumbnail])],
controllers: [ThumbnailController],
providers: [ThumbnailService],
exports: [ThumbnailService],

View File

@ -1,20 +1,26 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { EntityRepository } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { EMBEDDABLE_IMAGE_TYPES } from "../../constants";
import { prisma } from "../../prisma";
import { EMBEDDABLE_IMAGE_TYPES } from "@micro/common";
import { File } from "../file/file.entity";
import { Thumbnail } from "./thumbnail.entity";
import { FileService } from "../file/file.service";
import { File } from "@prisma/client";
import { StorageService } from "../storage/storage.service";
@Injectable()
export class ThumbnailService {
private static readonly THUMBNAIL_SIZE = 200;
private static readonly THUMBNAIL_TYPE = "image/webp";
constructor(private storageService: StorageService, private fileService: FileService) {}
constructor(
@InjectRepository(Thumbnail) private thumbnailRepo: EntityRepository<Thumbnail>,
private storageService: StorageService,
private fileService: FileService
) {}
async getThumbnail(fileId: string) {
const thumbnail = await prisma.thumbnail.findFirst({ where: { id: fileId } });
const thumbnail = await this.thumbnailRepo.findOne(fileId);
if (!thumbnail) throw new NotFoundException();
return thumbnail;
}
@ -30,25 +36,20 @@ export class ThumbnailService {
const transformer = sharp().resize(ThumbnailService.THUMBNAIL_SIZE).webp({ quality: 40 });
const data = await stream.pipe(transformer).toBuffer();
const duration = Date.now() - start;
const thumbnail = prisma.thumbnail.create({
data: {
id: file.id,
data: data,
duration: duration,
size: data.length,
file: {
connect: {
id: file.id,
},
},
},
const thumbnail = this.thumbnailRepo.create({
id: file.id,
data: data,
duration: duration,
size: data.length,
file: file,
});
await this.thumbnailRepo.persistAndFlush(thumbnail);
return thumbnail;
}
async sendThumbnail(fileId: string, request: FastifyRequest, reply: FastifyReply) {
const existing = await prisma.thumbnail.findFirst({ where: { id: fileId } });
const existing = await this.thumbnailRepo.findOne(fileId);
if (existing) {
return reply.header("X-Micro-Generated", "false").header("Content-Type", ThumbnailService.THUMBNAIL_TYPE).send(existing.data);
}

View File

@ -1,5 +1,5 @@
import { IsLowercase, IsNotIn, IsString, MaxLength, MinLength } from "class-validator";
import blocklist from "../../../data/blocklist.json";
import blocklist from "../../../blocklist.json";
export class CreateUserDto {
@MaxLength(20)

View File

@ -0,0 +1,17 @@
import { Type } from "class-transformer";
import { IsNumber, IsOptional, Max, Min } from "class-validator";
export class Pagination {
@IsNumber()
@Min(0)
@IsOptional()
@Type(() => Number)
offset = 0;
@IsNumber()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
limit = 50;
}

View File

@ -1,27 +1,40 @@
import { BadRequestException, Body, Controller, ForbiddenException, Get, Param, Post, Put, Query, UseGuards } from "@nestjs/common";
import { EntityRepository } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
Param,
Post,
Put,
Query,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import { nanoid } from "nanoid";
import { Permission } from "../../constants";
import { JWTAuthGuard } from "../../guards/jwt.guard";
import { prisma } from "../../prisma";
import { JWTPayloadUser } from "../../strategies/jwt.strategy";
import { Permission } from "@micro/common";
import { User } from "./user.entity";
import { RequirePermissions, UserId } from "../auth/auth.decorators";
import { AuthService, TokenType } from "../auth/auth.service";
import { FileService } from "../file/file.service";
import { JWTPayloadUser } from "../auth/strategies/jwt.strategy";
import { InviteService } from "../invite/invite.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { UserFilesQueryDto } from "./dto/user-files-query.dto";
import { UserService } from "./user.service";
import { JWTAuthGuard } from "../auth/guards/jwt.guard";
import { Pagination } from "./dto/pagination.dto";
import { CreateUserDto } from "./dto/create-user.dto";
@Controller()
export class UserController {
constructor(
@InjectRepository(User) private userRepo: EntityRepository<User>,
private userService: UserService,
private inviteService: InviteService,
private fileService: FileService,
private authService: AuthService
) {}
@Get("api/user")
@Get("user")
@UseGuards(JWTAuthGuard)
async getUser(@UserId() userId: string) {
const user = await this.userService.getUser(userId);
@ -29,28 +42,23 @@ export class UserController {
return user;
}
@Post("/api/user")
@Post("user")
async createUser(@Body() data: CreateUserDto) {
const invite = await this.inviteService.verifyToken(data.invite);
const invite = await this.inviteService.get(data.invite);
if (!invite) throw new UnauthorizedException("Invalid invite.");
return this.userService.createUser(data, invite);
}
@Get("api/user/files")
@Get("user/files")
@UseGuards(JWTAuthGuard)
async getUserFiles(@UserId() userId: string, @Query() dto?: UserFilesQueryDto) {
const files = await this.userService.getUserFiles(userId, dto);
return files.map((file) =>
Object.assign(file, {
displayName: this.fileService.getFileDisplayName(file),
urls: this.fileService.getFileUrls(file),
})
);
async getUserFiles(@UserId() userId: string, @Query() pagination: Pagination) {
return this.userService.getUserFiles(userId, pagination);
}
@Get("api/user/token")
@Get("user/token")
@UseGuards(JWTAuthGuard)
async getUserToken(@UserId() userId: string) {
const user = await this.userService.getUser(userId, true);
const user = await this.userService.getUser(userId);
if (!user) throw new ForbiddenException("Unknown user.");
const token = await this.authService.signToken<JWTPayloadUser>(TokenType.USER, {
name: user.username,
@ -61,16 +69,18 @@ export class UserController {
return { token };
}
@Put("api/user/token")
@Put("user/token")
@UseGuards(JWTAuthGuard)
async resetUserToken(@UserId() userId: string) {
const secret = nanoid();
await prisma.user.update({ where: { id: userId }, data: { secret } });
const reference = this.userRepo.getReference(userId);
reference.secret = secret;
await this.userRepo.persistAndFlush(reference);
return this.getUserToken(userId);
}
// temporary until admin UI
@Get("api/user/:id/delete")
@Get("user/:id/delete")
@RequirePermissions(Permission.DELETE_USERS)
@UseGuards(JWTAuthGuard)
async deleteUser(@Param("id") targetId: string) {
@ -85,7 +95,7 @@ export class UserController {
}
// temporary until admin UI
@Get("api/user/:id/tags/add/:tag")
@Get("user/:id/tags/add/:tag")
@RequirePermissions(Permission.ADD_USER_TAGS)
@UseGuards(JWTAuthGuard)
async addTagToUser(@Param("id") targetId: string, @Param("tag") tag: string) {
@ -95,18 +105,12 @@ export class UserController {
throw new BadRequestException("User already has that tag.");
}
await prisma.user.update({
where: { id: target.id },
data: {
tags: [...target.tags, tag.toLowerCase()],
},
});
target.tags.push(tag.toLowerCase());
return { added: true, tag };
}
// temporary until admin UI
@Get("api/user/:id/tags/remove/:tag")
@Get("user/:id/tags/remove/:tag")
@RequirePermissions(Permission.ADD_USER_TAGS)
@UseGuards(JWTAuthGuard)
async removeTagFromUser(@Param("id") targetId: string, @Param("tag") tag: string) {
@ -116,13 +120,7 @@ export class UserController {
throw new BadRequestException("User does not have that tag.");
}
await prisma.user.update({
where: { id: target.id },
data: {
tags: target.tags.filter((existing) => existing !== tag),
},
});
target.tags = target.tags.filter((existing) => existing !== tag);
return { removed: true, tag };
}
}

View File

@ -0,0 +1,34 @@
import { Collection, Entity, OneToMany, OneToOne, PrimaryKey, Property } from "@mikro-orm/core";
import { File } from "../file/file.entity";
import { generateContentId } from "../../helpers/generate-content-id.helper";
import { Invite } from "../invite/invite.entity";
import { Exclude } from "class-transformer";
@Entity({ tableName: "users" })
export class User {
@PrimaryKey({ type: String })
id = generateContentId();
@Property({ unique: true, index: true })
username!: string;
@Property({ type: Number })
permissions = 0;
@Property()
@Exclude()
password!: string;
@Property()
secret!: string;
@OneToOne()
invite!: Invite;
@Property()
tags: string[] = [];
@OneToMany(() => File, (file) => file.owner, { orphanRemoval: true })
@Exclude()
files = new Collection<File>(this);
}

View File

@ -1,4 +1,7 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { forwardRef, Module } from "@nestjs/common";
import { File } from "../file/file.entity";
import { User } from "./user.entity";
import { AuthModule } from "../auth/auth.module";
import { FileModule } from "../file/file.module";
import { InviteModule } from "../invite/invite.module";
@ -6,7 +9,7 @@ import { UserController } from "./user.controller";
import { UserService } from "./user.service";
@Module({
imports: [forwardRef(() => InviteModule), AuthModule, forwardRef(() => FileModule)],
imports: [forwardRef(() => InviteModule), AuthModule, forwardRef(() => FileModule), MikroOrmModule.forFeature([User, File])],
controllers: [UserController],
providers: [UserService],
exports: [UserService],

View File

@ -0,0 +1,75 @@
import { Permission } from "@micro/common";
import { EntityRepository, QueryOrder } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import { ConflictException, Injectable } from "@nestjs/common";
import bcrypt from "bcrypt";
import { nanoid } from "nanoid";
import { generateContentId } from "../../helpers/generate-content-id.helper";
import { File } from "../file/file.entity";
import { Invite } from "../invite/invite.entity";
import { InviteService } from "../invite/invite.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { Pagination } from "./dto/pagination.dto";
import { User } from "./user.entity";
@Injectable()
export class UserService {
constructor(
@InjectRepository(User) private userRepo: EntityRepository<User>,
@InjectRepository(File) private fileRepo: EntityRepository<File>,
private inviteService: InviteService
) {}
getUser(id: string) {
return this.userRepo.findOne(id);
}
getUserFiles(userId: string, pagination: Pagination) {
return this.fileRepo.find(
{
owner: userId,
},
{
limit: pagination.limit,
orderBy: {
createdAt: QueryOrder.DESC,
},
}
);
}
async deleteUser(id: string) {
const user = this.userRepo.getReference(id);
this.userRepo.remove(user);
}
async createUser(data: CreateUserDto, invite: Invite) {
const hashedPassword = await bcrypt.hash(data.password, 10);
const existing = await this.userRepo.findOne({ username: data.username });
if (existing) throw new ConflictException("A user with that username already exists.");
const user = this.userRepo.create({
id: generateContentId(),
secret: nanoid(),
password: hashedPassword,
username: data.username,
invite: invite.id,
permissions: invite.permissions,
});
await this.inviteService.consume(invite);
await this.userRepo.persistAndFlush(user);
return user;
}
checkPermissions(permissions: Permission | number, permission: Permission | number) {
return (permissions & permission) === permission;
}
addPermissions(permissions: Permission | number, permission: Permission | number) {
permissions |= permission;
}
clearPermissions(permissions: Permission | number, permission: Permission | number) {
permissions &= ~permission;
}
}

View File

@ -0,0 +1,17 @@
import { Type } from "@mikro-orm/core";
export class TimestampType extends Type<Date | undefined, number | undefined> {
convertToDatabaseValue(value: Date | undefined): number | undefined {
if (!value) return value;
return value.getTime();
}
convertToJSValue(value: number | undefined): Date | undefined {
if (value == null) return value;
return new Date(+value);
}
getColumnType() {
return "bigint";
}
}

View File

@ -1,15 +1,12 @@
import type { File, Link, User } from "@prisma/client";
import type { FastifyReply } from "fastify";
import type { RenderableResponse } from "nest-next";
import type { File } from "./modules/file/file.entity";
import type { User } from "./modules/user/user.entity";
import type { AppController } from "./modules/app.controller";
import type { FileController } from "./modules/file/file.controller";
import { HostsController } from "./modules/hosts/hosts.controller";
import type { HostsController } from "./modules/hosts/hosts.controller";
import type { InviteController } from "./modules/invite/invite.controller";
import type { LinkController } from "./modules/link/link.controller";
import type { UserController } from "./modules/user/user.controller";
export type { File, User, Link };
export type RenderableReply = RenderableResponse & FastifyReply;
export type { File, User };
export type Await<T> = T extends {
then: (onfulfilled?: (value: infer U) => unknown) => unknown;
}
@ -26,10 +23,7 @@ export type GetUploadTokenData = Await<ReturnType<UserController["getUserToken"]
export type PutUploadTokenData = Await<ReturnType<UserController["resetUserToken"]>>;
// file
export type GetFileData = Await<ReturnType<FileController["getFile"]>>;
// link
export type GetLinkData = Await<ReturnType<LinkController["getLink"]>>;
export type GetFileData = File;
// app
export type GetServerConfigData = Await<ReturnType<AppController["getConfig"]>>;

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["./src/**/*.ts", "./src/data/blocklist.json"]
}

View File

@ -0,0 +1,12 @@
{
"name": "@micro/common",
"version": "0.0.1",
"repository": "https://github.com/sylv/micro.git",
"author": "Ryan <ryan@sylver.me>",
"license": "GPL-3.0",
"private": true,
"main": "./src/index.ts",
"engine": {
"node": ">=16"
}
}

View File

@ -6,7 +6,7 @@ export const Endpoints = {
CONFIG: "/api/config",
HOSTS: "/api/hosts",
USER: "/api/user",
UPLOAD: "/api/upload",
UPLOAD: "/api/file",
USER_FILES: "/api/user/files",
USER_TOKEN: "/api/user/token",
FILE: (fileId: string) => `/api/file/${fileId}`,

View File

@ -0,0 +1,2 @@
export * from "./constants";
export * from "./helpers/is-object.helper";

View File

@ -0,0 +1,19 @@
const withTM = require("next-transpile-modules")(["@micro/common"]);
module.exports = withTM({
async rewrites() {
return [
{
source: "/(f|file)/:fileId.:extension",
destination: "http://localhost:8080/file/:fileId.:extension*",
},
{
source: "/f/:fileId",
destination: "/file/:fileId",
},
{
source: "/api/:path*",
destination: "http://localhost:8080/:path*",
},
];
},
});

42
packages/web/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "@micro/web",
"version": "0.0.1",
"repository": "https://github.com/sylv/micro.git",
"author": "Ryan <ryan@sylver.me>",
"license": "GPL-3.0",
"private": true,
"engine": {
"node": ">=16"
},
"scripts": {
"watch": "next dev",
"start": "next start",
"build": "next build"
},
"dependencies": {
"@headlessui/react": "^1.4.1",
"@micro/api": "workspace:^0.0.1",
"@micro/common": "workspace:^0.0.1",
"autoprefixer": "^10.3.5",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.1",
"dayjs": "^1.10.7",
"generate-avatar": "1.4.10",
"http-status-codes": "^2.1.4",
"nanoid": "^3.1.25",
"next": "11.0.1",
"next-transpile-modules": "^8.0.0",
"postcss": "^8.3.7",
"prism-react-renderer": "^1.2.1",
"react": "17.0.2",
"react-dom": "^17.0.2",
"react-feather": "^2.0.9",
"react-infinite-scroll-component": "^6.1.0",
"swr": "^1.0.1",
"tailwindcss": "^2.2.15"
},
"devDependencies": {
"@types/react": "^17.0.24",
"typescript": "^4.4.3"
}
}

View File

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 293 B

View File

@ -1,6 +1,6 @@
import Head from "next/head";
import { FunctionComponent } from "react";
import { GetFileData } from "../../types";
import { GetFileData } from "@micro/api";
export const FileEmbedContainer: FunctionComponent<{ file: GetFileData; children: React.ReactChild }> = (props) => {
return (

View File

@ -1,4 +1,4 @@
import { GetFileData } from "../../types";
import { GetFileData } from "@micro/api";
export const FileEmbedDefault = ({ file }: { file: GetFileData }) => {
return (

View File

@ -1,6 +1,6 @@
import Head from "next/head";
import { EMBEDDABLE_IMAGE_TYPES } from "../../constants";
import { GetFileData } from "../../types";
import { EMBEDDABLE_IMAGE_TYPES } from "@micro/common";
import { GetFileData } from "@micro/api";
export const FileEmbedImage = ({ file }: { file: GetFileData }) => {
return (

View File

@ -7,7 +7,7 @@ import languages from "../../../data/languages.json";
import { getFileLanguage } from "../../../helpers/get-file-language.helper";
import { http } from "../../../helpers/http.helper";
import { useToasts } from "../../../hooks/use-toasts.helper";
import { GetFileData } from "../../../types";
import { GetFileData } from "@micro/api";
import { Button } from "../../button/button";
import { Dropdown } from "../../dropdown/dropdown";
import { DropdownTab } from "../../dropdown/dropdown-tab";

View File

@ -2,7 +2,7 @@ import classNames from "classnames";
import Highlight, { defaultProps } from "prism-react-renderer";
import React from "react";
import { getFileLanguage } from "../../../helpers/get-file-language.helper";
import { GetFileData } from "../../../types";
import { GetFileData } from "@micro/api";
import { FileEmbedTextContainer } from "./file-embed-text-container";
import { theme } from "./prism-theme";

View File

@ -1,5 +1,5 @@
import { EMBEDDABLE_VIDEO_TYPES } from "../../constants";
import { GetFileData } from "../../types";
import { EMBEDDABLE_VIDEO_TYPES } from "@micro/common";
import { GetFileData } from "@micro/api";
export const FileEmbedVideo = ({ file }: { file: GetFileData }) => {
return (

View File

@ -1,5 +1,5 @@
import { FunctionComponent, useMemo } from "react";
import { GetFileData } from "../../types";
import { GetFileData } from "@micro/api";
import { FileEmbedDefault } from "./file-embed-default";
import { FileEmbedContainer } from "./file-embed-container";
import { FileEmbedText } from "./file-embed-text/file-embed-text";

View File

@ -1,5 +1,5 @@
import { FunctionComponent } from "react";
import { GetFileData } from "../../types";
import { GetFileData } from "@micro/api";
import { Link } from "../link";
import { FileListCardContent } from "./file-list-card-content";

View File

@ -1,30 +1,34 @@
import { FunctionComponent, useEffect, useState } from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import { Endpoints } from "../../constants";
import { Endpoints } from "@micro/common";
import { http } from "../../helpers/http.helper";
import { GetUserFilesData } from "../../types";
import { GetUserFilesData } from "@micro/api";
import { Card } from "../card";
import { Spinner } from "../spinner";
import { FileListCard } from "./file-list-card";
import Error from "../../pages/_error";
const PER_PAGE = 24;
export const FileList: FunctionComponent = () => {
const [files, setFiles] = useState<GetUserFilesData>([]);
const [loading, setLoading] = useState(true);
const [cursor, setCursor] = useState<string | null | undefined>();
const hasMore = cursor !== null;
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<any>(null);
const [offset, setOffset] = useState(0);
const fetchData = async () => {
try {
if (cursor === null) return;
if (error || loading || !hasMore) return;
setLoading(true);
let url = Endpoints.USER_FILES + `?take=${PER_PAGE}`;
if (cursor) url += `&cursor=${cursor}`;
let url = Endpoints.USER_FILES + `?offset=${offset}&limit=${PER_PAGE}`;
const response = await http(url.toString());
const body = (await response.json()) as GetUserFilesData;
const isFullPage = body.length === PER_PAGE;
setCursor(body[0] && isFullPage ? body[body.length - 1].id : null);
setFiles((files) => [...files, ...body]);
setHasMore(isFullPage);
setOffset(offset + PER_PAGE);
setFiles(files.concat(body));
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
@ -32,12 +36,16 @@ export const FileList: FunctionComponent = () => {
useEffect(() => {
fetchData();
});
}, []);
if (!files[0] && !loading) {
return <Card>You have not uploaded anything yet. Once you do, files will appear here.</Card>;
}
if (error) {
return <Error message={error.message} status={500} />;
}
return (
<InfiniteScroll
next={fetchData}

Some files were not shown because too many files have changed in this diff Show More