Rewrite in Crystal as a 0x0 clone
This commit is contained in:
parent
c4c177bd67
commit
c150387c16
|
@ -1,3 +0,0 @@
|
|||
.env
|
||||
.svelte-kit
|
||||
build/
|
|
@ -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
|
|
@ -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
|
|
@ -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'
|
||||
}
|
||||
};
|
|
@ -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,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
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
27
Dockerfile
27
Dockerfile
|
@ -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"]
|
|
@ -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.
|
57
README.md
57
README.md
|
@ -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
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
host: example.com
|
||||
port: 80
|
||||
database_url: postgres://postgres@127.0.0.1/paste69
|
||||
storage:
|
||||
type: local
|
||||
path: ./uploads
|
|
@ -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;
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
providers=["node"]
|
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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;
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
@ -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
|
||||
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
|||
require "./spec_helper"
|
||||
|
||||
describe Paste69 do
|
||||
# TODO: Write tests
|
||||
|
||||
it "works" do
|
||||
false.should eq(true)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,2 @@
|
|||
require "spec"
|
||||
require "../src/paste69"
|
|
@ -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 {};
|
16
src/app.html
16
src/app.html
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
require "./main"
|
||||
|
||||
ADI.container.athena_console_application.run
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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',
|
||||
};
|
||||
};
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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 |
|
@ -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>
|
|
@ -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}
|
||||
/>
|
|
@ -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}
|
|
@ -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"e=${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>
|
||||
|
|
@ -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>
|
|
@ -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]>;
|
||||
}
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
export default interface PasteSchema {
|
||||
id: string;
|
||||
contents: string;
|
||||
highlight: string;
|
||||
encrypted: boolean;
|
||||
burnAfterReading: boolean;
|
||||
createdAt: Date;
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
|
@ -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/**"
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
|
@ -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 />
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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>
|
|
@ -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>
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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>
|
|
@ -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,
|
||||
)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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(),
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
rm: cannot remove '{{ path.split("/")[1] }}': Permission denied
|
|
@ -0,0 +1,5 @@
|
|||
<pre>
|
||||
404 - NOT FOUND
|
||||
===============
|
||||
No paste found at path {{ path }}
|
||||
</pre>
|
|
@ -0,0 +1 @@
|
|||
Could not determine remote file size (no Content-Length in response header; shoot admin).
|
|
@ -0,0 +1 @@
|
|||
Remote file too large ({{ headers["Content-Length"]|filesizeformat(true) }} > {{ config["max_content_length"]|filesizeformat(true) }}).
|
|
@ -0,0 +1 @@
|
|||
451 Unavailable For Legal Reasons
|
|
@ -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>
|
|
@ -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>
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -1,2 +0,0 @@
|
|||
// Async sleep
|
||||
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@ -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 '';
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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
|
|
@ -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;
|
|
@ -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;
|
|
@ -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
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
});
|
Loading…
Reference in New Issue