mirror of https://github.com/sylv/micro.git
feat: mikroorm, convert to monorepo
This commit is contained in:
parent
e72ae7bb57
commit
a58fd3ad77
|
@ -37,4 +37,7 @@ dist
|
|||
/data
|
||||
|
||||
# nektos/act secrets file
|
||||
.secrets
|
||||
.secrets
|
||||
|
||||
packages/web/.next
|
||||
packages/api/data
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
|
@ -10,5 +10,6 @@
|
|||
},
|
||||
"headwind.classRegex": {
|
||||
"typescriptreact": "className(?:s\\((?:\\s+?)?|=)[\"'`]([A-z0-9-:/ ]+)[\"'`]"
|
||||
}
|
||||
},
|
||||
"cSpell.words": ["xbytes"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
module.exports = {
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/f/:fileId",
|
||||
destination: "/file/:fileId",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
87
package.json
87
package.json
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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}\\/?`);
|
||||
}
|
||||
}
|
|
@ -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 };
|
|
@ -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;
|
|
@ -0,0 +1,5 @@
|
|||
import { randomBytes } from "crypto";
|
||||
|
||||
export function generateDeleteKey() {
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
|
@ -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}`);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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", "", {
|
|
@ -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;
|
|
@ -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,
|
||||
}),
|
|
@ -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 {
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
|
@ -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" {
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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],
|
|
@ -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}.`);
|
||||
}
|
|
@ -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) {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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}`;
|
||||
}
|
||||
}
|
|
@ -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],
|
|
@ -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.`);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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],
|
|
@ -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);
|
||||
}
|
|
@ -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)
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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],
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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"]>>;
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["./src/**/*.ts", "./src/data/blocklist.json"]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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}`,
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./constants";
|
||||
export * from "./helpers/is-object.helper";
|
|
@ -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*",
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
|
@ -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"
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 293 B |
|
@ -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 (
|
|
@ -1,4 +1,4 @@
|
|||
import { GetFileData } from "../../types";
|
||||
import { GetFileData } from "@micro/api";
|
||||
|
||||
export const FileEmbedDefault = ({ file }: { file: GetFileData }) => {
|
||||
return (
|
|
@ -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 (
|
|
@ -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";
|
|
@ -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";
|
||||
|
|
@ -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 (
|
|
@ -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";
|
|
@ -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";
|
||||
|
|
@ -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
Loading…
Reference in New Issue