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
|
/data
|
||||||
|
|
||||||
# nektos/act secrets file
|
# 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": {
|
"headwind.classRegex": {
|
||||||
"typescriptreact": "className(?:s\\((?:\\s+?)?|=)[\"'`]([A-z0-9-:/ ]+)[\"'`]"
|
"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.
|
- [ ] 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
|
- [ ] 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.
|
- 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
|
## discord
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# THIS FILE IS FOR DEVELOPMENT ONLY.
|
# THIS FILE IS FOR DEVELOPMENT ONLY.
|
||||||
# If you want complete examples on how to host micro, see the /examples directory.
|
# If you want complete examples on how to host micro, see the /examples directory.
|
||||||
|
# Persistence is not setup for this postgres instance.
|
||||||
version: "3"
|
version: "3"
|
||||||
services:
|
services:
|
||||||
postgres:
|
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",
|
"license": "GPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engine": {
|
"engine": {
|
||||||
"node": ">=14 <15"
|
"node": ">=16"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "cross-env NODE_ENV=development tsup src/main.ts --watch --onSuccess \"node .next/api/main.js\"",
|
"lint": "eslint --fix ./packages/*/src/**/*.{ts,tsx,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"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^13.1.0",
|
"@commitlint/cli": "^13.1.0",
|
||||||
"@commitlint/config-conventional": "^13.1.0",
|
"@commitlint/config-conventional": "^13.1.0",
|
||||||
"@ryanke/eslint-config": "^1.0.1",
|
"@tsconfig/node16": "^1.0.2",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@typescript-eslint/eslint-plugin": "^4.31.2",
|
||||||
"@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",
|
|
||||||
"eslint": "^7.32.0",
|
"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-react-hooks": "^4.2.0",
|
||||||
"eslint-plugin-unicorn": "^35.0.0",
|
"eslint-plugin-unicorn": "^35.0.0",
|
||||||
"husky": "^7.0.1",
|
"husky": "^7.0.2",
|
||||||
"prettier": "^2.3.2",
|
"prettier": "^2.4.1"
|
||||||
"prisma": "^2.27.0",
|
|
||||||
"rimraf": "^3.0.0",
|
|
||||||
"tsup": "^4.12.5",
|
|
||||||
"typescript": "^4.3.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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.
|
// using pattern.test. we should cache this or create it once during the transform.
|
||||||
const escaped = escapeString(this.key);
|
const escaped = escapeString(this.key);
|
||||||
const pattern = escaped.replace("\\{\\{username\\}\\}", "(?<username>[a-z0-9-{}]+?)");
|
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 { 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
|
// note: changing this will require changes to the file.service.ts regex
|
||||||
export const contentIdLength = 6;
|
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 { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
|
||||||
import cookie from "fastify-cookie";
|
import cookie from "fastify-cookie";
|
||||||
import multipart, { FastifyMultipartOptions } from "fastify-multipart";
|
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 { AppModule } from "./modules/app.module";
|
||||||
|
import { HostsGuard } from "./modules/hosts/hosts.guard";
|
||||||
|
|
||||||
const limits: FastifyMultipartOptions = {
|
const limits: FastifyMultipartOptions = {
|
||||||
limits: {
|
limits: {
|
||||||
|
@ -24,15 +21,24 @@ async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger("bootstrap");
|
const logger = new Logger("bootstrap");
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter);
|
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter);
|
||||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(new Reflector(), {}));
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(new Reflector(), {}));
|
||||||
app.useGlobalInterceptors(new RedirectInterceptor());
|
app.useGlobalGuards(new HostsGuard());
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, forbidUnknownValues: true }));
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
transformOptions: {
|
||||||
|
enableImplicitConversion: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
app.register(cookie as any);
|
app.register(cookie as any);
|
||||||
app.register(multipart as any, limits);
|
app.register(multipart as any, limits);
|
||||||
|
|
||||||
const service = app.get(RenderService);
|
|
||||||
service.setErrorHandler(errorHandler);
|
|
||||||
await app.listen(8080, "0.0.0.0", (error, address) => {
|
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 {
|
export class AppController {
|
||||||
constructor(private hostsService: HostsService) {}
|
constructor(private hostsService: HostsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get("config")
|
||||||
@Render("index")
|
|
||||||
getHome() {
|
|
||||||
return this.getConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("api/config")
|
|
||||||
async getConfig() {
|
async getConfig() {
|
||||||
const hosts = this.hostsService.getHosts([]);
|
const hosts = this.hostsService.getHosts([]);
|
||||||
return {
|
return {
|
||||||
inquiries: config.inquiries,
|
inquiries: config.inquiries,
|
||||||
uploadLimit: config.uploadLimit,
|
uploadLimit: config.uploadLimit,
|
||||||
allowTypes: config.allowTypes,
|
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 { Module } from "@nestjs/common";
|
||||||
import { PassportModule } from "@nestjs/passport";
|
import { PassportModule } from "@nestjs/passport";
|
||||||
import { RenderModule } from "nest-next";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import next from "next";
|
|
||||||
import { IS_DEV } from "../constants";
|
|
||||||
import { JWTStrategy } from "../strategies/jwt.strategy";
|
|
||||||
import { PasswordStrategy } from "../strategies/password.strategy";
|
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { DeletionModule } from "./deletion/deletion.module";
|
|
||||||
import { FileModule } from "./file/file.module";
|
import { FileModule } from "./file/file.module";
|
||||||
import { HostsModule } from "./hosts/hosts.module";
|
import { HostsModule } from "./hosts/hosts.module";
|
||||||
import { InviteModule } from "./invite/invite.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 { StorageModule } from "./storage/storage.module";
|
||||||
import { ThumbnailModule } from "./thumbnail/thumbnail.module";
|
import { ThumbnailModule } from "./thumbnail/thumbnail.module";
|
||||||
import { UserModule } from "./user/user.module";
|
import { UserModule } from "./user/user.module";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import MikroOrmOptions from "../mikro-orm.config";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [],
|
providers: [],
|
||||||
imports: [
|
imports: [
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JWTStrategy,
|
|
||||||
StorageModule,
|
StorageModule,
|
||||||
HostsModule,
|
HostsModule,
|
||||||
PasswordStrategy,
|
|
||||||
DeletionModule,
|
|
||||||
AuthModule,
|
AuthModule,
|
||||||
FileModule,
|
FileModule,
|
||||||
ThumbnailModule,
|
ThumbnailModule,
|
||||||
InviteModule,
|
InviteModule,
|
||||||
LinkModule,
|
|
||||||
UploadModule,
|
|
||||||
UserModule,
|
UserModule,
|
||||||
|
MikroOrmModule.forRoot(MikroOrmOptions),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
RenderModule.forRootAsync(next({ dev: IS_DEV }), {
|
|
||||||
passthrough404: true,
|
|
||||||
viewsDir: null,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
|
@ -1,10 +1,10 @@
|
||||||
import { Controller, Post, Req, Res, UseGuards } from "@nestjs/common";
|
import { Controller, Post, Req, Res, UseGuards } from "@nestjs/common";
|
||||||
import { FastifyReply, FastifyRequest } from "fastify";
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { config } from "../../config";
|
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 { AuthService, TokenType } from "./auth.service";
|
||||||
import ms from "ms";
|
import ms from "ms";
|
||||||
|
import { PasswordAuthGuard } from "./guards/password.guard";
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
@ -18,7 +18,7 @@ export class AuthController {
|
||||||
|
|
||||||
constructor(private authService: AuthService) {}
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
@Post("api/auth/login")
|
@Post("auth/login")
|
||||||
@UseGuards(PasswordAuthGuard)
|
@UseGuards(PasswordAuthGuard)
|
||||||
async login(@Req() request: FastifyRequest, @Res() reply: FastifyReply) {
|
async login(@Req() request: FastifyRequest, @Res() reply: FastifyReply) {
|
||||||
const payload: JWTPayloadUser = { name: request.user.username, id: request.user.id, secret: request.user.secret };
|
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 });
|
.send({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("api/auth/logout")
|
@Post("auth/logout")
|
||||||
async logout(@Res() reply: FastifyReply) {
|
async logout(@Res() reply: FastifyReply) {
|
||||||
return reply
|
return reply
|
||||||
.setCookie("token", "", {
|
.setCookie("token", "", {
|
|
@ -1,8 +1,8 @@
|
||||||
import { applyDecorators, createParamDecorator, ExecutionContext, SetMetadata, UseGuards } from "@nestjs/common";
|
import { applyDecorators, createParamDecorator, ExecutionContext, SetMetadata, UseGuards } from "@nestjs/common";
|
||||||
import { FastifyRequest } from "fastify";
|
import { FastifyRequest } from "fastify";
|
||||||
import { Permission } from "../../constants";
|
import { Permission } from "@micro/common";
|
||||||
import { JWTAuthGuard } from "../../guards/jwt.guard";
|
import { JWTAuthGuard } from "./guards/jwt.guard";
|
||||||
import { PermissionGuard } from "../../guards/permission.guard";
|
import { PermissionGuard } from "./guards/permission.guard";
|
||||||
|
|
||||||
export const RequirePermissions = (...permissions: Permission[]) => {
|
export const RequirePermissions = (...permissions: Permission[]) => {
|
||||||
let aggregate = 0;
|
let aggregate = 0;
|
|
@ -1,14 +1,19 @@
|
||||||
|
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { config } from "../../config";
|
import { config } from "../../config";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { JWTStrategy } from "./strategies/jwt.strategy";
|
||||||
|
import { PasswordStrategy } from "./strategies/password.strategy";
|
||||||
|
import { User } from "../user/user.entity";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService],
|
providers: [AuthService, PasswordStrategy, JWTStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
imports: [
|
imports: [
|
||||||
|
MikroOrmModule.forFeature([User]),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: config.secret,
|
secret: config.secret,
|
||||||
}),
|
}),
|
|
@ -1,8 +1,8 @@
|
||||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { FastifyRequest } from "fastify";
|
import { FastifyRequest } from "fastify";
|
||||||
import { Permission } from "../constants";
|
import { Permission } from "@micro/common";
|
||||||
import { UserService } from "../modules/user/user.service";
|
import { UserService } from "../../user/user.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionGuard implements CanActivate {
|
export class PermissionGuard implements CanActivate {
|
|
@ -1,11 +1,13 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
/* 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 { ForbiddenException, Injectable } from "@nestjs/common";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { FastifyRequest } from "fastify";
|
import { FastifyRequest } from "fastify";
|
||||||
import { Strategy } from "passport-jwt";
|
import { Strategy } from "passport-jwt";
|
||||||
import { config } from "../config";
|
import { config } from "../../../config";
|
||||||
import { TokenType } from "../modules/auth/auth.service";
|
import { User } from "../../user/user.entity";
|
||||||
import { prisma } from "../prisma";
|
import { TokenType } from "../auth.service";
|
||||||
|
|
||||||
export interface JWTPayloadUser {
|
export interface JWTPayloadUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -15,7 +17,7 @@ export interface JWTPayloadUser {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JWTStrategy extends PassportStrategy(Strategy) {
|
export class JWTStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor() {
|
constructor(@InjectRepository(User) private userRepo: EntityRepository<User>) {
|
||||||
super({
|
super({
|
||||||
audience: TokenType.USER,
|
audience: TokenType.USER,
|
||||||
ignoreExpiration: false,
|
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
|
// but they're convenient so why not keep them, in the future this requirement
|
||||||
// might be removed.
|
// might be removed.
|
||||||
if (!payload.secret) throw new ForbiddenException("Outdated JWT - refresh your sesion.");
|
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.");
|
if (!user) throw new ForbiddenException("Invalid token secret.");
|
||||||
return user;
|
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 { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import { Strategy } from "passport-local";
|
|
||||||
import { FastifyRequest } from "fastify";
|
import { FastifyRequest } from "fastify";
|
||||||
import { prisma } from "../prisma";
|
import { Strategy } from "passport-local";
|
||||||
|
import { User } from "../../user/user.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PasswordStrategy extends PassportStrategy(Strategy) {
|
export class PasswordStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(@InjectRepository(User) private userRepo: EntityRepository<User>) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
async validate(username: string, password: string): Promise<FastifyRequest["user"]> {
|
async validate(username: string, password: string): Promise<FastifyRequest["user"]> {
|
||||||
const lowerUsername = username.toLowerCase();
|
const lowerUsername = username.toLowerCase();
|
||||||
const user = await prisma.user.findFirst({
|
const user = await this.userRepo.findOne({
|
||||||
where: { username: lowerUsername },
|
username: lowerUsername,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) throw new UnauthorizedException();
|
if (!user) throw new UnauthorizedException();
|
|
@ -1,5 +1,5 @@
|
||||||
import { User } from "@prisma/client";
|
import { MicroHost } from "../classes/MicroHost";
|
||||||
import { MicroHost } from "../../src/classes/MicroHost";
|
import { User } from "./user/user.entity";
|
||||||
import "fastify";
|
import "fastify";
|
||||||
|
|
||||||
declare module "fastify" {
|
declare module "fastify" {
|
|
@ -13,73 +13,64 @@ import {
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { FastifyRequest } from "fastify";
|
import { classToPlain } from "class-transformer";
|
||||||
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { MultipartFile } from "fastify-multipart";
|
import { MultipartFile } from "fastify-multipart";
|
||||||
|
import mime from "mime-types";
|
||||||
import { config } from "../../config";
|
import { config } from "../../config";
|
||||||
import { JWTAuthGuard } from "../../guards/jwt.guard";
|
|
||||||
import { isImageScraper } from "../../helpers/is-image-scraper.helper";
|
import { isImageScraper } from "../../helpers/is-image-scraper.helper";
|
||||||
import { RenderableReply } from "../../types";
|
|
||||||
import { UserId } from "../auth/auth.decorators";
|
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 { HostsService } from "../hosts/hosts.service";
|
||||||
import { UserService } from "../user/user.service";
|
import { UserService } from "../user/user.service";
|
||||||
import { FileService } from "./file.service";
|
import { FileService } from "./file.service";
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class FileController {
|
export class FileController {
|
||||||
constructor(
|
constructor(private fileService: FileService, private userService: UserService, private hostsService: HostsService) {}
|
||||||
private fileService: FileService,
|
|
||||||
private deletionService: DeletionService,
|
|
||||||
private userService: UserService,
|
|
||||||
private hostsService: HostsService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get(["file/:key", "f/:key"])
|
@Get("file/:key")
|
||||||
async getFilePage(@Res() reply: RenderableReply, @Req() request: FastifyRequest, @Param("key") key: string) {
|
async getFile(@Res() reply: FastifyReply, @Param("key") key: string, @Request() request: FastifyRequest) {
|
||||||
const clean = this.fileService.cleanFileKey(key);
|
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)) {
|
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 scraper = isImageScraper(request.headers["user-agent"]);
|
||||||
const directOverride = scraper && (!scraper.types || scraper.types.includes(file.type));
|
const isDirect = (scraper && (!scraper.types || scraper.types.includes(file.type))) || !!clean.ext;
|
||||||
if (clean.ext || directOverride) {
|
if (isDirect) {
|
||||||
return this.fileService.sendFile(clean.id, request, reply);
|
return this.fileService.sendFile(clean.id, request, reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.render("file/[fileId]", {
|
return reply.send(classToPlain(file));
|
||||||
fileId: clean.id,
|
|
||||||
file: JSON.stringify(file),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("api/file/:id")
|
@Delete("file/:id")
|
||||||
async getFile(@Param("id") id: string, @Request() request: FastifyRequest) {
|
|
||||||
return this.fileService.getFile(id, request.host);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete("api/file/:id")
|
|
||||||
@UseGuards(JWTAuthGuard)
|
@UseGuards(JWTAuthGuard)
|
||||||
async deleteFile(@Param("id") id: string, @UserId() userId: string) {
|
async deleteFile(@Param("id") id: string, @UserId() userId: string) {
|
||||||
await this.fileService.deleteFile(id, userId);
|
await this.fileService.deleteFile(id, userId);
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("api/file")
|
@Post("file")
|
||||||
@UseGuards(JWTAuthGuard)
|
@UseGuards(JWTAuthGuard)
|
||||||
async createFile(@UserId() userId: string, @Req() request: FastifyRequest, @Headers("x-micro-host") hosts = config.rootHost.url) {
|
async createFile(@UserId() userId: string, @Req() request: FastifyRequest, @Headers("x-micro-host") hosts = config.rootHost.url) {
|
||||||
const user = await this.userService.getUser(userId);
|
const user = await this.userService.getUser(userId);
|
||||||
if (!user) throw new ForbiddenException("Unknown user.");
|
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;
|
const upload = (await request.file()) as MultipartFile | undefined;
|
||||||
if (!upload) throw new BadRequestException("Missing upload.");
|
if (!upload) throw new BadRequestException("Missing upload.");
|
||||||
const host = await this.hostsService.resolveHost(hosts, user.tags, true);
|
const host = await this.hostsService.resolveHost(hosts, user.tags, true);
|
||||||
const file = await this.fileService.createFile(upload, request, user, host);
|
const file = await this.fileService.createFile(upload, request, user, host);
|
||||||
const deletionUrl = this.deletionService.createToken(ContentType.FILE, file.id);
|
return file;
|
||||||
return Object.assign(this.fileService.getFileUrls(file), {
|
|
||||||
delete: deletionUrl,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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 { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
import { DeletionModule } from "../deletion/deletion.module";
|
import { Module } from "@nestjs/common";
|
||||||
import { HostsModule } from "../hosts/hosts.module";
|
import { HostsModule } from "../hosts/hosts.module";
|
||||||
import { StorageModule } from "../storage/storage.module";
|
import { StorageModule } from "../storage/storage.module";
|
||||||
import { UserModule } from "../user/user.module";
|
import { UserModule } from "../user/user.module";
|
||||||
import { FileController } from "./file.controller";
|
import { FileController } from "./file.controller";
|
||||||
|
import { File } from "./file.entity";
|
||||||
import { FileService } from "./file.service";
|
import { FileService } from "./file.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => DeletionModule), StorageModule, HostsModule, UserModule],
|
imports: [StorageModule, HostsModule, UserModule, MikroOrmModule.forFeature([File])],
|
||||||
controllers: [FileController],
|
controllers: [FileController],
|
||||||
providers: [FileService],
|
providers: [FileService],
|
||||||
exports: [FileService],
|
exports: [FileService],
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { EntityRepository } from "@mikro-orm/core";
|
||||||
|
import { InjectRepository } from "@mikro-orm/nestjs";
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
@ -8,28 +10,29 @@ import {
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Cron, CronExpression } from "@nestjs/schedule";
|
import { Cron, CronExpression } from "@nestjs/schedule";
|
||||||
import { File } from "@prisma/client";
|
|
||||||
import contentRange from "content-range";
|
import contentRange from "content-range";
|
||||||
import { FastifyReply, FastifyRequest } from "fastify";
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { Multipart } from "fastify-multipart";
|
import { Multipart } from "fastify-multipart";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import mimeType from "mime-types";
|
|
||||||
import { PassThrough } from "stream";
|
import { PassThrough } from "stream";
|
||||||
import xbytes from "xbytes";
|
import xbytes from "xbytes";
|
||||||
import { MicroHost } from "../../classes/MicroHost";
|
import { MicroHost } from "../../classes/MicroHost";
|
||||||
import { config } from "../../config";
|
import { config } from "../../config";
|
||||||
import { EMBEDDABLE_IMAGE_TYPES } from "../../constants";
|
|
||||||
import { contentIdLength, generateContentId } from "../../helpers/generate-content-id.helper";
|
import { contentIdLength, generateContentId } from "../../helpers/generate-content-id.helper";
|
||||||
import { getStreamType } from "../../helpers/get-stream-type.helper";
|
import { getStreamType } from "../../helpers/get-stream-type.helper";
|
||||||
import { prisma } from "../../prisma";
|
|
||||||
import { HostsService } from "../hosts/hosts.service";
|
import { HostsService } from "../hosts/hosts.service";
|
||||||
import { StorageService } from "../storage/storage.service";
|
import { StorageService } from "../storage/storage.service";
|
||||||
|
import { File } from "./file.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileService implements OnApplicationBootstrap {
|
export class FileService implements OnApplicationBootstrap {
|
||||||
private static readonly FILE_KEY_REGEX = new RegExp(`^(?<id>.{${contentIdLength}})(?<ext>\\.[A-z0-9]{2,})?$`);
|
private static readonly FILE_KEY_REGEX = new RegExp(`^(?<id>.{${contentIdLength}})(?<ext>\\.[A-z0-9]{2,})?$`);
|
||||||
private readonly logger = new Logger(FileService.name);
|
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 } {
|
cleanFileKey(key: string): { id: string; ext?: string } {
|
||||||
const groups = FileService.FILE_KEY_REGEX.exec(key)?.groups;
|
const groups = FileService.FILE_KEY_REGEX.exec(key)?.groups;
|
||||||
|
@ -38,35 +41,30 @@ export class FileService implements OnApplicationBootstrap {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFile(id: string, host: MicroHost) {
|
async getFile(id: string, host: MicroHost) {
|
||||||
const file = await prisma.file.findFirst({ where: { id } });
|
const file = await this.fileRepo.findOne(id);
|
||||||
if (!file) throw new NotFoundException("Unknown file.");
|
if (!file) throw new NotFoundException(`Unknown file "${id}"`);
|
||||||
if (!this.hostsService.checkHostCanSendFile(file, host)) {
|
if (!this.hostsService.checkHostCanSendFile(file, host)) {
|
||||||
throw new NotFoundException("Your file is in another castle.");
|
throw new NotFoundException("Your file is in another castle.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.assign(file, {
|
return file;
|
||||||
displayName: this.getFileDisplayName(file),
|
|
||||||
urls: this.getFileUrls(file),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFile(id: string, ownerId: string | null) {
|
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 (!file) throw new NotFoundException();
|
||||||
if (ownerId && file.ownerId !== ownerId) {
|
if (ownerId && file.owner.id !== ownerId) {
|
||||||
throw new UnauthorizedException("You cannot delete other users files.");
|
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) {
|
if (filesWithHash === 1) {
|
||||||
await this.storageService.delete(file.hash);
|
await this.storageService.delete(file.hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// await prisma.file.delete({ where: { id: file.id } });
|
await this.fileRepo.removeAndFlush(file);
|
||||||
// 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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFile(
|
async createFile(
|
||||||
|
@ -92,63 +90,47 @@ export class FileService implements OnApplicationBootstrap {
|
||||||
|
|
||||||
const fileId = generateContentId();
|
const fileId = generateContentId();
|
||||||
const { hash, size } = await this.storageService.create(uploadStream);
|
const { hash, size } = await this.storageService.create(uploadStream);
|
||||||
const file = await prisma.file.create({
|
const file = this.fileRepo.create({
|
||||||
data: {
|
id: fileId,
|
||||||
id: fileId,
|
type: type,
|
||||||
type: type,
|
name: multipart.filename,
|
||||||
name: multipart.filename,
|
owner: owner.id,
|
||||||
ownerId: owner.id,
|
host: host?.key,
|
||||||
host: host?.key,
|
hash: hash,
|
||||||
hash: hash,
|
size: size,
|
||||||
size: size,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.fileRepo.persistAndFlush(file);
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendFile(fileId: string, request: FastifyRequest, reply: FastifyReply) {
|
async sendFile(fileId: string, request: FastifyRequest, reply: FastifyReply) {
|
||||||
const file = await this.getFile(fileId, request.host);
|
const file = await this.getFile(fileId, request.host);
|
||||||
const range = request.headers["content-range"] ? contentRange.parse(request.headers["content-range"]) : null;
|
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);
|
const stream = this.storageService.createReadStream(file.hash, range);
|
||||||
if (range) reply.header("Content-Range", contentRange.format(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
|
return reply
|
||||||
.header("ETag", `"${file.hash}"`)
|
.header("ETag", `"${file.hash}"`)
|
||||||
.header("Accept-Ranges", "bytes")
|
.header("Accept-Ranges", "bytes")
|
||||||
.header("Content-Type", file.type)
|
.header("Content-Type", type)
|
||||||
.header("Content-Length", file.size)
|
.header("Content-Length", file.size)
|
||||||
.header("Last-Modified", file.createdAt)
|
.header("Last-Modified", file.createdAt)
|
||||||
.header("Content-Disposition", `inline; filename="${displayName}"`)
|
.header("Content-Disposition", `inline; filename="${file.displayName}"`)
|
||||||
.send(stream);
|
.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)
|
@Cron(CronExpression.EVERY_HOUR)
|
||||||
async purgeFiles() {
|
async purgeFiles() {
|
||||||
if (!config.purge) return;
|
if (!config.purge) return;
|
||||||
const createdBefore = new Date(Date.now() - config.purge.afterTime);
|
const createdBefore = new Date(Date.now() - config.purge.afterTime);
|
||||||
const files = await prisma.file.findMany({
|
|
||||||
where: {
|
const files = await this.fileRepo.find({
|
||||||
size: { gte: config.purge.overLimit },
|
size: {
|
||||||
createdAt: {
|
$gte: config.purge.overLimit,
|
||||||
lte: createdBefore,
|
},
|
||||||
},
|
createdAt: {
|
||||||
|
$lte: createdBefore,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -167,6 +149,7 @@ export class FileService implements OnApplicationBootstrap {
|
||||||
onApplicationBootstrap() {
|
onApplicationBootstrap() {
|
||||||
if (config.purge) {
|
if (config.purge) {
|
||||||
const size = xbytes(config.purge.overLimit, { space: false });
|
const size = xbytes(config.purge.overLimit, { space: false });
|
||||||
|
// todo: swap out luxon for dayjs
|
||||||
const age = DateTime.local().minus(config.purge.afterTime).toRelative();
|
const age = DateTime.local().minus(config.purge.afterTime).toRelative();
|
||||||
this.logger.warn(`Purging files is enabled for files over ${size} uploaded more than ${age}.`);
|
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 { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
import { classToPlain } from "class-transformer";
|
import { classToPlain } from "class-transformer";
|
||||||
import { JWTAuthGuard } from "../../guards/jwt.guard";
|
|
||||||
import { UserId } from "../auth/auth.decorators";
|
import { UserId } from "../auth/auth.decorators";
|
||||||
|
import { JWTAuthGuard } from "../auth/guards/jwt.guard";
|
||||||
import { UserService } from "../user/user.service";
|
import { UserService } from "../user/user.service";
|
||||||
import { HostsService } from "./hosts.service";
|
import { HostsService } from "./hosts.service";
|
||||||
|
|
||||||
@Controller("api/hosts")
|
@Controller("hosts")
|
||||||
export class HostsController {
|
export class HostsController {
|
||||||
constructor(private userService: UserService, private hostsService: HostsService) {}
|
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;
|
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
|
// todo: if host.wildcard, we should check to make sure the file owner
|
||||||
// matches the given username in the request url. so uploads to
|
// 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
|
// 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 { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { User } from "../user/user.entity";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { UserModule } from "../user/user.module";
|
import { UserModule } from "../user/user.module";
|
||||||
import { InviteController } from "./invite.controller";
|
import { InviteController } from "./invite.controller";
|
||||||
import { InviteService } from "./invite.service";
|
import { InviteService } from "./invite.service";
|
||||||
|
import { Invite } from "./invite.entity";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => UserModule), AuthModule],
|
imports: [forwardRef(() => UserModule), AuthModule, MikroOrmModule.forFeature([User, Invite])],
|
||||||
controllers: [InviteController],
|
controllers: [InviteController],
|
||||||
providers: [InviteService],
|
providers: [InviteService],
|
||||||
exports: [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 { promisify } from "util";
|
||||||
import { ExifTransformer } from "../../classes/ExifTransformer";
|
import { ExifTransformer } from "../../classes/ExifTransformer";
|
||||||
import { config } from "../../config";
|
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);
|
const pipeline = promisify(stream.pipeline);
|
||||||
|
|
|
@ -7,13 +7,7 @@ import { ThumbnailService } from "./thumbnail.service";
|
||||||
export class ThumbnailController {
|
export class ThumbnailController {
|
||||||
constructor(private fileService: FileService, private thumbnailService: ThumbnailService) {}
|
constructor(private fileService: FileService, private thumbnailService: ThumbnailService) {}
|
||||||
|
|
||||||
@Get("t/:key")
|
@Get("thumbnail/:id")
|
||||||
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")
|
|
||||||
async getThumbnail(@Param("id") id: string) {
|
async getThumbnail(@Param("id") id: string) {
|
||||||
return this.thumbnailService.getThumbnail(id);
|
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 { StorageModule } from "../storage/storage.module";
|
||||||
import { ThumbnailController } from "./thumbnail.controller";
|
import { ThumbnailController } from "./thumbnail.controller";
|
||||||
import { ThumbnailService } from "./thumbnail.service";
|
import { ThumbnailService } from "./thumbnail.service";
|
||||||
|
import { Thumbnail } from "./thumbnail.entity";
|
||||||
|
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StorageModule, FileModule],
|
imports: [StorageModule, FileModule, MikroOrmModule.forFeature([Thumbnail])],
|
||||||
controllers: [ThumbnailController],
|
controllers: [ThumbnailController],
|
||||||
providers: [ThumbnailService],
|
providers: [ThumbnailService],
|
||||||
exports: [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 { FastifyReply, FastifyRequest } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { EMBEDDABLE_IMAGE_TYPES } from "../../constants";
|
import { EMBEDDABLE_IMAGE_TYPES } from "@micro/common";
|
||||||
import { prisma } from "../../prisma";
|
import { File } from "../file/file.entity";
|
||||||
|
import { Thumbnail } from "./thumbnail.entity";
|
||||||
import { FileService } from "../file/file.service";
|
import { FileService } from "../file/file.service";
|
||||||
import { File } from "@prisma/client";
|
|
||||||
import { StorageService } from "../storage/storage.service";
|
import { StorageService } from "../storage/storage.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ThumbnailService {
|
export class ThumbnailService {
|
||||||
private static readonly THUMBNAIL_SIZE = 200;
|
private static readonly THUMBNAIL_SIZE = 200;
|
||||||
private static readonly THUMBNAIL_TYPE = "image/webp";
|
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) {
|
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();
|
if (!thumbnail) throw new NotFoundException();
|
||||||
return thumbnail;
|
return thumbnail;
|
||||||
}
|
}
|
||||||
|
@ -30,25 +36,20 @@ export class ThumbnailService {
|
||||||
const transformer = sharp().resize(ThumbnailService.THUMBNAIL_SIZE).webp({ quality: 40 });
|
const transformer = sharp().resize(ThumbnailService.THUMBNAIL_SIZE).webp({ quality: 40 });
|
||||||
const data = await stream.pipe(transformer).toBuffer();
|
const data = await stream.pipe(transformer).toBuffer();
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
const thumbnail = prisma.thumbnail.create({
|
const thumbnail = this.thumbnailRepo.create({
|
||||||
data: {
|
id: file.id,
|
||||||
id: file.id,
|
data: data,
|
||||||
data: data,
|
duration: duration,
|
||||||
duration: duration,
|
size: data.length,
|
||||||
size: data.length,
|
file: file,
|
||||||
file: {
|
|
||||||
connect: {
|
|
||||||
id: file.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.thumbnailRepo.persistAndFlush(thumbnail);
|
||||||
return thumbnail;
|
return thumbnail;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendThumbnail(fileId: string, request: FastifyRequest, reply: FastifyReply) {
|
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) {
|
if (existing) {
|
||||||
return reply.header("X-Micro-Generated", "false").header("Content-Type", ThumbnailService.THUMBNAIL_TYPE).send(existing.data);
|
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 { IsLowercase, IsNotIn, IsString, MaxLength, MinLength } from "class-validator";
|
||||||
import blocklist from "../../../data/blocklist.json";
|
import blocklist from "../../../blocklist.json";
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@MaxLength(20)
|
@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 { nanoid } from "nanoid";
|
||||||
import { Permission } from "../../constants";
|
import { Permission } from "@micro/common";
|
||||||
import { JWTAuthGuard } from "../../guards/jwt.guard";
|
import { User } from "./user.entity";
|
||||||
import { prisma } from "../../prisma";
|
|
||||||
import { JWTPayloadUser } from "../../strategies/jwt.strategy";
|
|
||||||
import { RequirePermissions, UserId } from "../auth/auth.decorators";
|
import { RequirePermissions, UserId } from "../auth/auth.decorators";
|
||||||
import { AuthService, TokenType } from "../auth/auth.service";
|
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 { 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 { 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()
|
@Controller()
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectRepository(User) private userRepo: EntityRepository<User>,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private inviteService: InviteService,
|
private inviteService: InviteService,
|
||||||
private fileService: FileService,
|
|
||||||
private authService: AuthService
|
private authService: AuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("api/user")
|
@Get("user")
|
||||||
@UseGuards(JWTAuthGuard)
|
@UseGuards(JWTAuthGuard)
|
||||||
async getUser(@UserId() userId: string) {
|
async getUser(@UserId() userId: string) {
|
||||||
const user = await this.userService.getUser(userId);
|
const user = await this.userService.getUser(userId);
|
||||||
|
@ -29,28 +42,23 @@ export class UserController {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("/api/user")
|
@Post("user")
|
||||||
async createUser(@Body() data: CreateUserDto) {
|
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);
|
return this.userService.createUser(data, invite);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("api/user/files")
|
@Get("user/files")
|
||||||
@UseGuards(JWTAuthGuard)
|
@UseGuards(JWTAuthGuard)
|
||||||
async getUserFiles(@UserId() userId: string, @Query() dto?: UserFilesQueryDto) {
|
async getUserFiles(@UserId() userId: string, @Query() pagination: Pagination) {
|
||||||
const files = await this.userService.getUserFiles(userId, dto);
|
return this.userService.getUserFiles(userId, pagination);
|
||||||
return files.map((file) =>
|
|
||||||
Object.assign(file, {
|
|
||||||
displayName: this.fileService.getFileDisplayName(file),
|
|
||||||
urls: this.fileService.getFileUrls(file),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("api/user/token")
|
@Get("user/token")
|
||||||
@UseGuards(JWTAuthGuard)
|
@UseGuards(JWTAuthGuard)
|
||||||
async getUserToken(@UserId() userId: string) {
|
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.");
|
if (!user) throw new ForbiddenException("Unknown user.");
|
||||||
const token = await this.authService.signToken<JWTPayloadUser>(TokenType.USER, {
|
const token = await this.authService.signToken<JWTPayloadUser>(TokenType.USER, {
|
||||||
name: user.username,
|
name: user.username,
|
||||||
|
@ -61,16 +69,18 @@ export class UserController {
|
||||||
return { token };
|
return { token };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put("api/user/token")
|
@Put("user/token")
|
||||||
@UseGuards(JWTAuthGuard)
|
@UseGuards(JWTAuthGuard)
|
||||||
async resetUserToken(@UserId() userId: string) {
|
async resetUserToken(@UserId() userId: string) {
|
||||||
const secret = nanoid();
|
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);
|
return this.getUserToken(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// temporary until admin UI
|
// temporary until admin UI
|
||||||
@Get("api/user/:id/delete")
|
@Get("user/:id/delete")
|
||||||
@RequirePermissions(Permission.DELETE_USERS)
|
@RequirePermissions(Permission.DELETE_USERS)
|
||||||
@UseGuards(JWTAuthGuard)
|
@UseGuards(JWTAuthGuard)
|
||||||
async deleteUser(@Param("id") targetId: string) {
|
async deleteUser(@Param("id") targetId: string) {
|
||||||
|
@ -85,7 +95,7 @@ export class UserController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// temporary until admin UI
|
// temporary until admin UI
|
||||||
@Get("api/user/:id/tags/add/:tag")
|
@Get("user/:id/tags/add/:tag")
|
||||||
@RequirePermissions(Permission.ADD_USER_TAGS)
|
@RequirePermissions(Permission.ADD_USER_TAGS)
|
||||||
@UseGuards(JWTAuthGuard)
|
@UseGuards(JWTAuthGuard)
|
||||||
async addTagToUser(@Param("id") targetId: string, @Param("tag") tag: string) {
|
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.");
|
throw new BadRequestException("User already has that tag.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.update({
|
target.tags.push(tag.toLowerCase());
|
||||||
where: { id: target.id },
|
|
||||||
data: {
|
|
||||||
tags: [...target.tags, tag.toLowerCase()],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { added: true, tag };
|
return { added: true, tag };
|
||||||
}
|
}
|
||||||
|
|
||||||
// temporary until admin UI
|
// temporary until admin UI
|
||||||
@Get("api/user/:id/tags/remove/:tag")
|
@Get("user/:id/tags/remove/:tag")
|
||||||
@RequirePermissions(Permission.ADD_USER_TAGS)
|
@RequirePermissions(Permission.ADD_USER_TAGS)
|
||||||
@UseGuards(JWTAuthGuard)
|
@UseGuards(JWTAuthGuard)
|
||||||
async removeTagFromUser(@Param("id") targetId: string, @Param("tag") tag: string) {
|
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.");
|
throw new BadRequestException("User does not have that tag.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.update({
|
target.tags = target.tags.filter((existing) => existing !== tag);
|
||||||
where: { id: target.id },
|
|
||||||
data: {
|
|
||||||
tags: target.tags.filter((existing) => existing !== tag),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { removed: true, 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 { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { File } from "../file/file.entity";
|
||||||
|
import { User } from "./user.entity";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { FileModule } from "../file/file.module";
|
import { FileModule } from "../file/file.module";
|
||||||
import { InviteModule } from "../invite/invite.module";
|
import { InviteModule } from "../invite/invite.module";
|
||||||
|
@ -6,7 +9,7 @@ import { UserController } from "./user.controller";
|
||||||
import { UserService } from "./user.service";
|
import { UserService } from "./user.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => InviteModule), AuthModule, forwardRef(() => FileModule)],
|
imports: [forwardRef(() => InviteModule), AuthModule, forwardRef(() => FileModule), MikroOrmModule.forFeature([User, File])],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
exports: [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 { File } from "./modules/file/file.entity";
|
||||||
import type { FastifyReply } from "fastify";
|
import type { User } from "./modules/user/user.entity";
|
||||||
import type { RenderableResponse } from "nest-next";
|
|
||||||
import type { AppController } from "./modules/app.controller";
|
import type { AppController } from "./modules/app.controller";
|
||||||
import type { FileController } from "./modules/file/file.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 { InviteController } from "./modules/invite/invite.controller";
|
||||||
import type { LinkController } from "./modules/link/link.controller";
|
|
||||||
import type { UserController } from "./modules/user/user.controller";
|
import type { UserController } from "./modules/user/user.controller";
|
||||||
|
|
||||||
export type { File, User, Link };
|
export type { File, User };
|
||||||
export type RenderableReply = RenderableResponse & FastifyReply;
|
|
||||||
export type Await<T> = T extends {
|
export type Await<T> = T extends {
|
||||||
then: (onfulfilled?: (value: infer U) => unknown) => unknown;
|
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"]>>;
|
export type PutUploadTokenData = Await<ReturnType<UserController["resetUserToken"]>>;
|
||||||
|
|
||||||
// file
|
// file
|
||||||
export type GetFileData = Await<ReturnType<FileController["getFile"]>>;
|
export type GetFileData = File;
|
||||||
|
|
||||||
// link
|
|
||||||
export type GetLinkData = Await<ReturnType<LinkController["getLink"]>>;
|
|
||||||
|
|
||||||
// app
|
// app
|
||||||
export type GetServerConfigData = Await<ReturnType<AppController["getConfig"]>>;
|
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",
|
CONFIG: "/api/config",
|
||||||
HOSTS: "/api/hosts",
|
HOSTS: "/api/hosts",
|
||||||
USER: "/api/user",
|
USER: "/api/user",
|
||||||
UPLOAD: "/api/upload",
|
UPLOAD: "/api/file",
|
||||||
USER_FILES: "/api/user/files",
|
USER_FILES: "/api/user/files",
|
||||||
USER_TOKEN: "/api/user/token",
|
USER_TOKEN: "/api/user/token",
|
||||||
FILE: (fileId: string) => `/api/file/${fileId}`,
|
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 Head from "next/head";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import { GetFileData } from "../../types";
|
import { GetFileData } from "@micro/api";
|
||||||
|
|
||||||
export const FileEmbedContainer: FunctionComponent<{ file: GetFileData; children: React.ReactChild }> = (props) => {
|
export const FileEmbedContainer: FunctionComponent<{ file: GetFileData; children: React.ReactChild }> = (props) => {
|
||||||
return (
|
return (
|
|
@ -1,4 +1,4 @@
|
||||||
import { GetFileData } from "../../types";
|
import { GetFileData } from "@micro/api";
|
||||||
|
|
||||||
export const FileEmbedDefault = ({ file }: { file: GetFileData }) => {
|
export const FileEmbedDefault = ({ file }: { file: GetFileData }) => {
|
||||||
return (
|
return (
|
|
@ -1,6 +1,6 @@
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { EMBEDDABLE_IMAGE_TYPES } from "../../constants";
|
import { EMBEDDABLE_IMAGE_TYPES } from "@micro/common";
|
||||||
import { GetFileData } from "../../types";
|
import { GetFileData } from "@micro/api";
|
||||||
|
|
||||||
export const FileEmbedImage = ({ file }: { file: GetFileData }) => {
|
export const FileEmbedImage = ({ file }: { file: GetFileData }) => {
|
||||||
return (
|
return (
|
|
@ -7,7 +7,7 @@ import languages from "../../../data/languages.json";
|
||||||
import { getFileLanguage } from "../../../helpers/get-file-language.helper";
|
import { getFileLanguage } from "../../../helpers/get-file-language.helper";
|
||||||
import { http } from "../../../helpers/http.helper";
|
import { http } from "../../../helpers/http.helper";
|
||||||
import { useToasts } from "../../../hooks/use-toasts.helper";
|
import { useToasts } from "../../../hooks/use-toasts.helper";
|
||||||
import { GetFileData } from "../../../types";
|
import { GetFileData } from "@micro/api";
|
||||||
import { Button } from "../../button/button";
|
import { Button } from "../../button/button";
|
||||||
import { Dropdown } from "../../dropdown/dropdown";
|
import { Dropdown } from "../../dropdown/dropdown";
|
||||||
import { DropdownTab } from "../../dropdown/dropdown-tab";
|
import { DropdownTab } from "../../dropdown/dropdown-tab";
|
|
@ -2,7 +2,7 @@ import classNames from "classnames";
|
||||||
import Highlight, { defaultProps } from "prism-react-renderer";
|
import Highlight, { defaultProps } from "prism-react-renderer";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getFileLanguage } from "../../../helpers/get-file-language.helper";
|
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 { FileEmbedTextContainer } from "./file-embed-text-container";
|
||||||
import { theme } from "./prism-theme";
|
import { theme } from "./prism-theme";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { EMBEDDABLE_VIDEO_TYPES } from "../../constants";
|
import { EMBEDDABLE_VIDEO_TYPES } from "@micro/common";
|
||||||
import { GetFileData } from "../../types";
|
import { GetFileData } from "@micro/api";
|
||||||
|
|
||||||
export const FileEmbedVideo = ({ file }: { file: GetFileData }) => {
|
export const FileEmbedVideo = ({ file }: { file: GetFileData }) => {
|
||||||
return (
|
return (
|
|
@ -1,5 +1,5 @@
|
||||||
import { FunctionComponent, useMemo } from "react";
|
import { FunctionComponent, useMemo } from "react";
|
||||||
import { GetFileData } from "../../types";
|
import { GetFileData } from "@micro/api";
|
||||||
import { FileEmbedDefault } from "./file-embed-default";
|
import { FileEmbedDefault } from "./file-embed-default";
|
||||||
import { FileEmbedContainer } from "./file-embed-container";
|
import { FileEmbedContainer } from "./file-embed-container";
|
||||||
import { FileEmbedText } from "./file-embed-text/file-embed-text";
|
import { FileEmbedText } from "./file-embed-text/file-embed-text";
|
|
@ -1,5 +1,5 @@
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import { GetFileData } from "../../types";
|
import { GetFileData } from "@micro/api";
|
||||||
import { Link } from "../link";
|
import { Link } from "../link";
|
||||||
import { FileListCardContent } from "./file-list-card-content";
|
import { FileListCardContent } from "./file-list-card-content";
|
||||||
|
|
|
@ -1,30 +1,34 @@
|
||||||
import { FunctionComponent, useEffect, useState } from "react";
|
import { FunctionComponent, useEffect, useState } from "react";
|
||||||
import InfiniteScroll from "react-infinite-scroll-component";
|
import InfiniteScroll from "react-infinite-scroll-component";
|
||||||
import { Endpoints } from "../../constants";
|
import { Endpoints } from "@micro/common";
|
||||||
import { http } from "../../helpers/http.helper";
|
import { http } from "../../helpers/http.helper";
|
||||||
import { GetUserFilesData } from "../../types";
|
import { GetUserFilesData } from "@micro/api";
|
||||||
import { Card } from "../card";
|
import { Card } from "../card";
|
||||||
import { Spinner } from "../spinner";
|
import { Spinner } from "../spinner";
|
||||||
import { FileListCard } from "./file-list-card";
|
import { FileListCard } from "./file-list-card";
|
||||||
|
import Error from "../../pages/_error";
|
||||||
|
|
||||||
const PER_PAGE = 24;
|
const PER_PAGE = 24;
|
||||||
|
|
||||||
export const FileList: FunctionComponent = () => {
|
export const FileList: FunctionComponent = () => {
|
||||||
const [files, setFiles] = useState<GetUserFilesData>([]);
|
const [files, setFiles] = useState<GetUserFilesData>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(false);
|
||||||
const [cursor, setCursor] = useState<string | null | undefined>();
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const hasMore = cursor !== null;
|
const [error, setError] = useState<any>(null);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
if (cursor === null) return;
|
if (error || loading || !hasMore) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
let url = Endpoints.USER_FILES + `?take=${PER_PAGE}`;
|
let url = Endpoints.USER_FILES + `?offset=${offset}&limit=${PER_PAGE}`;
|
||||||
if (cursor) url += `&cursor=${cursor}`;
|
|
||||||
const response = await http(url.toString());
|
const response = await http(url.toString());
|
||||||
const body = (await response.json()) as GetUserFilesData;
|
const body = (await response.json()) as GetUserFilesData;
|
||||||
const isFullPage = body.length === PER_PAGE;
|
const isFullPage = body.length === PER_PAGE;
|
||||||
setCursor(body[0] && isFullPage ? body[body.length - 1].id : null);
|
setHasMore(isFullPage);
|
||||||
setFiles((files) => [...files, ...body]);
|
setOffset(offset + PER_PAGE);
|
||||||
|
setFiles(files.concat(body));
|
||||||
|
} catch (error) {
|
||||||
|
setError(error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
@ -32,12 +36,16 @@ export const FileList: FunctionComponent = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
if (!files[0] && !loading) {
|
if (!files[0] && !loading) {
|
||||||
return <Card>You have not uploaded anything yet. Once you do, files will appear here.</Card>;
|
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 (
|
return (
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
next={fetchData}
|
next={fetchData}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue