Skip to content

Commit 8bd82cc

Browse files
authored
feat: storage streaming (#22)
## Summary - add memory, local filesystem, and S3-compatible paste storage backends - run SeaweedFS as the development S3 service through Docker Compose - stream raw API uploads through bounded private spool files while calculating size and SHA-256 - store UTF-8/NUL-free textual pastes up to 8 KiB inline in PostgreSQL and use blob storage for larger or binary content - persist normalized content types and safely represent binary bodies in the API, Rust client, CLI, and LiveView - harden upload finalization, read integrity, cleanup, and raw browser responses ## Storage and upload behavior Paste bodies are limited to 1 MiB by default. Raw request bodies are read in bounded 64 KiB chunks into private temporary files while size and SHA-256 are calculated. Bodies are stored inline in PostgreSQL only when they: - are at or below `TEXTBIN_INLINE_PASTE_BYTES` (8 KiB by default) - have a textual content type - contain valid UTF-8 without NUL bytes All other bodies use the configured local or S3-compatible backend. Development uses SeaweedFS through Docker Compose; simple self-hosting can select local filesystem storage. ## Safety and durability - synchronize local file contents, renames, directory creation, deletion, and temporary-file cleanup before acknowledging operations - journal pending blob uploads in PostgreSQL so interrupted uploads can be recovered after process or host failure - atomically coordinate cleanup claims with paste insertion to prevent committed objects from being deleted - register active spool files by owner process and periodically remove only stale, unowned files - verify persisted size and SHA-256 whenever paste content is read - disable redirects and retries for manually signed one-shot S3 streams - limit persisted content types to the database boundary Raw browser responses only allow passive `text/plain` and `application/json` content inline. HTML, XML, SVG, JavaScript, binary, and other user-provided types are attachments. Responses include `X-Content-Type-Options: nosniff` and a sandboxed CSP. ## API and CLI compatibility Text responses retain the existing `data` string. Binary responses use: ```json { \"data\": null, \"data_base64\": \"/wAB\", \"data_encoding\": \"base64\", \"content_type\": \"application/octet-stream\" } ``` The Rust client now exposes paste data as bytes and preserves the text/binary discriminator. The workspace version is bumped to `0.2.0` to document this deliberate pre-1.0 compatibility change. CLI raw output preserves arbitrary bytes exactly. ## Database changes - add storage metadata and location fields to pastes - add normalized `content_type` - add `pending_uploads` cleanup journal with claim leases ## CI and verification GitHub Actions exercises memory, local filesystem, and real S3-compatible storage through a matrix. SeaweedFS is only started for the S3 matrix entry. Validated locally with: - `mix precommit` — 268 tests - memory backend — 268 tests - local backend — 268 tests - SeaweedFS/S3 backend — 268 tests - `cargo test --workspace` — 53 tests - `make lint` — Credo and Clippy pass A final Oracle review returned **SHIP** with no remaining blockers. --------- Co-authored-by: Darwin D Wu <darwin67@users.noreply.github.com>
1 parent 460660a commit 8bd82cc

37 files changed

Lines changed: 2133 additions & 112 deletions

‎Cargo.lock‎

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ members = ["crates/cli", "crates/client"]
33
resolver = "3"
44

55
[workspace.package]
6-
version = "0.1.0"
6+
version = "0.2.0"
77
edition = "2024"
88
license = "GPL-3.0-or-later"
99

1010
[workspace.dependencies]
1111
anyhow = "1.0.100"
12+
base64 = "0.22.1"
1213
clap = { version = "4.6.1", features = ["derive", "env"] }
1314
directories = "6.0.0"
1415
keyring = "4.1.5"

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ Then run `mix setup` and `mix phx.server` locally as usual.
1919

2020
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
2121

22+
## API binary content
23+
24+
API v1 returns UTF-8 textual paste bodies in `data`. Arbitrary binary bodies use
25+
an explicit Base64 representation so JSON remains valid:
26+
27+
```json
28+
{
29+
"data": null,
30+
"data_base64": "/wAB",
31+
"data_encoding": "base64",
32+
"content_type": "application/octet-stream"
33+
}
34+
```
35+
36+
The `textbin-client` 0.2 release reflects this binary-capable contract by
37+
exposing `Paste.data` as bytes instead of a Rust `String`. This is a deliberate
38+
pre-1.0 breaking change; consumers that only accept text should use
39+
`Paste::text()`.
40+
2241
## Learn more
2342

2443
* Official website: https://www.phoenixframework.org/

‎config/config.exs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ config :textbin,
2626
allow_guest_pastes: false,
2727
guest_paste_ttl: "6h",
2828
max_paste_bytes: 1_048_576,
29+
inline_paste_bytes: 8_192,
30+
upload_tmp_dir: Path.join(System.tmp_dir!(), "textbin-uploads"),
31+
upload_cleanup_enabled: true,
32+
upload_cleanup_interval_ms: :timer.minutes(15),
33+
upload_cleanup_stale_after_seconds: :timer.hours(1) |> div(1_000),
2934
expiration_cleanup_enabled: true,
3035
expiration_cleanup_interval_ms: :timer.minutes(15),
3136
expiration_cleanup_batch_size: 500

‎config/runtime.exs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ if System.get_env("PHX_SERVER") do
2020
config :textbin, TextbinWeb.Endpoint, server: true
2121
end
2222

23+
if upload_tmp_dir = System.get_env("TEXTBIN_UPLOAD_TMP_DIR") do
24+
config :textbin, upload_tmp_dir: upload_tmp_dir
25+
end
26+
27+
if inline_paste_bytes = System.get_env("TEXTBIN_INLINE_PASTE_BYTES") do
28+
case Integer.parse(inline_paste_bytes) do
29+
{value, ""} when value >= 0 -> config :textbin, inline_paste_bytes: value
30+
_result -> raise "TEXTBIN_INLINE_PASTE_BYTES must be a non-negative integer"
31+
end
32+
end
33+
2334
storage_backend = System.get_env("TEXTBIN_STORAGE_BACKEND")
2435

2536
case storage_backend do

‎crates/cli/src/base/show.rs‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ pub fn handle(args: &ShowArgs, settings: &Settings) -> Result<()> {
4343
let client = settings.client()?;
4444
let paste = client.get_paste(&args.id)?;
4545

46-
if args.raw {
46+
if args.raw || paste.text().is_none() {
4747
write_raw(io::stdout().lock(), &paste.data)?;
4848
return Ok(());
4949
}
@@ -56,8 +56,8 @@ pub fn handle(args: &ShowArgs, settings: &Settings) -> Result<()> {
5656
Ok(())
5757
}
5858

59-
fn write_raw(mut writer: impl Write, data: &str) -> io::Result<()> {
60-
writer.write_all(data.as_bytes())
59+
fn write_raw(mut writer: impl Write, data: &[u8]) -> io::Result<()> {
60+
writer.write_all(data)
6161
}
6262

6363
fn print_code_area(content: &str) {
@@ -73,23 +73,27 @@ fn format_code_area(content: &str) -> String {
7373
}
7474

7575
fn render_paste(paste: &Paste, use_color: bool) -> Result<String> {
76+
let data = paste
77+
.text()
78+
.context("binary paste cannot be rendered as text")?;
79+
7680
if use_color {
77-
highlight_paste(paste)
81+
highlight_paste(paste, data)
7882
} else {
79-
Ok(paste.data.clone())
83+
Ok(data.to_string())
8084
}
8185
}
8286

83-
fn highlight_paste(paste: &Paste) -> Result<String> {
84-
let language = Language::guess(Some(&paste.syntax_highlight), &paste.data);
87+
fn highlight_paste(paste: &Paste, data: &str) -> Result<String> {
88+
let language = Language::guess(Some(&paste.syntax_highlight), data);
8589
let theme = themes::get("onedark").context("failed to load Lumis theme: onedark")?;
8690
let formatter = TerminalBuilder::new()
8791
.language(language)
8892
.theme(Some(theme))
8993
.build()
9094
.context("failed to build terminal syntax highlighter")?;
9195

92-
Ok(lumis::highlight(&paste.data, formatter))
96+
Ok(lumis::highlight(data, formatter))
9397
}
9498

9599
#[cfg(test)]
@@ -98,9 +102,11 @@ mod tests {
98102

99103
fn paste(data: &str, syntax_highlight: &str) -> Paste {
100104
Paste {
101-
data: data.to_string(),
105+
data: data.as_bytes().to_vec(),
106+
content_type: "text/plain".to_string(),
102107
syntax_highlight: syntax_highlight.to_string(),
103108
visibility: "private".to_string(),
109+
is_text: true,
104110
}
105111
}
106112

@@ -118,7 +124,7 @@ mod tests {
118124
fn write_raw_preserves_content_without_adding_a_newline() {
119125
let mut output = Vec::new();
120126

121-
write_raw(&mut output, "paste without newline").unwrap();
127+
write_raw(&mut output, b"paste without newline").unwrap();
122128

123129
assert_eq!(output, b"paste without newline");
124130
}
@@ -127,11 +133,20 @@ mod tests {
127133
fn write_raw_preserves_trailing_newlines() {
128134
let mut output = Vec::new();
129135

130-
write_raw(&mut output, "paste\n\n").unwrap();
136+
write_raw(&mut output, b"paste\n\n").unwrap();
131137

132138
assert_eq!(output, b"paste\n\n");
133139
}
134140

141+
#[test]
142+
fn write_raw_preserves_arbitrary_binary_data() {
143+
let mut output = Vec::new();
144+
145+
write_raw(&mut output, &[255, 0, 1]).unwrap();
146+
147+
assert_eq!(output, [255, 0, 1]);
148+
}
149+
135150
#[test]
136151
fn render_paste_without_color_returns_raw_data() {
137152
let paste = paste("fn main() {}\n", "rust");

‎crates/client/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ edition.workspace = true
55
license.workspace = true
66

77
[dependencies]
8+
base64.workspace = true
89
reqwest.workspace = true
910
serde.workspace = true
1011
serde_json.workspace = true

‎crates/client/src/lib.rs‎

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
use base64::Engine as _;
12
use reqwest::StatusCode;
23
use reqwest::blocking::Body;
34
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
45
use serde::Deserialize;
6+
use serde::Deserializer;
7+
use serde::de::Error as _;
58
use std::collections::BTreeMap;
69
use std::error;
710
use std::fmt;
@@ -309,16 +312,74 @@ pub struct ApiTokenMetadata {
309312
pub inserted_at: String,
310313
}
311314

312-
#[derive(Debug, Clone, Deserialize)]
315+
#[derive(Debug, Clone)]
313316
pub struct Paste {
314-
pub data: String,
317+
pub data: Vec<u8>,
318+
pub content_type: String,
315319
pub syntax_highlight: String,
316320
pub visibility: String,
321+
pub is_text: bool,
322+
}
323+
324+
impl Paste {
325+
pub fn text(&self) -> Option<&str> {
326+
if self.is_text && !self.data.contains(&0) {
327+
std::str::from_utf8(&self.data).ok()
328+
} else {
329+
None
330+
}
331+
}
332+
}
333+
334+
impl<'de> Deserialize<'de> for Paste {
335+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
336+
where
337+
D: Deserializer<'de>,
338+
{
339+
let paste = PasteResponse::deserialize(deserializer)?;
340+
let (data, is_text) = match (
341+
paste.data,
342+
paste.data_base64,
343+
paste.data_encoding.as_deref(),
344+
) {
345+
(Some(data), None, None) => (data.into_bytes(), true),
346+
(None, Some(data), Some("base64")) => base64::engine::general_purpose::STANDARD
347+
.decode(data)
348+
.map(|data| (data, false))
349+
.map_err(D::Error::custom)?,
350+
_ => return Err(D::Error::custom("invalid paste data representation")),
351+
};
352+
353+
Ok(Self {
354+
data,
355+
content_type: paste.content_type,
356+
syntax_highlight: paste.syntax_highlight,
357+
visibility: paste.visibility,
358+
is_text,
359+
})
360+
}
361+
}
362+
363+
#[derive(Deserialize)]
364+
struct PasteResponse {
365+
data: Option<String>,
366+
data_base64: Option<String>,
367+
data_encoding: Option<String>,
368+
#[serde(default = "default_content_type")]
369+
content_type: String,
370+
syntax_highlight: String,
371+
visibility: String,
372+
}
373+
374+
fn default_content_type() -> String {
375+
"text/plain".to_string()
317376
}
318377

319378
#[derive(Debug, Clone, Deserialize)]
320379
pub struct CreatedPaste {
321380
pub id: String,
381+
#[serde(default = "default_content_type")]
382+
pub content_type: String,
322383
pub syntax_highlight: String,
323384
pub visibility: String,
324385
pub expires_at: Option<String>,
@@ -559,6 +620,48 @@ mod tests {
559620
assert_eq!(created.token.name, "Laptop");
560621
}
561622

623+
#[test]
624+
fn decodes_text_and_binary_paste_representations() {
625+
let text = decode_json::<ShowResponse>(
626+
r#"{"data":{"data":"hello","content_type":"text/plain","syntax_highlight":"plain","visibility":"public"}}"#,
627+
)
628+
.unwrap()
629+
.data;
630+
let binary = decode_json::<ShowResponse>(
631+
r#"{"data":{"data":null,"data_base64":"/wAB","data_encoding":"base64","content_type":"application/octet-stream","syntax_highlight":"plain","visibility":"public"}}"#,
632+
)
633+
.unwrap()
634+
.data;
635+
636+
assert_eq!(text.data, b"hello");
637+
assert_eq!(text.text(), Some("hello"));
638+
assert_eq!(binary.data, [255, 0, 1]);
639+
assert_eq!(binary.text(), None);
640+
}
641+
642+
#[test]
643+
fn base64_representation_remains_binary_for_utf8_shaped_bytes() {
644+
for encoded in ["aGVsbG8=", "AA=="] {
645+
let body = format!(
646+
r#"{{"data":{{"data":null,"data_base64":"{encoded}","data_encoding":"base64","content_type":"application/octet-stream","syntax_highlight":"plain","visibility":"public"}}}}"#
647+
);
648+
let paste = decode_json::<ShowResponse>(&body).unwrap().data;
649+
650+
assert_eq!(paste.text(), None);
651+
}
652+
}
653+
654+
#[test]
655+
fn defaults_content_type_for_older_server_responses() {
656+
let paste = decode_json::<ShowResponse>(
657+
r#"{"data":{"data":"hello","syntax_highlight":"plain","visibility":"public"}}"#,
658+
)
659+
.unwrap()
660+
.data;
661+
662+
assert_eq!(paste.content_type, "text/plain");
663+
}
664+
562665
#[test]
563666
fn builds_canonical_paste_url() {
564667
let client = Client::new("https://demo.textbin.com/");

‎lib/textbin/application.ex‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ defmodule Textbin.Application do
1313
Textbin.Repo,
1414
{DNSCluster, query: Application.get_env(:textbin, :dns_cluster_query) || :ignore},
1515
{Phoenix.PubSub, name: Textbin.PubSub}
16-
] ++ storage_children() ++ expiration_cleanup_children() ++ [TextbinWeb.Endpoint]
16+
] ++
17+
storage_children() ++
18+
expiration_cleanup_children() ++ upload_cleanup_children() ++ [TextbinWeb.Endpoint]
1719

1820
# See https://hexdocs.pm/elixir/Supervisor.html
1921
# for other strategies and supported options
@@ -37,6 +39,14 @@ defmodule Textbin.Application do
3739
end
3840
end
3941

42+
defp upload_cleanup_children do
43+
if Application.get_env(:textbin, :upload_cleanup_enabled, true) do
44+
[Textbin.Pastes.UploadCleaner]
45+
else
46+
[]
47+
end
48+
end
49+
4050
defp storage_children do
4151
case Textbin.Storage.child_spec() do
4252
nil -> []

0 commit comments

Comments
 (0)