Skip to content

Repository files navigation

haste-server

中文 · English

線上 Demo:https://haste.exptech.dev/

貼上程式碼或 log,拿到一個短分享碼。單一 Go binary:JSON API、raw 端點、React 前端全部內嵌其中。

  • 短 hash 風格分享碼,且結構上不可能碰撞 —— k7Qm2Xp9,不是 123
  • 寫入即鎖死。 沒有編輯或刪除路徑,且由資料庫本身強制執行。
  • 儲存前先確認 —— 一個對話框收下標題(15 字以內,選填)與保存期限,因為寫入即鎖死,這些之後都改不了。
  • 可設定的臨時貼文 —— 1 小時到 30 天,附即時倒數與 UTC+8 失效時刻。沒設定就只是沒有時限,不代表永久保存。
  • 繁體中文 / English / 日本語,自動偵測瀏覽器語系,可手動切換。
  • 內建 API 文件/docs),可直接試打端點。
  • 空間上限,而非承諾。 每次寫入都檢查容量;除了自己設定的期限之外,不顯示任何伺服器無法保證的保存時間。
  • 行號連結 —— #L17-L25,跟 GitHub 一樣可定位、可分享。
  • 內建字典的極致壓縮。 300 bytes 的 log 片段只佔約 19 bytes。
  • 可下載成檔案,檔名為分享碼加上對應副檔名。
  • SQLite 讀寫分離連線池、WAL、48 MiB page cache。
  • React 19 + Tailwind 4 + shadcn/ui,Shiki 支援 80+ 語言,邊打邊上色,深淺色模式。

快速開始

需要 Go 1.26+、Node 22+,以及 C 工具鏈(zstd 是 C 函式庫)。

cp .env.example .env
make build
./bin/haste

或用 Docker:

docker compose up --build

然後開啟 http://localhost:8080

要改前端的話,把 API 和 Vite dev server 同時跑起來 —— Vite 會把 /api/raw/documents 代理到 8080 port:

make dev # API 在 :8080
make dev-web # UI 在 :5173

開發時請開 :5173 而不是 :8080。改前端要重新 make build 才會嵌進 binary。

操作方式

動作方法
儲存⌘/Ctrl + S,或按儲存 —— 會先跳出確認對話框
命名對話框裡的標題欄,15 字以內,選填
設定期限對話框裡的時鐘選單 —— 1h ~ 30d,或不限制
查看失效時刻點狀態列的倒數 —— 顯示 UTC+8 的絕對時間
切換語言標題列的 文/A 按鈕
載入檔案直接拖進編輯器
選取單行點該行行號 → #L17
選取範圍Shift + 點另一個行號 → #L17-L25
複製連結C —— 有選取行時會一併帶上
複製內容複製鈕;行號不會被複製進去
原始檔 / 下載 / 開新的R / S / N

語言會邊打邊偵測並即時上色。選單顯示 Auto · Dart;自己選過之後就固定不再自動切換。

標題是選填的,最多 15 個字元(不是 bytes —— 15 個中文字就是 15 個字元)。設了標題,連結預覽與瀏覽器分頁就顯示它,取代自動產生的 Python · 410 字元;沒設就照舊。標題和內容一樣不可變更,且會拒收控制字元與雙向覆寫字元 —— 那些在別人的聊天視窗裡是偽裝工具,不是排版工具。

期限預設是「不限制」。選了「不限制」再儲存時會跳出提醒,因為它只代表沒有設定刪除時間,不是承諾永久保存 —— 空間不足時,最久沒被開啟的貼文仍然會先被清掉。

API

所有端點都套用與 UI 相同的字元上限。

# 從檔案建立
curl --data-binary @main.go http://localhost:8080/api/pastes
# 用 JSON 建立,並指定語言
curl -X POST http://localhost:8080/api/pastes \
-H 'Content-Type: application/json' \
-d '{"content":"print(1)","language":"python","title":"正式站崩潰紀錄"}'# 直接送檔案 —— body 就是貼文本身,不帶任何設定。
curl --data-binary @debug.log http://localhost:8080/api/pastes
# 讀回來
curl http://localhost:8080/api/pastes/LkKzpZ2q # JSON,含內容
curl http://localhost:8080/raw/LkKzpZ2q # text/plain# 存成檔案 —— 檔名由伺服器決定
curl -OJ http://localhost:8080/download/LkKzpZ2q # -> LkKzpZ2q.dart

建立成功會回傳分享碼、各種 URL、下載檔名,以及壓縮結果:

{
"key": "LkKzpZ2q",
"url": "http://localhost:8080/LkKzpZ2q",
"rawUrl": "http://localhost:8080/raw/LkKzpZ2q",
"downloadUrl": "http://localhost:8080/download/LkKzpZ2q",
"filename": "LkKzpZ2q.dart",
"language": "dart",
"title": "正式站崩潰紀錄",
"chars": 231,
"bytes": 231,
"stored": 162,
"ratio": 1.43,
"createdAt": "2026-08-18T16:19:25Z",
"expiresAt": "2026-08-18T22:19:25Z"
}

expiresAt 只有在建立時指定了期限才會出現。它是沒有這個欄位,不是 null —— 缺席代表沒設定刪除時間,而不是承諾永久保存。

期限只接受固定的幾個值,不是一個範圍:

0(不限制)· 3600 · 21600 · 43200 · 86400 · 259200 · 604800 · 1209600 · 2592000
1h 6h 12h 1d 3d 7d 14d 30d

其他任何值都回 400 bad_expiry,連 7200(2 小時)這種完全合理的要求也一樣。因為清理是每小時跑一次,相鄰的任意值之間並沒有伺服器真的分辨得出來的差別 —— 收下一個實際上會被抹平成整點的值,比直接拒絕更糟。這份清單由 /api/configexpiryOptionsSecs 發布,前端的選單就是照它建出來的,兩邊不會各說各話。

Method路徑用途
POST/api/pastes建立。JSON 封裝或直接送 raw body。
GET/api/pastes/{key}讀取 JSON,含內容。
GET/raw/{key}text/plain 讀取。
GET/download/{key}下載成 {key}.{副檔名}
GET/api/config伺服器實際套用的限制。
GET/api/stats現存筆數與整體壓縮比。預設關閉。
GET/healthz存活檢查。
POST/documents原版 haste-server 協定。
GET/documents/{key}原版 haste-server 協定。

設定一律走 JSON 封裝。titlelanguageexpiresIn 曾經也能用 query string 帶,現在會被回 400 —— 靜靜忽略的話,還在送 ?expiresIn=1h 的腳本會繼續運作,卻默默產出永不過期的貼文,把一個承諾翻成它的反面。非 JSON 的 body 整份當成貼文內容,這也是既有 CLI 包裝工具的用法。

/documents 使用原版 haste 的傳輸格式,既有的 CLI 包裝工具不必改就能繼續用。

/api/stats 預設回 404。它是運維端點,不是公開端點 —— 那些數字對攻擊者比對使用者有用得多:總量每次只跳動一則貼文,輪詢就能推出每一則新貼文的原始與壓縮大小;usedFraction 讓灌爆空間上限變成一件有進度條的事;而 count 下降就是別人的貼文正在被擠掉的收據。要開啟就設 HASTE_STATS=token(配 HASTE_STATS_TOKEN)或 HASTE_STATS=public

同一份說明在 /docs 有可互動的版本:每個端點都能展開看參數、回應與 curl 範例,並直接對你正在瀏覽的這台伺服器試打。

錯誤一律回 {"error": "code", "message": "..."} 並搭配對應狀態碼:400 空白或格式錯誤、413 超過上限、429 觸發流量限制、503 寫入佇列已滿、404 找不到或已被清除。

設定

所有設定都在 .env(註解完整的清單見 .env.example)。實際的環境變數優先於檔案內容。

