Rewrite in Crystal as a 0x0 clone

This commit is contained in:
Chris W 2024-01-02 17:54:12 -07:00
parent c4c177bd67
commit c150387c16
100 changed files with 1343 additions and 6087 deletions

View File

@ -1,3 +0,0 @@
.env
.svelte-kit
build/

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

View File

@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -1,34 +0,0 @@
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
],
rules: {
'ts-expect-error': 'allow-with-description',
'ts-ignore': 'allow-with-description'
}
};

19
.gitignore vendored
View File

@ -1,10 +1,9 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/docs/
/lib/
/bin/
/.shards/
*.dwarf
*.env
config/config.yml
config/templates/*.j2
uploads

1
.npmrc
View File

@ -1 +0,0 @@
engine-strict=true

1
.nvmrc
View File

@ -1 +0,0 @@
v20.9.0

View File

@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -1,9 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@ -1,27 +0,0 @@
# Build the svelte kit project
FROM node:20.7.0-alpine as build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm ci --prod
# Serve the built project
FROM node:20.7.0-alpine as serve
USER node:node
WORKDIR /app
COPY --from=build --chown=node:node /app/build ./build
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json .
ENTRYPOINT ["node", "build/index.js"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Chris W <cawatson1993@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,38 +1,47 @@
# create-svelte
# Paste69
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
This project has undergone several changes, but here's where we are now:
Paste69 is a clone of the popular pastebin service 0x45.st, but written in Crystal using [the Athena framework](https://athenaframework.org/) rather than in Python with Flask.
## Creating a project
## Installation
If you're seeing this, you've probably already done this step. Congrats!
Installation requires [Crystal](https://crystal-lang.org/) and Postgres. Other databases might be supported in the future.
Clone this repo:
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
git clone https://github.com/watzon/paste69
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
Install dependencies:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
shards install
```
## Building
To create a production version of your app:
Copy and modify the config file:
```bash
npm run build
cp config/config.example.yml config/config.yml
vim config/config.yml
```
You can preview the production build with `npm run preview`.
Build the executables, migrate the database, and run the server:
```bash
shards build
./bin/cli db:migrate # this assumes the dabase exists, if not run db:create first
./bin/server
```
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
## Development
Feel free to make pull requests!
## Contributing
1. Fork it (<https://github.com/watzon/paste69/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request
## Contributors
- [Chris W](https://github.com/watzon) - creator and maintainer

View File

@ -0,0 +1,6 @@
host: example.com
port: 80
database_url: postgres://postgres@127.0.0.1/paste69
storage:
type: local
path: ./uploads

View File

View File

@ -0,0 +1,24 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS pastes (
id BIGSERIAL PRIMARY KEY,
sha256 TEXT UNIQUE NOT NULL,
ext TEXT NOT NULL,
mime TEXT NOT NULL,
addr TEXT,
ua TEXT,
removed BOOLEAN NOT NULL DEFAULT FALSE,
nsfw_score REAL,
expiration BIGINT,
mgmt_token TEXT,
secret TEXT,
last_vscan TIMESTAMP,
size BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE IF EXISTS pastes;

View File

@ -0,0 +1,13 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS urls (
id BIGSERIAL PRIMARY KEY,
url TEXT NOT NULL,
hits BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE urls;

View File

@ -1 +0,0 @@
providers=["node"]

3655
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +0,0 @@
{
"name": "paste69-svelte",
"version": "0.0.1",
"private": true,
"engines": {
"node": "^20.0.0"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"start": "node ./build/index.js"
},
"devDependencies": {
"@skeletonlabs/skeleton": "^2.2.0",
"@skeletonlabs/tw-plugin": "^0.2.1",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.20.4",
"@tailwindcss/forms": "^0.5.6",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "^20.8.2",
"@types/prismjs": "^1.26.1",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"postcss": "^8.4.24",
"postcss-load-config": "^4.0.1",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"svelte-tabler": "^0.6.3",
"tailwindcss": "^3.3.2",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2"
},
"type": "module",
"dependencies": {
"@sentry/node": "^7.73.0",
"@ts-stack/markdown": "^1.5.0",
"highlight.js": "^11.8.0",
"highlightjs-zig": "^1.0.2",
"mongodb": "^6.1.0",
"random-words": "^2.0.0"
}
}

View File

@ -1,13 +0,0 @@
const tailwindcss = require('tailwindcss');
const autoprefixer = require('autoprefixer');
const config = {
plugins: [
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
tailwindcss(),
//But others, like autoprefixer, need to run after,
autoprefixer
]
};
module.exports = config;

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

90
shard.lock Normal file
View File

@ -0,0 +1,90 @@
version: 2.0
shards:
athena:
git: https://github.com/athena-framework/framework.git
version: 0.18.2+git.commit.94d851b88d20506bb6f4033b5d1488682fc3822e
athena-clock:
git: https://github.com/athena-framework/clock.git
version: 0.1.1
athena-config:
git: https://github.com/athena-framework/config.git
version: 0.3.3
athena-console:
git: https://github.com/athena-framework/console.git
version: 0.3.4
athena-dependency_injection:
git: https://github.com/athena-framework/dependency-injection.git
version: 0.3.8
athena-event_dispatcher:
git: https://github.com/athena-framework/event-dispatcher.git
version: 0.2.3
athena-image_size:
git: https://github.com/athena-framework/image-size.git
version: 0.1.2
athena-negotiation:
git: https://github.com/athena-framework/negotiation.git
version: 0.1.4
athena-routing:
git: https://github.com/athena-framework/routing.git
version: 0.1.8
athena-serializer:
git: https://github.com/athena-framework/serializer.git
version: 0.3.4
athena-validator:
git: https://github.com/athena-framework/validator.git
version: 0.3.2
awscr-s3:
git: https://github.com/taylorfinnell/awscr-s3.git
version: 0.8.3
awscr-signer:
git: https://github.com/taylorfinnell/awscr-signer.git
version: 0.8.2
crecto:
path: ../crecto
version: 0.12.1+git.commit.316e925683090e7304fd223e5a20f20be79af645
crinja:
git: https://github.com/straight-shoota/crinja.git
version: 0.8.1
db:
git: https://github.com/crystal-lang/crystal-db.git
version: 0.10.1
magic:
git: https://github.com/dscottboggs/magic.cr.git
version: 1.1.0
micrate:
git: https://github.com/amberframework/micrate.git
version: 0.12.0
pg:
git: https://github.com/will/crystal-pg.git
version: 0.23.2
poncho:
git: https://github.com/icyleaf/poncho.git
version: 0.4.0
popcorn:
git: https://github.com/icyleaf/popcorn.git
version: 0.3.0
totem:
git: https://github.com/icyleaf/totem.git
version: 0.7.0

39
shard.yml Normal file
View File

@ -0,0 +1,39 @@
name: paste69
version: 0.1.0
authors:
- Chris W <cawatson1993@gmail.com>
targets:
server:
main: src/server.cr
cli:
main: src/console.cr
dependencies:
athena:
github: athena-framework/framework
branch: master
crinja:
github: straight-shoota/crinja
crecto:
# github: Crecto/crecto
# branch: master
path: ../crecto
pg:
github: will/crystal-pg
version: ~> 0.23.2
totem:
github: icyleaf/totem
poncho:
github: icyleaf/poncho
micrate:
github: amberframework/micrate
awscr-s3:
github: taylorfinnell/awscr-s3
magic:
github: dscottboggs/magic.cr
crystal: '>= 1.10.1'
license: MIT

9
spec/paste69_spec.cr Normal file
View File

@ -0,0 +1,9 @@
require "./spec_helper"
describe Paste69 do
# TODO: Write tests
it "works" do
false.should eq(true)
end
end

2
spec/spec_helper.cr Normal file
View File

@ -0,0 +1,2 @@
require "spec"
require "../src/paste69"

12
src/app.d.ts vendored
View File

@ -1,12 +0,0 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};

View File

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="w-full h-full dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/images/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#111721" />
<meta name="description" content="Paste69 is a free to use, open source pastebin featuring an api, syntax highlighting, encrypted pastes, and burn after reading." />
<meta name="keywords" content="paste69, paste, pastebin, 0x45, hastebin, code, text, encrypt, decrypt, burn after reading, open source, api, syntax highlighting" />
<meta name="author" content="Chris Watson" />
%sveltekit.head%
</head>
<body class="w-full h-full" data-sveltekit-preload-data="hover" data-theme="skeleton">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -1,41 +0,0 @@
/* Write your global styles here, in PostCSS syntax */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
@font-face {
font-family: 'Monaspace Neon';
src: url('$lib/assets/fonts/MonaspaceNeon-Regular.woff') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Monaspace Neon';
src: url('$lib/assets/fonts/MonaspaceNeon-Bold.woff') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Monaspace Neon';
src: url('$lib/assets/fonts/MonaspaceNeon-Italic.woff') format('truetype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Monaspace Neon';
src: url('$lib/assets/fonts/MonaspaceNeon-BoldItalic.woff') format('truetype');
font-weight: bold;
font-style: italic;
font-display: swap;
}
}
@layer components {
pre, code {
font-family: 'Monaspace Neon', monospace !important;
}
}

16
src/commands/db/create.cr Normal file
View File

@ -0,0 +1,16 @@
require "micrate"
module Paste69
@[ACONA::AsCommand("db:create")]
@[ADI::Register(public: true)]
class Commands::DB::Create < ACON::Command
def initialize(@config : Paste69::ConfigManager); end
protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status
Micrate.connection_url = @config.get("database_url").as_s
Micrate::Cli.create_database
Status::SUCCESS
end
end
end

16
src/commands/db/down.cr Normal file
View File

@ -0,0 +1,16 @@
require "micrate"
module Paste69
@[ACONA::AsCommand("db:down")]
@[ADI::Register(public: true)]
class Commands::DB::Down < ACON::Command
def initialize(@config : Paste69::ConfigManager); end
protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status
Micrate.connection_url = @config.get("database_url").as_s
Micrate::Cli.run_down
Status::SUCCESS
end
end
end

View File

@ -0,0 +1,16 @@
require "micrate"
module Paste69
@[ACONA::AsCommand("db:migrate")]
@[ADI::Register(public: true)]
class Commands::DB::Migrate < ACON::Command
def initialize(@config : Paste69::ConfigManager); end
protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status
Micrate.connection_url = @config.get("database_url").as_s
Micrate::Cli.run_up
Status::SUCCESS
end
end
end

3
src/console.cr Normal file
View File

@ -0,0 +1,3 @@
require "./main"
ADI.container.athena_console_application.run

View File

@ -0,0 +1,16 @@
module Paste69
@[ADI::Register(public: true)]
class HomeController < ATH::Controller
def initialize(@config : Paste69::ConfigManager, @utils : Paste69::UtilsService, @crinja : Paste69::CrinjaService); end
@[ARTA::Get("/")]
def index : ATH::Response
template = @crinja.get_template("index.html.j2")
rendered = template.render({
fhost_url: @utils.url_for("/"),
config: @config.config.to_h,
})
ATH::Response.new(rendered, 200, HTTP::Headers{"Content-Type" => "text/html"})
end
end
end

View File

@ -0,0 +1,126 @@
module Paste69
@[ADI::Register(public: true)]
class PasteController < ATH::Controller
def initialize(@config : Paste69::ConfigManager, @utils : Paste69::UtilsService, @url_encoder : Paste69::UrlEncoder, @db : Paste69::DBService); end
@[ARTA::Get("/{id}")]
@[ARTA::Post("/{id}")]
@[ARTA::Get("/{secret}/{id}")]
@[ARTA::Post("/{secret}/{id}")]
def get_paste(req : ATH::Request, id : String, secret : String? = nil) : ATH::Response
path = id.split("/").first
sufs = File.extname(path)
name = File.basename(path, sufs)
if name.includes?(".")
raise ATH::Exceptions::NotFound.new("Not found")
end
id = @url_encoder.debase(name)
if sufs.size > 0
if (paste = @db.get(Paste, id)) && paste.ext == sufs
if secret != paste.secret
raise ATH::Exceptions::NotFound.new("Not found")
end
if paste.removed
raise Exceptions::UnavailableForLegalReasons.new("Paste removed")
end
if req.method == "POST"
fd = @utils.parse_formdata(req.request)
if parts = fd["token"]?
token = String.new(parts[1])
raise ATH::Exceptions::BadRequest.new("Invalid token") unless token == paste.mgmt_token!
else
raise ATH::Exceptions::BadRequest.new("Missing token")
end
if fd.has_key?("delete")
paste.delete
return ATH::Response.new("", status: 200)
elsif parts = fd["expires"]?
_, expires = parts
requested_expiration = String.new(expires).to_i64?
raise ATH::Exceptions::BadRequest.new("Invalid expiration") unless requested_expiration
paste.expiration = requested_expiration
@db.update(paste)
return ATH::Response.new("", status: 202)
end
raise ATH::Exceptions::NotFound.new("Not found")
end
if file = paste.retrieve
return ATH::Response.new(String.new(file), headers: HTTP::Headers{
"Content-Type" => paste.mime!.to_s,
"Content-Length" => paste.size!.to_s,
"X-Expires" => paste.expiration!.to_s
})
else
raise ATH::Exceptions::NotFound.new("Not found")
end
end
else
if req.method == "POST"
raise ATH::Exceptions::MethodNotAllowed.new(["GET"], "Method not allowed")
end
if path.includes?("/")
raise ATH::Exceptions::NotFound.new("Not found")
end
if u = @db.get(URL, id)
spawn do
u.hits = u.hits! + 1
@db.update(u)
end
return ATH::RedirectResponse.new(u.url!, :permanent_redirect)
end
end
raise ATH::Exceptions::NotFound.new("Not found")
end
@[ARTA::Post("/")]
def create_paste(req : ATH::Request) : ATH::Response
form = @utils.parse_formdata(req.request)
_, secret = form["secret"]? || {nil, nil}
_, expires = form["expires"]? || {nil, nil}
content_type = req.headers["Content-Type"]?
remote_addr = req.headers["Remote-Addr"]?
user_agent = req.headers["User-Agent"]?
if form.has_key?("file")
filename, body = form["file"]
@utils.store_file(
body,
content_type,
filename,
expires ? String.new(expires).to_i64 : nil,
remote_addr,
user_agent,
!!secret,
)
elsif form.has_key?("url")
_, body = form["url"]
@utils.store_url(
String.new(body),
remote_addr,
user_agent,
!!secret,
)
elsif form.has_key?("shorten")
_, body = form["shorten"]
@utils.shorten(String.new(body))
else
raise ATH::Exceptions::BadRequest.new("Bad request")
end
end
end
end

