steid

@jamesgill /

1//! Downloading one instance backup — `/{handle}/settings/backup.tar.gz`.
2//!
3//! A route rather than a page, exactly like [`archive`](super::archive): the response is
4//! a file, and one as large as the whole instance, so it streams rather than being
5//! collected.
6//!
7//! Unlike a repository archive it cannot stream straight out of a subprocess: a backup
8//! has to *exist* before it can be sent — the database snapshot is written, the tar is
9//! built, and only then is there anything to hand over. So it is built in a scratch
10//! directory inside the data directory, opened, and **unlinked while open**: the
11//! response reads from a file descriptor pointing at bytes with no name, which is what
12//! guarantees the copy of the database cannot outlive the request whatever happens to
13//! it. A client that disconnects mid-download leaves nothing behind.
14
15use std::{path::PathBuf, time::SystemTime};
16
17use topcoat::{
18 Result,
19 context::{Cx, app_context},
20 router::{
21 Response,
22 error::not_found,
23 header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE},
24 route,
25 },
26};
27
28use crate::{
29 application::{AppConfig, Error, archive_name, back_up_for},
30 domain::DomainError,
31 infrastructure::backup::{SqliteDatabaseSnapshot, TarGzBackupArchive, database_file},
32};
33
34use super::{
35 context::{current_actor, instance_owner, orgs, repos, server_error, users},
36 git::{GitBody, too_many_requests},
37 profile::profile_for,
38 rate_limit::backup_throttled,
39};
40
41/// Where a backup builds itself, under the data directory.
42///
43/// The same directory `steid backup` uses, and the leading dot is doing the same work:
44/// a handle is `[a-z0-9-]` and can never start with one, so nothing here can collide
45/// with a repository and the packer skips the whole directory — which is what keeps a
46/// backup from containing the scratch copy it is being built from.
47const WORKSPACE: &str = ".backup";
48
49/// The whole instance, as one `tar.gz`.
50///
51/// **Owner only, and a 404 for everyone else** — the same answer a private repository
52/// gives a stranger, for the same reason: an instance's backup URL should not confirm
53/// that it exists to somebody who may not have it.
54///
55/// The ownership check happens **before** the rate limiter, which is the other way round
56/// from every other limited route here. Deliberately: the work behind this one is
57/// unreachable without a session, so counting a stranger's request would let a stranger
58/// spend the owner's two-an-hour budget and deny them their own backup. What a stranger
59/// costs is one indexed lookup, which is what every page costs.
60///
61/// **No subprocesses.** The database is copied through sqlx and the tar is written in
62/// this process — the first expensive route in Steid that spawns no `git`.
63#[route(GET "/{handle}/settings/backup.tar.gz")]
64async fn backup_download(cx: &Cx) -> Result<Response<GitBody>> {
65 // The handle has to be the viewer's own settings page as well as the instance's
66 // owner, so that `/someone-else/settings/backup.tar.gz` is not a working URL for a
67 // signed-in owner. Two questions, both answered before anything is written.
68 let profile = profile_for(cx).await?;
69 let actor = current_actor(cx).await?;
70
71 if !profile.viewer_is_owner || !instance_owner(cx).await? {
72 return Err(not_found().into());
73 }
74
75 if let Some(retry_after) = backup_throttled(cx) {
76 return too_many_requests(retry_after);
77 }
78
79 let config = app_context::<AppConfig>(cx);
80 let database = database_file(&config.database_url).ok_or_else(|| {
81 server_error(std::io::Error::other(
82 "STEID_DATABASE_URL names no file, so there is nothing to back up",
83 ))
84 })?;
85
86 let root = config.data_dir.join(WORKSPACE);
87 tokio::fs::create_dir_all(&root)
88 .await
89 .map_err(server_error)?;
90
91 // A directory of its own per request, removed when this value drops — so two
92 // downloads cannot delete each other's work, and a panic between here and the end
93 // still cleans up.
94 let workspace = tempfile::TempDir::new_in(&root).map_err(server_error)?;
95
96 let now = SystemTime::now();
97 let filename = archive_name(now);
98 let destination: PathBuf = workspace.path().join(&filename);
99
100 let written = back_up_for(
101 &actor,
102 now,
103 &destination,
104 &config.data_dir,
105 workspace.path(),
106 &users(cx),
107 &orgs(cx),
108 &repos(cx),
109 &SqliteDatabaseSnapshot::new(super::context::pool(cx).clone(), database),
110 &TarGzBackupArchive::new(),
111 )
112 .await
113 .map_err(|error| match error {
114 // Re-checked by the use case, and it is the use case's answer that counts. The
115 // 404 above is the fast path, not the authorization.
116 Error::Domain(DomainError::Forbidden) => not_found().into(),
117 other => server_error(other),
118 })?;
119
120 let file = tokio::fs::File::open(&destination)
121 .await
122 .map_err(server_error)?;
123
124 // Unlinked while open: the descriptor stays valid and the bytes are freed the moment
125 // the response body is dropped, read to the end or not. Dropping the workspace takes
126 // the database snapshot with it.
127 let _ = tokio::fs::remove_file(&destination).await;
128 drop(workspace);
129
130 Response::builder()
131 .header(CONTENT_TYPE, "application/gzip")
132 .header(NOSNIFF.0, NOSNIFF.1)
133 .header(CONTENT_DISPOSITION, disposition(&filename))
134 .header(CONTENT_LENGTH, written.bytes)
135 // Every backup is a different one, and this one is every private byte the
136 // instance holds. Nothing between here and the browser may keep a copy.
137 .header(CACHE_CONTROL, "no-store, private")
138 .body(GitBody::new(Box::pin(file)))
139 .map_err(server_error)
140}
141
142/// The header that stops a browser second-guessing a `Content-Type`.
143const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
144
145/// The `Content-Disposition` for the download.
146///
147/// The filename is [`archive_name`]'s, which is a version and a timestamp and nothing
148/// else — but it is filtered anyway, for the reason
149/// [`archive`](super::archive::archive_url)'s is: "the caller already validated it" is
150/// how header injection arrives later.
151fn disposition(filename: &str) -> String {
152 let safe: String = filename
153 .chars()
154 .map(|char| match char {
155 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => char,
156 _ => '_',
157 })
158 .collect();
159
160 format!("attachment; filename=\"{safe}\"")
161}
162
163/// The URL a backup downloads from.
164pub(super) fn backup_url(handle: &str) -> String {
165 format!("/{handle}/settings/backup.tar.gz")
166}
167
168/// When the scheduled backup last ran, or `None` when none is configured.
169///
170/// The marker file's modification time and nothing else — see
171/// [`AppConfig::backup_marker`]. One `stat` on a settings page, so it is done inline
172/// rather than off the runtime: an `install.sh` timer that has never run leaves no file,
173/// which reads the same as no schedule, which is the honest answer either way.
174pub(super) fn last_scheduled_backup(cx: &Cx) -> Option<SystemTime> {
175 let marker = app_context::<AppConfig>(cx).backup_marker.as_ref()?;
176
177 std::fs::metadata(marker)
178 .ok()
179 .and_then(|metadata| metadata.modified().ok())
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn a_backup_url_hangs_off_the_settings_page() {
188 assert_eq!(backup_url("james"), "/james/settings/backup.tar.gz");
189 }
190
191 #[test]
192 fn a_filename_is_reduced_to_what_a_header_can_carry() {
193 assert_eq!(
194 disposition("steid-0.4.0-20260908T142530Z.tar.gz"),
195 "attachment; filename=\"steid-0.4.0-20260908T142530Z.tar.gz\""
196 );
197 assert_eq!(
198 disposition("a\"b\r\nc.gz"),
199 "attachment; filename=\"a_b__c.gz\""
200 );
201 }
202}