變數預設值說明
HASTE_ADDR:8080監聽位址。
HASTE_MAX_CHARS40000算 Unicode 字元,不是 bytes。
HASTE_CODE_MIN_LEN8分享碼最短長度,1–10 個 base62 字元。
HASTE_MAX_BYTES1GiB硬上限;寫入時淘汰 LRU。0 = 不限。
HASTE_TTL_ACCESS關閉多久沒被讀取就清除。
HASTE_TTL_CREATE關閉建立超過多久就清除。
HASTE_CLEANUP_INTERVAL1h套用兩個 TTL 的掃描間隔。
HASTE_ZSTD_LEVEL191–22。
HASTE_WRITE_CONCURRENCYCPU 核心數同時寫入數;超過則排隊。
HASTE_WRITE_QUEUE512排隊上限,超過直接回 503。
HASTE_SQLITE_CACHE_MB48每條連線的 page cache。
HASTE_READ_POOLmin(NumCPU, 8)讀取連線數;寫入端永遠只有 1 條。
HASTE_RATE_RPS1每 IP 每秒可建立筆數;0 表示不限制。
HASTE_STATSoff/api/stats 給誰看:off / token / public
HASTE_STATS_TOKENtoken 模式的 bearer token,至少 16 字元。
HASTE_BASE_URL自動推導放在反向代理後面時要設;也是 /docs 顯示的 Base URL。
HASTE_TRUST_PROXYfalse只在你自己掌控的代理後面才開啟。

運作原理

分享碼

分享碼來自計數器而非亂數,所以碰撞不是「機率很低」,而是結構上不可能,也永遠不需要對資料庫做重試迴圈。

但直接發放計數器值,會讓每一筆貼文都只差一個增量就能在網址列被猜到。因此計數器會經過一個帶金鑰的 Feistel network 搭配 cycle walking —— 這是對「該長度的碼空間」的雙射:仍然唯一,但輸出與隨機 base62 hash 無從區分。連續建立的貼文長這樣:

wxaTLCgp DDj5XO4k ACHwVAYu idpfsjAB G0VtfB3v

分享碼從 HASTE_CODE_MIN_LEN 個字元起跳(預設 8,共 2.2e14 組),只有在該空間真的用盡時才會變長。面對暴力掃描,長度是唯一有意義的變因,所以除非你特別想要極短連結,否則不建議調低:

長度組合數
65.7e10
73.5e12
82.2e14
91.4e16

金鑰來自 HASTE_ID_SECRET,未設定時會在首次啟動時產生並持久化。更換金鑰不會破壞既有貼文:分享碼是存下來的,不是即時推導的。

產生器也會拒絕任何會遮蔽路由的碼(apirawdownloadmanifest 等),因此分享連結與伺服器路徑在任一方向都不可能衝突。

不可變性

貼文寫入即鎖死。沒有更新或刪除端點,而且即使有寫入真的抵達資料表,BEFORE UPDATE trigger 也會直接中止 —— 所以未來任何程式路徑、migration,或是有人開 sqlite3 進去,都無法悄悄改寫一個已經分享出去的碼。資料只會被整列移除,永遠不會被改寫。

trigger 明確列出要保護的欄位,因此 accessed_at(LRU 用的存取時間)仍可寫入,而讀者能觀察到的一切都是凍結的。

行號連結

行號是用 CSS counter 畫出來的,不是文字,因此永遠不會被拖進選取範圍、跟著程式碼一起被複製。但這樣就沒有東西可以點,所以每一行另外掛了一個空的 anchor 疊在行號區上:可點、可連結,本身沒有任何文字會跑到剪貼簿。

選取狀態只存在於 URL fragment,沒有第二份狀態 —— 網址列看到的連結,永遠就是對方會收到的連結。

Log 嚴重等級

貼上 log 時,TRACE / DEBUG / INFO / WARN / ERROR / FATAL 會依嚴重程度上色。

Shiki 的 log grammar 本身有標出 log.errorlog.warning 這類 scope,但 GitHub 主題沒有對應規則,於是每個等級都掉回它搭便車的通用 scope —— 結果是反的:WARN 繼承 markup.deleted 變紅色,ERROR 繼承 string.regexp 變藍色,比上一行的警告看起來還平靜,而淺色模式下更是幾乎與內文同色。因此這裡替那些 scope 補上了主題規則,用 GitHub 自家的 Primer 色票,讓等級順序回到讀者預期的樣子。

保留策略

保留是預算,不是承諾。唯一的例外是貼文自己要求的期限:那是對拿到連結的人做出的承諾,所以它在清理順序中排第一位,也是 UI 上唯一會顯示的時間。