View File

@ -0,0 +1,9 @@
module Paste69
module Exceptions
class ContentTooLarge < Athena::Framework::Exceptions::HTTPException
def initialize(message : String, cause : Exception | Nil = nil, headers : HTTP::Headers = HTTP::Headers.new)
super(:payload_too_large, message, cause, headers)
end
end
end
end

View File

@ -0,0 +1,9 @@
module Paste69
module Exceptions
class LengthRequired < Athena::Framework::Exceptions::HTTPException
def initialize(message : String, cause : Exception | Nil = nil, headers : HTTP::Headers = HTTP::Headers.new)
super(:length_required, message, cause, headers)
end
end
end
end

View File

@ -0,0 +1,9 @@
module Paste69
module Exceptions
class UnavailableForLegalReasons < Athena::Framework::Exceptions::HTTPException
def initialize(message : String, cause : Exception | Nil = nil, headers : HTTP::Headers = HTTP::Headers.new)
super(:unavailable_for_legal_reasons, message, cause, headers)
end
end
end
end

View File

@ -0,0 +1,9 @@
module Paste69
module Exceptions
class URITooLong < Athena::Framework::Exceptions::HTTPException
def initialize(message : String, cause : Exception | Nil = nil, headers : HTTP::Headers = HTTP::Headers.new)
super(:uri_too_long, message, cause, headers)
end
end
end
end

View File

@ -1,21 +0,0 @@
import { env } from '$env/dynamic/private';
import { Mongo } from '$lib/db/index';
import * as Sentry from '@sentry/node';
import type { Handle, HandleServerError } from '@sveltejs/kit';
Sentry.init({
dsn: env.SENTRY_DSN,
});
interface ServerError extends Error {
code?: string;
}
export const handleError: HandleServerError = ({ error, event }) => {
Sentry.captureException(error, { extra: { event } });
return {
message: 'Uh oh! An unexpected error occurred.',
code: (error as ServerError)?.code ?? '500',
};
};

View File

@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns:serif="http://www.serif.com/"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1010.9 930.9"
style="enable-background:new 0 0 1010.9 930.9;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#118979;}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#149C8B;}
</style>
<g transform="matrix(1,0,0,1,-1339,0)">
<g transform="matrix(0.862194,0,0,1.01029,1279.19,36.8297)">
<rect id="No-Bg" x="29.7" y="-109.9" serif:id="No Bg" class="st0" width="1251.7" height="1068.3">
</rect>
<g id="No-Bg1" serif:id="No Bg">
<g transform="matrix(1.15983,0,0,0.989813,-103.977,-35.1696)">
<g transform="matrix(1,0,0,1,-569.977,-312.934)">
<path class="st1" d="M1730.3,724.8c0.8,148.9-23.4,272.5-72.7,370.6c-49.3,98.1-111.5,147.1-186.8,147.1
c-84.1,0-152.9-44.4-206.6-133.3c-42.4-27.2-63.7-54.1-63.7-80.5c0-6.4,1.2-12.8,3.6-19.2c10.4-28,44-42,100.9-42
c44,0,75.3,9.6,93.7,28.8c6.4,7.2,9.6,16.4,9.6,27.6c0,3.2-0.4,8.4-1.2,15.6c-0.8,7.2-1.2,12.4-1.2,15.6c0,11.2,4,20,12,26.4
c5.6,22.4,18.4,33.2,38.4,32.4c17.6-0.8,36.8-27.2,57.6-79.3c20.8-52.1,32.8-104.9,36-158.6c-24.8,32.8-58.5,49.2-100.9,49.2
c-66.5,0-121.3-26-164.6-78.1c-40-47.2-62.1-105.7-66.1-175.4c-3.2-56.1,14.8-122.9,54.1-200.6c44.8-90.5,96.5-135.7,155-135.7
c92.1,0,165.4,34.6,219.8,103.9C1701.9,508.8,1729.5,603.9,1730.3,724.8z M1528.5,646.8c4-19.2,6-40.8,6-64.9
c0-63.3-11.6-98.1-34.8-104.5c-21.6-5.6-43.6,8.8-66.1,43.2c-20,28.8-34,62.1-42,99.7c-5.6,24-8.4,48.4-8.4,73.3
c0,64.9,17.2,100.9,51.7,108.1c17.6,4,36.4-12.4,56.5-49.2C1508.1,720.4,1520.5,685.2,1528.5,646.8z"/>
</g>
<g transform="matrix(7.4705,0,0,7.4705,-3607.69,-3511.85)">
<path class="st2" d="M575.1,501.9c-1.4,3.8-6,5.8-13.8,5.8c-6,0-10.3-1.3-12.8-4c-0.9-1-1.3-2.2-1.3-3.6c0-0.5,0.1-1.3,0.2-2.2
c0.1-0.9,0.2-1.7,0.2-2.2c0-1.4-0.5-2.6-1.6-3.5c-0.8-3.2-2.5-4.7-5.3-4.4c-2.4,0.1-5.1,3.7-7.9,10.8
c-2.9,7.1-4.5,14.4-4.9,21.8c3.4-4.6,8-6.9,13.8-6.9c9.1,0,16.6,3.6,22.6,10.7c5.5,6.6,8.5,14.6,9.1,24
c0.4,7.7-2,16.9-7.4,27.5c-6.1,12.4-13.2,18.6-21.2,18.6c-12.6,0-22.7-4.7-30.1-14.2c-7.5-9.5-11.3-22.5-11.4-39
c-0.1-20.4,3.2-37.4,10-50.9c6.8-13.5,15.3-20.3,25.6-20.3c11.5,0,21,6.1,28.3,18.3c5.8,3.8,8.7,7.5,8.7,11
C575.6,500.1,575.4,501,575.1,501.9z M549.4,555.2c0.8-3.2,1.2-6.5,1.2-9.9c0-9-2.4-14-7.1-15c-2.4-0.4-5,1.8-7.7,6.8
c-2.3,4.4-4,9.3-5.1,14.7c-0.5,2.6-0.8,5.6-0.8,8.9c0,8.7,1.6,13.4,4.8,14.2c3,0.8,6-1.2,9.1-5.8
C546.4,565,548.3,560.4,549.4,555.2z"/>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1,13 +0,0 @@
<script lang="ts">
import type { HTMLButtonAttributes } from "svelte/elements";
interface $$Props extends HTMLButtonAttributes {}
</script>
<button
{...$$restProps}
on:click
class="px-2 py-2 text-gray-100 border border-gray-300 bg-teal-500 hover:bg-teal-800 disabled:bg-gray-700 bg-opacity-30 transition-colors"
>
<slot />
</button>

View File

@ -1,22 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { HTMLTextareaAttributes } from 'svelte/elements';
export let contents = '';
let ref: HTMLTextAreaElement;
interface $$Props extends HTMLTextareaAttributes {
contents: string;
}
onMount(() => {
ref.focus();
});
</script>
<textarea
class="pl-12 pt-5 w-full h-full bg-transparent border-none resize-none outline-none font-monaspaceNeon text-lg"
bind:value={contents}
bind:this={ref}
{...$$restProps}
/>

View File

@ -1,55 +0,0 @@
<script lang="ts">
import { SlideToggle } from '@skeletonlabs/skeleton';
/** Exposes parent props to this component. */
export let parent: any;
import { getModalStore } from '@skeletonlabs/skeleton';
const modalStore = getModalStore();
// Form Data
const formData = {
encrypt: false,
password: undefined,
burnAfterReading: false,
};
// Custom submit function to pass the response and close the modal.
function onFormSubmit(): void {
if ($modalStore[0].response) $modalStore[0].response(formData);
modalStore.close();
}
// Base Classes
const cBase = 'card p-4 w-modal shadow-xl space-y-4';
const cHeader = 'text-2xl font-bold';
const cForm = 'border border-surface-500 p-4 space-y-4 rounded-container-token';
</script>
{#if $modalStore[0]}
<div class="modal-example-form {cBase}">
<header class={cHeader}>Options for Saving</header>
<article>Some extra options for saving. Encrypting your paste will make it require a password for anyone to open it. Burn after reading makes your paste viewable only once.</article>
<form class="modal-form {cForm}">
<div class="grid grid-cols-3">
<div class="col-span-1">Encrypt</div>
<SlideToggle name="slide" bind:checked={formData.encrypt} />
</div>
{#if formData.encrypt}
<div class="grid grid-cols-3">
<div class="col-span-1">Password</div>
<input class="input col-span-2" type="password" bind:value={formData.password} placeholder="Encryption password..." />
</div>
{/if}
<div class="grid grid-cols-3">
<div class="col-span-1">Burn After Reading</div>
<SlideToggle name="slide" bind:checked={formData.burnAfterReading} />
</div>
</form>
<!-- prettier-ignore -->
<footer class="modal-footer {parent.regionFooter}">
<button class="btn {parent.buttonNeutral}" on:click={parent.onClose}>{parent.buttonTextCancel}</button>
<button class="btn {parent.buttonPositive}" on:click={onFormSubmit}>Save Paste</button>
</footer>
</div>
{/if}

View File

@ -1,130 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { getModalStore, getToastStore } from '@skeletonlabs/skeleton';
import type { ModalSettings } from '@skeletonlabs/skeleton';
import { BrandFacebook, BrandMastodon, BrandMessenger, BrandTelegram, BrandTwitter, BrandWhatsapp, ClipboardCopy, Copy } from "svelte-tabler";
export let pasteUrl: string;
export let shareMessage: string = 'Check out this paste on Paste69: {url}'
const dispatch = createEventDispatcher();
const modalStore = getModalStore();
const toastStore = getToastStore();
const mastodonUrlModal: ModalSettings = {
type: 'prompt',
title: 'Mastodon instance URL',
valueAttr: { type: 'text', required: true, placeholder: 'https://mastodon.social' },
response: (r: string | false) => {
if (!r) return;
const url = new URL(r);
const host = url.host;
window.open(`https://${host}/share?text=${encodeURIComponent(shareMessage)}`, '_blank');
},
};
const shareOnMastodon = async () => modalStore.trigger(mastodonUrlModal);
const copyUrl = () => {
navigator.clipboard.writeText(pasteUrl);
toastStore.trigger({
message: 'Copied paste URL to clipboard.',
background: 'variant-filled-success'
});
}
$: shareMessage = shareMessage.replace('{url}', pasteUrl)
</script>
<div class="grid grid-cols-1 justify-center items-center py-6 px-4 bg-slate-800">
<div class="flex flex-col items-center gap-4">
<!-- Twitter Share Button -->
<a
class="p-2 bg-gray-900 rounded w-min"
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(
shareMessage
)}`}
target="_blank"
rel="noopener noreferrer"
title="Share on Twitter"
>
<BrandTwitter size="28" />
</a>
<!-- Facebook Share Button -->
<a
class="p-2 bg-gray-900 rounded w-min"
href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
pasteUrl
)}`}
target="_blank"
rel="noopener noreferrer"
title="Share on Facebook"
>
<BrandFacebook size="28" />
</a>
<!-- Messenger Share Button -->
<a
class="p-2 bg-gray-900 rounded w-min"
href={`https://www.facebook.com/dialog/send?app_id=521270401588372&display=popup&quote=${encodeURIComponent(shareMessage)}`}
target="_blank"
rel="noopener noreferrer"
title="Share on Messenger"
>
<BrandMessenger size="28" />
</a>
<!-- Whatsapp Share Button -->
<a
class="p-2 bg-gray-900 rounded w-min"
href={`https://api.whatsapp.com/send?text=${encodeURIComponent(
shareMessage
)}`}
target="_blank"
rel="noopener noreferrer"
title="Share on Whatsapp"
>
<BrandWhatsapp size="28" />
</a>
<!-- Telegram Share Button -->
<a
class="p-2 bg-gray-900 rounded w-min"
href={`https://telegram.me/share/url?text=${encodeURIComponent(shareMessage)}`}
target="_blank"
rel="noopener noreferrer"
title="Share on Telegram"
>
<BrandTelegram size="28" />
</a>
<!-- Mastodon Share Button -->
<button
class="p-2 bg-gray-900 rounded w-min"
title="Share on Mastodon"
on:click={shareOnMastodon}
>
<BrandMastodon size="28" />
</button>
<!-- Copy URL Button -->
<button
class="p-2 bg-gray-900 rounded w-min"
title="Copy URL"
on:click={copyUrl}
>
<Copy size="28" />
</button>
<!-- Copy content button -->
<button
class="p-2 bg-gray-900 rounded w-min"
title="Copy content"
on:click={() => dispatch('copy')}
>
<ClipboardCopy size="28" />
</button>
</div>
</div>

