aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to 'src/service_storage.rs')
-rw-r--r--src/service_storage.rs263+237 −26
1 files changed, 237 insertions, 26 deletions
diff --git a/src/service_storage.rs b/src/service_storage.rs
index bce2c79..fbf0bca 100644
--- a/src/service_storage.rs
+++ b/src/service_storage.rs
@@ -1,50 +1,261 @@
// SPDX-FileCopyrightText: 2026 Nikolay Govorov <me@govorov.online>
// SPDX-License-Identifier: AGPL-3.0-or-later
-use std::{io, path, sync};
-use tokio::fs;
+use std::path::PathBuf;
+use std::sync;
+
+use tokio::io::AsyncWriteExt;
+
+use sha2::{Digest, Sha256};
+use sqlx::{FromRow, Pool, Sqlite, query, query_as, sqlite};
+use thiserror::Error;
use super::service_config::ConfigService;
-#[derive(Clone)]
+const SQLITE_POOL_SIZE: u32 = 16;
+const INLINE_THRESHOLD: usize = 256 * 1024; // 256 KB
+
+fn hash_blob(scope: &str, filename: &str, bytes: &[u8]) -> Vec<u8> {
+ let mut hasher = Sha256::new();
+ hasher.update(scope.as_bytes());
+ hasher.update(b"/");
+ hasher.update(filename.as_bytes());
+ hasher.update(b"\0");
+ hasher.update(bytes);
+ hasher.finalize().to_vec()
+}
+
+#[derive(Debug, Clone)]
+pub struct Blob(pub bytes::Bytes);
+
+impl std::ops::Deref for Blob {
+ type Target = bytes::Bytes;
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl sqlx::Type<Sqlite> for Blob {
+ fn type_info() -> sqlite::SqliteTypeInfo {
+ <Vec<u8> as sqlx::Type<Sqlite>>::type_info() // BLOB
+ }
+
+ fn compatible(ty: &sqlite::SqliteTypeInfo) -> bool {
+ <Vec<u8> as sqlx::Type<Sqlite>>::compatible(ty)
+ }
+}
+
+impl<'r> sqlx::decode::Decode<'r, Sqlite> for Blob {
+ fn decode(
+ value: sqlite::SqliteValueRef<'r>,
+ ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
+ let slice: &'r [u8] = <&'r [u8] as sqlx::decode::Decode<Sqlite>>::decode(value)?;
+ Ok(Blob(bytes::Bytes::copy_from_slice(slice)))
+ }
+}
+
+#[allow(unused)]
+#[derive(Debug, Clone, FromRow)]
pub struct File {
- pub bytes: bytes::Bytes,
+ pub id: u64,
+ pub scope: String,
+ pub created_at: chrono::DateTime<chrono::Utc>,
+
+ pub file_name: String,
+ pub file_size: i64,
+ pub file_bytes: Blob,
+ pub inlined: bool,
+}
+
+#[derive(Debug, Error)]
+pub enum StorageError {
+ #[error("file already exists: {0}/{1}")]
+ AlreadyExists(String, String),
+
+ #[error("blob integrity check failed")]
+ IntegrityError,
+
+ #[error("failed to connect index db: {0}")]
+ DbError(#[from] sqlx::Error),
+
+ #[error("io error: {0}")]
+ IoError(#[from] std::io::Error),
}
pub struct StorageService {
- config: sync::Arc<ConfigService>,
+ sqlite: Pool<Sqlite>,
+ storage_dir: PathBuf,
}
impl StorageService {
- pub fn new(config: sync::Arc<ConfigService>) -> Self {
- Self { config }
+ pub async fn new(config: sync::Arc<ConfigService>) -> Result<Self, StorageError> {
+ let storage_dir = config.dirname().to_path_buf();
+ let connection = format!(
+ "sqlite:{}?mode=rwc",
+ storage_dir.join("index.sqlite").to_str().unwrap()
+ );
+
+ let pool: Pool<Sqlite> = sqlite::SqlitePoolOptions::new()
+ .max_connections(SQLITE_POOL_SIZE)
+ .connect(&connection)
+ .await?;
+
+ let storage = Self {
+ sqlite: pool,
+ storage_dir,
+ };
+ storage.msetupdb().await?;
+
+ Ok(storage)
}
- pub async fn get(&self, filename: &str) -> Result<Option<File>, io::Error> {
- let data_path = self.data_path(filename)?;
- let bytes = match fs::read(&data_path).await {
- Ok(bytes) => bytes,
- Err(err) => {
- return if err.kind() == io::ErrorKind::NotFound {
- Ok(None)
- } else {
- Err(err)
- };
- }
+ async fn msetupdb(&self) -> Result<(), StorageError> {
+ query("PRAGMA foreign_keys = ON;")
+ .execute(&self.sqlite)
+ .await?;
+ query("PRAGMA journal_mode = WAL;")
+ .execute(&self.sqlite)
+ .await?;
+ query("PRAGMA busy_timeout = 5000;")
+ .execute(&self.sqlite)
+ .await?;
+
+ query(
+ "CREATE TABLE IF NOT EXISTS datafiles(
+ id INTEGER PRIMARY KEY,
+ scope TEXT NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+ file_name TEXT NOT NULL,
+ file_size INTEGER NOT NULL,
+ file_bytes BLOB,
+ inlined INTEGER NOT NULL,
+
+ UNIQUE (scope, file_name)
+ ) STRICT;",
+ )
+ .execute(&self.sqlite)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn put(
+ &self,
+ scope: &str,
+ filename: &str,
+ bytes: &bytes::Bytes,
+ ) -> Result<(), StorageError> {
+ let inlined = bytes.len() <= INLINE_THRESHOLD;
+
+ let stored_bytes: Vec<u8> = if inlined {
+ bytes.to_vec()
+ } else {
+ self.write_blob(scope, filename, bytes).await?
};
- Ok(Some(File {
- bytes: bytes.into(),
- }))
+ let result = query("INSERT INTO datafiles (scope, file_name, file_size, file_bytes, inlined) VALUES (?1, ?2, ?3, ?4, ?5);")
+ .bind(scope)
+ .bind(filename)
+ .bind(bytes.len() as i64)
+ .bind(&stored_bytes)
+ .bind(inlined)
+ .execute(&self.sqlite)
+ .await;
+
+ match result {
+ Ok(_) => Ok(()),
+ Err(e) => {
+ if !inlined {
+ let _ = self.delete_blob(&stored_bytes).await;
+ }
+ match e {
+ sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => Err(
+ StorageError::AlreadyExists(scope.to_string(), filename.to_string()),
+ ),
+ _ => Err(e.into()),
+ }
+ }
+ }
}
- pub async fn put(&self, filename: &str, entry: &File) -> Result<(), io::Error> {
- let data_path = self.data_path(filename)?;
- fs::write(&data_path, &entry.bytes).await?;
+ async fn write_blob(
+ &self,
+ scope: &str,
+ filename: &str,
+ bytes: &bytes::Bytes,
+ ) -> Result<Vec<u8>, StorageError> {
+ let hash = hash_blob(scope, filename, bytes);
+ let hash_hex = hex::encode(&hash);
+
+ let blob_dir = self.storage_dir.join(&hash_hex[0..2]).join(&hash_hex[2..4]);
+ let blob_path = blob_dir.join(&hash_hex);
+
+ tokio::fs::create_dir_all(&blob_dir).await?;
+
+ let mut file = tokio::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&blob_path)
+ .await
+ .map_err(|e| {
+ if e.kind() == std::io::ErrorKind::AlreadyExists {
+ StorageError::AlreadyExists(scope.to_string(), filename.to_string())
+ } else {
+ StorageError::IoError(e)
+ }
+ })?;
+
+ file.write_all(bytes).await?;
+ file.sync_all().await?;
+
+ Ok(hash)
+ }
+
+ fn blob_path(&self, hash: &[u8]) -> PathBuf {
+ let hash_hex = hex::encode(hash);
+ self.storage_dir
+ .join(&hash_hex[0..2])
+ .join(&hash_hex[2..4])
+ .join(&hash_hex)
+ }
+
+ async fn delete_blob(&self, hash: &[u8]) -> Result<(), StorageError> {
+ tokio::fs::remove_file(self.blob_path(hash)).await?;
Ok(())
}
- fn data_path(&self, filename: &str) -> Result<path::PathBuf, io::Error> {
- Ok(self.config.dirname().join(filename))
+ pub async fn get(&self, scope: &str, filename: &str) -> Result<Option<File>, StorageError> {
+ let file: Option<File> =
+ query_as("SELECT * FROM datafiles WHERE scope = ?1 AND file_name = ?2")
+ .bind(scope)
+ .bind(filename)
+ .fetch_optional(&self.sqlite)
+ .await?;
+
+ match file {
+ None => Ok(None),
+ Some(file) if file.inlined => Ok(Some(file)),
+ Some(mut file) => {
+ let bytes = self.read_blob(scope, filename, &file.file_bytes).await?;
+ file.file_bytes = Blob(bytes);
+ Ok(Some(file))
+ }
+ }
+ }
+
+ async fn read_blob(
+ &self,
+ scope: &str,
+ filename: &str,
+ expected_hash: &[u8],
+ ) -> Result<bytes::Bytes, StorageError> {
+ let bytes = tokio::fs::read(self.blob_path(expected_hash)).await?;
+
+ let actual_hash = hash_blob(scope, filename, &bytes);
+ if actual_hash != expected_hash {
+ return Err(StorageError::IntegrityError);
+ }
+
+ Ok(bytes::Bytes::from(bytes))
}
}