期限存的是絕對時刻,不是一段長度,所以伺服器重啟不會讓貼文多活一輪。到期後立刻停止提供 —— 讀取查詢本身就會過濾掉過期的列 —— 連結因此準時失效;實際從資料庫刪除則等下一次清理,最多晚一小時。這個延遲寫在期限選單裡,不必讓人自己推敲。到期時刻本身也不可變更:expires_at 和內容一樣被 trigger 鎖住,寫下去就不能改。

HASTE_MAX_BYTES 是唯一的硬保證。它在每次 insert 的同一個交易裡檢查,必要時淘汰最久沒被讀取的貼文來騰出空間。只靠每小時掃描是不夠的:一波突發流量能讓資料庫超標整整一小時。另有兩個選用的 TTL 做額外修剪,一個看最後存取、一個看建立時間,兩者預設都關閉,留空即代表停用該規則。掃描時依優先度套用:貼文自己的期限 → 空間 → 存取時間 → 建立時間。到期的先清,它們騰出的空間才能算進容量,而不是反過來把還活著的貼文擠掉。

要追蹤最後存取時間就得在讀取時寫入,那會讓每一次讀取都塞回單一寫入連線。所以讀取只把時間戳記排進記憶體,每分鐘用一個交易批次寫回。掉了一次 flush 只會損失 LRU 的精確度,不會掉資料。不可變性 trigger 明確列出內容欄位,因此 accessed_at 仍可移動,而讀者能觀察到的一切維持凍結。

字元上限預設 40000,是經典 haste-server(maxLength: 400000)的十分之一。這個選擇有實測依據:40000 字元約等於 300 行結構化 log、1500 行原始碼,或 40000 個中文字(約 30 頁 A4);同時它是編輯器還能在 16 ms 一幀內完成即時上色的最後尺寸(40k 約 14 ms、60k 約 21 ms、100k 約 37 ms)。再往上就得關掉上色才能維持流暢,那個代價比多出來的空間更不值得。

請求 body 上限是字元上限的 12 倍 bytes。用 4 倍(UTF-8 一個字元最多 4 bytes)看似夠,但 JSON 允許把字元寫成 \uXXXX,而 Python 的 json.dumps 預設就這麼做 —— 一個中文字變 6 bytes,BMP 以外的字元是代理對共 12 bytes 卻只算一個 code point。反向代理的 client_max_body_size 要跟著放行。

容量該設多少取決於大家貼什麼。以下是 4000 字元的實測(滿版 40000 字元的 log 約 1 KB,不可壓縮的中文約 85 KB):

內容原始壓縮後磁碟/筆1 GiB 可放
Go 原始碼4000 B250 B360 B3.0M
結構化/JSON log4000 B~325 B442 B2.4M
英文散文4000 B1048 B1434 B749k
中文散文12 KB3907 B4162 B258k
不可壓縮中文12 KB8734 B8937 B120k

壓縮

貼文頂多幾 KB,而這正是通用壓縮器最吃虧的長度:大半輸入都花在讓壓縮器「認識」這份資料,之後才有辦法便宜地編碼。所以這裡預先準備了一份常見原始碼與 log 片段的字典(dict/v1.txt),一開始就把模型餵給它。每筆貼文會同時壓「有字典」和「沒字典」兩種版本,取較小的那個,並逐列記錄使用的 codec,因此日後修改字典也不會讓已存資料失效。

演算法與等級都是量出來的,不是猜的。以 160 筆滿版貼文(log、程式碼、散文、不可壓縮資料)實測:

Codec字典B/筆編碼解碼
zstd -19 + 字典760345 µs4 µs
brotli q117664.1 ms14 µs
zstd -19799576 µs4 µs
zstd -4 + 字典81115 µs4 µs
deflate -9 + 字典84199 µs14 µs
gzip -991682 µs17 µs
xz (LZMA2)954577 µs211 µs
bzip2 -9965267 µs61 µs

bzip2 和 xz 敬陪末座並不意外,只要把輸入大小算進去就懂了:區塊排序與大型 LZMA 視窗都需要遠超過 4 KB 的資料才能回本。level 20–22 在這裡產出與 19 完全相同的位元組,所以預設之上已無空間可爭。