View File

@ -1,70 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { Copy, DeviceFloppy, TextPlus, CaretDown } from 'svelte-tabler';
import { getModalStore, type ModalComponent, type ModalSettings } from '@skeletonlabs/skeleton';
import logo from '$lib/assets/logo.svg';
import Button from './Button.svelte';
import MoreOptionsForm from './MoreOptionsForm.svelte';
const modalStore = getModalStore();
const dispatch = createEventDispatcher();
let extraOptions = {
encrypt: false,
password: undefined as string | undefined,
burnAfterReading: false,
};
export let disableSave: boolean = false;
export let disableCopy: boolean = false;
export let disableMoreOptions: boolean = false;
const modalComponent: ModalComponent = {
ref: MoreOptionsForm,
}
const modalSettings: ModalSettings = {
type: 'component',
component: modalComponent,
response: (data: typeof extraOptions) => {
if (!data) return;
extraOptions = data;
onSave();
},
}
const onSave = () => dispatch('save', extraOptions);
const onNew = () => dispatch('new');
const onCopy = () => dispatch('copy');
</script>
<div class="flex flex-row max-w-full md:min-w-[595px] bg-slate-800">
<a
href="/about"
class="flex flex-col items-center justify-center py-2 px-8 bg-gray-900 bg-opacity-50"
>
<img class="w-8" src={logo} alt="Paste69 Logo" />
</a>
<div class="flex flex-col items-center justify-center pt-4 pb-2 px-12 w-full">
<div class="flex flex-row justify-between gap-2 w-full">
<Button title="Save" disabled={disableSave} on:click={onSave}>
<DeviceFloppy size="22" />
</Button>
<Button title="New" on:click={onNew}>
<TextPlus size="22" />
</Button>
<Button title="Copy" disabled={disableCopy} on:click={onCopy}>
<Copy size="22" />
</Button>
</div>
<!-- More options button with horizontal line through the word -->
<button
disabled={disableMoreOptions}
on:click={() => modalStore.trigger(modalSettings)}
class="w-full text-center text-sm text-gray-300 disabled:text-gray-400 border-b border-gray-500 leading-[0.1em] mt-4 mb-2"
>
<span class="bg-slate-800 px-2">More Options</span>
</button>
</div>
</div>

View File

@ -1,53 +0,0 @@
import { MongoClient } from 'mongodb';
import { env } from '$env/dynamic/private';
import type { Db, Collection } from 'mongodb';
import type PasteSchema from "./paste-schema";
// let client: MongoClient;
// let db: Db;
// let pastes: Collection<PasteSchema>;
// const connect = async (url: string) => {
// client = new MongoClient(url);
// await client.connect();
// const db = client.db("paste69");
// pastes = db.collection<PasteSchema>("pastes");
// }
// export {
// db,
// pastes,
// connect,
// }
interface Collections {
pastes: Collection<PasteSchema>;
}
export class Mongo {
private static instance: Promise<MongoClient> | null = null;
private constructor() {}
public static getClient(): Promise<MongoClient> {
if (!Mongo.instance) {
Mongo.instance = MongoClient.connect(env.DB_URL);
}
return Mongo.instance;
}
public static async getDb(): Promise<Db> {
const client = await Mongo.getClient();
return client.db("paste69");
}
public static async getCollection<T extends PasteSchema>(name: string): Promise<Collection<T>> {
const db = await Mongo.getDb();
return db.collection<T>(name);
}
public static async getNamedCollection<name extends keyof Collections>(name: name): Promise<Collections[name]> {
return Mongo.getCollection(name) as Promise<Collections[name]>;
}
}

View File

@ -1,8 +0,0 @@
export default interface PasteSchema {
id: string;
contents: string;
highlight: string;
encrypted: boolean;
burnAfterReading: boolean;
createdAt: Date;
}

View File

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

22
src/main.cr Normal file
View File

@ -0,0 +1,22 @@
require "uri"
require "mime"
# Shards
require "athena"
require "crinja"
require "crecto"
require "awscr-s3"
require "magic"
require "pg"
require "totem"
require "totem/config_types/env"
# Application
# require "./utils/**"
require "./services/**"
require "./models/**"
require "./exceptions/**"
require "./commands/**"
require "./middleware/**"
require "./controllers/**"

View File

@ -0,0 +1,40 @@
module Paste69
@[ADI::Register(alias: ATH::ErrorRendererInterface, public: true)]
struct ErrorHandler
include Athena::Framework::ErrorRendererInterface
def initialize(
@request_store : ATH::RequestStore,
@config : Paste69::ConfigManager,
@crinja : Paste69::CrinjaService,
); end
# :inherit:
def render(exception : ::Exception) : ATH::Response
if exception.is_a? ATH::Exceptions::HTTPException
status = exception.status
headers = exception.headers
else
status = HTTP::Status::INTERNAL_SERVER_ERROR
headers = HTTP::Headers.new
end
template = "#{status.to_i}.html.j2"
templates = @crinja.renderer.loader.list_templates
if !templates.includes?(template)
template = "500.html.j2"
end
body = @crinja.get_template(template)
.render({
path: @request_store.request.path,
headers: headers.to_h,
config: @config.config.to_h,
})
headers["content-type"] = "text/html"
ATH::Response.new body, status, headers
end
end
end

View File

@ -0,0 +1,49 @@
module Paste69
@[ADI::Register]
struct StaticFileListener
include AED::EventListenerInterface
# This could be parameter if the directory changes between environments.
private PUBLIC_DIR = Path.new("public").expand
# Run this listener with a very high priority so it is invoked before any application logic.
@[AEDA::AsEventListener(priority: 256)]
def on_request(event : ATH::Events::Request) : Nil
# Fallback if the request method isn't intended for files.
# Alternatively, a 405 could be thrown if the server is dedicated to serving files.
return unless event.request.method.in? "GET", "HEAD"
original_path = event.request.path
request_path = URI.decode original_path
# File path cannot contains '\0' (NUL).
if request_path.includes? '\0'
raise ATH::Exceptions::BadRequest.new "File path cannot contain NUL bytes."
end
request_path = Path.posix request_path
expanded_path = request_path.expand "/"
file_path = PUBLIC_DIR.join expanded_path.to_kind Path::Kind.native
is_dir = Dir.exists? file_path
is_dir_path = original_path.ends_with? '/'
event.response = if request_path != expanded_path || is_dir && !is_dir_path
redirect_path = expanded_path
if is_dir && !is_dir_path
redirect_path = expanded_path.join ""
end
# Request is a directory but acting as a file,
# redirect to the actual directory URL.
ATH::RedirectResponse.new redirect_path
elsif File.file? file_path
ATH::BinaryFileResponse.new file_path
else
# Nothing to do.
return
end
end
end
end

172
src/models/paste.cr Normal file
View File

@ -0,0 +1,172 @@
require "digest/sha256"
module Paste69
class Paste < Crecto::Model
extend ServiceIncluder
include ServiceIncluder
unique_constraint :sha256
schema "pastes" do
field :id, Int64, primary_key: true
field :sha256, String
field :ext, String
field :mime, String
field :addr, String
field :ua, String
field :removed, Bool, default: false
field :nsfw_score, Float64
field :expiration, Int64
field :mgmt_token, String
field :secret, String
field :last_vscan, Time
field :size, Int64
end
def nsfw?
threshold = config.get("nsfw.threshold").as_f
(self.nsfw_score || 0.0) > threshold
end
def name
"#{url_encoder.enbase(self.id.not_nil!)}#{self.ext}"
end
def url
utils.url_for(self.name, secret: self.secret, anchor: self.nsfw? ? "nsfw" : nil) + "\n"
end
def path
storage_type = config.get("storage.type").as_s
if storage_type == "local"
File.join(config.get("storage.path").as_s, self.sha256!)
elsif storage_type == "s3"
self.sha256!
else
raise "unknown storage type: #{storage_type}"
end
end
def delete(permenant = false)
storage_type = config.get("storage.type").as_s
self.expiration = nil
self.mgmt_token = nil
self.removed = permenant
db_service.update(self)
if storage_type == "local"
File.delete?(self.path)
elsif storage_type == "s3"
s3_client.delete_object(config.get("storage.s3.bucket").as_s, self.sha256!)
else
raise "unknown storage type: #{storage_type}"
end
end
# requested_expiration can be:
# - `nil`, to use the longest allowed file lifespan
# - a duration (in hours) that the file should live for
# - a timestamp in epoch millis that the file should expire at
#
# Any value greater that the longest allowed file lifespan will be rounded down to that
# value.
def self.store(data, content_type : String? = nil, filename : String? = nil, requested_expiration : Int64? = nil, addr : String? = nil, ua : String? = nil, secret : Bool = false)
digest = Digest::SHA256.hexdigest(data)
expiration = utils.expiration_millis(data.size, requested_expiration)
is_new = true
is_updated = false
if paste = db_service.get_by(Paste, sha256: digest)
# If the file already exists
if paste.removed
# The file was removed by moderation, so don't accept it back
raise Exceptions::UnavailableForLegalReasons.new("The file was removed by moderation")
end
if paste.expiration.nil?
# The file has either expired or been deleted, so give it a new expiration date
paste.expiration = expiration.to_i64
# Also generate a new management token
paste.mgmt_token = Random.new.urlsafe_base64(config.get("storage.secret_bytes").as_i)
is_updated = true
else
# The file already exists, update the expiration as needed
paste.expiration = [paste.expiration!, expiration].max.to_i64
is_new = false
end
else
mime = utils.get_mime(data, filename)
ext = utils.get_ext(mime, filename)
paste = Paste.new
paste.sha256 = digest
paste.ext = ext
paste.mime = mime
paste.expiration = expiration.to_i64
paste.mgmt_token = Random.new.urlsafe_base64(config.get("storage.secret_bytes").as_i)
end
paste.addr = addr
paste.ua = ua
if is_new && secret
paste.secret = Random.new.urlsafe_base64(config.get("storage.secret_bytes").as_i)
end
storage_type = config.get("storage.type").as_s
if storage_type == "local"
path = config.get("storage.path").as_s
Dir.mkdir_p(path)
path = path + "/" + digest
if !File.exists?(path)
File.open(path, "w") do |paste|
paste.write(data)
end
end
elsif storage_type == "s3"
s3_client.put_object(config.get("storage.s3.bucket").as_s, digest, data)
else
raise "Unknown storage type: #{storage_type}"
end
paste.size = data.size
if !paste.nsfw_score && config.get("nsfw.detect").as_bool
# TODO: Kick-off the NSFW detection
end
if is_updated
paste = db_service.update(paste)
else
paste = db_service.insert(paste)
end
{ paste.not_nil!.instance, is_new }
end
def retrieve : Bytes?
return nil if self.expiration.nil? || self.mgmt_token.nil?
storage_type = config.get("storage.type").as_s
if storage_type == "local"
uploads_dir = config.get("storage.path").as_s
path = File.join(uploads_dir, self.sha256!)
if File.exists?(path)
File.read(path).to_slice
end
elsif storage_type == "s3"
begin
resp = s3_client.get_object(config.get("storage.s3.bucket").as_s, self.sha256!)
resp.body.to_slice
rescue ex
end
else
raise "Unknown storage type: #{storage_type}"
end
end
end
end

31
src/models/url.cr Normal file
View File

@ -0,0 +1,31 @@
module Paste69
class URL < Crecto::Model
extend ServiceIncluder
include ServiceIncluder
schema "urls" do
field :id, Int64, primary_key: true
field :url, String
field :hits, Int64, default: 0
end
def name
url_encoder.enbase(self.id.not_nil!)
end
def get_url
utils.url_for(name) + "\n"
end
def self.get(url : String)
if u = db_service.get_by(URL, url: url)
u
else
u = URL.new
u.url = url
u = db_service.insert(u)
u.instance
end
end
end
end

View File

