├── .gitignore ├── pastebin-server ├── .gitignore ├── rustfmt.toml ├── justfile ├── src │ ├── lib.rs │ ├── time.rs │ ├── block_rules.rs │ ├── dto.rs │ ├── error.rs │ ├── anti_bot.rs │ ├── crypto.rs │ ├── config.rs │ ├── main.rs │ ├── redis.rs │ ├── web.rs │ └── svc.rs ├── pastebin-server.toml ├── Cargo.toml └── Cargo.lock ├── pastebin-front ├── env.d.ts ├── public │ ├── robots.txt │ └── favicon.ico ├── src │ ├── components │ │ ├── XButton.vue │ │ ├── CodeView.vue │ │ ├── MarkdownView.vue │ │ ├── XView.vue │ │ └── XModal.vue │ ├── styles │ │ ├── prismjs.css │ │ ├── form.css │ │ ├── index.css │ │ └── btn.css │ ├── main.ts │ ├── router.ts │ ├── data │ │ ├── store.ts │ │ ├── expiration.ts │ │ ├── download.ts │ │ ├── dto.d.ts │ │ ├── lang.ts │ │ └── api.ts │ ├── hooks │ │ ├── useHighlight.ts │ │ ├── useCopyBtn.ts │ │ └── useKatex.ts │ ├── App.vue │ └── pages │ │ ├── EditorPage.vue │ │ └── ViewPage.vue ├── .prettierrc.json ├── tsconfig.json ├── tsconfig.node.json ├── .gitignore ├── .eslintrc.cjs ├── index.html ├── prismjs-custom.ts ├── vite.config.ts ├── package.json └── README.md ├── justfile ├── pastebin.nginx.conf ├── scripts └── dist.sh ├── README.md ├── .github ├── dependabot.yml └── workflows │ └── ci.yml └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | /dist -------------------------------------------------------------------------------- /pastebin-server/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /pastebin-front/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /pastebin-front/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /pastebin-server/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | single_line_let_else_max_width = 100 3 | -------------------------------------------------------------------------------- /pastebin-front/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nugine/pastebin/HEAD/pastebin-front/public/favicon.ico -------------------------------------------------------------------------------- /pastebin-server/justfile: -------------------------------------------------------------------------------- 1 | dev: 2 | cargo fmt 3 | cargo clippy 4 | cargo test 5 | 6 | install: 7 | cargo install --path . 8 | 9 | -------------------------------------------------------------------------------- /pastebin-front/src/components/XButton.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | dist: 2 | ./scripts/dist.sh 3 | 4 | clean: 5 | cd pastebin-front && rm -rf node_modules 6 | cd pastebin-server && rm -rf target 7 | rm -rf dist 8 | -------------------------------------------------------------------------------- /pastebin-front/src/styles/prismjs.css: -------------------------------------------------------------------------------- 1 | /* 修改 prismjs theme "coy" */ 2 | pre[class*="language-"]::after, 3 | pre[class*="language-"]::before { 4 | box-shadow: none !important; 5 | display: none; 6 | } 7 | -------------------------------------------------------------------------------- /pastebin-server/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | 3 | mod anti_bot; 4 | mod block_rules; 5 | mod crypto; 6 | mod dto; 7 | mod error; 8 | mod redis; 9 | mod svc; 10 | mod time; 11 | 12 | pub mod config; 13 | pub mod web; 14 | -------------------------------------------------------------------------------- /pastebin-front/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": true, 4 | "tabWidth": 4, 5 | "useTabs": false, 6 | "singleQuote": false, 7 | "printWidth": 100, 8 | "trailingComma": "es5" 9 | } -------------------------------------------------------------------------------- /pastebin-front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["./src/*"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /pastebin-front/src/main.ts: -------------------------------------------------------------------------------- 1 | import "./styles/index.css"; 2 | 3 | import { createApp } from "vue"; 4 | import { createPinia } from "pinia"; 5 | 6 | import App from "./App.vue"; 7 | import router from "./router"; 8 | 9 | const app = createApp(App); 10 | 11 | app.use(createPinia()); 12 | app.use(router); 13 | 14 | app.mount("#app"); 15 | -------------------------------------------------------------------------------- /pastebin-front/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "playwright.config.*", 8 | "prismjs.custom.ts" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "types": ["node"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pastebin.nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | server_name "pastebin"; 5 | 6 | gzip on; 7 | gzip_comp_level 4; 8 | gzip_types application/javascript text/css application/json; 9 | gzip_vary on; 10 | gzip_static on; 11 | 12 | location / { 13 | proxy_pass http://localhost:3000; 14 | } 15 | 16 | location /api/ { 17 | proxy_pass http://localhost:8000; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pastebin-server/pastebin-server.toml: -------------------------------------------------------------------------------- 1 | [server] 2 | bind_addr = "localhost:8000" 3 | host_addr = "localhost" 4 | 5 | [security] 6 | secret_key = "magic" 7 | max_http_body_length = 262144 # 256 KiB 8 | max_expiration_seconds = 2592123 # 30 days 9 | max_qps = 1000 10 | max_title_chars = 64 11 | block_rules = [] 12 | anti_bot = true 13 | 14 | [redis] 15 | url = "redis://localhost:6379" 16 | key_prefix = "pastebin" 17 | max_open_connections = 32 18 | -------------------------------------------------------------------------------- /pastebin-front/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /pastebin-front/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("@rushstack/eslint-patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/eslint-config-typescript", 10 | "@vue/eslint-config-prettier/skip-formatting", 11 | ], 12 | parserOptions: { 13 | ecmaVersion: "latest", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /pastebin-server/src/time.rs: -------------------------------------------------------------------------------- 1 | use std::time::{SystemTime, UNIX_EPOCH}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// seconds since the unix epoch 6 | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 7 | pub struct UnixTimestamp(u64); 8 | 9 | impl UnixTimestamp { 10 | pub fn now() -> Option { 11 | let d = SystemTime::now().duration_since(UNIX_EPOCH).ok()?; 12 | Some(UnixTimestamp(d.as_secs())) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pastebin-front/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 在线剪贴板 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /pastebin-front/src/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router"; 2 | import EditorPage from "./pages/EditorPage.vue"; 3 | import ViewPage from "./pages/ViewPage.vue"; 4 | 5 | export default createRouter({ 6 | history: createWebHistory(import.meta.env.BASE_URL), 7 | routes: [ 8 | { path: "/:key", component: ViewPage }, 9 | { path: "/", component: EditorPage }, 10 | { path: "/:pathMatch(.*)", redirect: "/" }, 11 | ], 12 | strict: true, 13 | sensitive: true, 14 | }); 15 | -------------------------------------------------------------------------------- /pastebin-front/src/data/store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { reactive } from "vue"; 3 | 4 | import type { PastebinRecord } from "./dto"; 5 | 6 | import { DEFAULT_EXPIRATION } from "./expiration"; 7 | import { DEFAULT_LANG } from "./lang"; 8 | 9 | export const useStore = defineStore("store", () => { 10 | const record: PastebinRecord = reactive({ 11 | title: "", 12 | lang: DEFAULT_LANG, 13 | expiration_seconds: DEFAULT_EXPIRATION, 14 | content: "", 15 | }); 16 | 17 | return { record }; 18 | }); 19 | -------------------------------------------------------------------------------- /pastebin-front/src/hooks/useHighlight.ts: -------------------------------------------------------------------------------- 1 | import { watch, type Ref } from "vue"; 2 | import prismjs from "prismjs"; 3 | 4 | export function useHighlight(div: Ref, content: Ref) { 5 | const highlight = () => { 6 | const e = div.value; 7 | if (e) { 8 | prismjs.highlightAllUnder(e); 9 | } 10 | }; 11 | 12 | watch( 13 | content, 14 | (_newVal, _oldVal, onCleanup) => { 15 | const timer = setTimeout(highlight, 60); 16 | onCleanup(() => clearTimeout(timer)); 17 | }, 18 | { immediate: true, flush: "post" } 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /pastebin-front/src/data/expiration.ts: -------------------------------------------------------------------------------- 1 | export interface Expiration { 2 | value: number; 3 | display: string; 4 | } 5 | 6 | export const EXPIRATIONS: Expiration[] = [ 7 | { 8 | value: 3600, 9 | display: "1 小时", 10 | }, 11 | { 12 | value: 3600 * 24, 13 | display: "1 天", 14 | }, 15 | { 16 | value: 3600 * 24 * 3, 17 | display: "3 天", 18 | }, 19 | { 20 | value: 3600 * 24 * 7, 21 | display: "7 天", 22 | }, 23 | { 24 | value: 3600 * 24 * 30, 25 | display: "30 天", 26 | }, 27 | ]; 28 | 29 | export const DEFAULT_EXPIRATION = EXPIRATIONS[2].value; 30 | -------------------------------------------------------------------------------- /pastebin-front/src/components/CodeView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 21 | -------------------------------------------------------------------------------- /pastebin-front/src/App.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 28 | 29 | 32 | -------------------------------------------------------------------------------- /pastebin-front/src/data/download.ts: -------------------------------------------------------------------------------- 1 | export function isValidFileName(s: string): boolean { 2 | if (s === "") return false; 3 | const invalidChars = "~`!@#$%^&*()-+={}[]|:;\"'<>,.?/\b\f\n\r\t\v\\\0"; 4 | for (const ch of s.split("")) { 5 | if (invalidChars.indexOf(ch) !== -1) return false; 6 | } 7 | return true; 8 | } 9 | 10 | export function downloadFile(filename: string, content: string) { 11 | const a = document.createElement("a"); 12 | a.download = filename; 13 | a.href = URL.createObjectURL(new Blob([content])); 14 | a.style.display = "none"; 15 | document.body.appendChild(a); 16 | a.click(); 17 | document.body.removeChild(a); 18 | } 19 | -------------------------------------------------------------------------------- /pastebin-front/src/data/dto.d.ts: -------------------------------------------------------------------------------- 1 | interface RecordBase { 2 | title: string; 3 | lang: string; 4 | content: string; 5 | expiration_seconds: number; 6 | } 7 | 8 | interface SavedRecordMixin { 9 | saving_time: number; 10 | view_count: number; 11 | } 12 | 13 | export type PastebinRecord = RecordBase & Partial; 14 | 15 | export type SaveRecordInput = RecordBase; 16 | 17 | export interface SaveRecordOutput { 18 | key: string; 19 | } 20 | 21 | export interface FindRecordInput { 22 | key: string; 23 | } 24 | 25 | export type FindRecordOutput = RecordBase & SavedRecordMixin; 26 | 27 | export interface PastebinError { 28 | code: number; 29 | message: string; 30 | } 31 | -------------------------------------------------------------------------------- /pastebin-server/src/block_rules.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::dto::SaveRecordInput; 3 | 4 | use anyhow::Result; 5 | use regex::RegexSet; 6 | 7 | pub struct BlockRules { 8 | rules: RegexSet, 9 | } 10 | 11 | impl BlockRules { 12 | pub fn new(config: &Config) -> Result> { 13 | let Some(regexps) = &config.security.block_rules else { return Ok(None) }; 14 | 15 | if regexps.is_empty() { 16 | return Ok(None); 17 | } 18 | 19 | let rules = RegexSet::new(regexps)?; 20 | Ok(Some(Self { rules })) 21 | } 22 | 23 | pub fn is_match(&self, input: &SaveRecordInput) -> bool { 24 | self.rules.is_match(&input.title) || self.rules.is_match(&input.content) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pastebin-front/src/components/MarkdownView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 23 | -------------------------------------------------------------------------------- /pastebin-front/src/hooks/useCopyBtn.ts: -------------------------------------------------------------------------------- 1 | import copyToClipboard from "copy-to-clipboard"; 2 | import { computed, ref } from "vue"; 3 | 4 | export function useCopyBtn(content: () => string) { 5 | const btnClasses = { 6 | none: [], 7 | success: ["btn-success"], 8 | failure: ["btn-failure"], 9 | }; 10 | const copyStatus = ref("none"); 11 | 12 | function handleCopy() { 13 | const result = copyToClipboard(content()); 14 | copyStatus.value = result ? "success" : "failure"; 15 | const resetTime = 600; 16 | setTimeout(() => (copyStatus.value = "none"), resetTime); 17 | } 18 | 19 | const copyBtnClass = computed(() => btnClasses[copyStatus.value]); 20 | 21 | return { 22 | copyBtnClass, 23 | handleCopy, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /pastebin-front/prismjs-custom.ts: -------------------------------------------------------------------------------- 1 | export const LANGUAGES: [string, string, string][] = [ 2 | ["markdown", "Markdown", ".md"], 3 | ["html", "HTML", ".html"], 4 | ["css", "CSS", ".css"], 5 | ["javascript", "JavaScript", ".js"], 6 | ["bash", "Bash", ".sh"], 7 | ["c", "C", ".c"], 8 | ["cpp", "C++", ".cpp"], 9 | ["cs", "C#", ".cs"], 10 | ["erlang", "Erlang", ".erl"], 11 | ["go", "Go", ".go"], 12 | ["haskell", "Haskell", ".hs"], 13 | ["rust", "Rust", ".rs"], 14 | ["java", "Java", ".java"], 15 | ["json", "JSON", ".json"], 16 | ["kotlin", "Kotlin", ".kt"], 17 | ["latex", "LaTeX", ".tex"], 18 | ["php", "PHP", ".php"], 19 | ["python", "Python", ".py"], 20 | ["scala", "Scala", ".scala"], 21 | ["sql", "SQL", ".sql"], 22 | ["toml", "TOML", ".toml"], 23 | ["typescript", "TypeScript", ".ts"], 24 | ]; 25 | -------------------------------------------------------------------------------- /pastebin-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pastebin-server" 3 | version = "0.4.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [dependencies] 8 | anyhow = "1.0.89" 9 | axum = "0.7.9" 10 | bytestring = { version = "1.5.0", features = ["serde"] } 11 | camino = "1.2.1" 12 | clap = { version = "4.5.19", features = ["derive"] } 13 | mobc-redis = "0.9.0" 14 | rand = "0.9.0" 15 | regex = "1.12.2" 16 | serde = { version = "1.0.210", features = ["derive"] } 17 | serde_json = "1.0.128" 18 | serde_repr = "0.1.19" 19 | short-crypt = "1.0.28" 20 | thiserror = "2.0.3" 21 | tokio = { version = "1.48.0", features = ["full"] } 22 | toml = "0.9.8" 23 | tower = { version = "0.5.1", features = ["limit", "buffer", "load-shed"] } 24 | tracing = "0.1.40" 25 | tracing-futures = "0.2.5" 26 | tracing-subscriber = { version = "0.3.18", features = ["env-filter", "time", "local-time"] } 27 | -------------------------------------------------------------------------------- /scripts/dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | TIME=$(date -u +"%Y%m%d%H%M%S") 4 | 5 | DIST="$PWD"/dist 6 | FRONTEND="$DIST"/frontend 7 | BACKEND="$DIST"/backend 8 | 9 | mkdir -p "$FRONTEND" 10 | mkdir -p "$BACKEND" 11 | 12 | pushd pastebin-front 13 | npm install 14 | npm run build 15 | cp -r dist/* "$FRONTEND" 16 | popd 17 | 18 | pushd pastebin-server 19 | if [ -n "$ZIGBUILD" ]; then 20 | cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.27 21 | cp target/x86_64-unknown-linux-gnu/release/pastebin-server "$BACKEND" 22 | else 23 | cargo build --release 24 | cp target/release/pastebin-server "$BACKEND" 25 | fi 26 | cp pastebin-server.toml "$BACKEND" 27 | popd 28 | 29 | pushd "$DIST" 30 | zip -r pastebin.dist."$TIME".zip frontend backend 31 | rm -rf frontend backend 32 | popd 33 | 34 | echo "done" 35 | -------------------------------------------------------------------------------- /pastebin-front/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from "node:url"; 2 | 3 | import { defineConfig } from "vite"; 4 | import vue from "@vitejs/plugin-vue"; 5 | import vueJsx from "@vitejs/plugin-vue-jsx"; 6 | 7 | import prismjs from "vite-plugin-prismjs"; 8 | import { LANGUAGES } from "./prismjs-custom"; 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig({ 12 | plugins: [ 13 | vue(), 14 | vueJsx(), 15 | prismjs({ 16 | languages: LANGUAGES.map((tuple) => tuple[0]), 17 | plugins: ["line-numbers"], 18 | theme: "coy", 19 | css: true, 20 | }), 21 | ], 22 | resolve: { 23 | alias: { 24 | "@": fileURLToPath(new URL("./src", import.meta.url)), 25 | }, 26 | }, 27 | server: { 28 | port: 3000, 29 | }, 30 | preview: { 31 | port: 3000, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pastebin 2 | 3 | 在线剪贴板 4 | 5 | ## 开发 6 | 7 | 前置要求 8 | 9 | + Node.js 10 | + Rust 11 | + Nginx 12 | + Redis 13 | + [just](https://github.com/casey/just) 14 | 15 | 下载后端依赖 16 | 17 | ```bash 18 | cd pastebin-server 19 | cargo fetch 20 | ``` 21 | 22 | 下载前端依赖 23 | 24 | ```bash 25 | cd pastebin-front 26 | npm install 27 | ``` 28 | 29 | 启用 nginx 配置文件 30 | 31 | ```bash 32 | sudo ln -s $PWD/pastebin.nginx.conf /etc/nginx/sites-enabled/pastebin 33 | sudo nginx -t 34 | sudo nginx -s reload 35 | ``` 36 | 37 | 启动后端 38 | 39 | ```bash 40 | cd pastebin-server 41 | cargo run --release 42 | ``` 43 | 44 | 启动前端开发服务器 45 | 46 | ```bash 47 | cd pastebin-front 48 | npm run dev 49 | ``` 50 | 51 | 打开页面 52 | 53 | ## 部署 54 | 55 | 编译并打包前端与后端 56 | 57 | ```bash 58 | just dist 59 | ``` 60 | 61 | 将 dist 目录下的最新压缩包上传至服务器,解压并修改配置,自行部署 62 | 63 | ## 其他 64 | 65 | 删除生成文件,释放空间 66 | 67 | ```bash 68 | just clean 69 | ``` 70 | -------------------------------------------------------------------------------- /pastebin-front/src/data/lang.ts: -------------------------------------------------------------------------------- 1 | import { LANGUAGES } from "../../prismjs-custom"; 2 | 3 | export interface Lang { 4 | value: string; 5 | display: string; 6 | ext: string; 7 | } 8 | 9 | export const LANGS: Lang[] = (() => { 10 | const langs: Lang[] = [convert(LANGUAGES[0]), convert(["plaintext", "纯文本", ".txt"])]; 11 | 12 | const others = [...LANGUAGES.slice(1)]; 13 | others.sort((lhs, rhs) => compareString(lhs[1], rhs[1])); 14 | others.forEach((tuple) => langs.push(convert(tuple))); 15 | 16 | return langs; 17 | })(); 18 | 19 | function convert(tuple: [string, string, string]): Lang { 20 | return { value: tuple[0], display: tuple[1], ext: tuple[2] }; 21 | } 22 | 23 | function compareString(lhs: string, rhs: string): number { 24 | return lhs < rhs ? -1 : lhs === rhs ? 0 : 1; 25 | } 26 | 27 | export function findLangExt(langValue: string): string | null { 28 | const ans = LANGS.find((lang) => lang.value === langValue); 29 | return ans ? ans.ext : null; 30 | } 31 | 32 | export const DEFAULT_LANG = LANGS[0].value; 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/pastebin-server" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | ignore: 13 | - dependency-name: "*" 14 | update-types: ["version-update:semver-patch"] 15 | groups: 16 | backend: 17 | patterns: 18 | - "*" 19 | - package-ecosystem: "npm" 20 | directory: "/pastebin-front" 21 | schedule: 22 | interval: "monthly" 23 | ignore: 24 | - dependency-name: "*" 25 | update-types: ["version-update:semver-patch"] 26 | groups: 27 | frontend: 28 | patterns: 29 | - "*" 30 | -------------------------------------------------------------------------------- /pastebin-front/src/components/XView.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | 22 | 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | branches: 7 | - main 8 | schedule: # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onschedule 9 | - cron: '0 0 * * 0' # at midnight of each sunday 10 | 11 | name: CI 12 | 13 | jobs: 14 | server: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: dtolnay/rust-toolchain@nightly 19 | with: 20 | components: rustfmt, clippy 21 | - name: Rust check 22 | run: | 23 | cd pastebin-server 24 | cargo fmt --all -- --check 25 | cargo clippy -- -D warnings 26 | cargo test 27 | cargo build --release 28 | 29 | front: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-node@v4 34 | with: 35 | node-version: 22 36 | - name: Vue check 37 | run: | 38 | cd pastebin-front 39 | npm ci 40 | npm run build 41 | -------------------------------------------------------------------------------- /pastebin-front/src/styles/form.css: -------------------------------------------------------------------------------- 1 | .form-group { 2 | margin-bottom: 1rem; 3 | } 4 | 5 | .form-group label { 6 | display: inline-block; 7 | margin-bottom: 0.5rem; 8 | } 9 | 10 | .form-control { 11 | display: block; 12 | width: 100%; 13 | height: calc(1.5em + 0.75rem + 2px); 14 | padding: 0.375rem 0.75rem; 15 | font-size: 1rem; 16 | font-weight: 400; 17 | line-height: 1.5; 18 | color: #495057; 19 | background-color: #fff; 20 | background-clip: padding-box; 21 | border: 1px solid #ced4da; 22 | border-radius: 0.25rem; 23 | transition: 24 | border-color 0.15s ease-in-out, 25 | box-shadow 0.15s ease-in-out; 26 | } 27 | 28 | .form-control:focus:not(.form-control-invalid) { 29 | color: #495057; 30 | background-color: #fff; 31 | border-color: #80bdff; 32 | outline: 0; 33 | box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); 34 | } 35 | 36 | .form-control-invalid { 37 | border-color: #dc3545; 38 | } 39 | 40 | textarea.form-control { 41 | height: auto; 42 | overflow: auto; 43 | resize: vertical; 44 | } 45 | -------------------------------------------------------------------------------- /pastebin-server/src/dto.rs: -------------------------------------------------------------------------------- 1 | use crate::crypto::Key; 2 | use crate::time::UnixTimestamp; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | type SharedString = bytestring::ByteString; 7 | 8 | #[derive(Debug, Serialize, Deserialize)] 9 | pub struct Record { 10 | pub title: SharedString, 11 | pub lang: SharedString, 12 | pub content: SharedString, 13 | pub expiration_seconds: u32, 14 | pub saving_time: UnixTimestamp, 15 | } 16 | 17 | #[derive(Debug, Serialize, Deserialize)] 18 | #[serde(deny_unknown_fields)] 19 | pub struct SaveRecordInput { 20 | pub title: SharedString, 21 | pub lang: SharedString, 22 | pub content: SharedString, 23 | pub expiration_seconds: u32, 24 | } 25 | 26 | #[derive(Debug, Serialize, Deserialize)] 27 | pub struct SaveRecordOutput { 28 | pub key: Key, 29 | } 30 | 31 | #[derive(Debug, Serialize, Deserialize)] 32 | #[serde(deny_unknown_fields)] 33 | pub struct FindRecordInput { 34 | pub key: String, 35 | } 36 | 37 | #[derive(Debug, Serialize, Deserialize)] 38 | pub struct FindRecordOutput { 39 | #[serde(flatten)] 40 | pub record: Record, 41 | pub view_count: u64, 42 | } 43 | -------------------------------------------------------------------------------- /pastebin-front/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import "./btn.css"; 2 | @import "./form.css"; 3 | @import "./prismjs.css"; 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | margin: 0; 11 | padding: 0; 12 | 13 | font-family: 14 | -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, 15 | "Apple Color Emoji", "Segoe UI Emoji"; 16 | 17 | font-size: 1rem; 18 | font-weight: 400; 19 | line-height: 1.5; 20 | 21 | width: 100%; 22 | min-height: 100vh; 23 | } 24 | 25 | #app { 26 | width: 100%; 27 | min-height: 100vh; 28 | 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | } 33 | 34 | a { 35 | color: #007bff; 36 | text-decoration: none; 37 | background-color: transparent; 38 | } 39 | 40 | a:hover { 41 | text-decoration: underline; 42 | } 43 | 44 | .container-lg { 45 | width: 100%; 46 | } 47 | 48 | @media (min-width: 992px) { 49 | .container-lg { 50 | max-width: 960px; 51 | } 52 | } 53 | 54 | @media (min-width: 1200px) { 55 | .container-lg { 56 | max-width: 1140px; 57 | } 58 | } 59 | 60 | .code-area { 61 | font-family: "Fira Code"; 62 | } 63 | -------------------------------------------------------------------------------- /pastebin-server/src/error.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_repr::{Deserialize_repr, Serialize_repr}; 4 | 5 | #[derive(Debug, Serialize, Deserialize)] 6 | pub struct PastebinError { 7 | pub code: PastebinErrorCode, 8 | pub message: String, 9 | } 10 | 11 | #[repr(u16)] 12 | #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] 13 | pub enum PastebinErrorCode { 14 | InternalError = 1001, 15 | Unavailable = 1002, 16 | 17 | BadKey = 2001, 18 | TooLongExpirations = 2002, 19 | TooLongTitle = 2003, 20 | 21 | NotFound = 3001, 22 | } 23 | 24 | impl PastebinErrorCode { 25 | pub fn status(&self) -> StatusCode { 26 | use PastebinErrorCode::*; 27 | 28 | match self { 29 | InternalError => StatusCode::INTERNAL_SERVER_ERROR, 30 | Unavailable => StatusCode::SERVICE_UNAVAILABLE, 31 | BadKey => StatusCode::BAD_REQUEST, 32 | TooLongExpirations => StatusCode::BAD_REQUEST, 33 | TooLongTitle => StatusCode::BAD_REQUEST, 34 | NotFound => StatusCode::NOT_FOUND, 35 | } 36 | } 37 | } 38 | 39 | impl From for PastebinError { 40 | fn from(code: PastebinErrorCode) -> Self { 41 | let message = format!("{code:?}"); 42 | Self { code, message } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pastebin-front/src/data/api.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FindRecordInput, 3 | FindRecordOutput, 4 | PastebinError, 5 | SaveRecordInput, 6 | SaveRecordOutput, 7 | } from "./dto"; 8 | import { mande, type MandeError } from "mande"; 9 | 10 | const records = mande("/api/records", { 11 | mode: "same-origin", 12 | }); 13 | 14 | export type Result = { ok: true; value: T } | { ok: false; error: E }; 15 | 16 | function resultOk(value: T): Result { 17 | return { ok: true, value }; 18 | } 19 | 20 | function resultErr(error: E): Result { 21 | return { ok: false, error }; 22 | } 23 | 24 | export async function saveRecord( 25 | input: SaveRecordInput 26 | ): Promise> { 27 | try { 28 | const value: SaveRecordOutput = await records.put(input); 29 | return resultOk(value); 30 | } catch (exc) { 31 | const error = (exc as MandeError).body; 32 | return resultErr(error); 33 | } 34 | } 35 | 36 | export async function findRecord( 37 | input: FindRecordInput 38 | ): Promise> { 39 | try { 40 | const value: FindRecordOutput = await records.get(input.key); 41 | return resultOk(value); 42 | } catch (exc) { 43 | const error = (exc as MandeError).body; 44 | return resultErr(error); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pastebin-front/src/hooks/useKatex.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch, type Ref } from "vue"; 2 | import type { RenderMathInElementOptions } from "katex/contrib/auto-render"; 3 | import type renderMathInElement from "katex/contrib/auto-render"; 4 | 5 | import "katex/dist/katex.min.css"; 6 | 7 | const katexOptions: RenderMathInElementOptions = { 8 | delimiters: [ 9 | { left: "$$", right: "$$", display: true }, 10 | { left: "\\(", right: "\\)", display: false }, 11 | { left: "\\[", right: "\\]", display: true }, 12 | { left: "$", right: "$", display: false }, 13 | ], 14 | errorColor: "#cc0000", 15 | throwOnError: false, 16 | strict: "ignore", 17 | }; 18 | 19 | export function useKatex(div: Ref, content: Ref) { 20 | const katex = ref(null); 21 | 22 | import("katex/contrib/auto-render").then((module) => { 23 | katex.value = module.default; 24 | }); 25 | 26 | const render = () => { 27 | const e = div.value; 28 | const f = katex.value; 29 | if (e && f) { 30 | f(e, katexOptions); 31 | } 32 | }; 33 | 34 | watch( 35 | [content, katex], 36 | (_newVal, _oldVal, onCleanup) => { 37 | const timer = setTimeout(render, 60); 38 | onCleanup(() => clearTimeout(timer)); 39 | }, 40 | { immediate: true, flush: "post" } 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /pastebin-server/src/anti_bot.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::mutable_key_type)] // false positive 2 | 3 | use crate::config::Config; 4 | use crate::crypto::Key; 5 | 6 | use std::collections::HashMap; 7 | use std::ops::Not; 8 | use std::time::Duration; 9 | 10 | use anyhow::Result; 11 | use tokio::spawn; 12 | use tokio::sync::Mutex; 13 | use tokio::task::JoinHandle; 14 | use tokio::time::sleep; 15 | 16 | pub struct AntiBot { 17 | watch_task_map: Mutex>>, 18 | } 19 | 20 | impl AntiBot { 21 | pub fn new(config: &Config) -> Result> { 22 | if config.security.anti_bot.not() { 23 | return Ok(None); 24 | } 25 | let activate_task_map = Mutex::new(HashMap::new()); 26 | Ok(Some(Self { 27 | watch_task_map: activate_task_map, 28 | })) 29 | } 30 | 31 | pub async fn watch_deactivate(&self, key: &Key, on_fail: impl FnOnce() + Send + 'static) { 32 | let key = key.clone(); 33 | 34 | let mut guard = self.watch_task_map.lock().await; 35 | let map = &mut *guard; 36 | 37 | let task = spawn(async move { 38 | sleep(Duration::from_secs(2)).await; 39 | on_fail(); 40 | }); 41 | 42 | map.insert(key, task); 43 | } 44 | 45 | pub async fn cancel_deactivate(&self, key: &Key) { 46 | let mut guard = self.watch_task_map.lock().await; 47 | let map = &mut *guard; 48 | 49 | if let Some(task) = map.remove(key) { 50 | task.abort(); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pastebin-front/src/styles/btn.css: -------------------------------------------------------------------------------- 1 | .btn-bar { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | margin: 1em 0; 6 | } 7 | 8 | .btn-bar .btn { 9 | margin: 0 0.2rem; 10 | } 11 | 12 | .btn { 13 | display: inline-block; 14 | 15 | padding: 0.375rem 0.75rem; 16 | 17 | color: #333; 18 | background-color: transparent; 19 | 20 | border-width: 1px; 21 | border-style: solid; 22 | border-color: #ccc; 23 | border-radius: 0.25rem; 24 | 25 | text-align: center; 26 | 27 | font-weight: 400; 28 | font-size: 1rem; 29 | line-height: 1.5; 30 | 31 | box-shadow: none; 32 | transition: box-shadow 0.1s linear; 33 | 34 | user-select: none; 35 | } 36 | 37 | .btn:hover, 38 | .btn:focus, 39 | .btn:active { 40 | border-color: #0099ff; 41 | } 42 | 43 | .btn:hover { 44 | cursor: pointer; 45 | } 46 | 47 | .btn:active { 48 | box-shadow: 0 0 4px #0099ff; 49 | } 50 | 51 | .btn:disabled { 52 | border-color: #ccc; 53 | cursor: default; 54 | box-shadow: none; 55 | } 56 | 57 | .btn-success, 58 | .btn-success:hover, 59 | .btn-success:focus, 60 | .btn-success:active { 61 | color: white; 62 | background-color: #28a745; 63 | border-color: #28a745; 64 | } 65 | 66 | .btn-success:focus { 67 | box-shadow: 0 0 3px #00dd00; 68 | } 69 | 70 | .btn-success svg path { 71 | stroke: white; 72 | } 73 | 74 | .btn-failure, 75 | .btn-failure:hover, 76 | .btn-failure:focus, 77 | .btn-failure:active { 78 | color: white; 79 | background-color: #c82333; 80 | border-color: #c82333; 81 | } 82 | -------------------------------------------------------------------------------- /pastebin-server/src/crypto.rs: -------------------------------------------------------------------------------- 1 | use bytestring::ByteString; 2 | use serde::{Deserialize, Serialize}; 3 | use short_crypt::ShortCrypt; 4 | 5 | #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] 6 | pub struct Key(ByteString); 7 | 8 | impl Key { 9 | #[inline(always)] 10 | pub fn as_str(&self) -> &str { 11 | &self.0 12 | } 13 | } 14 | 15 | pub struct Crypto(ShortCrypt); 16 | 17 | impl Crypto { 18 | pub fn new(secret_key: &str) -> Self { 19 | Self(ShortCrypt::new(secret_key)) 20 | } 21 | 22 | pub fn generate(&self) -> Key { 23 | let rand_bytes: [u8; 4] = rand::random(); 24 | 25 | let mut s: String = self.0.encrypt_to_qr_code_alphanumeric(&rand_bytes); 26 | s.make_ascii_lowercase(); 27 | Key(s.into()) 28 | } 29 | 30 | pub fn validate(&self, input: &str) -> Option { 31 | // 忽略输入的大小写 32 | let mut s: Box = input.to_ascii_uppercase().into(); 33 | 34 | let v = self.0.decrypt_qr_code_alphanumeric(&s).ok()?; 35 | if v.len() != 4 { 36 | return None; 37 | } 38 | 39 | // 统一表示为小写 40 | s.make_ascii_lowercase(); 41 | Some(Key(s.into())) 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use super::*; 48 | 49 | #[test] 50 | fn basic() { 51 | let secret_key = "asdf"; 52 | let crypto = Crypto::new(secret_key); 53 | let k1 = crypto.generate(); 54 | println!("k1 = {k1:?}"); 55 | 56 | let k2 = crypto.validate(k1.as_str()).unwrap(); 57 | assert_eq!(k1, k2); 58 | 59 | let k3 = crypto.validate(&k1.as_str().to_ascii_uppercase()).unwrap(); 60 | assert_eq!(k1, k3); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /pastebin-server/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | use anyhow::Result; 4 | use camino::Utf8Path; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize)] 8 | #[serde(deny_unknown_fields)] 9 | pub struct Config { 10 | pub server: ServerConfig, 11 | pub security: SecurityConfig, 12 | pub redis: RedisConfig, 13 | } 14 | 15 | #[derive(Debug, Clone, Serialize, Deserialize)] 16 | pub struct ServerConfig { 17 | pub bind_addr: String, 18 | pub host_addr: String, 19 | } 20 | 21 | #[derive(Debug, Clone, Serialize, Deserialize)] 22 | pub struct SecurityConfig { 23 | pub secret_key: String, 24 | 25 | // TODO: serde with human readable bytes representation 26 | // 27 | pub max_http_body_length: usize, 28 | 29 | pub max_expiration_seconds: u32, 30 | pub max_qps: u32, 31 | 32 | pub max_title_chars: usize, 33 | 34 | pub block_rules: Option>, 35 | 36 | pub anti_bot: bool, 37 | } 38 | 39 | #[derive(Debug, Clone, Serialize, Deserialize)] 40 | pub struct RedisConfig { 41 | pub url: String, 42 | pub key_prefix: String, 43 | pub max_open_connections: u64, 44 | } 45 | 46 | impl Config { 47 | pub fn from_toml(path: &Utf8Path) -> Result { 48 | let content = fs::read_to_string(path)?; 49 | let config = toml::from_str(&content)?; 50 | Ok(config) 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod tests { 56 | use super::*; 57 | 58 | #[test] 59 | fn example_config() { 60 | let config_path = concat!(env!("CARGO_MANIFEST_DIR"), "/pastebin-server.toml"); 61 | let config = Config::from_toml(config_path.as_ref()).unwrap(); 62 | println!("{config:#?}"); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pastebin-front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pastebin-front", 3 | "version": "0.4.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "run-p fmt-check type-check build-only", 8 | "preview": "vite preview", 9 | "build-only": "vite build", 10 | "type-check": "vue-tsc --noEmit", 11 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", 12 | "fmt": "prettier --write src/ *.ts *.cjs *.json *.html", 13 | "fmt-check": "prettier --check src/ *.ts *.cjs *.json *.html" 14 | }, 15 | "dependencies": { 16 | "@icon-park/vue-next": "^1.4.2", 17 | "copy-to-clipboard": "^3.3.3", 18 | "katex": "^0.16.21", 19 | "mande": "^2.0.7", 20 | "markdown-it": "^14.1.0", 21 | "pinia": "^3.0.1", 22 | "prismjs": "^1.30.0", 23 | "qrcode": "^1.5.3", 24 | "vite-plugin-prismjs": "^0.0.8", 25 | "vue": "^3.4.3", 26 | "vue-router": "^4.6.3" 27 | }, 28 | "devDependencies": { 29 | "@rushstack/eslint-patch": "^1.15.0", 30 | "@types/katex": "^0.16.2", 31 | "@types/markdown-it": "^14.1.2", 32 | "@types/node": "^24.10.1", 33 | "@types/prismjs": "^1.26.0", 34 | "@types/qrcode": "^1.5.1", 35 | "@vitejs/plugin-vue": "^6.0.2", 36 | "@vitejs/plugin-vue-jsx": "^5.1.2", 37 | "@vue/eslint-config-prettier": "^10.2.0", 38 | "@vue/eslint-config-typescript": "^14.6.0", 39 | "@vue/tsconfig": "^0.7.0", 40 | "eslint": "^9.39.1", 41 | "eslint-plugin-vue": "^10.6.2", 42 | "npm-run-all": "^4.1.5", 43 | "prettier": "^3.7.3", 44 | "typescript": "5.7.2", 45 | "vite": "^7.2.6", 46 | "vue-tsc": "^3.1.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pastebin-front/README.md: -------------------------------------------------------------------------------- 1 | # vue-scaffold-mini 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. 12 | 13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: 14 | 15 | 1. Disable the built-in TypeScript Extension 16 | 1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette 17 | 2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` 18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. 19 | 20 | ## Customize configuration 21 | 22 | See [Vite Configuration Reference](https://vitejs.dev/config/). 23 | 24 | ## Project Setup 25 | 26 | ```sh 27 | npm install 28 | ``` 29 | 30 | ### Compile and Hot-Reload for Development 31 | 32 | ```sh 33 | npm run dev 34 | ``` 35 | 36 | ### Type-Check, Compile and Minify for Production 37 | 38 | ```sh 39 | npm run build 40 | ``` 41 | 42 | ### Lint with [ESLint](https://eslint.org/) 43 | 44 | ```sh 45 | npm run lint 46 | ``` 47 | -------------------------------------------------------------------------------- /pastebin-server/src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![deny(clippy::all)] 3 | 4 | use pastebin_server::config::Config; 5 | 6 | use std::io::IsTerminal; 7 | 8 | use anyhow::Context; 9 | use anyhow::Result; 10 | use axum::Router; 11 | use camino::Utf8Path; 12 | use camino::Utf8PathBuf; 13 | use clap::Parser; 14 | use tokio::net::TcpListener; 15 | use tracing::info; 16 | 17 | #[derive(clap::Parser)] 18 | struct Opt { 19 | #[clap(long)] 20 | #[clap(default_value = "pastebin-server.toml")] 21 | pub config: Utf8PathBuf, 22 | } 23 | 24 | fn main() -> Result<()> { 25 | setup_tracing(); 26 | 27 | let opt = Opt::parse(); 28 | 29 | let config = load_config(&opt.config)?; 30 | run(config) 31 | } 32 | 33 | #[tokio::main] 34 | async fn run(config: Config) -> Result<()> { 35 | let app = pastebin_server::web::build(&config)?; 36 | serve(app, &config.server.bind_addr).await?; 37 | Ok(()) 38 | } 39 | 40 | fn setup_tracing() { 41 | use tracing_subscriber::filter::{EnvFilter, LevelFilter}; 42 | use tracing_subscriber::fmt::time::OffsetTime; 43 | 44 | let env_filter = EnvFilter::builder() 45 | .with_default_directive(LevelFilter::INFO.into()) 46 | .from_env_lossy(); 47 | 48 | let enable_color = std::io::stdout().is_terminal(); 49 | 50 | let timer = OffsetTime::local_rfc_3339().expect("could not get local time offset"); 51 | 52 | tracing_subscriber::fmt() 53 | .pretty() 54 | .with_env_filter(env_filter) 55 | .with_ansi(enable_color) 56 | .with_timer(timer) 57 | .init() 58 | } 59 | 60 | fn load_config(path: &Utf8Path) -> Result { 61 | Config::from_toml(path).with_context(|| format!("Failed to read config from {path:?}")) 62 | } 63 | 64 | async fn serve(app: Router, addr: &str) -> Result<()> { 65 | let listener = TcpListener::bind(addr).await?; 66 | info!("listening on {}", addr); 67 | axum::serve(listener, app.into_make_service()) 68 | .with_graceful_shutdown(shutdown_signal()) 69 | .await?; 70 | Ok(()) 71 | } 72 | 73 | async fn shutdown_signal() { 74 | let _ = tokio::signal::ctrl_c().await; 75 | } 76 | -------------------------------------------------------------------------------- /pastebin-front/src/components/XModal.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 69 | 70 | 97 | -------------------------------------------------------------------------------- /pastebin-server/src/redis.rs: -------------------------------------------------------------------------------- 1 | use crate::config::RedisConfig; 2 | use crate::crypto::Key; 3 | use crate::dto::Record; 4 | 5 | use mobc_redis::mobc; 6 | use mobc_redis::redis; 7 | use redis::aio::MultiplexedConnection; 8 | use redis::AsyncCommands; 9 | 10 | use anyhow::Result; 11 | use tracing::error; 12 | 13 | pub struct RedisStorage { 14 | key_prefix: Box, 15 | pool: mobc::Pool, 16 | } 17 | 18 | const VIEW_COUNT_FIELD: &str = "view_count"; 19 | const JSON_FIELD: &str = "json"; 20 | 21 | async fn exists(conn: &mut MultiplexedConnection, redis_key: &str) -> Result { 22 | let exists: bool = conn 23 | .exists(redis_key) 24 | .await 25 | .inspect_err(|err| error!(?err))?; 26 | Ok(exists) 27 | } 28 | 29 | impl RedisStorage { 30 | pub fn new(config: &RedisConfig) -> Result { 31 | let key_prefix = config.key_prefix.clone().into_boxed_str(); 32 | 33 | let pool = { 34 | let client = redis::Client::open(&*config.url)?; 35 | let manager = mobc_redis::RedisConnectionManager::new(client); 36 | mobc::Pool::builder() 37 | .max_open(config.max_open_connections) 38 | .build(manager) 39 | }; 40 | 41 | Ok(Self { key_prefix, pool }) 42 | } 43 | 44 | async fn get_conn(&self) -> Result> { 45 | let conn = self.pool.get().await.inspect_err(|err| error!(?err))?; 46 | Ok(conn) 47 | } 48 | 49 | fn concat_key(&self, key: &Key) -> String { 50 | format!("{}:{}", self.key_prefix, key.as_str()) 51 | } 52 | 53 | pub async fn save( 54 | &self, 55 | key_gen: impl Fn() -> Key, 56 | record: &Record, 57 | expiration_seconds: u32, 58 | ) -> Result { 59 | let mut conn = self.get_conn().await?; 60 | 61 | let (key, redis_key) = loop { 62 | let key = key_gen(); 63 | let redis_key = self.concat_key(&key); 64 | 65 | if !exists(&mut conn, &redis_key).await? { 66 | break (key, redis_key); 67 | } 68 | }; 69 | 70 | let json = serde_json::to_string(record).unwrap(); 71 | 72 | redis::pipe() 73 | .atomic() 74 | .hset(&redis_key, VIEW_COUNT_FIELD, 0_u64) 75 | .hset(&redis_key, JSON_FIELD, &*json) 76 | .expire(&redis_key, expiration_seconds as i64) 77 | .query_async::<()>(&mut *conn) 78 | .await 79 | .inspect_err(|err| error!(?err))?; 80 | 81 | Ok(key) 82 | } 83 | 84 | pub async fn access(&self, key: &Key) -> Result> { 85 | let mut conn = self.get_conn().await?; 86 | let redis_key = self.concat_key(key); 87 | 88 | if !exists(&mut conn, &redis_key).await? { 89 | return Ok(None); 90 | } 91 | 92 | let (view, json): (u64, String) = redis::pipe() 93 | .atomic() 94 | .hincr(&redis_key, VIEW_COUNT_FIELD, 1_u64) 95 | .hget(&redis_key, JSON_FIELD) 96 | .query_async(&mut *conn) 97 | .await 98 | .inspect_err(|err| error!(?err))?; 99 | 100 | let record: Record = serde_json::from_str(&json).inspect_err(|err| error!(?err))?; 101 | Ok(Some((record, view))) 102 | } 103 | 104 | pub async fn delete(&self, key: &Key) -> Result { 105 | let mut conn = self.get_conn().await?; 106 | let redis_key = self.concat_key(key); 107 | 108 | let deleted: bool = conn.del(redis_key).await.inspect_err(|err| error!(?err))?; 109 | Ok(deleted) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /pastebin-server/src/web.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::dto::*; 3 | use crate::error::PastebinError; 4 | use crate::error::PastebinErrorCode; 5 | use crate::svc::PastebinService; 6 | 7 | use std::sync::Arc; 8 | use std::time::Duration; 9 | 10 | use axum::error_handling::HandleErrorLayer; 11 | use axum::extract::DefaultBodyLimit; 12 | use axum::extract::Path; 13 | use axum::extract::Request; 14 | use axum::extract::State; 15 | use axum::http::StatusCode; 16 | use axum::middleware::Next; 17 | use axum::response::IntoResponse; 18 | use axum::response::Response; 19 | use axum::routing::get; 20 | use axum::routing::put; 21 | use axum::BoxError; 22 | use axum::Json; 23 | use axum::Router; 24 | 25 | use anyhow::Result; 26 | use serde::Serialize; 27 | use tracing::error; 28 | use tracing::warn; 29 | use tracing_futures::Instrument; 30 | 31 | pub fn build(config: &Config) -> Result { 32 | let svc = PastebinService::new(config)?; 33 | let state = Arc::new(svc); 34 | 35 | let tower_middleware = tower::ServiceBuilder::new() 36 | .layer(HandleErrorLayer::new(handle_error)) 37 | .buffer(4096) 38 | .load_shed() 39 | .rate_limit(config.security.max_qps.into(), Duration::from_secs(1)) 40 | .into_inner(); 41 | 42 | let router = Router::new() 43 | .route("/api/records/:key", get(find_record)) 44 | .route("/api/records", put(save_record)) 45 | .with_state(state.clone()) 46 | .layer(axum::middleware::from_fn_with_state(state, axum_middleware)) 47 | .layer(tower_middleware) 48 | .layer(DefaultBodyLimit::max(config.security.max_http_body_length)); 49 | 50 | Ok(router) 51 | } 52 | 53 | async fn handle_error(err: BoxError) -> Response { 54 | if err.is::() { 55 | warn!("load shed: overloaded"); 56 | return error_response(PastebinErrorCode::Unavailable.into()); 57 | } 58 | 59 | error!(?err); 60 | error_response(PastebinErrorCode::InternalError.into()) 61 | } 62 | 63 | async fn axum_middleware(State(svc): AppState, req: Request, next: Next) -> Response { 64 | let _svc = svc; 65 | 66 | let x_forwarded_for = req.headers().get("x-forwarded-for"); 67 | let x_real_ip = req.headers().get("x-real-ip"); 68 | let span = tracing::debug_span!( 69 | "axum_middleware", 70 | method = %req.method(), 71 | path = %req.uri().path(), 72 | ?x_forwarded_for, 73 | ?x_real_ip 74 | ); 75 | 76 | let res = next.run(req).instrument(span).await; 77 | 78 | // hide error details from serde_json 79 | if res.status() == StatusCode::UNPROCESSABLE_ENTITY { 80 | return StatusCode::UNPROCESSABLE_ENTITY.into_response(); 81 | } 82 | 83 | res 84 | } 85 | 86 | fn json_result(ret: Result) -> Response 87 | where 88 | T: Serialize, 89 | { 90 | match ret { 91 | Ok(val) => Json(val).into_response(), 92 | Err(err) => error_response(err), 93 | } 94 | } 95 | 96 | fn error_response(err: PastebinError) -> Response { 97 | let status = err.code.status(); 98 | let mut res = Json(err).into_response(); 99 | *res.status_mut() = status; 100 | res 101 | } 102 | 103 | type AppState = State>; 104 | 105 | /// GET /api/records/:key 106 | /// 107 | /// -> JSON FindRecordOutput 108 | #[tracing::instrument(skip(svc))] 109 | pub async fn find_record(State(svc): AppState, Path(key): Path) -> Response { 110 | json_result(svc.find_record(FindRecordInput { key }).await) 111 | } 112 | 113 | /// PUT /api/records 114 | /// 115 | /// JSON SaveRecordInput -> JSON SaveRecordOutput 116 | #[tracing::instrument(skip(svc, payload))] 117 | pub async fn save_record(State(svc): AppState, Json(payload): Json) -> Response { 118 | json_result(svc.save_record(payload).await) 119 | } 120 | -------------------------------------------------------------------------------- /pastebin-front/src/pages/EditorPage.vue: -------------------------------------------------------------------------------- 1 |