1use std::time::Duration;
2
3use axum::body::Bytes;
4use futures_util::Stream;
5use rusty_s3::actions::{GetObject, HeadObject, ListObjectsV2, PutObject, S3Action};
6use rusty_s3::{Bucket, Credentials, UrlStyle};
7
8use crate::error::Error;
9use crate::namespace::Namespace;
10
11#[derive(Clone)]
18pub struct S3Store {
19 bucket: Bucket,
20 credentials: Credentials,
21 client: reqwest::Client,
22 lifetime: Duration,
23 redirect: bool,
24}
25
26pub struct S3Config {
27 pub endpoint: String,
28 pub bucket: String,
29 pub region: String,
30 pub access_key: String,
31 pub secret_key: String,
32 pub path_style: bool,
33 pub redirect: bool,
34 pub lifetime: Duration,
39}
40
41impl S3Store {
42 pub fn new(config: &S3Config) -> Result<Self, Error> {
43 let style = if config.path_style {
44 UrlStyle::Path
45 } else {
46 UrlStyle::VirtualHost
47 };
48
49 let bucket = Bucket::new(
50 config
51 .endpoint
52 .parse()
53 .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
54 style,
55 config.bucket.clone(),
56 config.region.clone(),
57 )
58 .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
59
60 Ok(Self {
61 bucket,
62 credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
63 client: reqwest::Client::new(),
64 lifetime: config.lifetime,
65 redirect: config.redirect,
66 })
67 }
68
69 fn content_key(oid: &str) -> String {
70 format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
71 }
72
73 fn marker_key(ns: &Namespace, oid: &str) -> String {
74 format!(
75 "{}/{}/{}/{}/{oid}",
76 ns.org(),
77 ns.repo(),
78 &oid[0..2],
79 &oid[2..4]
80 )
81 }
82
83 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
84 if crate::storage::LocalStore::validate_oid(oid).is_err() {
85 return false;
86 }
87
88 self.head(&Self::marker_key(ns, oid)).await.is_ok()
89 }
90
91 async fn head(&self, key: &str) -> Result<u64, Error> {
96 let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
97 let url = action.sign(self.lifetime);
98
99 let response = self.client.head(url).send().await.map_err(|_| {
100 Error::Storage(std::io::Error::other("the object store is unreachable"))
101 })?;
102
103 if !response.status().is_success() {
104 return Err(Error::NotFound);
105 }
106
107 response
111 .headers()
112 .get(reqwest::header::CONTENT_LENGTH)
113 .and_then(|value| value.to_str().ok())
114 .and_then(|value| value.parse().ok())
115 .ok_or_else(|| {
116 Error::Storage(std::io::Error::other(
117 "the object store gave no object size",
118 ))
119 })
120 }
121
122 pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
123 crate::storage::LocalStore::validate_oid(oid)?;
128
129 self.head(&Self::content_key(oid)).await
130 }
131
132 pub async fn read(
138 &self,
139 oid: &str,
140 start: u64,
141 length: u64,
142 ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
143 crate::storage::LocalStore::validate_oid(oid)?;
144
145 let key = Self::content_key(oid);
146 let action = GetObject::new(&self.bucket, Some(&self.credentials), &key);
147 let url = action.sign(self.lifetime);
148
149 let response = self
150 .client
151 .get(url)
152 .header(
153 reqwest::header::RANGE,
154 format!("bytes={start}-{}", start + length.saturating_sub(1)),
155 )
156 .send()
157 .await
158 .map_err(|_| {
159 Error::Storage(std::io::Error::other("the object store is unreachable"))
160 })?;
161
162 if !response.status().is_success() {
163 return Err(Error::NotFound);
164 }
165
166 Ok(response.bytes_stream())
167 }
168
169 pub fn presigned_download(&self, oid: &str) -> Option<String> {
175 if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
176 return None;
177 }
178
179 let key = Self::content_key(oid);
180
181 Some(
182 GetObject::new(&self.bucket, Some(&self.credentials), &key)
183 .sign(self.lifetime)
184 .to_string(),
185 )
186 }
187
188 async fn put(&self, key: &str, body: reqwest::Body, length: u64) -> Result<(), Error> {
189 let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
190 let url = action.sign(self.lifetime);
191
192 let response = self
193 .client
194 .put(url)
195 .header(reqwest::header::CONTENT_LENGTH, length)
199 .body(body)
200 .send()
201 .await
202 .map_err(|_| {
203 Error::Storage(std::io::Error::other("the object store is unreachable"))
204 })?;
205
206 let status = response.status();
207 if !status.is_success() {
208 let detail = response.text().await.unwrap_or_default();
213
214 return Err(Error::Storage(std::io::Error::other(format!(
215 "the object store refused a write with {status}: {}",
216 detail.trim()
217 ))));
218 }
219
220 Ok(())
221 }
222
223 pub async fn store(
233 &self,
234 ns: &Namespace,
235 oid: &str,
236 staged: &std::path::Path,
237 ) -> Result<(), Error> {
238 crate::storage::LocalStore::validate_oid(oid)?;
239
240 if self.head(&Self::content_key(oid)).await.is_err() {
241 let file = tokio::fs::File::open(staged).await?;
242 let length = file.metadata().await?.len();
243 let stream = tokio_util::io::ReaderStream::new(file);
244
245 self.put(
246 &Self::content_key(oid),
247 reqwest::Body::wrap_stream(stream),
248 length,
249 )
250 .await?;
251 }
252
253 self.put(
254 &Self::marker_key(ns, oid),
255 reqwest::Body::from(Vec::new()),
256 0,
257 )
258 .await
259 }
260
261 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
266 let prefix = format!("{}/{}/", ns.org(), ns.repo());
267 let mut objects = 0;
268 let mut bytes = 0;
269
270 for oid in self.list(&prefix).await {
271 objects += 1;
272 bytes += self.size_of(&oid).await.unwrap_or_default();
273 }
274
275 (objects, bytes)
276 }
277
278 async fn list(&self, prefix: &str) -> Vec<String> {
279 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
280 action.with_prefix(prefix);
281
282 let response = match self.client.get(action.sign(self.lifetime)).send().await {
286 Ok(response) => response,
287 Err(error) => {
288 tracing::warn!(%error, "the object store could not be listed");
289 return Vec::new();
290 }
291 };
292
293 let body = match response.text().await {
294 Ok(body) => body,
295 Err(error) => {
296 tracing::warn!(%error, "the listing could not be read");
297 return Vec::new();
298 }
299 };
300
301 let listing = match ListObjectsV2::parse_response(&body) {
302 Ok(listing) => listing,
303 Err(error) => {
304 tracing::warn!(%error, "the listing could not be parsed");
305 return Vec::new();
306 }
307 };
308
309 listing
310 .contents
311 .into_iter()
312 .filter_map(|object| object.key.rsplit('/').next().map(str::to_owned))
313 .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
314 .collect()
315 }
316}
317
318#[cfg(test)]
319pub(crate) mod tests;