@ -1,30 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import ToolBox from '$lib/components/ToolBox.svelte';
import { ChevronRight } from 'svelte-tabler';
</script>
<svelte:head>
<title>Paste69 - {$page.status} error</title>
</svelte:head>
<div class="absolute top-[23px] left-[5px]">
<ChevronRight />
</div>
<div class="flex flex-row items-center justify-center h-screen">
<div class="text-xl text-center">
<h1 class="h1 mb-2 p-0">{$page.status}</h1>
<p class="mb-2">{$page.error?.message}</p>
</div>
</div>
<div class="fixed bottom-0 right-0 w-full md:w-auto">
<ToolBox
disableSave={true}
disableCopy={true}
disableMoreOptions={true}
on:new={() => goto('/')}
/>
</div>

View File

@ -1,20 +0,0 @@
<script>
import '../app.postcss';
import { env } from "$env/dynamic/public";
import { Modal, Toast, initializeStores } from '@skeletonlabs/skeleton';
initializeStores();
const umami_url = env.PUBLIC_UMAMI_URL;
const umami_site_id = env.PUBLIC_UMAMI_SITE_ID;
</script>
<svelte:head>
{#if umami_url && umami_site_id}
<script async src="{umami_url}/script.js" data-website-id={umami_site_id}></script>
{/if}
</svelte:head>
<Modal />
<Toast />
<slot />

View File

@ -1,16 +0,0 @@
import { Mongo } from "$lib/db/index";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ url }) => {
// Check if the `copyFrom` query parameter is present
const copyFrom = url.searchParams.get("copy-from");
if (copyFrom) {
// If it is, we need to fetch the paste from the database
// and return it to the client.
const pastes = await Mongo.getNamedCollection("pastes");
const paste = await pastes.findOne({ id: copyFrom });
return {
paste: structuredClone(paste),
}
}
}

View File

@ -1,70 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import { ChevronRight } from 'svelte-tabler';
import Editor from '$lib/components/Editor.svelte';
import ToolBox from '$lib/components/ToolBox.svelte';
import { goto } from '$app/navigation';
import { getToastStore } from '@skeletonlabs/skeleton';
import { paste } from '../stores/app';
const toastStore = getToastStore();
export let data: PageData;
let contents = data.paste?.contents ?? '';
const newPaste = () => {
contents = '';
goto('/');
};
const savePaste = async (event: any) => {
const res = await fetch('/api/pastes', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents,
raw: true,
...event.detail,
})
});
if (res.ok) {
const { id, highlight, burnAfterReading } = await res.json();
if (burnAfterReading) {
goto(`/${id}/created`);
} else {
goto(`/${id}.${highlight}`);
}
} else {
toastStore.trigger({
message: 'Failed to save paste.',
background: 'variant-filled-error',
autohide: false,
})
}
};
</script>
<svelte:head>
<title>Paste69 - Paste, if you dare</title>
</svelte:head>
<div class="absolute top-[23px] left-[5px]">
<ChevronRight />
</div>
<Editor
placeholder="Paste something, type something, do something."
bind:contents={contents}
/>
<div class="fixed bottom-0 right-0 w-full md:w-auto">
<ToolBox
disableSave={!contents}
disableCopy={true}
on:save={savePaste}
on:new={newPaste}
/>
</div>

View File

@ -1,37 +0,0 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { Mongo } from '$lib/db/index';
import { env } from '$env/dynamic/private';
export const load: PageLoad = async ({ params }) => {
const [id, ext] = params.slug.split('.');
// Fetch the paste
const pastes = await Mongo.getNamedCollection('pastes');
const paste = await pastes.findOne({ id });
if (!paste) {
throw error(404, 'Paste not found');
}
const highlight = ext || paste.highlight;
const pasteUrl = `${env.SITE_URL}/${id}.${highlight}`;
const ogImageUrl = `${env.SITE_URL}/images/paste/${id}.${highlight}`;
// Build the response object
const response = {
id: paste.id,
pasteUrl,
ogImageUrl,
contents: paste.contents,
encrypted: paste.encrypted,
highlight: highlight,
burnAfterReading: paste.burnAfterReading,
}
// If the paste is set as burnAfterReading, delete it
if (paste.burnAfterReading) {
await pastes.deleteOne({ id });
}
return response;
};

View File

