diff --git a/.prettierrc b/.prettierrc index 94d737c..d3c9635 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,4 @@ { - "printWidth": 120 + "printWidth": 120, + "singleQuote": true } \ No newline at end of file diff --git a/README.md b/README.md index d586146..8e8459c 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ If you need help, join the [discord server](https://discord.gg/VDMX6VQRZm). This 1. Install `git`, `docker` and `docker-compose` 2. Download the files in this repository, `git clone https://github.com/sylv/micro.git` 3. Copy the example configs to the current directory, `cp ./micro/example/* ./` -4. Fill out `.microrc`, `Caddyfile` and `docker-compose.yml`. **It is extremely important you read through each of the 3 files and make sure you understand what they do.** Specifically, `.microrc` contains a secret that handles authentication, if it is not a secure random string everyone can sign in as anyone they want without a password. +4. Fill out `.microrc.yaml`, `Caddyfile` and `docker-compose.yml`. **It is extremely important you read through each of the 3 files and make sure you understand what they do.** Specifically, `.microrc.yaml` contains a secret that handles authentication, if it is not a secure random string everyone can sign in as anyone they want without a password. 5. Run `docker-compose up -d` to start the database and micro. 6. Get the startup invite by doing `docker-compose logs micro` and copying the invite URL that should be somewhere towards the end of the log. Go to that URL to create the first account. @@ -64,7 +64,7 @@ Setup is now complete and your instance should be working. When updates come out I've made a best effort attempt to make migration as painless as possible, mostly for my own sanity. These steps are quite in-depth but in reality the migration should be fairly simple for most users. If you get stuck at any point, please join the [discord server](https://discord.gg/VDMX6VQRZm) and ask for help. 1. Create a backup of the database and the data directory. -2. Update your `.microrc` with the changes seen in [example config](example/.microrc), notable changes are `database` is now `databaseUrl` and `publicPastes` has been added. +2. Update your `.microrc` with the changes seen in [example config](example/.microrc.yaml) (your config may be in json with the example now being yaml, but the keys are 1:1), notable changes are `database` is now `databaseUrl` and `publicPastes` has been added. 3. Change the docker image from `sylver/micro` or `sylver/micro:master` to `sylver/micro:main` 4. Change the port from `8080` to `3000`. If you are using the example config, do this in `Caddyfile` by changing `micro:8080` to `micro:3000`. 5. Start the container. It should exit on startup with an error message saying that there is data that must be migrated. If it does not, you did not update the image tag correctly or it cannot detect data to be migrated. @@ -77,8 +77,6 @@ After that, you should be able to use it as normal. Thumbnails are the only data - [ ] Ratelimiting - [ ] Admin UI -- [ ] Pastes should use the same embeds that files use they get all the same benefits (markdown previews, better syntax highlighting, etc) -- [ ] Run migrations on start (requires migrations to be compiled and available at runtime) - [ ] `publicPastes=false` should hide the paste button and show an error on the paste page unless the user is signed in. - [ ] 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 diff --git a/example/.microrc.yaml b/example/.microrc.yaml index 00875b5..1cdca06 100644 --- a/example/.microrc.yaml +++ b/example/.microrc.yaml @@ -35,6 +35,13 @@ publicPastes: true # overLimit: 1MB # files over this size will be purged # afterTime: 1d # after this many days +# allowTypes is a list of file types that can be uploaded. +# a prefix will be expanded into known types, so `video` becomes `video/mp4`, `video/webm`, etc. +# omit entirely to allow all types to be uploaded. +# allowTypes: +# - video +# - image +# - image/png hosts: # the first host in the list is the default host diff --git a/packages/api/src/classes/MicroConfig.ts b/packages/api/src/classes/MicroConfig.ts index 9f66c7c..8cdeae8 100644 --- a/packages/api/src/classes/MicroConfig.ts +++ b/packages/api/src/classes/MicroConfig.ts @@ -1,4 +1,4 @@ -import { Transform, Type } from "class-transformer"; +import { Transform, Type } from 'class-transformer'; import { IsBoolean, IsDefined, @@ -11,19 +11,19 @@ import { Max, NotEquals, ValidateNested, -} from "class-validator"; -import fileType from "file-type"; -import path from "path"; -import xbytes from "xbytes"; -import { MicroConfigPurge } from "./MicroConfigPurge"; -import { MicroHost } from "./MicroHost"; +} from 'class-validator'; +import fileType from 'file-type'; +import path from 'path'; +import xbytes from 'xbytes'; +import { MicroConfigPurge } from './MicroConfigPurge'; +import { MicroHost } from './MicroHost'; export class MicroConfig { - @IsUrl({ require_tld: false, require_protocol: true, protocols: ["postgresql", "postgres"] }) + @IsUrl({ require_tld: false, require_protocol: true, protocols: ['postgresql', 'postgres'] }) databaseUrl: string; @IsString() - @NotEquals("YOU_SHALL_NOT_PASS") + @NotEquals('YOU_SHALL_NOT_PASS') secret: string; @IsEmail() @@ -31,7 +31,7 @@ export class MicroConfig { @IsNumber() @Transform(({ value }) => xbytes.parseSize(value)) - uploadLimit = xbytes.parseSize("50MB"); + uploadLimit = xbytes.parseSize('50MB'); @IsBoolean() publicPastes: boolean; @@ -42,9 +42,29 @@ export class MicroConfig { maxPasteLength = 500000; @IsString({ each: true }) - @IsIn([...fileType.mimeTypes.values()]) @IsOptional() - allowTypes?: string[]; + @Transform(({ value }) => { + if (!value) return value; + const clean: string[] = []; + for (const type of value) { + const stripped = type.replace(/\/\*$/, ''); + if (stripped.includes('/')) { + if (!fileType.mimeTypes.has(type)) { + throw new Error(`Invalid mime type: ${type}`); + } + + clean.push(type); + continue; + } + + for (const knownType of fileType.mimeTypes.values()) { + if (knownType.startsWith(stripped)) clean.push(knownType); + } + } + + return new Set(clean); + }) + allowTypes?: Set; @IsString() @Transform(({ value }) => path.resolve(value)) diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index a60f6c5..5fb9f27 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -1,13 +1,13 @@ -import { loadConfig } from "@ryanke/venera"; -import { plainToClass } from "class-transformer"; -import { validateSync } from "class-validator"; -import { MicroConfig } from "./classes/MicroConfig"; +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 data = loadConfig('micro'); const config = plainToClass(MicroConfig, data, { exposeDefaultValues: true }); -const errors = validateSync(config); +const errors = validateSync(config, { forbidUnknownValues: true }); if (errors.length) { - const clean = errors.map((error) => error.toString()).join("\n"); + const clean = errors.map((error) => error.toString()).join('\n'); console.dir(config, { depth: null }); console.error(clean); process.exit(1); diff --git a/packages/api/src/migrations/.snapshot-micro.json b/packages/api/src/migrations/.snapshot-micro.json index 416d076..499585b 100644 --- a/packages/api/src/migrations/.snapshot-micro.json +++ b/packages/api/src/migrations/.snapshot-micro.json @@ -258,6 +258,16 @@ "nullable": true, "mappedType": "string" }, + "title": { + "name": "title", + "type": "varchar(128)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 128, + "mappedType": "string" + }, "content": { "name": "content", "type": "varchar(500000)", @@ -366,6 +376,15 @@ "nullable": false, "mappedType": "string" }, + "hostname": { + "name": "hostname", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "string" + }, "destination": { "name": "destination", "type": "varchar(1024)", @@ -376,15 +395,6 @@ "length": 1024, "mappedType": "string" }, - "hostname": { - "name": "hostname", - "type": "varchar(255)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "string" - }, "clicks": { "name": "clicks", "type": "int", diff --git a/packages/api/src/migrations/Migration20220620200410.ts b/packages/api/src/migrations/Migration20220620200410.ts new file mode 100644 index 0000000..e2fc540 --- /dev/null +++ b/packages/api/src/migrations/Migration20220620200410.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20220620200410 extends Migration { + + async up(): Promise { + this.addSql('alter table "pastes" add column "title" varchar(128) null;'); + } + + async down(): Promise { + this.addSql('alter table "pastes" drop column "title";'); + } + +} diff --git a/packages/api/src/modules/app.controller.ts b/packages/api/src/modules/app.controller.ts index 53facae..25679c4 100644 --- a/packages/api/src/modules/app.controller.ts +++ b/packages/api/src/modules/app.controller.ts @@ -1,14 +1,14 @@ -import { Controller, Get, Req } from "@nestjs/common"; -import { FastifyRequest } from "fastify"; -import { config } from "../config"; -import { UserId } from "./auth/auth.decorators"; -import { UserService } from "./user/user.service"; +import { Controller, Get, Req } from '@nestjs/common'; +import { FastifyRequest } from 'fastify'; +import { config } from '../config'; +import { UserId } from './auth/auth.decorators'; +import { UserService } from './user/user.service'; @Controller() export class AppController { constructor(private userService: UserService) {} - @Get("config") + @Get('config') async getConfig(@Req() request: FastifyRequest, @UserId() userId?: string) { let tags: string[] = []; if (userId) { diff --git a/packages/api/src/modules/file/file.service.ts b/packages/api/src/modules/file/file.service.ts index b1c94b9..7b9a0d3 100644 --- a/packages/api/src/modules/file/file.service.ts +++ b/packages/api/src/modules/file/file.service.ts @@ -1,6 +1,6 @@ -import { Multipart } from "@fastify/multipart"; -import { EntityRepository, MikroORM, UseRequestContext } from "@mikro-orm/core"; -import { InjectRepository } from "@mikro-orm/nestjs"; +import { Multipart } from '@fastify/multipart'; +import { EntityRepository, MikroORM, UseRequestContext } from '@mikro-orm/core'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { BadRequestException, Injectable, @@ -9,21 +9,21 @@ import { OnApplicationBootstrap, PayloadTooLargeException, UnauthorizedException, -} from "@nestjs/common"; -import { Cron, CronExpression } from "@nestjs/schedule"; -import contentRange from "content-range"; -import { FastifyReply, FastifyRequest } from "fastify"; -import { DateTime } from "luxon"; -import { PassThrough } from "stream"; -import xbytes from "xbytes"; -import { MicroHost } from "../../classes/MicroHost"; -import { config } from "../../config"; -import { generateContentId } from "../../helpers/generate-content-id.helper"; -import { getStreamType } from "../../helpers/get-stream-type.helper"; -import { HostService } from "../host/host.service"; -import { StorageService } from "../storage/storage.service"; -import { User } from "../user/user.entity"; -import { File } from "./file.entity"; +} from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import contentRange from 'content-range'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { DateTime } from 'luxon'; +import { PassThrough } from 'stream'; +import xbytes from 'xbytes'; +import { MicroHost } from '../../classes/MicroHost'; +import { config } from '../../config'; +import { generateContentId } from '../../helpers/generate-content-id.helper'; +import { getStreamType } from '../../helpers/get-stream-type.helper'; +import { HostService } from '../host/host.service'; +import { StorageService } from '../storage/storage.service'; +import { User } from '../user/user.entity'; +import { File } from './file.entity'; @Injectable() export class FileService implements OnApplicationBootstrap { @@ -38,7 +38,7 @@ export class FileService implements OnApplicationBootstrap { async getFile(id: string, request: FastifyRequest) { const file = await this.fileRepo.findOneOrFail(id); if (file.hostname && !file.canSendTo(request)) { - throw new NotFoundException("Your file is in another castle."); + throw new NotFoundException('Your file is in another castle.'); } return file; @@ -47,7 +47,7 @@ export class FileService implements OnApplicationBootstrap { async deleteFile(id: string, ownerId: string | null) { const file = await this.fileRepo.findOneOrFail(id); if (ownerId && file.owner.id !== ownerId) { - throw new UnauthorizedException("You cannot delete other users files."); + throw new UnauthorizedException('You cannot delete other users files.'); } // todo: this should use a subscriber for file delete events, should also do @@ -67,9 +67,9 @@ export class FileService implements OnApplicationBootstrap { host: MicroHost | undefined ): Promise { if (host) this.hostService.checkUserCanUploadTo(host, owner); - if (!request.headers["content-length"]) throw new BadRequestException('Missing "Content-Length" header.'); - if (+request.headers["content-length"] >= config.uploadLimit) { - const size = xbytes(+request.headers["content-length"]); + if (!request.headers['content-length']) throw new BadRequestException('Missing "Content-Length" header.'); + if (+request.headers['content-length'] >= config.uploadLimit) { + const size = xbytes(+request.headers['content-length']); this.logger.warn( `User ${owner.id} tried uploading a ${size} file, which is over the configured upload size limit.` ); @@ -80,7 +80,7 @@ export class FileService implements OnApplicationBootstrap { const typeStream = stream.pipe(new PassThrough()); const uploadStream = stream.pipe(new PassThrough()); const type = (await getStreamType(multipart.filename, typeStream)) ?? multipart.mimetype; - if (config.allowTypes?.includes(type) === false) { + if (config.allowTypes && !config.allowTypes.has(type) === false) { throw new BadRequestException(`"${type}" is not supported by this server.`); } @@ -91,7 +91,7 @@ export class FileService implements OnApplicationBootstrap { type: type, name: multipart.filename, owner: owner.id, - hostname: host?.normalised.replace("{{username}}", owner.username), + hostname: host?.normalised.replace('{{username}}', owner.username), hash: hash, size: size, }); @@ -102,20 +102,20 @@ export class FileService implements OnApplicationBootstrap { async sendFile(fileId: string, request: FastifyRequest, reply: FastifyReply) { const file = await this.getFile(fileId, request); - 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 stream = this.storageService.createReadStream(file.hash, range); - if (range) reply.header("Content-Range", contentRange.format(range)); - const type = file.type.startsWith("text") ? `${file.type}; charset=UTF-8` : file.type; + if (range) reply.header('Content-Range', contentRange.format(range)); + const type = file.type.startsWith('text') ? `${file.type}; charset=UTF-8` : file.type; return reply - .header("ETag", `"${file.hash}"`) - .header("Accept-Ranges", "bytes") - .header("Content-Type", type) - .header("Content-Length", file.size) - .header("Last-Modified", file.createdAt) - .header("Content-Disposition", `inline; filename="${file.displayName}"`) - .header("Cache-Control", "public, max-age=31536000") - .header("Expires", DateTime.local().plus({ years: 1 }).toHTTP()) - .header("X-Content-Type-Options", "nosniff") + .header('ETag', `"${file.hash}"`) + .header('Accept-Ranges', 'bytes') + .header('Content-Type', type) + .header('Content-Length', file.size) + .header('Last-Modified', file.createdAt) + .header('Content-Disposition', `inline; filename="${file.displayName}"`) + .header('Cache-Control', 'public, max-age=31536000') + .header('Expires', DateTime.local().plus({ years: 1 }).toHTTP()) + .header('X-Content-Type-Options', 'nosniff') .send(stream); } diff --git a/packages/api/src/modules/paste/paste.controller.ts b/packages/api/src/modules/paste/paste.controller.ts index 6e2c463..a96494b 100644 --- a/packages/api/src/modules/paste/paste.controller.ts +++ b/packages/api/src/modules/paste/paste.controller.ts @@ -64,6 +64,12 @@ export class PasteController { return this.pasteService.getPaste(pasteId, request); } + @Get(":pasteId/content") + async getContent(@Param("pasteId") pasteId: string, @Req() request: FastifyRequest) { + const file = await this.pasteService.getPaste(pasteId, request); + return file.content; + } + @Post(":pasteId/burn") @HttpCode(204) async burn(@Param("pasteId") pasteId: string) { diff --git a/packages/api/src/modules/paste/paste.entity.ts b/packages/api/src/modules/paste/paste.entity.ts index 0747733..59cac37 100644 --- a/packages/api/src/modules/paste/paste.entity.ts +++ b/packages/api/src/modules/paste/paste.entity.ts @@ -1,16 +1,20 @@ -import { Entity, IdentifiedReference, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core"; -import { IsBoolean, IsNumber, IsOptional, IsString, Length } from "class-validator"; -import { config } from "../../config"; -import { generateContentId } from "../../helpers/generate-content-id.helper"; -import { WithHostname } from "../host/host.entity"; -import { User } from "../user/user.entity"; +import { Entity, IdentifiedReference, ManyToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core'; +import { IsBoolean, IsNumber, IsOptional, IsString, Length } from 'class-validator'; +import { config } from '../../config'; +import { generateContentId } from '../../helpers/generate-content-id.helper'; +import { WithHostname } from '../host/host.entity'; +import { User } from '../user/user.entity'; +import mime from 'mime-types'; -@Entity({ tableName: "pastes" }) +@Entity({ tableName: 'pastes' }) export class Paste extends WithHostname { @PrimaryKey({ type: String }) id = generateContentId(); - @Property({ type: "varchar", length: 500000 }) + @Property({ type: 'varchar', length: 128, nullable: true }) + title?: string; + + @Property({ type: 'varchar', length: 500000 }) content: string; @Property({ nullable: true }) @@ -56,10 +60,21 @@ export class Paste extends WithHostname { }; } - [OptionalProps]: "owner" | "createdAt" | "expiresAt" | "extension" | "urls" | "paths"; + @Property({ persist: false }) + get type() { + if (!this.extension) return; + return mime.lookup(this.extension); + } + + [OptionalProps]: 'owner' | 'createdAt' | 'expiresAt' | 'extension' | 'urls' | 'paths'; } export class CreatePasteDto { + @IsString() + @IsOptional() + @Length(1, 100) + title?: string; + @IsString() @Length(1, config.maxPasteLength) content: string; diff --git a/packages/api/src/serializer.interceptor.ts b/packages/api/src/serializer.interceptor.ts index 32a9e9e..a7b5649 100644 --- a/packages/api/src/serializer.interceptor.ts +++ b/packages/api/src/serializer.interceptor.ts @@ -8,6 +8,7 @@ export class SerializerInterceptor implements NestInterceptor { return next.handle().pipe( map((data) => { if (data === null || data === undefined) return data; + if (typeof data === "string") return data; if (typeof data === "object") { void response.header("Content-Type", "application/json"); return JSON.stringify(data); diff --git a/packages/web/src/components/container.tsx b/packages/web/src/components/container.tsx index 7b14469..46a2ffc 100644 --- a/packages/web/src/components/container.tsx +++ b/packages/web/src/components/container.tsx @@ -1,5 +1,5 @@ -import classNames from "classnames"; -import { FC, ReactNode } from "react"; +import classNames from 'classnames'; +import { FC, ReactNode } from 'react'; export interface ContainerProps { centerX?: boolean; @@ -14,12 +14,12 @@ export const Container: FC = (props) => { const centerX = props.centerX ?? props.center; const centerY = props.centerY ?? props.center; const center = centerX ?? centerY; - const classes = classNames(props.className, "px-4 mx-auto", { - "sm:max-w-screen-sm md:max-w-screen-md lg:max-w-screen-lg xl:max-w-screen-xl": !props.small, - "flex justify-center flex-col": center, - "absolute top-16 bottom-0 right-0 left-0": centerY, - "items-center": centerX, - "max-w-xs": props.small, + const classes = classNames(props.className, 'px-4 mx-auto', { + 'sm:max-w-screen-sm md:max-w-screen-md lg:max-w-screen-lg xl:max-w-screen-xl': !props.small, + 'flex justify-center flex-col': center, + 'absolute top-16 bottom-0 right-0 left-0': centerY, + 'items-center': centerX, + 'max-w-xs': props.small, }); return
{props.children}
; diff --git a/packages/web/src/components/embed/embed-container.tsx b/packages/web/src/components/embed/embed-container.tsx new file mode 100644 index 0000000..635896f --- /dev/null +++ b/packages/web/src/components/embed/embed-container.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames'; +import Head from 'next/head'; +import { FC, ReactNode } from 'react'; +import { Embeddable } from './embeddable'; + +export const EmbedContainer: FC<{ data: Embeddable; children: ReactNode; centre?: boolean; className?: string }> = ({ + data, + children, + className, + centre = true, +}) => { + const classes = classNames( + 'flex items-center col-span-5 rounded shadow-2xl bg-dark-200 max-h-[75vh] min-h-[15em]', + centre && 'justify-center', + className + ); + + return ( +
+ + + + + + + {children} +
+ ); +}; diff --git a/packages/web/src/components/file-embed/file-embed-default.tsx b/packages/web/src/components/embed/embed-default.tsx similarity index 67% rename from packages/web/src/components/file-embed/file-embed-default.tsx rename to packages/web/src/components/embed/embed-default.tsx index f1f37aa..b2d5601 100644 --- a/packages/web/src/components/file-embed/file-embed-default.tsx +++ b/packages/web/src/components/embed/embed-default.tsx @@ -1,9 +1,9 @@ -import { GetFileData } from "@ryanke/micro-api"; +import { Embeddable } from "./embeddable"; -export const FileEmbedDefault = ({ file }: { file: GetFileData }) => { +export const EmbedDefault = ({ data }: { data: Embeddable }) => { return (
-

{file.type}

+

{data.type}

No preview available for this file type.
); diff --git a/packages/web/src/components/embed/embed-image.tsx b/packages/web/src/components/embed/embed-image.tsx new file mode 100644 index 0000000..7258d2c --- /dev/null +++ b/packages/web/src/components/embed/embed-image.tsx @@ -0,0 +1,35 @@ +import Head from "next/head"; +import { Embeddable } from "./embeddable"; + +export const EmbedImage = ({ data }: { data: Embeddable }) => { + return ( + <> + + + + + {data.displayName} + + ); +}; + +EmbedImage.embeddable = (data: Embeddable) => { + switch (data.type) { + case "image/png": + case "image/jpeg": + case "image/gif": + case "image/svg+xml": + case "image/webp": + case "image/bmp": + case "image/tiff": + return true; + default: + return false; + } +}; diff --git a/packages/web/src/components/embed/embed-markdown.tsx b/packages/web/src/components/embed/embed-markdown.tsx new file mode 100644 index 0000000..9998dc5 --- /dev/null +++ b/packages/web/src/components/embed/embed-markdown.tsx @@ -0,0 +1,38 @@ +import useSWR from "swr"; +import { Markdown } from "../markdown"; +import { PageLoader } from "../page-loader"; +import { EmbedContainer } from "./embed-container"; +import { EmbedDefault } from "./embed-default"; +import { Embeddable } from "./embeddable"; +import { textFetcher } from "./text-fetcher"; + +export const EmbedMarkdown = ({ data }: { data: Embeddable }) => { + const content = + data.content || useSWR(data.content === undefined ? data.paths.direct : null, { fetcher: textFetcher }); + + if (content.error) { + return ( + + + + ); + } + + if (!content.data) { + return ; + } + + return ( + + {content.data} + + ); +}; + +const MAX_MARKDOWN_SIZE = 1_000_000; // 1mb +EmbedMarkdown.embeddable = (data: Embeddable) => { + if (data.size > MAX_MARKDOWN_SIZE) return false; + if (data.type === "text/markdown") return true; + if (data.type === "text/plain" && data.displayName?.endsWith("md")) return true; + return false; +}; diff --git a/packages/web/src/components/embed/embed-text.tsx b/packages/web/src/components/embed/embed-text.tsx new file mode 100644 index 0000000..a8a9235 --- /dev/null +++ b/packages/web/src/components/embed/embed-text.tsx @@ -0,0 +1,43 @@ +import { Language } from "prism-react-renderer"; +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import { getFileLanguage } from "../../helpers/get-file-language.helper"; +import { PageLoader } from "../page-loader"; +import { SyntaxHighlighter } from "../syntax-highlighter/syntax-highlighter"; +import { EmbedDefault } from "./embed-default"; +import { Embeddable } from "./embeddable"; +import { textFetcher } from "./text-fetcher"; + +const DEFAULT_LANGUAGE = getFileLanguage("diff")!; +const MAX_SIZE = 1_000_000; // 1mb + +export const EmbedText = ({ data }: { data: Embeddable }) => { + const [language, setLanguage] = useState(getFileLanguage(data.displayName) ?? DEFAULT_LANGUAGE); + const content = data.content ?? useSWR(data.paths.direct, { fetcher: textFetcher }); + + useEffect(() => { + // re-calculate language on fileName change + setLanguage(getFileLanguage(data.displayName) ?? DEFAULT_LANGUAGE); + }, [data.displayName]); + + if (content.error) { + return ; + } + + if (!content.data) { + return ; + } + + return ( + + {content.data} + + ); +}; + +EmbedText.embeddable = (data: Embeddable) => { + if (data.type.startsWith("text/")) return true; + if (getFileLanguage(data.displayName)) return true; + if (data.size > MAX_SIZE) return false; + return false; +}; diff --git a/packages/web/src/components/embed/embed-video.tsx b/packages/web/src/components/embed/embed-video.tsx new file mode 100644 index 0000000..ef69608 --- /dev/null +++ b/packages/web/src/components/embed/embed-video.tsx @@ -0,0 +1,20 @@ +import { Embeddable } from "./embeddable"; + +export const EmbedVideo = ({ file }: { file: Embeddable }) => { + return ( + + ); +}; + +EmbedVideo.embeddable = (data: Embeddable) => { + switch (data.type) { + case "video/mp4": + case "video/webm": + case "video/ogg": + return true; + default: + return false; + } +}; diff --git a/packages/web/src/components/embed/embed.tsx b/packages/web/src/components/embed/embed.tsx new file mode 100644 index 0000000..53a41f0 --- /dev/null +++ b/packages/web/src/components/embed/embed.tsx @@ -0,0 +1,49 @@ +import { FC, useMemo } from "react"; +import { EmbedContainer } from "./embed-container"; +import { EmbedDefault } from "./embed-default"; +import { EmbedImage } from "./embed-image"; +import { EmbedMarkdown } from "./embed-markdown"; +import { EmbedText } from "./embed-text"; +import { EmbedVideo } from "./embed-video"; +import { Embeddable } from "./embeddable"; + +export const Embed: FC<{ data: Embeddable }> = ({ data }) => { + const isText = useMemo(() => EmbedText.embeddable(data), [data]); + const isImage = useMemo(() => EmbedImage.embeddable(data), [data]); + const isVideo = useMemo(() => EmbedVideo.embeddable(data), [data]); + const isMarkdown = useMemo(() => EmbedMarkdown.embeddable(data), [data]); + + if (isMarkdown) { + return ; + } + + if (isText) { + return ( + + + + ); + } + + if (isImage) { + return ( + + + + ); + } + + if (isVideo) { + return ( + + + + ); + } + + return ( + + + + ); +}; diff --git a/packages/web/src/components/embed/embeddable.ts b/packages/web/src/components/embed/embeddable.ts new file mode 100644 index 0000000..7da2727 --- /dev/null +++ b/packages/web/src/components/embed/embeddable.ts @@ -0,0 +1,12 @@ +export interface Embeddable { + type: string; + size: number; + displayName?: string; + height?: number; + width?: number; + content?: { data?: string | null; error?: any }; + paths: { + direct: string; + view?: string; + }; +} diff --git a/packages/web/src/components/file-embed/text-fetcher.ts b/packages/web/src/components/embed/text-fetcher.ts similarity index 100% rename from packages/web/src/components/file-embed/text-fetcher.ts rename to packages/web/src/components/embed/text-fetcher.ts diff --git a/packages/web/src/components/file-embed/file-embed-container.tsx b/packages/web/src/components/file-embed/file-embed-container.tsx deleted file mode 100644 index c611c35..0000000 --- a/packages/web/src/components/file-embed/file-embed-container.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { GetFileData } from "@ryanke/micro-api"; -import classNames from "classnames"; -import Head from "next/head"; -import { FC, ReactNode } from "react"; - -export const FileEmbedContainer: FC<{ file: GetFileData; children: ReactNode; className?: string }> = ({ - file, - children, - className, -}) => { - const classes = classNames( - "flex items-center justify-center col-span-5 rounded shadow-2xl bg-dark-200 max-h-[75vh] min-h-[3em]", - className - ); - - return ( -
- - - - - - - {children} -
- ); -}; diff --git a/packages/web/src/components/file-embed/file-embed-image.tsx b/packages/web/src/components/file-embed/file-embed-image.tsx deleted file mode 100644 index 2814751..0000000 --- a/packages/web/src/components/file-embed/file-embed-image.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { GetFileData } from "@ryanke/micro-api"; -import Head from "next/head"; - -export const FileEmbedImage = ({ file }: { file: GetFileData }) => { - return ( - <> - - - - - {file.displayName} - - ); -}; - -FileEmbedImage.embeddable = (file: GetFileData) => { - switch (file.type) { - case "image/png": - case "image/jpeg": - case "image/gif": - case "image/svg+xml": - case "image/webp": - case "image/bmp": - case "image/tiff": - return true; - default: - return false; - } -}; diff --git a/packages/web/src/components/file-embed/file-embed-markdown.tsx b/packages/web/src/components/file-embed/file-embed-markdown.tsx deleted file mode 100644 index 402bcc1..0000000 --- a/packages/web/src/components/file-embed/file-embed-markdown.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { GetFileData } from "@ryanke/micro-api"; -import useSWR from "swr"; -import { Markdown } from "../markdown"; -import { PageLoader } from "../page-loader"; -import { FileEmbedContainer } from "./file-embed-container"; -import { FileEmbedDefault } from "./file-embed-default"; -import { textFetcher } from "./text-fetcher"; - -export const FileEmbedMarkdown = ({ file }: { file: GetFileData }) => { - const content = useSWR(file.paths.direct, { fetcher: textFetcher }); - - if (content.error) { - return ( - - - - ); - } - - if (!content.data) { - return ; - } - - return ( - - {content.data} - - ); -}; - -const MAX_MARKDOWN_SIZE = 1_000_000; // 1mb -FileEmbedMarkdown.embeddable = (file: GetFileData) => { - if (file.type !== "text/markdown") return false; - if (file.size > MAX_MARKDOWN_SIZE) return false; - return true; -}; diff --git a/packages/web/src/components/file-embed/file-embed-text.tsx b/packages/web/src/components/file-embed/file-embed-text.tsx deleted file mode 100644 index f857276..0000000 --- a/packages/web/src/components/file-embed/file-embed-text.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { GetFileData } from "@ryanke/micro-api"; -import { Language } from "prism-react-renderer"; -import React, { useEffect, useState } from "react"; -import useSWR from "swr"; -import { getFileLanguage } from "../../helpers/get-file-language.helper"; -import { PageLoader } from "../page-loader"; -import { SyntaxHighlighter } from "../syntax-highlighter/syntax-highlighter"; -import { FileEmbedDefault } from "./file-embed-default"; -import { textFetcher } from "./text-fetcher"; - -const DEFAULT_LANGUAGE = getFileLanguage("diff")!; -const MAX_FILE_SIZE = 1_000_000; // 1mb - -export const FileEmbedText = ({ file }: { file: GetFileData }) => { - const [language, setLanguage] = useState(getFileLanguage(file.displayName) ?? DEFAULT_LANGUAGE); - const content = useSWR(file.paths.direct, { fetcher: textFetcher }); - - useEffect(() => { - // re-calculate language on fileName change - setLanguage(getFileLanguage(file.displayName) ?? DEFAULT_LANGUAGE); - }, [file.displayName]); - - if (content.error) { - return ; - } - - if (!content.data) { - return ; - } - - return ( - - {content.data} - - ); -}; - -FileEmbedText.embeddable = (file: GetFileData) => { - if (file.type.startsWith("text/")) return true; - if (getFileLanguage(file.displayName)) return true; - if (file.size > MAX_FILE_SIZE) return false; - return false; -}; diff --git a/packages/web/src/components/file-embed/file-embed-video.tsx b/packages/web/src/components/file-embed/file-embed-video.tsx deleted file mode 100644 index 9bd8c3d..0000000 --- a/packages/web/src/components/file-embed/file-embed-video.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { GetFileData } from "@ryanke/micro-api"; - -export const FileEmbedVideo = ({ file }: { file: GetFileData }) => { - return ( - - ); -}; - -FileEmbedVideo.embeddable = (file: GetFileData) => { - switch (file.type) { - case "video/mp4": - case "video/webm": - case "video/ogg": - return true; - default: - return false; - } -}; diff --git a/packages/web/src/components/file-embed/file-embed.tsx b/packages/web/src/components/file-embed/file-embed.tsx deleted file mode 100644 index bd206ed..0000000 --- a/packages/web/src/components/file-embed/file-embed.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { GetFileData } from "@ryanke/micro-api"; -import { FC, useMemo } from "react"; -import { FileEmbedContainer } from "./file-embed-container"; -import { FileEmbedDefault } from "./file-embed-default"; -import { FileEmbedImage } from "./file-embed-image"; -import { FileEmbedMarkdown } from "./file-embed-markdown"; -import { FileEmbedText } from "./file-embed-text"; -import { FileEmbedVideo } from "./file-embed-video"; - -export const FileEmbed: FC<{ file: GetFileData }> = (props) => { - const isText = useMemo(() => FileEmbedText.embeddable(props.file), [props.file]); - const isImage = useMemo(() => FileEmbedImage.embeddable(props.file), [props.file]); - const isVideo = useMemo(() => FileEmbedVideo.embeddable(props.file), [props.file]); - const isMarkdown = useMemo(() => FileEmbedMarkdown.embeddable(props.file), [props.file]); - - if (isMarkdown) { - return ; - } - - if (isText) { - return ( - - - - ); - } - - if (isImage) { - return ( - - - - ); - } - - if (isVideo) { - return ( - - - - ); - } - - return ( - - - - ); -}; diff --git a/packages/web/src/components/syntax-highlighter/syntax-highlighter.tsx b/packages/web/src/components/syntax-highlighter/syntax-highlighter.tsx index e2812ff..5a6458b 100644 --- a/packages/web/src/components/syntax-highlighter/syntax-highlighter.tsx +++ b/packages/web/src/components/syntax-highlighter/syntax-highlighter.tsx @@ -1,8 +1,8 @@ -import classNames from "classnames"; -import Highlight, { defaultProps, Language } from "prism-react-renderer"; -import { HTMLProps, memo, useState } from "react"; -import { theme } from "./prism-theme"; -import { SyntaxHighlighterControls } from "./syntax-highlighter-controls"; +import classNames from 'classnames'; +import Highlight, { defaultProps, Language } from 'prism-react-renderer'; +import { HTMLProps, memo, useState } from 'react'; +import { theme } from './prism-theme'; +import { SyntaxHighlighterControls } from './syntax-highlighter-controls'; export interface SyntaxHighlighterProps extends HTMLProps { children: string; @@ -19,8 +19,8 @@ export const SyntaxHighlighter = memo( return ( {({ className, style, tokens, getLineProps, getTokenProps }) => { - const containerClasses = classNames(className, "text-left overflow-x-auto h-full", additionalClasses); - const parentClasses = classNames("relative", parentClassName); + const containerClasses = classNames(className, 'text-left overflow-x-auto h-full', additionalClasses); + const parentClasses = classNames('relative', parentClassName); return (
@@ -28,7 +28,7 @@ export const SyntaxHighlighter = memo(
                 {tokens.map((line, index) => {
                   const props = getLineProps({ line, key: index });
-                  const classes = classNames(props.className, "table-row");
+                  const classes = classNames(props.className, 'table-row');
 
                   return (
                     // handled by getLineProps
diff --git a/packages/web/src/pages/file/[fileId].tsx b/packages/web/src/pages/file/[fileId].tsx
index 2f84e68..acf7204 100644
--- a/packages/web/src/pages/file/[fileId].tsx
+++ b/packages/web/src/pages/file/[fileId].tsx
@@ -7,7 +7,7 @@ import { FC, ReactNode, useState } from "react";
 import { Download, Share, Trash } from "react-feather";
 import useSWR from "swr";
 import { Container } from "../../components/container";
-import { FileEmbed } from "../../components/file-embed/file-embed";
+import { Embed } from "../../components/embed/embed";
 import { PageLoader } from "../../components/page-loader";
 import { Spinner } from "../../components/spinner";
 import { Title } from "../../components/title";
@@ -33,6 +33,7 @@ const FileOption: FC<{ children: ReactNode; className?: string; onClick: () => v
     "flex items-center gap-2 shrink-0 transition-colors duration-100 hover:text-gray-300",
     className
   );
+
   return (
     
       {children}
@@ -109,7 +110,16 @@ export default function File({ fallbackData }: FileProps) {
           

{file.data.displayName}

{formatBytes(file.data.size)}
- +
diff --git a/packages/web/src/pages/paste/[pasteId].tsx b/packages/web/src/pages/paste/[pasteId].tsx index 8271b91..e9d3285 100644 --- a/packages/web/src/pages/paste/[pasteId].tsx +++ b/packages/web/src/pages/paste/[pasteId].tsx @@ -1,20 +1,19 @@ -import { GetPasteData } from "@ryanke/micro-api"; -import { GetServerSidePropsContext, GetServerSidePropsResult } from "next"; -import { useRouter } from "next/router"; -import { Language } from "prism-react-renderer"; -import { useEffect, useState } from "react"; -import { BookOpen, Clock, Trash } from "react-feather"; -import useSWR from "swr"; -import { Container } from "../../components/container"; -import { PageLoader } from "../../components/page-loader"; -import { SyntaxHighlighter } from "../../components/syntax-highlighter/syntax-highlighter"; -import { Time } from "../../components/time"; -import { decryptContent } from "../../helpers/encrypt.helper"; -import { fetcher } from "../../helpers/fetcher.helper"; -import { hashToObject } from "../../helpers/hash-to-object"; -import { http, HTTPError } from "../../helpers/http.helper"; -import { Warning } from "../../warning"; -import ErrorPage from "../_error"; +import { GetPasteData } from '@ryanke/micro-api'; +import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { BookOpen, Clock, Trash } from 'react-feather'; +import useSWR from 'swr'; +import { Container } from '../../components/container'; +import { Embed } from '../../components/embed/embed'; +import { PageLoader } from '../../components/page-loader'; +import { Time } from '../../components/time'; +import { decryptContent } from '../../helpers/encrypt.helper'; +import { fetcher } from '../../helpers/fetcher.helper'; +import { hashToObject } from '../../helpers/hash-to-object'; +import { http, HTTPError } from '../../helpers/http.helper'; +import { Warning } from '../../warning'; +import ErrorPage from '../_error'; export interface ViewPasteProps { fallbackData?: GetPasteData; @@ -22,9 +21,9 @@ export interface ViewPasteProps { export default function ViewPaste({ fallbackData }: ViewPasteProps) { const router = useRouter(); - const [burn] = useState(() => router.query.burn !== "false"); + const [burn] = useState(() => router.query.burn !== 'false'); const [burnt, setBurnt] = useState(false); - const [content, setContent] = useState(""); + const [content, setContent] = useState(null); const [error, setError] = useState(null); const [missingKey, setMissingKey] = useState(false); const pasteId = router.query.pasteId as string | undefined; @@ -39,7 +38,7 @@ export default function ViewPaste({ fallbackData }: ViewPasteProps) { useEffect(() => { // handle decrypting encrypted pastes if (!paste.data?.content) { - if (content) setContent(""); + if (content) setContent(''); return; } @@ -63,7 +62,7 @@ export default function ViewPaste({ fallbackData }: ViewPasteProps) { setContent(content); }) .catch((error) => { - setContent(""); + setContent(''); setError(error); }); }, [paste.data?.content, router]); @@ -74,7 +73,7 @@ export default function ViewPaste({ fallbackData }: ViewPasteProps) { // scrapers (like when you paste it into discord) would burn the paste when discord checks for opengraph/an image/etc. if (!burn || !paste.data?.burn) return; http(`paste/${pasteId}/burn`, { - method: "POST", + method: 'POST', }).then((res) => { if (res.ok) { setBurnt(true); @@ -85,9 +84,9 @@ export default function ViewPaste({ fallbackData }: ViewPasteProps) { useEffect(() => { // remove the burn query param const url = new URL(window.location.href); - if (url.searchParams.has("burn")) { - url.searchParams.delete("burn"); - window.history.replaceState({}, "", url.toString()); + if (url.searchParams.has('burn')) { + url.searchParams.delete('burn'); + window.history.replaceState({}, '', url.toString()); } }, []); @@ -110,7 +109,7 @@ export default function ViewPaste({ fallbackData }: ViewPasteProps) { } return ( - + {paste.data.burn && !burn && !burnt && ( This paste will be burnt by the next person to view this page and will be unviewable. @@ -122,24 +121,36 @@ export default function ViewPaste({ fallbackData }: ViewPasteProps) { This paste has been burnt and will be gone once you close or reload this page. )} -
- - {content} - + {paste.data.title && ( +

{paste.data.title}

+ )} +
+

- {content.length} characters + {content?.length ?? 0} characters - {" "} + {' '} Created {paste.data.expiresAt && !burnt && ( - {" "} + {' '} Expires diff --git a/packages/web/src/pages/paste/index.tsx b/packages/web/src/pages/paste/index.tsx index 3f7fcf6..0b4378d 100644 --- a/packages/web/src/pages/paste/index.tsx +++ b/packages/web/src/pages/paste/index.tsx @@ -1,62 +1,62 @@ -import { CreatePasteBody, GetPasteData } from "@ryanke/micro-api"; -import { useState } from "react"; -import { Button } from "../../components/button/button"; -import { Container } from "../../components/container"; -import { Select } from "../../components/input/select"; -import { Title } from "../../components/title"; -import { encryptContent } from "../../helpers/encrypt.helper"; -import { http } from "../../helpers/http.helper"; -import ErrorPage from "../_error"; +import { CreatePasteBody, GetPasteData } from '@ryanke/micro-api'; +import { useState } from 'react'; +import { Button } from '../../components/button/button'; +import { Container } from '../../components/container'; +import { Select } from '../../components/input/select'; +import { Title } from '../../components/title'; +import { encryptContent } from '../../helpers/encrypt.helper'; +import { http } from '../../helpers/http.helper'; +import ErrorPage from '../_error'; const EXPIRY_OPTIONS = [ - { name: "15 minutes", value: 15 }, - { name: "30 minutes", value: 30 }, - { name: "1 hour", value: 60 }, - { name: "2 hours", value: 120 }, - { name: "4 hours", value: 240 }, - { name: "8 hours", value: 480 }, - { name: "1 day", value: 1440 }, - { name: "2 days", value: 2880 }, - { name: "4 days", value: 4320 }, - { name: "1 week", value: 10080 }, - { name: "2 weeks", value: 20160 }, - { name: "1 month", value: 43200 }, - { name: "1 year", value: 525600 }, + { name: '15 minutes', value: 15 }, + { name: '30 minutes', value: 30 }, + { name: '1 hour', value: 60 }, + { name: '2 hours', value: 120 }, + { name: '4 hours', value: 240 }, + { name: '8 hours', value: 480 }, + { name: '1 day', value: 1440 }, + { name: '2 days', value: 2880 }, + { name: '4 days', value: 4320 }, + { name: '1 week', value: 10080 }, + { name: '2 weeks', value: 20160 }, + { name: '1 month', value: 43200 }, + { name: '1 year', value: 525600 }, ]; const TYPE_OPTIONS = [ - { name: "Markdown", ext: "md" }, - { name: "Plain Text", ext: "txt" }, - { name: "HTML", ext: "html" }, - { name: "JSON", ext: "json" }, - { name: "XML", ext: "xml" }, - { name: "SQL", ext: "sql" }, - { name: "JavaScript", ext: "js" }, - { name: "TypeScript", ext: "ts" }, - { name: "JSX", ext: "jsx" }, - { name: "TSX", ext: "tsx" }, - { name: "CSS", ext: "css" }, - { name: "SCSS", ext: "scss" }, - { name: "SASS", ext: "sass" }, - { name: "LESS", ext: "less" }, - { name: "GraphQL", ext: "graphql" }, - { name: "C", ext: "c" }, - { name: "C++", ext: "cpp" }, - { name: "C#", ext: "cs" }, - { name: "Python", ext: "py" }, - { name: "R", ext: "r" }, - { name: "Ruby", ext: "rb" }, - { name: "Shell", ext: "sh" }, - { name: "Java", ext: "java" }, - { name: "Kotlin", ext: "kt" }, - { name: "Go", ext: "go" }, - { name: "Swift", ext: "swift" }, - { name: "Rust", ext: "rs" }, - { name: "YAML", ext: "yaml" }, - { name: "PHP", ext: "php" }, - { name: "Perl", ext: "pl" }, - { name: "PowerShell", ext: "ps1" }, - { name: "Batch", ext: "bat" }, + { name: 'Markdown', ext: 'md' }, + { name: 'Plain Text', ext: 'txt' }, + { name: 'HTML', ext: 'html' }, + { name: 'JSON', ext: 'json' }, + { name: 'XML', ext: 'xml' }, + { name: 'SQL', ext: 'sql' }, + { name: 'JavaScript', ext: 'js' }, + { name: 'TypeScript', ext: 'ts' }, + { name: 'JSX', ext: 'jsx' }, + { name: 'TSX', ext: 'tsx' }, + { name: 'CSS', ext: 'css' }, + { name: 'SCSS', ext: 'scss' }, + { name: 'SASS', ext: 'sass' }, + { name: 'LESS', ext: 'less' }, + { name: 'GraphQL', ext: 'graphql' }, + { name: 'C', ext: 'c' }, + { name: 'C++', ext: 'cpp' }, + { name: 'C#', ext: 'cs' }, + { name: 'Python', ext: 'py' }, + { name: 'R', ext: 'r' }, + { name: 'Ruby', ext: 'rb' }, + { name: 'Shell', ext: 'sh' }, + { name: 'Java', ext: 'java' }, + { name: 'Kotlin', ext: 'kt' }, + { name: 'Go', ext: 'go' }, + { name: 'Swift', ext: 'swift' }, + { name: 'Rust', ext: 'rs' }, + { name: 'YAML', ext: 'yaml' }, + { name: 'PHP', ext: 'php' }, + { name: 'Perl', ext: 'pl' }, + { name: 'PowerShell', ext: 'ps1' }, + { name: 'Batch', ext: 'bat' }, ]; export default function Paste() { @@ -64,10 +64,13 @@ export default function Paste() { const [encrypt, setEncrypt] = useState(true); const [burn, setBurn] = useState(false); const [paranoid, setParanoid] = useState(false); - const [content, setContent] = useState(""); - const [extension, setExtension] = useState("md"); + const [content, setContent] = useState(''); + const [extension, setExtension] = useState(null); const [pasting, setPasting] = useState(false); const [error, setError] = useState(null); + const [title, setTitle] = useState(); + const inferredExtension = extension || (content.includes('# ') ? 'md' : 'txt'); + console.log({ extension, inferredExtension }); const submitDisabled = !content || pasting; const submit = async () => { @@ -77,8 +80,9 @@ export default function Paste() { content: content, burn: burn, encrypted: false, - extension: extension, + extension: inferredExtension, paranoid: paranoid, + title: title, }; if (expiryMinutes) { @@ -95,8 +99,8 @@ export default function Paste() { } const response = await http(`paste`, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); @@ -123,6 +127,12 @@ export default function Paste() {

New Paste

+ setTitle(event.target.value || undefined)} + />