Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

This document provides detailed information about the Save-Rust API endpoints, including request/response schemas and error handling.

## Transport

Save-Rust always serves its API over a Unix domain socket.

Localhost TCP on `127.0.0.1:8080` is disabled by default. To enable it explicitly, set both `SAVE_ENABLE_TCP=1` and `SAVE_API_TOKEN=<random-secret>`.

When TCP is enabled, requests to `/api/*` must include:

```http
Authorization: Bearer <SAVE_API_TOKEN>
```

## Table of Contents
- [General Endpoints](#general-endpoints)
- [Groups Endpoints](#groups-endpoints)
Expand Down Expand Up @@ -491,4 +503,4 @@ Error Response (404 Not Found):
"status": "error",
"error": "Group, repository, or file not found: [detailed error message]"
}
```
```
24 changes: 12 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ ios = []
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
save-dweb-backend = { git = "https://github.com/OpenArchive/save-dweb-backend", tag = "v0.3.3" }
save-dweb-backend = { git = "https://github.com/OpenArchive/save-dweb-backend", tag = "v0.3.4" }
tokio = { version = "^1.43", default-features = false, features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Expand Down Expand Up @@ -63,10 +63,10 @@ serial_test = "2.0"
# Patch iroh crates to relax hickory-resolver pin for veilid-core 0.5.2 compat.
# All iroh workspace crates must come from the same source to avoid duplicate type errors.
[patch.crates-io]
iroh = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" }
iroh-net = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" }
iroh-base = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" }
iroh-metrics = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" }
iroh-blobs = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" }
iroh-docs = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" }
iroh-gossip = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" }
iroh = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" }
iroh-net = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" }
iroh-base = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" }
iroh-metrics = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" }
iroh-blobs = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" }
iroh-docs = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" }
iroh-gossip = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" }
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,20 @@ The project temporarily patches `veilid-core` `v0.5.1` via the `[patch."https://

# API Documentation

The Save-Rust API provides HTTP endpoints for managing groups, repositories, and media files. For detailed API documentation including request/response schemas and error handling, please see [API.md](API.md).
The Save-Rust API provides endpoints for managing groups, repositories, and media files. For detailed API documentation including request/response schemas and error handling, please see [API.md](API.md).

## Transport

The service always listens on a Unix domain socket.

Localhost TCP on `127.0.0.1:8080` is disabled by default. To enable it explicitly, set:

- `SAVE_ENABLE_TCP=1`
- `SAVE_API_TOKEN=<random-secret>`

When TCP is enabled, requests under `/api` must include:

- `Authorization: Bearer <SAVE_API_TOKEN>`

## Available Endpoints

Expand Down Expand Up @@ -64,4 +77,4 @@ Base path: `/api/groups/{group_id}/repos/{repo_id}/media`
* `GET /{file_name}` - Downloads a specific file from a repository.
* `DELETE /{file_name}` - Deletes a specific file from a repository.

For detailed information about request/response formats, error handling, and examples, please refer to the [API Documentation](API.md).
For detailed information about request/response formats, error handling, and examples, please refer to the [API Documentation](API.md).
8 changes: 6 additions & 2 deletions src/bin/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
//! cargo run --bin save-server [-- <base_dir>]
//!
//! The server listens on:
//! - HTTP: http://0.0.0.0:8080
//! - Unix socket: <base_dir>/save-server.sock
//! - Optional HTTP: http://127.0.0.1:8080 when SAVE_ENABLE_TCP=1 and SAVE_API_TOKEN is set
//!
//! Set RUST_LOG to control log verbosity, e.g.:
//! RUST_LOG=debug cargo run --bin save-server
Expand Down Expand Up @@ -35,7 +35,11 @@ async fn main() -> anyhow::Result<()> {
println!("save-server v{}", env!("CARGO_PKG_VERSION"));
println!(" Data directory: {base_dir}");
println!(" Unix socket: {socket_path}");
println!(" HTTP: http://127.0.0.1:8080");
println!(" HTTP: disabled by default");
if std::env::var("SAVE_ENABLE_TCP").ok().as_deref() == Some("1") {
println!(" HTTP: http://127.0.0.1:8080");
println!(" Auth: Authorization: Bearer $SAVE_API_TOKEN");
}

save::server::start(&base_dir, &socket_path).await
}
101 changes: 99 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@ pub mod utils;
#[cfg(test)]
mod tests {
use std::env;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;

use super::*;
use actix_web::{test, web, App};
use actix_web::{http::header, middleware, test, web, App, HttpResponse};
use anyhow::Result;
use models::{RequestName, RequestUrl, SnowbirdFile, SnowbirdGroup, SnowbirdRepo};
use save_dweb_backend::{common::DHTEntity, constants::TEST_GROUP_NAME};
use serde::{Deserialize, Serialize};
use serde_json::json;
use server::{status, health, set_backend, clear_backend};
use server::{clear_backend, health, require_tcp_api_token, set_backend, status, TcpAuthConfig};
use tmpdir::TmpDir;
use base64_url::base64;
use base64_url::base64::Engine;
Expand Down Expand Up @@ -319,6 +320,102 @@ mod tests {

Ok(())
}

#[actix_web::test]
async fn test_api_middleware_allows_requests_without_peer_addr() {
let app = test::init_service(
App::new()
.wrap(middleware::from_fn(require_tcp_api_token))
.service(
web::scope("/api").route(
"/probe",
web::get().to(|| async { HttpResponse::Ok().json(json!({ "status": "ok" })) }),
),
),
)
.await;

let req = test::TestRequest::get().uri("/api/probe").to_request();
let resp = test::call_service(&app, req).await;

assert!(resp.status().is_success());
}

#[actix_web::test]
async fn test_api_middleware_rejects_tcp_requests_without_token() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(TcpAuthConfig {
token: Arc::new("secret-token".to_string()),
}))
.wrap(middleware::from_fn(require_tcp_api_token))
.service(
web::scope("/api").route(
"/probe",
web::get().to(|| async { HttpResponse::Ok().json(json!({ "status": "ok" })) }),
),
),
)
.await;

let req = test::TestRequest::get()
.uri("/api/probe")
.peer_addr(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 45678))
.to_request();
let resp = test::call_service(&app, req).await;

assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
}

#[actix_web::test]
async fn test_api_middleware_allows_tcp_requests_with_token() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(TcpAuthConfig {
token: Arc::new("secret-token".to_string()),
}))
.wrap(middleware::from_fn(require_tcp_api_token))
.service(
web::scope("/api").route(
"/probe",
web::get().to(|| async { HttpResponse::Ok().json(json!({ "status": "ok" })) }),
),
),
)
.await;

let req = test::TestRequest::get()
.uri("/api/probe")
.peer_addr(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 45678))
.insert_header((header::AUTHORIZATION, "Bearer secret-token"))
.to_request();
let resp = test::call_service(&app, req).await;

assert!(resp.status().is_success());
}

#[actix_web::test]
async fn test_non_api_routes_do_not_require_tcp_token() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(TcpAuthConfig {
token: Arc::new("secret-token".to_string()),
}))
.wrap(middleware::from_fn(require_tcp_api_token))
.service(status)
.service(health),
)
.await;

let req = test::TestRequest::get()
.uri("/status")
.peer_addr(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 45678))
.to_request();
let resp = test::call_service(&app, req).await;

assert!(resp.status().is_success());
}

#[actix_web::test]
#[serial]
async fn test_upload_list_delete() -> Result<()> {
Expand Down
Loading
Loading