@ -1,157 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import ToolBox from '$lib/components/ToolBox.svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { highlight } from '$utils/hljs';
import { markdown } from '$utils/markdown';
import { getModalStore, getToastStore, type ModalSettings } from '@skeletonlabs/skeleton';
import { sleep } from '$utils/index';
import { ChevronLeft, ChevronRight } from 'svelte-tabler';
import ShareMenu from '$lib/components/ShareMenu.svelte';
let codeRef: HTMLPreElement;
const modalStore = getModalStore();
const toastStore = getToastStore();
export let data: PageData;
let decryptedData: string | undefined = undefined;
// Select all the text in the code block when it is double clicked.
const selectAll = () => {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(codeRef);
selection?.removeAllRanges();
selection?.addRange(range);
};
// If the highlight is set to 'md' or 'markdown', and the
// query contains either 'render' or 'render=true'
// then we will render the markdown.
const renderMarkdown =
(data.highlight === 'md' || data.highlight === 'markdown') &&
(($page.url.searchParams.has('render') && !$page.url.searchParams.get('render')) ||
$page.url.searchParams.get('render') === 'true');
if (data.encrypted && !decryptedData) {
let modalOptions: ModalSettings;
const onResponse = async (password?: string) => {
if (!password || password.length === 0) {
await sleep(500);
return modalStore.trigger(modalOptions);
}
// Use the /api/pastes/[id]/decrypt endpoint to decrypt the paste
// and set the decryptedData variable to the result.
const result = await fetch(`/api/pastes/${data.id}/decrypt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ password })
});
if (result.ok) {
const { contents: decrypted } = await result.json();
decryptedData = decrypted;
} else {
modalStore.trigger(modalOptions);
await sleep(500);
const id = toastStore.trigger({
message: 'Failed to decrypt paste.',
background: 'variant-filled-error'
});
}
};
modalOptions = {
type: 'prompt',
title: 'Encrypted Paste',
body: 'Enter the password to decrypt the paste.',
valueAttr: {
type: 'password',
required: 'true',
placeholder: 'Password',
class: 'modal-prompt-input input px-4 py-2'
},
response: onResponse
};
modalStore.trigger(modalOptions);
}
let shareMenuOpen = false;
const toggleShareMenu = () => {
shareMenuOpen = !shareMenuOpen;
};
const copyContents = () => {
navigator.clipboard.writeText(decryptedData ?? data.contents);
toastStore.trigger({
message: 'Copied paste contents to clipboard.',
background: 'variant-filled-success'
});
};
$: contents = renderMarkdown
? markdown(decryptedData ?? data.contents)
: highlight(decryptedData ?? data.contents, data.highlight);
</script>
<svelte:head>
<title>Paste69 - Paste {data.id}</title>
<meta name="description" content="Paste69 - Paste {data.id}" />
<meta property="og:title" content="Paste69 - Paste {data.id}" />
<meta property="og:image" content={data.ogImageUrl} />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1224" />
<meta property="og:image:height" content="605" />
<meta property="og:image:alt" content="Paste69 - Paste {data.id}" />
<meta property="og:url" content={data.pasteUrl} />
<meta property="og:type" content="website" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="Paste69 - Paste {data.id}" />
<meta property="twitter:image" content={data.ogImageUrl} />
</svelte:head>
{#if renderMarkdown}
<!-- prettier-ignore -->
<div class="markdown text-xl max-w-[90ch] pl-12 pt-4 pb-24">{@html contents}</div>
<div class="absolute top-[23px] left-[5px]">
<ChevronRight />
</div>
{:else}
<pre
class="pl-2 pt-4 pb-24 min-h-full max-w-full break-words whitespace-pre-line overflow-x-auto"
bind:this={codeRef}
on:dblclick={() => selectAll()}><code>{@html contents}</code></pre>
{/if}
<div
class="fixed right-0 top-1/2 -translate-y-1/2 transition-all {shareMenuOpen ||
'translate-x-[80px]'}"
>
<button on:click={toggleShareMenu} class="px-0.5 py-2 bg-slate-800 absolute top-1/2 -translate-y-1/2 -translate-x-full grid grid-flow-col auto-cols-min items-center justify-center">
{#if shareMenuOpen}
<ChevronRight size="18"/>
{:else}
<ChevronLeft size="18"/>
{/if}
<div class="text-gray-400 tracking-widest" style="writing-mode: vertical-rl;">SHARE</div>
</button>
<ShareMenu pasteUrl={data.pasteUrl} on:copy={copyContents} />
</div>
<div class="fixed bottom-0 right-0 w-full md:w-auto">
<ToolBox
disableSave={true}
disableMoreOptions={true}
disableCopy={data.encrypted}
on:new={() => goto('/')}
on:copy={() => goto(`/?copy-from=${data.id}`)}
/>
</div>

View File

@ -1,19 +0,0 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { Mongo } from '$lib/db/index';
import { env } from '$env/dynamic/private';
export const load: PageLoad = async ({ params }) => {
const pastes = await Mongo.getNamedCollection('pastes');
const paste = await pastes.findOne({ id: params.slug });
if (!paste) {
throw error(404, 'Paste not found');
}
const pasteUrl = `${env.SITE_URL}/${paste.id}.${paste.highlight}`;
return {
pasteUrl,
};
};

View File

@ -1,31 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import { goto } from "$app/navigation";
import ToolBox from "$lib/components/ToolBox.svelte";
import { ChevronRight } from "svelte-tabler";
export let data: PageData;
</script>
<svelte:head>
<title>Paste69 - Burnable paste created</title>
</svelte:head>
<div class="absolute top-[18px] left-[5px]">
<ChevronRight />
</div>
<div class="fixed bottom-0 right-0">
<ToolBox
disableSave={true}
disableCopy={true}
disableMoreOptions={true}
on:new={() => goto('/')}
/>
</div>
<div class="flex flex-col w-full h-full justify-center items-center">
<div class="text-center text-xl">
<h1 class="h1 mb-6 text-center">Your burnable paste has been created.</h1>
<p class="mb-2">Share the following link with someone, and once they view it, it will be deleted forever.</p>
<a class="text-2xl underline" href={data.pasteUrl}>{data.pasteUrl}</a>
</div>
</div>

View File

@ -1,228 +0,0 @@
<script lang="ts">
import { ChevronDown, ChevronRight, ChevronUp, Copy, DeviceFloppy, InfoCircleFilled, Moon, Sun, TextPlus } from 'svelte-tabler';
import { storeHighlightJs } from '@skeletonlabs/skeleton';
import { CodeBlock } from '@skeletonlabs/skeleton';
import ToolBox from '$lib/components/ToolBox.svelte';
import hljs from '$utils/hljs';
import { extensionMap } from '$utils/languages';
import { goto } from '$app/navigation';
storeHighlightJs.set(hljs);
let showLanguages = false;
const toggleLanguages = () => {
showLanguages = !showLanguages;
};
// Split languages into four columns.
const languages = Object.entries(extensionMap).reduce((acc, [language, extensions], i) => {
const column = Math.floor(i / Math.ceil(Object.entries(extensionMap).length / 5));
acc[column] = acc[column] || [] as { language: string, extensions: string[] }[];
acc[column].push({ language, extensions: extensions.map(ext => `.${ext}`) });
return acc;
}, [] as { language: string, extensions: string[] }[][]);
</script>
<svelte:head>
<title>Paste69 - About</title>
</svelte:head>
<div class="absolute top-[23px] left-[5px]">
<ChevronRight />
</div>
<div class="pl-12 pt-4 pb-24 max-w-[100ch]">
<h1 class="h1 mb-2">Paste69</h1>
<p class="mb-6 mt-6">
Paste69 is a pastebin service built with
<a class="underline hover:text-gray-300" href="https://kit.svelte.dev">SvelteKit</a>. It's a simple, fast, and easy to use pastebin
service based on HasteBin. Like HasteBin, it's also open source and can be found over on
<a class="underline hover:text-gray-300" href="https://github.com/watzon/paste69">GitHub</a>.
</p>
<p class="mb-6 mt-6">
Code highlighting is handled with the help of <a class="underline hover:text-gray-300" href="https://highlightjs.org/">highlight.js</a>.
So if you have any issues with language detection or missing languages, take it up with them. Available languages (with their
extensions) are as follows:
<button
class="inline-block ml-2 px-2"
on:click={toggleLanguages}
>
{showLanguages ? 'Hide' : 'Show'} Languages
{#if showLanguages}
<ChevronUp class="inline-block w-4 h-4" />
{:else}
<ChevronDown class="inline-block w-4 h-4" />
{/if}
</button>
</p>
{#if showLanguages}
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-4">
{#each languages as column}
<div>
{#each column as { language, extensions }}
<div class="mb-2">
<span class="font-bold">{language}</span>
<span class="text-gray-400"> ({extensions.join(', ')})</span>
</div>
{/each}
</div>
{/each}
</div>
{/if}
<h2 id="usage" class="h2 mt-12 mb-2"><a class="underline hover:text-gray-300" href="#usage">Usage</a></h2>
<p class="mb-6 mt-6">
To create a paste, go <a class="underline hover:text-gray-300" href="/">home</a> or click the "New" button (<TextPlus
class="inline-block w-4 h-4"
/>) in the tool box in the bottom right corner of the page. Paste whatever text you want into
the editor, and click the "Save" button (<DeviceFloppy class="inline-block w-4 h-4" />) to
create the paste.
</p>
<p class="mb-6 mt-6">
To copy an existing paste, click the "Copy" button (<Copy class="inline-block w-4 h-4" />)
in the tool box in the bottom right corner of the page. This will start a new paste with the
contents of the existing paste.
</p>
<h2 id="cli-script" class="h2 mt-12 mb-2"><a class="underline hover:text-gray-300" href="#cli-script">CLI Script</a></h2>
<p class="mb-6 mt-6">
To make it easier to create pastes, a CLI script is available. The script can be found <a
class="underline hover:text-gray-300" href="/paste69.sh">here</a
>. To use the script:
</p>
<pre class="bg-gray-900 py-2 px-4 mb-2 block rounded-lg"><code class="language-bash">curl -O https://0x45.st/paste69.sh && chmod +x paste69.sh
./paste69.sh --help
# Paste69 CLI script
#
# Usage:
# paste69 <file> [options]
# cat <file> | paste69 [options]
#
# Options:
# -h, --help Show this help text.
# -l, --language <language> Set the language of the paste.
# -p, --password <password> Set a password for the paste. This enables encryption.
# -x, --burn Burn the paste after it is viewed once.
# -r, --raw Return the raw JSON response.
# -c, --copy Copy the paste URL to the clipboard.</code></pre>
<p class="mb-6 mt-6">To create a paste with the script, simply pipe the contents of a file to the script:</p>
<code class="bg-gray-900 py-2 px-4 mb-2 block rounded-lg">cat file.md | ./paste69.sh
# https://0x45.st/some-random-id.md</code>
<h2 id="api" class="h2 mt-12 mb-2"><a class="underline hover:text-gray-300" href="#api">API</a></h2>
<p class="mb-6 mt-6">
Paste69 has a simple API for creating and fetching pastes. The API accepts JSON, form data, and plain text with
query parameters. The API will respond with JSON or plain text, depenant on the state of the `raw`
parameter.
</p>
<aside class="alert variant-filled-tertiary my-6">
<!-- Icon -->
<div><InfoCircleFilled size="42" /></div>
<!-- Message -->
<div class="alert-message">
<h3 class="h3">Note</h3>
<p>
The below examples are offered as curl commands to make things simple, but you can use whatever tool you want to
make requests to the API.
</p>
</div>
</aside>
<h3 id="api-creating-a-paste" class="h3 mt-8 mb-2">
<a class="underline hover:text-gray-300" href="#api-creating-a-paste">Creating a Paste</a>
</h3>
<p class="mb-6 mt-6">
To create a paste, send a POST request to <code>/api/pastes</code>. Valid parameters are as follows:
</p>
<ul class="list-disc list-inside mb-4">
<li>
<code>contents</code> - The contents of the paste.
</li>
<li>
<code>language</code> - The language of the paste. This is used for syntax highlighting. If no language is specified, the
API will attempt to detect the language.
</li>
<li>
<code>password</code> - A password to encrypt the paste with. This will enable encryption.
</li>
<li>
<code>burnAfterReading</code> - A boolean value to enable burn after reading. If this is set to true, the paste will be deleted
after it is viewed once.
</li>
</ul>
<h4 class="h4 mt-8 mb-2">Examples</h4>
<pre class="bg-gray-900 py-4 px-4 mb-2 block rounded-lg whitespace-normal">
<div class="text-sm font-medium text-gray-500 mb-2">JSON Request</div>
<code class="whitespace-normal">
$ curl -X POST -H "Content-Type: application/json" -d '{'{'}"contents": "paste contents"{'}'}' https://0x45.st/api/pastes
</code>
</pre>
<pre class="bg-gray-900 py-4 px-4 mb-2 block rounded-lg whitespace-normal">
<div class="text-sm font-medium text-gray-500 mb-2">Form Request</div>
<code class="whitespace-normal">
$ curl -X POST -F "contents=paste contents" https://0x45.st/api/pastes<br />
# or, with a file<br />
$ curl -X POST -F "contents=@file-name.txt" https://0x45.st/api/pastes
</code>
</pre>
<pre class="bg-gray-900 py-4 px-4 mb-2 block rounded-lg whitespace-normal">
<div class="text-sm font-medium text-gray-500 mb-2">Plaintext Request</div>
<code class="whitespace-normal">
$ curl -X POST -H "Content-Type: text/plain" -d "paste contents" https://0x45.st/api/pastes
</code>
</pre>
<p class="mb-6 mt-6">If the paste was successfully created, the API will respond with the following JSON:</p>
<pre class="bg-gray-900 py-2 px-4 mb-2 block rounded-lg"><code class="language-json">{'{'}
"id": "paste id",
"url": "https://0x45.st/some-random-id.md",
"contents": "paste contents",
"highlight": "txt",
"encrypted": false,
"burnAfterReading": false,
"created_at": "2021-08-05T07:30:00.000Z",
{'}'}</code></pre>
<h3 id="api-fetching-a-paste" class="h3 mt-4 mb-2">
<a class="underline hover:text-gray-300" href="#api-fetching-a-paste">Fetching a Paste</a>
</h3>
<p class="mb-6 mt-6">
To fetch a paste, send a GET request to{' '}
<code>/api/pastes/:id</code>. If the paste exists, the API will respond with the following JSON:
</p>
<pre class="bg-gray-900 py-2 px-4 mb-2 block rounded-lg"><code class="language-json">{'{'}
"id": "paste id",
"url": "https://0x45.st/some-random-id.md",
"contents": "paste contents",
"highlight": "txt",
"encrypted": false,
"burnAfterReading": false,
"created_at": "2021-08-05T07:30:00.000Z",
{'}'}</code></pre>
</div>
<div class="fixed bottom-0 right-0 w-full md:w-auto">
<ToolBox disableSave={true} disableCopy={true} on:new={() => goto('/')} />
</div>

View File

@ -1,166 +0,0 @@
import { detectLanguage } from "$utils/hljs";
import { encrypt as doEncrypt } from "$utils/crypto";
import { error, json, text, type RequestHandler } from "@sveltejs/kit";
import { Mongo } from "$lib/db/index";
import { env } from "$env/dynamic/private";
import { extensionMap } from "$utils/languages";
import { generate } from 'random-words';
interface PasteOptions {
contents?: string;
language?: string;
encrypt?: boolean;
password?: string;
burnAfterReading?: boolean;
raw?: boolean;
}
const isTrue = (value: string | boolean | undefined): boolean => {
if (typeof value === 'boolean') {
return value;
} else if (typeof value === 'string') {
return ['true', '1', 'yes', 'y', 'on'].includes(value.toLowerCase());
} else {
return false;
}
}
// Extract the options from the form data.
const extractOptionsFromForm = async (req: Request): Promise<PasteOptions> => {
const form = await req.formData();
const contents = form.get('contents');
const language = form.get('language') as string;
const password = form.get('password') as string;
const burnAfterReading = form.get('burnAfterReading') as string;
const raw = form.get('raw') as string;
let text: string;
// Check if contents is a file, if so read it.
if (contents instanceof File) {
text = await contents.text();
} else {
text = contents as string;
}
if (!text || text.length === 0) {
throw error(400, 'No contents provided for your paste.')
}
return {
contents: text,
language,
password,
burnAfterReading: isTrue(burnAfterReading),
raw: isTrue(raw),
};
};
// Extract the options from the JSON body.
const extractOptionsFromJSON = async (req: Request): Promise<PasteOptions> => {
const { contents, language, password, burnAfterReading, raw } = await req.json();
if (!contents || contents.length === 0) {
throw error(400, 'No contents provided for your paste.')
}
return {
contents,
language,
password,
burnAfterReading: isTrue(burnAfterReading),
raw: isTrue(raw),
};
};
// Extract the options from the query string, and body.
const extractOptionsFromQuery = async (req: Request): Promise<PasteOptions> => {
const url = new URL(req.url);
const query = url.searchParams;
const contents = await req.text();
if (!contents) {
throw error(400, 'No contents provided for your paste.')
}
const language = query.get('language') as string;
const password = query.get('password') as string;
const burnAfterReading = query.get('burnAfterReading') as string;
const raw = query.get('raw') as string;
return {
contents,
language,
password,
burnAfterReading: isTrue(burnAfterReading),
raw: isTrue(raw),
};
};
// Extract the options from the request, depending on the content type.
const extractOptions = async (req: Request): Promise<PasteOptions> => {
const contentType = req.headers.get('content-type') || '';
if (contentType.includes('form')) {
return await extractOptionsFromForm(req);
} else if (contentType.includes('json')) {
return await extractOptionsFromJSON(req);
} else {
return await extractOptionsFromQuery(req);
}
};
export const POST: RequestHandler = async ({ request }) => {
const { contents, language, password, burnAfterReading, raw } = await extractOptions(request);
const id = generate({ exactly: 3, join: '-' });
if (!contents || contents.length === 0) {
throw error(400, 'No contents provided');
}
if (language && language.length > 0) {
// Check the language names, as well as the extensions for each language.
// Try to make it as efficient as possible.
const lang = language.toLowerCase();
const languages = Object.keys(extensionMap);
const extensions = Object.values(extensionMap).flat();
if (!languages.includes(lang) && !extensions.includes(lang)) {
throw error(400, 'Invalid language');
}
}
let pasteContents: string = contents;
const highlight = language || detectLanguage(contents) || 'txt';
if (password) {
pasteContents = await doEncrypt(contents, password);
}
const data = {
id,
highlight,
encrypted: !!password,
contents: pasteContents,
burnAfterReading: !!burnAfterReading,
createdAt: new Date(),
};
const pastes = await Mongo.getNamedCollection("pastes");
const res = await pastes.insertOne(data);
const url = `${env.SITE_URL}/${id}.${highlight}`;
if (!res.acknowledged) {
throw error(500, 'Failed to create paste');
}
if (raw) {
return json({
...data,
url,
}, {
status: 201,
});
} else {
return text(url + '\n');
}
};

View File

@ -1,32 +0,0 @@
import { Mongo } from "$lib/db/index";
import { error, json, type RequestHandler } from "@sveltejs/kit";
// Fetch the paste with the given ID, returning it as a JSON object.
export const GET: RequestHandler = async ({ params }) => {
const { id } = params;
const pastes = await Mongo.getNamedCollection("pastes");
const paste = await pastes.findOne({ id });
if (!paste) {
throw error(404, 'Paste not found');
}
if (paste.encrypted) {
throw error(400, 'Paste is encrypted');
}
if (paste.burnAfterReading) {
await pastes.deleteOne({ id });
}
const data = {
id: paste.id,
highlight: paste.highlight,
contents: paste.contents,
burnAfterReading: paste.burnAfterReading,
createdAt: paste.createdAt,
}
return json(data);
};

View File

@ -1,35 +0,0 @@
import { error, json, type RequestHandler } from "@sveltejs/kit";
import { Mongo } from "$lib/db/index";
import { decrypt } from "$utils/crypto";
// Decrypt the paste with the given ID using the provided password.
export const POST: RequestHandler = async ({ params, request }) => {
const { id } = params;
const { password } = await request.json();
const pastes = await Mongo.getNamedCollection("pastes");
const paste = await pastes.findOne({ id });
if (!paste) {
throw error(404, 'Paste not found');
}
if (!paste.encrypted) {
throw error(400, 'Paste is not encrypted');
}
if (!password) {
throw error(400, 'No password provided for decryption.');
}
try {
const contents = await decrypt(paste.contents, password);
return json({
...paste,
contents,
});
} catch {
throw error(400, 'Invalid password');
}
};

View File

@ -1,49 +0,0 @@
import { Mongo } from "$lib/db/index";
import { env } from "$env/dynamic/private";
import { error, type RequestHandler } from "@sveltejs/kit";
const maxLines = 15;
export const GET: RequestHandler = async ({ params }) => {
const [id, ext] = params.slug.split('.');
const pastes = await Mongo.getNamedCollection("pastes");
const paste = await pastes.findOne({ id });
if (!paste) {
throw error(404, 'Paste not found');
}
if (paste.encrypted) {
throw error(404, 'Cannot generate image for encrypted paste');
}
if (paste.burnAfterReading) {
throw error(404, 'Cannot generate image for burnable paste');
}
const code = paste.contents;
const highlight = ext || paste.highlight;
let lines = code.split('\n').slice(0, maxLines).concat(Array.from({ length: maxLines - code.split('\n').length }, () => ''));
// Truncate each line to 80 characters, and pad the rest with spaces.
lines = lines.map(line => line.slice(0, 70).padEnd(70, ' '));
const title = `${env.SITE_URL}/${id}.${highlight}`;
const endpoint = `https://inkify.0x45.st/generate?code=${encodeURIComponent(lines.join('\n'))}&window_title=${encodeURIComponent(title)}&language=${encodeURIComponent(highlight)}&pad_horiz=5&pad_vert=5`;
const res = await fetch(endpoint);
const image = res.body;
if (image) {
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800, immutable',
},
});
} else {
throw error(500, 'Could not generate image');
}
};

View File

@ -1,47 +0,0 @@
import { Mongo } from "$lib/db/index";
import type { PageServerLoad } from "./$types";
import { env } from '$env/dynamic/private';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async () => {
// Check if the stats page is enabled
if (!env.STATS_ENABLED) {
throw error(404, 'Paste not found');
}
const pastes = await Mongo.getNamedCollection('pastes');
// Total pastes
const totalPastes = await pastes.countDocuments();
// Total encrypted pastes
const totalEncryptedPastes = await pastes.countDocuments({ encrypted: true });
// Total pastes with burnAfterReading enabled (and not yet read)
const totalBurnAfterReadingPastes = await pastes.countDocuments({ burnAfterReading: true });
// Average paste size
const averageContentSize = await pastes.aggregate([
{
$match: {
contents: { $exists: true, $ne: null },
},
},
{
$group: {
_id: null,
averageSize: { $avg: { $strLenBytes: "$contents" } },
},
},
]).toArray();
// Round the average size to 2 decimal places
const averageContentSizeRounded = Math.round(averageContentSize[0]?.averageSize * 100) / 100;
return {
totalPastes,
totalEncryptedPastes,
totalBurnAfterReadingPastes,
averagePasteSize: averageContentSizeRounded,
};
}

View File

@ -1,45 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import ToolBox from "$lib/components/ToolBox.svelte";
import { ChevronRight } from "svelte-tabler";
export let data: PageData;
const { totalPastes, totalEncryptedPastes, totalBurnAfterReadingPastes, averagePasteSize } = data;
</script>
<svelte:head>
<title>Paste69 - Server stats</title>
</svelte:head>
<div class="absolute top-[23px] left-[5px]">
<ChevronRight />
</div>
<div class="pl-12 pt-4 pb-24 max-w-[100ch]">
<h1 class="h1 mb-8">Server Stats</h1>
<p class="mb-4">
<span class="font-bold">Total pastes:</span> {totalPastes}
</p>
<p class="mb-4">
<span class="font-bold">Total encrypted pastes:</span> {totalEncryptedPastes}
</p>
<p class="mb-4">
<span class="font-bold">Total burn after reading pastes (unread):</span> {totalBurnAfterReadingPastes}
</p>
<p class="mb-4">
<span class="font-bold">Average paste size:</span> {averagePasteSize} bytes
</p>
</div>
<div class="fixed bottom-0 right-0 w-full md:w-auto">
<ToolBox
disableSave={true}
disableCopy={true}
on:new={() => goto('/')}
/>
</div>

6
src/server.cr Normal file
View File

@ -0,0 +1,6 @@
require "./main"
ATH.run(
port: ADI.container.config_manager.get("port").as_i,
host: ADI.container.config_manager.get("host").as_s,
)

View File

@ -0,0 +1,71 @@
module Paste69
@[ADI::Register(name: "config_manager", public: true)]
class ConfigManager
getter config : Totem::Config
DEFAULTS = {
"host" => nil,
"port" => 8080,
"database_url" => "sqlite://./db/data.db",
"templates_dir" => "src/templates",
"max_content_length" => 256 * 1024 * 1024,
"max_url_length" => 4096,
"use_x_sendfile" => false,
"max_ext_length" => 9,
"storage.path" => "./uploads",
"storage.type" => "local",
"storage.max_expiration" => 365_i64 * 24 * 60 * 60 * 1000,
"storage.min_expiration" => 30_i64 * 24 * 60 * 60 * 1000,
"storage.s3.region" => nil,
"storage.s3.bucket" => nil,
"storage.s3.access_key" => nil,
"storage.s3.secret_key" => nil,
"storage.secret_bytes" => 16,
"storage.ext_override" => {
"audio/flac" => ".flac",
"image/gif" => ".gif",
"image/jpeg" => ".jpg",
"image/png" => ".png",
"image/svg+xml" => ".svg",
"video/webm" => ".webm",
"video/x-matroska" => ".mkv",
"application/octet-stream" => ".bin",
"text/plain" => ".log",
"text/plain" => ".txt",
"text/x-diff" => ".diff",
},
"storage.mime_blacklist" => [
"application/x-dosexec",
"application/java-archive",
"application/java-vm"
],
"storage.upload_blacklist" => [] of String,
"nsfw.detect" => false,
"nsfw.threshold" => 0.608,
"vscan.socket" => nil,
"vscan.quarantine_path" => "./quarantine",
"vscan.interval" => 7.days.to_i,
"vscan.ignore" => [
"Eicar-Test-Signature",
"PUA.Win.Packer.XmMusicFile",
],
"url_alphabet" => "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
}
delegate :get, :set, to: @config
def initialize
config = @config = Totem.new("config", "/etc/paste69")
config.config_paths << "~/.totem"
config.config_paths << "~/.config/totem"
config.config_paths << "./config"
begin
config.load!
config.set_defaults(DEFAULTS)
rescue ex
puts "Fatal error loading config file: #{ex.message}"
exit(1)
end
end
end
end

View File

@ -0,0 +1,14 @@
module Paste69
@[ADI::Register(public: true, name: "crinja_service")]
class CrinjaService
getter renderer : Crinja
delegate :get_template, to: @renderer
def initialize(@config : Paste69::ConfigManager)
templates_dir = @config.get("templates_dir").as_s
renderer = @renderer = Crinja.new
renderer.loader = Crinja::Loader::FileSystemLoader.new(templates_dir)
end
end
end

View File

@ -0,0 +1,17 @@
module Paste69
@[ADI::Register(public: true, name: "db_service")]
class DBService
include Crecto::Repo
@@config = Crecto::Repo::Config.new
def initialize(@cfg : Paste69::ConfigManager)
config do |conf|
conf.adapter = Crecto::Adapters::Postgres
conf.uri = @cfg.get("database_url").as_s
end
Crecto::DbLogger.set_handler(STDOUT)
end
end
end

35
src/services/s3_client.cr Normal file
View File

@ -0,0 +1,35 @@
module Paste69
@[ADI::Register(public: true, name: "s3_client")]
class S3Client
getter client : Awscr::S3::Client?
def initialize(@config : Paste69::ConfigManager)
if config.get("storage.type") == "s3"
@client = Awscr::S3::Client.new(
@config.get("storage.s3.region").as_s,
@config.get("storage.s3.access_key").as_s,
@config.get("storage.s3.secret_key").as_s
)
end
end
def put_object(*args, **kwargs)
raise_if_not_configured
@client.not_nil!.put_object(*args, **kwargs)
end
def get_object(*args, **kwargs)
raise_if_not_configured
@client.not_nil!.get_object(*args, **kwargs)
end
def delete_object(*args, **kwargs)
raise_if_not_configured
@client.not_nil!.delete_object(*args, **kwargs)
end
private def raise_if_not_configured
raise "S3 storage is not configured" unless @client
end
end
end

View File

@ -0,0 +1,43 @@
module Paste69
module ServiceIncluder
protected def self.config
ADI.container.config_manager
end
protected def config
ADI.container.config_manager
end
protected def self.utils
ADI.container.utils_service
end
protected def utils
ADI.container.utils_service
end
protected def self.s3_client
ADI.container.s3_client
end
protected def s3_client
ADI.container.s3_client
end
protected def self.url_encoder
ADI.container.url_encoder
end
protected def url_encoder
ADI.container.url_encoder
end
protected def db_service
ADI.container.db_service
end
protected def self.db_service
ADI.container.db_service
end
end
end

View File

@ -0,0 +1,12 @@
module Paste69
@[ADI::Register(public: true, name: "type_checker")]
class TypeChecker
getter type_checker : Magic::TypeChecker
delegate :of?, to: @type_checker
def initialize
@type_checker = Magic.mime_type
end
end
end

View File

@ -0,0 +1,35 @@
module Paste69
@[ADI::Register(public: true, name: "url_encoder")]
class UrlEncoder
getter alphabet : String
getter min_length : Int32
def initialize(@config : Paste69::ConfigManager)
@alphabet = @config.get("url_alphabet").as_s
@min_length = 1
end
def enbase(x : Int) : String
n = self.alphabet.size
str = String.build do |str|
while x > 0
str << self.alphabet[x % n]
x = x // n
end
end
padding = self.alphabet[0].to_s * (self.min_length - str.size)
str + padding
end
def debase(str : String) : Int64
n = self.alphabet.size.to_i64
res = 0_i64
str.reverse.chars.each_with_index do |c, i|
idx = self.alphabet.index!(c).to_i64
res += idx * (n ** i)
end
res
end
end
end

View File

@ -0,0 +1,206 @@
module Paste69
@[ADI::Register(public: true, name: "utils_service")]
class UtilsService
def initialize(@config : Paste69::ConfigManager, @type_checker : Paste69::TypeChecker); end
def url_for(name, *, secret : String? = nil, anchor : String? = nil)
host = @config.get("host").as_s
port = @config.get("port").as_i
url = host
if port != 80 && port!= 443
url += ":#{port}"
end
url = secret ? File.join(url, secret, name) : File.join(url, name)
url += "##{anchor}" if anchor
scheme = port == 443 ? "https" : "http"
"#{scheme}://#{url}"
end
def is_fhost_url?(url : String)
uri = URI.parse(url)
return uri.host == @config.get("host").as_s
end
URL_REGEX = /^(?:https?:\/\/)([\w\.-]+)(?:\/.*)?$/
def url_valid?(url : String)
match = url =~ URL_REGEX
match != nil
end
# For a file of a given size, determine the largest allowed lifespan of that file
#
# Based on the current app's configuration: Specifically, the MAX_CONTENT_LENGTH, as well
# as STORAGE.{MIN,MAX}_EXPIRATION.
#
# This lifespan may be shortened by a user's request, but no files should be allowed to
# expire at a point after this number.
#
# Value returned is a duration in milliseconds.
def max_lifespan(size : Int32)
min_exp = @config.get("storage.min_expiration").as_i64
max_exp = @config.get("storage.max_expiration").as_i64
max_size = @config.get("max_content_length").as_i64
# min_exp + int((-max_exp + min_exp) * (filesize / max_size - 1) ** 3)
min_exp + ((max_exp - min_exp) * (size.to_f / max_size - 1) ** 3).to_i64
end
def shorten(url : String)
url = url.strip
if url.size > @config.get("max_url_length").as_i
raise Exceptions::URITooLong.new(url)
end
if !url_valid?(url) || is_fhost_url?(url)
raise ATH::Exceptions::BadRequest.new("Invalid URL")
end
u = URL.get(url)
ATH::Response.new(u.get_url)
end
def in_upload_blacklist(addr : String)
# TODO: Implement this
false
end
def store_file(data, content_type : String? = nil, filename : String? = nil, requested_expiration : Int64? = nil, addr : String? = nil, ua : String? = nil, secret : Bool = false)
if addr && in_upload_blacklist(addr)
raise Exceptions::UnavailableForLegalReasons.new("Your host is blocked from uploading files")
end
sf, is_new = Paste.store(data, content_type, filename, requested_expiration, addr, ua, secret)
res = ATH::Response.new(sf.url, headers: HTTP::Headers{ "X-Expires" => sf.expiration!.to_s })
if is_new
res.headers["X-Token"] = sf.mgmt_token!
end
res
end
def store_url(url : String, addr : String? = nil, ua : String? = nil, secret : Bool = false)
if is_fhost_url?(url)
raise ATH::Exceptions::BadRequest.new("Invalid URL")
end
headers = HTTP::Headers{ "Accept-Encoding" => "identity" }
res = HTTP::Client.get(url, headers: headers)
if res.status.to_i >= 300
raise ATH::Exceptions::BadRequest.new("URL response was not OK")
end
if res.headers.has_key?("Content-Length")
l = res.headers["Content-Length"].to_i64
if l <= @config.get("max_content_length").as_i64
return store_file(res.body.to_slice, res.headers["Content-Type"], nil, nil, addr, ua, secret)
else
raise Exceptions::ContentTooLarge.new("Content-Length was too large")
end
else
raise Exceptions::LengthRequired.new("Content-Length was not provided")
end
end
def parse_formdata(request : HTTP::Request) : Hash(String, Tuple(String?, Bytes))
form = {} of String => Tuple(String?, Bytes)
HTTP::FormData.parse(request) do |fd|
form[fd.name] = {fd.filename, fd.body.getb_to_end}
end
form
end
def get_ext(mime : String, filename : String? = nil)
if filename
ext = File.extname(filename)
max_len = @config.get("max_ext_length").as_i
if ext.size > max_len
ext = ext[0..max_len]
end
end
gmime = mime.split(";")[0]
guess = MIME.extensions(gmime).first?
if !ext
override = @config.get("storage.ext_override").as_h
if gmime.in?(override)
ext = override[gmime].as_s
elsif guess
ext = guess
else
ext = ".bin"
end
end
ext
end
def get_mime(buffer : Bytes, filename : String? = nil)
if filename
guess = MIME.from_filename?(filename)
end
# Detect the mimetype of the body
if !guess
# We need to do a little extra work to detect the mimetype
body_io = IO::Memory.new(buffer)
guess = @type_checker.of?(body_io)
end
mime = guess || "text/plain"
# Check the mimetype against the blacklist
if @config.get("storage.mime_blacklist").as_a.includes?(mime)
raise ATH::Exceptions::UnsupportedMediaType.new("Blacklisted filetype")
end
if mime.size > 128
raise ATH::Exceptions::BadRequest.new("Mimetype too long")
end
if mime.starts_with?("text/") && !mime.includes?("charset")
mime += "; charset=utf-8"
end
mime
end
# Returns the epoch millisecond that a file should expire
#
# Uses the expiration time provided by the user (requested_expiration)
# upper-bounded by an algorithm that computes the size based on the size of the
# file.
#
# That is, all files are assigned a computed expiration, which can voluntarily
# shortened by the user either by providing a timestamp in epoch millis or a
# duration in hours.
def expiration_millis(size : Int32, requested_expiration : Int64? = nil)
current_epoch_millis = Time.utc.to_unix_ms
# Maximum lifetime of the file in milliseconds
files_max_lifespan = max_lifespan(size)
# The latest allowed expiration date for this file, in epoch millis
files_max_expiration = files_max_lifespan + current_epoch_millis
if requested_expiration.nil?
files_max_expiration
elsif requested_expiration < 1_650_460_320_000
# Treat the requested expiration time as a duration in hours
requested_expiration_ms = requested_expiration * 60 * 60 * 1000
[requested_expiration_ms, files_max_expiration].min
else
# Treat the requested expiration time as a timestamp in epoch millis
[requested_expiration, files_max_expiration].min
end
end
end
end

View File

@ -1,9 +0,0 @@
import { writable } from 'svelte/store';
import type PasteSchema from '$lib/db/paste-schema';
export const paste = writable<PasteSchema>({
id: '',
contents: '',
highlight: '',
createdAt: new Date(),
});

View File

@ -0,0 +1 @@
rm: cannot remove '{{ path.split("/")[1] }}': Permission denied

View File

@ -0,0 +1,5 @@
<pre>
404 - NOT FOUND
===============
No paste found at path {{ path }}
</pre>

View File

@ -0,0 +1 @@
Could not determine remote file size (no Content-Length in response header; shoot admin).

View File

@ -0,0 +1 @@
Remote file too large ({{ headers["Content-Length"]|filesizeformat(true) }} > {{ config["max_content_length"]|filesizeformat(true) }}).

View File

@ -0,0 +1 @@
451 Unavailable For Legal Reasons

View File

@ -0,0 +1,5 @@
<pre>
500 - INTERNAL SERVER ERROR
===============
Something went wrong. If this is happening a lot, report the issue via email to chris 0x45.st
</pre>

View File

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Paste69</title>
</head>
<body>
<pre>
PASTE69
================
Paste69 is a clone of the amazing 0x0.st, but written in Crystal.
Be sure to check out the <a href="https://github.com/watzon/paste69">source code on Github</a>,
and the <a href="https://git.0x0.st/mia/0x0">original source</a> from mia.
HTTP POST files here:
curl -F'file=@yourfile.png' {{ fhost_url }}
You can also POST remote URLs:
curl -F'url=http://example.com/image.jpg' {{ fhost_url }}
If you don't want the resulting URL to be easy to guess:
curl -F'file=@yourfile.png' -Fsecret= {{ fhost_url }}
curl -F'url=http://example.com/image.jpg' -Fsecret= {{ fhost_url }}
Or you can shorten URLs:
curl -F'shorten=http://example.com/some/long/url' {{ fhost_url }}
It is possible to append your own file name to the URL:
{{ fhost_url }}/aaa.jpg/image.jpeg
File URLs are valid for at least 30 days and up to a year (see below).
Shortened URLs do not expire.
Files can be set to expire sooner by adding an "expires" parameter (in hours)
curl -F'file=@yourfile.png' -Fexpires=24 {{ fhost_url }}
OR by setting "expires" to a timestamp in epoch milliseconds
curl -F'file=@yourfile.png' -Fexpires=1681996320000 {{ fhost_url }}
Expired files won't be removed immediately, but will be removed as part of
the next purge.
Whenever a file that does not already exist or has expired is uploaded,
the HTTP response header includes an X-Token field. You can use this
to perform management operations on the file.
To delete the file immediately:
curl -Ftoken=token_here -Fdelete= {{ fhost_url }}/abc.txt
To change the expiration date (see above):
curl -Ftoken=token_here -Fexpires=3 {{ fhost_url }}/abc.txt
{% set max_size = config["max_content_length"]|filesizeformat(true) %}
Maximum file size: {{ max_size }}
Not allowed: {{ config["storage"]["mime_blacklist"]|join(", ") }}
FILE RETENTION PERIOD
---------------------
retention = min_age + (-max_age + min_age) * pow((file_size / max_size - 1), 3)
days
365 | \
| \
| \
| \
| \
| \
| ..
| \
197.5 | ----------..-------------------------------------------
| ..
| \
| ..
| ...
| ..
| ...
| ....
| ......
30 | ....................
0 256.0 512.0
MiB
ABUSE
-----
If you would like to request deletion, please contact watzon via
Telegram @ watzon, or send an email to chris 0x45.st (do not copy and paste).
Please allow up to 24 hours for a response.
</pre>
</body>

View File

@ -1,89 +0,0 @@
import { subtle } from 'crypto';
// for large strings, use this from https://stackoverflow.com/a/49124600
const buff_to_base64 = (buff: Iterable<number>) =>
btoa(new Uint8Array(buff).reduce((data, byte) => data + String.fromCharCode(byte), ''));
const base64_to_buf = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const enc = new TextEncoder();
const dec = new TextDecoder();
export async function encrypt(data: string, password: string) {
const encryptedData = await encryptData(data, password);
return encryptedData;
}
export async function decrypt(data: string, password: string) {
const decryptedData = await decryptData(data, password);
if (!decryptedData) {
throw new Error('Invalid password');
}
return decryptedData;
}
const getPasswordKey = (password: string) =>
subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
const deriveKey = (passwordKey: CryptoKey, salt: Uint8Array, keyUsage: KeyUsage[]) =>
subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 250000,
hash: 'SHA-256'
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
keyUsage
);
async function encryptData(secretData: string, password: string) {
try {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const passwordKey = await getPasswordKey(password);
const aesKey = await deriveKey(passwordKey, salt, ['encrypt']);
const encryptedContent = await subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
aesKey,
enc.encode(secretData)
);
const encryptedContentArr = new Uint8Array(encryptedContent);
const buff = new Uint8Array(salt.byteLength + iv.byteLength + encryptedContentArr.byteLength);
buff.set(salt, 0);
buff.set(iv, salt.byteLength);
buff.set(encryptedContentArr, salt.byteLength + iv.byteLength);
const base64Buff = buff_to_base64(buff);
return base64Buff;
} catch (e) {
throw new Error('Invalid password');
}
}
async function decryptData(encryptedData: string, password: string) {
try {
const encryptedDataBuff = base64_to_buf(encryptedData);
const salt = encryptedDataBuff.slice(0, 16);
const iv = encryptedDataBuff.slice(16, 16 + 12);
const data = encryptedDataBuff.slice(16 + 12);
const passwordKey = await getPasswordKey(password);
const aesKey = await deriveKey(passwordKey, salt, ['decrypt']);
const decryptedContent = await subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
aesKey,
data
);
return dec.decode(decryptedContent);
} catch (e) {
throw new Error('Invalid password');
}
}

View File

@ -1,46 +0,0 @@
import hljs from 'highlight.js';
import { extensionMap, languages } from './languages';
// Theme
import 'highlight.js/styles/github-dark.css';
const autoLanguages = ['js', 'ts', 'css', 'go', 'html', 'json', 'md', 'php', 'py', 'rb', 'rs', 'bash'];
for (const [lang, exts] of Object.entries(extensionMap)) {
if (exts.length > 0) {
// Remove leading underscore, replace other underscores with dashes
const name = lang.replace(/^_/, '').replace(/_/g, '-');
// @ts-expect-error: this is valid
hljs.registerLanguage(name, languages[lang]!);
hljs.registerAliases(exts, { languageName: lang });
}
}
export const detectLanguage = (code: string) => {
return hljs.highlightAuto(code, autoLanguages).language;
};
export const highlight = (code: string, lang: string | undefined = undefined) => {
if (!lang) {
return hljs.highlightAuto(code, autoLanguages).value;
} else {
return hljs.highlight(code, { language: lang }).value;
}
};
hljs.addPlugin({
'after:highlight': (result) => {
const code = result.value.split('\n').map((line, index) => {
const lineNumber = index + 1;
const paddedLineNumber = String(lineNumber).padStart(5, ' ');
return `<div class="flex flex-row items-start justify-start gap-2"><div class="text-gray-400 select-none shrink-0 min-w-[50px]">${paddedLineNumber}</div><pre class="whitespace-pre-wrap">${line}</pre></div>`;
}).join('\n');
result.value = `<div class="flex flex-col">${code}</div>`
},
});
// Re-export the `hljs` object
export default hljs;

View File

@ -1,2 +0,0 @@
// Async sleep
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

View File

@ -1,301 +0,0 @@
import ada from 'highlight.js/lib/languages/ada';
import arduino from 'highlight.js/lib/languages/arduino';
import asciidoc from 'highlight.js/lib/languages/asciidoc';
import awk from 'highlight.js/lib/languages/awk';
import bash from 'highlight.js/lib/languages/bash';
import basic from 'highlight.js/lib/languages/basic';
import bnf from 'highlight.js/lib/languages/bnf';
import brainfuck from 'highlight.js/lib/languages/brainfuck';
import c from 'highlight.js/lib/languages/c';
import clojure from 'highlight.js/lib/languages/clojure';
import cmake from 'highlight.js/lib/languages/cmake';
import coffeescript from 'highlight.js/lib/languages/coffeescript';
import cpp from 'highlight.js/lib/languages/cpp';
import crystal from 'highlight.js/lib/languages/crystal';
import csharp from 'highlight.js/lib/languages/csharp';
import css from 'highlight.js/lib/languages/css';
import d from 'highlight.js/lib/languages/d';
import dart from 'highlight.js/lib/languages/dart';
import delphi from 'highlight.js/lib/languages/delphi';
import diff from 'highlight.js/lib/languages/diff';
import django from 'highlight.js/lib/languages/django';
import dns from 'highlight.js/lib/languages/dns';
import dockerfile from 'highlight.js/lib/languages/dockerfile';
import ebnf from 'highlight.js/lib/languages/ebnf';
import elixir from 'highlight.js/lib/languages/elixir';
import elm from 'highlight.js/lib/languages/elm';
import erb from 'highlight.js/lib/languages/erb';
import erlang from 'highlight.js/lib/languages/erlang';
import fortran from 'highlight.js/lib/languages/fortran';
import fsharp from 'highlight.js/lib/languages/fsharp';
import gcode from 'highlight.js/lib/languages/gcode';
import glsl from 'highlight.js/lib/languages/glsl';
import gml from 'highlight.js/lib/languages/gml';
import go from 'highlight.js/lib/languages/go';
import gradle from 'highlight.js/lib/languages/gradle';
import graphql from 'highlight.js/lib/languages/graphql';
import groovy from 'highlight.js/lib/languages/groovy';
import haml from 'highlight.js/lib/languages/haml';
import handlebars from 'highlight.js/lib/languages/handlebars';
import haskell from 'highlight.js/lib/languages/haskell';
import haxe from 'highlight.js/lib/languages/haxe';
import http from 'highlight.js/lib/languages/http';
import hy from 'highlight.js/lib/languages/hy';
import ini from 'highlight.js/lib/languages/ini';
import java from 'highlight.js/lib/languages/java';
import javascript from 'highlight.js/lib/languages/javascript';
import json from 'highlight.js/lib/languages/json';
import julia from 'highlight.js/lib/languages/julia';
import kotlin from 'highlight.js/lib/languages/kotlin';
import latex from 'highlight.js/lib/languages/latex';
import less from 'highlight.js/lib/languages/less';
import lisp from 'highlight.js/lib/languages/lisp';
import llvm from 'highlight.js/lib/languages/llvm';
import lua from 'highlight.js/lib/languages/lua';
import makefile from 'highlight.js/lib/languages/makefile';
import markdown from 'highlight.js/lib/languages/markdown';
import matlab from 'highlight.js/lib/languages/matlab';
import moonscript from 'highlight.js/lib/languages/moonscript';
import nim from 'highlight.js/lib/languages/nim';
import nix from 'highlight.js/lib/languages/nix';
import objectivec from 'highlight.js/lib/languages/objectivec';
import ocaml from 'highlight.js/lib/languages/ocaml';
import openscad from 'highlight.js/lib/languages/openscad';
import perl from 'highlight.js/lib/languages/perl';
import pgsql from 'highlight.js/lib/languages/pgsql';
import php from 'highlight.js/lib/languages/php';
import plaintext from 'highlight.js/lib/languages/plaintext';
import pony from 'highlight.js/lib/languages/pony';
import powershell from 'highlight.js/lib/languages/powershell';
import protobuf from 'highlight.js/lib/languages/protobuf';
import python from 'highlight.js/lib/languages/python';
import qml from 'highlight.js/lib/languages/qml';
import r from 'highlight.js/lib/languages/r';
import reasonml from 'highlight.js/lib/languages/reasonml';
import ruby from 'highlight.js/lib/languages/ruby';
import rust from 'highlight.js/lib/languages/rust';
import scala from 'highlight.js/lib/languages/scala';
import scheme from 'highlight.js/lib/languages/scheme';
import scss from 'highlight.js/lib/languages/scss';
import shell from 'highlight.js/lib/languages/shell';
import smalltalk from 'highlight.js/lib/languages/smalltalk';
import sql from 'highlight.js/lib/languages/sql';
import stylus from 'highlight.js/lib/languages/stylus';
import swift from 'highlight.js/lib/languages/swift';
import tcl from 'highlight.js/lib/languages/tcl';
import typescript from 'highlight.js/lib/languages/typescript';
import vbnet from 'highlight.js/lib/languages/vbnet';
import vim from 'highlight.js/lib/languages/vim';
import x86asm from 'highlight.js/lib/languages/x86asm';
import xml from 'highlight.js/lib/languages/xml';
import yaml from 'highlight.js/lib/languages/yaml';
// @ts-ignore: No types available
// import zig from 'highlightjs-zig';
export const languages = {
ada,
arduino,
asciidoc,
awk,
bash,
basic,
bnf,
brainfuck,
c,
clojure,
cmake,
coffeescript,
cpp,
crystal,
csharp,
css,
d,
dart,
delphi,
diff,
django,
dns,
dockerfile,
ebnf,
elixir,
elm,
erb,
erlang,
fortran,
fsharp,
gcode,
glsl,
gml,
go,
gradle,
graphql,
groovy,
haml,
handlebars,
haskell,
haxe,
http,
hy,
ini,
java,
javascript,
json,
julia,
kotlin,
latex,
less,
lisp,
llvm,
lua,
makefile,
markdown,
matlab,
moonscript,
nim,
nix,
objectivec,
ocaml,
openscad,
perl,
pgsql,
php,
plaintext,
pony,
powershell,
protobuf,
python,
qml,
r,
reasonml,
ruby,
rust,
scala,
scheme,
scss,
shell,
smalltalk,
sql,
stylus,
swift,
tcl,
typescript,
vbnet,
vim,
x86asm,
xml,
yaml,
// zig,
};
export const extensionMap: Record<string, string[]> = {
ada: ['ada', 'adb', 'ads'],
arduino: ['ino'],
asciidoc: ['adoc'],
awk: ['awk'],
bash: ['sh', 'bash'],
basic: ['bas'],
bnf: ['bnf'],
brainfuck: ['b', 'bf'],
c: ['c'],
clojure: ['clj', 'cljc', 'cljx', 'cljs'],
cmake: ['cmake'],
coffeescript: ['coffee'],
cpp: ['cpp', 'cc', 'h', 'C', 'H', 'hpp'],
crystal: ['cr'],
csharp: ['cs'],
css: ['css'],
d: ['d'],
dart: ['dart'],
delphi: ['dpr', 'dpk', 'pas'],
diff: ['diff', 'patch'],
django: ['djhtml'],
dns: ['zone', 'bind'],
dockerfile: ['Dockerfile'],
ebnf: ['ebnf'],
elixir: ['ex', 'exs'],
elm: ['elm'],
erb: ['erb'],
erlang: ['erl', 'hrl'],
fortran: ['f', 'for', 'f90', 'f95'],
fsharp: ['fs', 'fsi'],
gcode: ['gcode'],
glsl: ['vert', 'frag', 'geom'],
gml: ['gml'],
go: ['go'],
gradle: ['gradle'],
graphql: ['graphql', 'gql'],
groovy: ['groovy'],
haml: ['haml'],
handlebars: ['hbs'],
haskell: ['hs'],
haxe: ['hx'],
http: ['http'],
hy: ['hy'],
ini: ['ini'],
java: ['java'],
javascript: ['js'],
json: ['json', 'json5', 'jsonc'],
julia: ['jl'],
kotlin: ['kt', 'kts', 'ktm'],
latex: ['tex'],
less: ['less'],
lisp: ['lisp'],
llvm: ['ll'],
lua: ['lua'],
makefile: ['Makefile'],
markdown: ['md', 'markdown', 'mkd'],
matlab: ['m'],
moonscript: ['moon'],
nim: ['nim'],
nix: ['nix'],
objectivec: ['m', 'mm'],
ocaml: ['ml', 'mli'],
openscad: ['scad'],
perl: ['pl', 'pm'],
pgsql: ['pgsql'],
php: ['php', 'php5', 'php7', 'php8'],
plaintext: ['txt', 'text', 'conf', 'def', 'list', 'log', 'in'],
pony: ['pony'],
powershell: ['ps1', 'psm1'],
protobuf: ['proto'],
python: ['py'],
qml: ['qml'],
r: ['R'],
reasonml: ['re', 'rei'],
ruby: ['rb'],
rust: ['rs'],
scala: ['scala'],
scheme: ['scm'],
scss: ['scss'],
shell: ['sh', 'bash', 'dash', 'ksh', 'csh', 'tcsh', 'zsh', 'fish'],
smalltalk: ['st'],
sql: ['sql'],
stylus: ['styl'],
swift: ['swift'],
tcl: ['tcl'],
typescript: ['ts'],
vbnet: ['vb'],
vim: ['vim'],
x86asm: ['s'],
xml: ['xml', 'svg', 'dtd'],
yaml: ['yaml', 'yml'],
// zig: ['zig'],
};
// Take a language (full name or extension) as input, and
// return the name of the language as it should
// be displayed to the user.
export function getLanguageName(lang: string) {
if (lang in languages) {
return lang;
}
for (const [name, exts] of Object.entries(extensionMap)) {
if (exts.includes(lang)) {
return name;
}
}
return '';
}

View File

@ -1,50 +0,0 @@
import { CodeBlock } from '@skeletonlabs/skeleton';
import { Marked, Renderer } from '@ts-stack/markdown';
import { highlight } from './hljs';
import { getLanguageName } from './languages';
class PasteRenderer extends Renderer {
override heading(text: string, level: number) {
const margin = level === 1 ? 8 : 2;
return `<h${level} class="h${level} mb-${margin} font-bold">${text}</h${level}>`;
}
override paragraph(text: string): string {
return `<p class="mt-0 mb-5">${text}</p>`;
}
override link(href: string, title: string, text: string): string {
return `<a href="${href}" title="${title}" target="_blank" rel="noopener noreferrer" class="underline">${text}</a>`;
}
override list(body: string, ordered?: boolean | undefined): string {
const tag = ordered ? 'ol' : 'ul';
return `<${tag} class="list-disc mt-5 mb-5 pl-7">${body}</${tag}>`;
}
override code(code: string, lang?: string | undefined): string {
const highlighted = highlight(code, lang);
const languageName = lang ? getLanguageName(lang) : '';
return `<div class="codeblock overflow-hidden shadow bg-neutral-900/90 text-sm text-white rounded-container-token" data-testid="codeblock">
<header class="codeblock-header text-xs text-white/50 uppercase flex justify-between items-center p-2 pl-4">
<span class="codeblock-language">${languageName}</span>
</header>
<pre class="codeblock-pre whitespace-pre-wrap break-all p-4 pt-1"><code class="codeblock-code language-bash lineNumbers">${highlighted}</code></pre>
</div>`
}
}
Marked.setOptions({
gfm: true,
tables: true,
breaks: false,
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: false,
renderer: new PasteRenderer(),
});
export function markdown(text: string) {
return Marked.parse(text);
}

View File

@ -1,179 +0,0 @@
#!/bin/bash
# Paste69 CLI script
PASTE69_URL=${PASTE69_URL:-"https://0x45.st"}
# Check if `curl` is installed
if ! command -v curl &> /dev/null; then
echo "Error: curl is not installed"
exit 1
fi
# Check if `jq` is installed
if ! command -v jq &> /dev/null; then
echo "Error: jq is not installed"
exit 1
fi
# Check for the presence of a clipboard program
cliptools=("xclip" "xsel" "pbcopy")
for tool in "${cliptools[@]}"; do
if command -v $tool &> /dev/null; then
case $tool in
xclip)
clipboard="xclip -selection clipboard"
;;
xsel)
clipboard="xsel --clipboard"
;;
pbcopy)
clipboard="pbcopy"
;;
esac
break
fi
done
# Show help text
function show_help {
echo "Paste69 CLI script"
echo ""
echo "Usage:"
echo " paste69 <file> [options]"
echo " cat <file> | paste69 [options]"
echo ""
echo "Options:"
echo " -h, --help Show this help text."
echo " -l, --language <language> Set the language of the paste."
echo " -p, --password <password> Set a password for the paste. This enables encryption."
echo " -x, --burn Burn the paste after it is viewed once."
echo " -r, --raw Return the raw JSON response."
echo " -c, --copy Copy the paste URL to the clipboard."
}
burn=false
encrypt=false
raw=false
# Parse arguments
while [[ $# -gt 0 ]]; do
key="$1"
case $key in
-h|--help)
show_help
exit 0
;;
-l|--language)
language="$2"
shift
shift
;;
-p|--password)
password="$2"
shift
shift
;;
-x|--burn)
burn=true
shift
;;
-r|--raw)
raw=true
shift
;;
-c|--copy)
copy=true
shift
;;
*)
if [ -z "$file" ]; then
file=$1
extension="${file##*.}"
else
echo "Error: too many arguments"
show_help
exit 1
fi
shift
;;
esac
done
# Check if data or a file was provided, otherwise error out
if [ -z "$file" ] && ! [ -p /dev/stdin ]; then
echo "Error: no data or file provided"
show_help
exit 1
fi
# Build the URL
url="$PASTE69_URL/api/pastes"
# Make the request
if [ ! -z "$file" ]; then
contents=$(/usr/bin/cat $file)
else
data=$(/usr/bin/cat)
# Check if the data is too large
if [ ${#data} -gt 10000 ]; then
echo "Error: stdin input too large. Use a file instead."
exit 1
fi
contents=$data
fi
# If password is set, we need to set the `encrypt` flag
if [ ! -z "$password" ]; then
encrypt=true
fi
# Create the JSON payload, and then clean it up
json=$(jq -n \
--arg contents "$contents" \
--arg extension "$extension" \
--arg language "$language" \
--arg password "$password" \
--argjson encrypt $encrypt \
--argjson burn $burn \
--argjson raw $raw \
'{"contents": $contents, "extension": $extension, "language": $language, "password": $password, "encrypt": $encrypt, "burnAfterReading": $burn, "raw": $raw}')
response=$(
curl -s -X POST $url \
-H "Content-Type: application/json" \
-d "$json"
)
if [ $? -ne 0 ]; then
echo "An error occurred while making the request"
exit 1
fi
# Check if there is an error
error=$(echo "$response" | jq -r '.message' 2>/dev/null || true)
if [ -n "$error" ] && [ "$error" != "null" ]; then
echo "Error: $error"
exit 1
fi
paste_url=$response
if [ "$raw" = true ]; then
paste_url=$(echo $response | jq -r '.url')
echo $response
else
echo $paste_url
fi
# If copy is set, copy the paste URL to the clipboard
if [ ! -z "$copy" ]; then
if [ ! -z "$clipboard" ]; then
echo $paste_url | $clipboard
else
echo "Error: the --copy flag requires a clipboard program to be installed"
echo "Install one of the following:"
for tool in "${cliptools[@]}"; do
echo " $tool"
done
exit 1
fi
fi

View File

@ -1,27 +0,0 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: [vitePreprocess({})],
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
alias: {
'$db/*': './src/lib/db/*',
'$utils/*': './src/utils/*',
},
csrf: {
checkOrigin: false,
}
}
};
export default config;

View File

@ -1,37 +0,0 @@
import { join } from 'path';
import type { Config } from 'tailwindcss';
import forms from '@tailwindcss/forms';
import { skeleton } from '@skeletonlabs/tw-plugin';
const config = {
darkMode: 'class',
content: [
'./src/**/*.{html,js,svelte,ts}',
join(require.resolve(
'@skeletonlabs/skeleton'),
'../**/*.{html,js,svelte,ts}'
)
],
theme: {
extend: {
fontFamily: {
monaspaceNeon: ['Monaspace Neon'],
},
},
},
plugins: [
forms,
skeleton({
themes: {
preset: ['skeleton'],
},
}),
],
safelist: [
'mb-8',
]
} satisfies Config;
export default config;

View File

@ -1,17 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@ -1,6 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
});