feat: use file embed for pastes

This commit is contained in:
sylv 2022-06-21 07:20:53 +08:00
parent 0ea831bb6b
commit 2d39cd5239
33 changed files with 529 additions and 418 deletions

View File

@ -1,3 +1,4 @@
{
"printWidth": 120
"printWidth": 120,
"singleQuote": true
}

View File

@ -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

View File

@ -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

View File

@ -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<string>;
@IsString()
@Transform(({ value }) => path.resolve(value))

View File

@ -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);

View File

@ -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",

View File

@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20220620200410 extends Migration {
async up(): Promise<void> {
this.addSql('alter table "pastes" add column "title" varchar(128) null;');
}
async down(): Promise<void> {
this.addSql('alter table "pastes" drop column "title";');
}
}

View File

@ -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) {

View File

@ -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<File> {
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);
}

View File

@ -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) {

View File

@ -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;

View File

@ -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);

View File

@ -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<ContainerProps> = (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 <div className={classes}>{props.children}</div>;

View File

@ -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 (
<div className={classes}>
<Head>
<meta name="twitter:title" content={data.displayName} />
<meta property="og:title" content={data.displayName} key="title" />
<meta property="og:url" content={data.paths.view} />
<meta property="og:type" content="article" />
</Head>
{children}
</div>
);
};

View File

@ -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 (
<div className="flex flex-col items-center justify-center w-full select-none h-44">
<h1 className="flex items-center mb-2 text-xl font-bold">{file.type}</h1>
<h1 className="flex items-center mb-2 text-xl font-bold">{data.type}</h1>
<span className="text-sm text-gray-500">No preview available for this file type.</span>
</div>
);

View File

@ -0,0 +1,35 @@
import Head from "next/head";
import { Embeddable } from "./embeddable";
export const EmbedImage = ({ data }: { data: Embeddable }) => {
return (
<>
<Head>
<meta name="twitter:image" content={data.paths.direct} />
<meta property="og:image" content={data.paths.direct} />
</Head>
<img
className="object-contain h-full"
src={data.paths.direct}
alt={data.displayName}
height={data.height}
width={data.width}
/>
</>
);
};
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;
}
};

View File

@ -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<string>(data.content === undefined ? data.paths.direct : null, { fetcher: textFetcher });
if (content.error) {
return (
<EmbedContainer centre={false} data={data}>
<EmbedDefault data={data} />
</EmbedContainer>
);
}
if (!content.data) {
return <PageLoader />;
}
return (
<EmbedContainer centre={false} data={data} className="max-h-max">
<Markdown className="p-4">{content.data}</Markdown>
</EmbedContainer>
);
};
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;
};

View File

@ -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<string>(data.paths.direct, { fetcher: textFetcher });
useEffect(() => {
// re-calculate language on fileName change
setLanguage(getFileLanguage(data.displayName) ?? DEFAULT_LANGUAGE);
}, [data.displayName]);
if (content.error) {
return <EmbedDefault data={data} />;
}
if (!content.data) {
return <PageLoader />;
}
return (
<SyntaxHighlighter language={language.key as Language} parentClassName="w-full h-full">
{content.data}
</SyntaxHighlighter>
);
};
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;
};

View File

@ -0,0 +1,20 @@
import { Embeddable } from "./embeddable";
export const EmbedVideo = ({ file }: { file: Embeddable }) => {
return (
<video controls loop playsInline className="h-full outline-none" height={file.height} width={file.width}>
<source src={file.paths.direct} type={file.type} />
</video>
);
};
EmbedVideo.embeddable = (data: Embeddable) => {
switch (data.type) {
case "video/mp4":
case "video/webm":
case "video/ogg":
return true;
default:
return false;
}
};

View File

@ -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 <EmbedMarkdown data={data} />;
}
if (isText) {
return (
<EmbedContainer centre={false} data={data}>
<EmbedText data={data} />
</EmbedContainer>
);
}
if (isImage) {
return (
<EmbedContainer data={data}>
<EmbedImage data={data} />
</EmbedContainer>
);
}
if (isVideo) {
return (
<EmbedContainer data={data}>
<EmbedVideo file={data} />
</EmbedContainer>
);
}
return (
<EmbedContainer data={data}>
<EmbedDefault data={data} />
</EmbedContainer>
);
};

View File

@ -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;
};
}

View File

@ -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 (
<div className={classes}>
<Head>
<meta name="twitter:title" content={file.displayName} />
<meta property="og:title" content={file.displayName} key="title" />
<meta property="og:url" content={file.paths.view} />
<meta property="og:type" content="article" />
</Head>
{children}
</div>
);
};

View File

@ -1,35 +0,0 @@
import { GetFileData } from "@ryanke/micro-api";
import Head from "next/head";
export const FileEmbedImage = ({ file }: { file: GetFileData }) => {
return (
<>
<Head>
<meta name="twitter:image" content={file.paths.direct} />
<meta property="og:image" content={file.paths.direct} />
</Head>
<img
className="object-contain h-full"
src={file.paths.direct}
alt={file.displayName}
height={file.metadata?.height}
width={file.metadata?.width}
/>
</>
);
};
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;
}
};

View File

@ -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<string>(file.paths.direct, { fetcher: textFetcher });
if (content.error) {
return (
<FileEmbedContainer file={file}>
<FileEmbedDefault file={file} />
</FileEmbedContainer>
);
}
if (!content.data) {
return <PageLoader />;
}
return (
<FileEmbedContainer file={file} className="max-h-max">
<Markdown className="p-4">{content.data}</Markdown>
</FileEmbedContainer>
);
};
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;
};

View File

@ -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<string>(file.paths.direct, { fetcher: textFetcher });
useEffect(() => {
// re-calculate language on fileName change
setLanguage(getFileLanguage(file.displayName) ?? DEFAULT_LANGUAGE);
}, [file.displayName]);
if (content.error) {
return <FileEmbedDefault file={file} />;
}
if (!content.data) {
return <PageLoader />;
}
return (
<SyntaxHighlighter language={language.key as Language} parentClassName="w-full h-full">
{content.data}
</SyntaxHighlighter>
);
};
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;
};

View File

@ -1,27 +0,0 @@
import { GetFileData } from "@ryanke/micro-api";
export const FileEmbedVideo = ({ file }: { file: GetFileData }) => {
return (
<video
controls
loop
playsInline
className="h-full outline-none"
height={file.metadata?.height}
width={file.metadata?.width}
>
<source src={file.paths.direct} type={file.type} />
</video>
);
};
FileEmbedVideo.embeddable = (file: GetFileData) => {
switch (file.type) {
case "video/mp4":
case "video/webm":
case "video/ogg":
return true;
default:
return false;
}
};

View File

@ -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 <FileEmbedMarkdown file={props.file} />;
}
if (isText) {
return (
<FileEmbedContainer file={props.file}>
<FileEmbedText file={props.file} />
</FileEmbedContainer>
);
}
if (isImage) {
return (
<FileEmbedContainer file={props.file}>
<FileEmbedImage file={props.file} />
</FileEmbedContainer>
);
}
if (isVideo) {
return (
<FileEmbedContainer file={props.file}>
<FileEmbedVideo file={props.file} />
</FileEmbedContainer>
);
}
return (
<FileEmbedContainer file={props.file}>
<FileEmbedDefault file={props.file} />
</FileEmbedContainer>
);
};

View File

@ -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<HTMLPreElement> {
children: string;
@ -19,8 +19,8 @@ export const SyntaxHighlighter = memo<SyntaxHighlighterProps>(
return (
<Highlight {...defaultProps} theme={theme} code={trimmed} language={language}>
{({ 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 (
<div className={parentClasses}>
@ -28,7 +28,7 @@ export const SyntaxHighlighter = memo<SyntaxHighlighterProps>(
<pre className={containerClasses} style={style} {...rest}>
{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

View File

@ -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 (
<span className={classes} onClick={onClick}>
{children}
@ -109,7 +110,16 @@ export default function File({ fallbackData }: FileProps) {
<h1 className="mr-2 text-xl font-bold truncate md:text-4xl md:break-all">{file.data.displayName}</h1>
<span className="text-xs text-gray-500">{formatBytes(file.data.size)}</span>
</div>
<FileEmbed file={file.data} />
<Embed
data={{
type: file.data.type,
paths: file.data.paths,
size: file.data.size,
displayName: file.data.displayName,
height: file.data.metadata?.height,
width: file.data.metadata?.width,
}}
/>
<div className="flex md:flex-col">
<div className="flex text-sm gap-3 text-gray-500 cursor-pointer md:flex-col">
<FileOption onClick={copyLink}>

View File

@ -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<string | null>(null);
const [error, setError] = useState<any>(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 (
<Container className="mb-10">
<Container className="mb-10 mt-10">
{paste.data.burn && !burn && !burnt && (
<Warning className="mb-4">
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.
</Warning>
)}
<div className="rounded overflow-hidden">
<SyntaxHighlighter language={paste.data.extension as Language} className="min-h-[20em]">
{content}
</SyntaxHighlighter>
{paste.data.title && (
<h1 className="mr-2 text-xl font-bold truncate md:text-4xl md:break-all mb-4">{paste.data.title}</h1>
)}
<div className="grid">
<Embed
data={{
type: paste.data.type,
size: paste.data.content.length,
displayName: `paste.${paste.data.extension}`,
content: { data: content, error: error },
paths: {
view: `/paste/${pasteId}`,
direct: `/paste/${pasteId}.txt`,
},
}}
/>
</div>
<p className="text-gray-600 text-sm mt-2 flex items-center gap-2">
<span className="flex items-center gap-2">
<BookOpen className="h-4 w-4" /> {content.length} characters
<BookOpen className="h-4 w-4" /> {content?.length ?? 0} characters
</span>
<span className="flex items-center gap-2">
<Clock className="h-4 w-4" />{" "}
<Clock className="h-4 w-4" />{' '}
<span>
Created <Time date={paste.data.createdAt} />
</span>
</span>
{paste.data.expiresAt && !burnt && (
<span className="flex items-center gap-2">
<Trash className="h-4 w-4" />{" "}
<Trash className="h-4 w-4" />{' '}
<span>
Expires <Time date={paste.data.expiresAt} />
</span>

View File

@ -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<string | null>(null);
const [pasting, setPasting] = useState(false);
const [error, setError] = useState<any>(null);
const [title, setTitle] = useState<string | undefined>();
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() {
<h1 className="text-4xl font-bold mb-4">New Paste</h1>
<div className="flex items-center gap-2"></div>
</div>
<input
className="w-full bg-dark-400 outline-none focus:outline-purple-400 focus:outline-1 mb-4 px-2 py-1"
placeholder="Title"
value={title}
onChange={(event) => setTitle(event.target.value || undefined)}
/>
<textarea
className="w-full h-64 p-2 bg-dark-400 outline-none focus:outline-purple-400 focus:outline-1 rounded placeholder:text-gray-600"
value={content}
@ -162,7 +172,7 @@ export default function Paste() {
))}
</Select>
<Select
value={extension}
value={inferredExtension}
className="w-auto"
onChange={(event) => {
setExtension(event.target.value);

View File

@ -76,12 +76,12 @@ export default function Upload() {
const body: GetFileData = await response.json();
const route = `/file/${body.id}`;
const isSameHost = body.host === config.data.host.normalised;
const isSameHost = body.hostname === config.data.host.normalised;
if (isSameHost) {
router.push(route);
}
location.href = body.urls.direct;
location.href = body.urls.view;
} catch (error: unknown) {
const message = getErrorMessage(error) ?? "An unknown error occured.";
setToast({ error: true, text: message });