「兩種都壓、取小的」大約值 1% 的總儲存空間,而這兩次壓縮彼此獨立,因此分在不同核心上跑。字典那條路有了現成模型,耗時大約只有純壓縮的一半,所以重疊之後拿到的是「較慢的那個」而非兩者之和:位元組完全相同,單筆寫入從 967 µs 降到 661 µs。

剩下的槓桿在字典而非演算法。用貼文訓練出來的字典(而非手寫)在保留評估集上量到 705 B/筆 —— 再省 7%,而且超過約 16 KB 的字典就沒有增益了。不過那個數字來自與評估集同源的合成樣本,在真實流量上重現之前,請當成上界看待。

儲存

SQLite 在 WAL 模式下允許一個寫入者與多個並行讀取者,所以伺服器就照這個形狀去建模:一條立即取得鎖的單連線寫入端,加上一池釘死在 query_only 的讀取連線。讀取完全不碰寫入鎖,也就消除了「共用連線池交錯讀寫」所導致的 database is locked 這類錯誤。

每條連線各自擁有 HASTE_SQLITE_CACHE_MB 的 page cache(預設 48 MiB),另有共享的 256 MiB mmap 視窗。只要掃描有實際刪除,就會順便做 WAL checkpoint,確保空間真的被釋放。

寫入需要入場控制,但原因跟直覺不同。SQLite 本身已經是隊列 —— SetMaxOpenConns(1) 會把交易 FIFO 序列化,而一次 insert 只要約 100 µs。真正昂貴的是壓縮,而它在交易之前執行、本身沒有任何上限。512 併發實測:放任不管時 p99 296 ms、最差 523 ms;限制成每核心一個寫入、佇列滿了就回 503,在相同吞吐下 p99 降到 111 ms、最差 117 ms。

編輯器

編輯器是一個透明的 textarea 疊在同一份文字的高光副本上。有兩件事讓它不會散掉:兩層由單一份排版度量驅動,而不是兩份會各自漂移的定義;以及只有外層容器捲動,因此不必同步捲動位置,也不會發生「其中一層有捲軸、文字變窄、換行位置就不同」的問題。

上色是在 render 期間同步計算的。若改成非同步重繪,可見文字會比游標慢一幀 —— 那看起來就像壞掉。

測試

make test

Go 測試涵蓋真正重要的不變量:跨層級的分享碼唯一性、每一層確實是一個置換、分享碼遵守最短長度且不洩漏順序、不可變性 trigger、讀取池拒絕寫入、pragma 確實套用到兩個連線池、空間上限在每一次寫入都成立、淘汰時移除的是最久沒被讀取的而非單純最舊的、兩個 TTL(含「預設關閉」這件事本身)、讀取在 flush 前不會寫入、寫入佇列滿時會拒絕、併發建立不重複、各語言的下載檔名,以及完整的 HTTP 介面(含各項限制與 raw 端點的防護標頭)。

有兩組測試是「報告」而非「斷言」,因為它們量的是所在機器:TestStorageFootprint 印出各類內容滿版貼文的實際磁碟成本,TestLevelTradeoff 印出每個 zstd 等級的大小與時間。它們支持的結論則由旁邊的一般斷言鎖住。

前端測試守住語言偵測 —— 那是一堆啟發式規則,而啟發式最容易悄悄退化:為某個語言放寬的規則,會默默搶走另一個語言的貼文。languages.test.ts 裡的每一個案例都來自真實的誤判,所以這份語料庫只會增加、不會刪減。lines.test.ts 則涵蓋 fragment 解析與範圍選取,包含 shift 往上點所產生的反向範圍。

專案結構

cmd/haste/ 進入點、graceful shutdown、保留策略掃描器
internal/config/ .env 載入與驗證
internal/id/ 計數器轉短碼(分層 + Feistel 置換)
internal/compress/ zstd codec 與內嵌字典
internal/store/ SQLite schema、讀寫連線池、查詢
internal/httpapi/ 路由、middleware、流量限制、SPA 服務
internal/webui/ 內嵌的前端建置產物
web/ React + Tailwind + shadcn/ui 原始碼

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages