Compare commits

...
Author SHA1 Message Date
aecsocket d760c173a4 Add dependent projects route 2026-06-02 15:14:00 +01:00
aecsocketandGitHub d61397097c Tilitfy webhook changes (#6267)
* Tilitfy webhook changes

* add env

* fix
2026-06-02 15:14:02 +02:00
Calum H.andGitHub cfe45b368c fix: memory issues when importing giant mrpack files (#6278)
* feat: dont load mrpacks into memory if they are local imports

* fix: frontend
2026-06-02 12:59:41 +00:00
20 changed files with 575 additions and 99 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ The website and app `prepr` commands
Each project may have its own `CLAUDE.md` with detailed instructions:
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
## Code Guidelines
Generated
+1
View File
@@ -10514,6 +10514,7 @@ dependencies = [
"flate2",
"fs4",
"futures",
"futures-lite 2.6.1",
"heck 0.5.0",
"hickory-resolver 0.25.2",
"indicatif",
+1
View File
@@ -85,6 +85,7 @@ eyre = "0.6.12"
flate2 = "1.1.4"
fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.1"
futures-util = "0.3.31"
heck = "0.5.0"
hex = "0.4.3"
@@ -26,7 +26,7 @@ export function setupFilePickerProvider() {
const file = await createFileFromPath(path, 'icon')
return { file, path, previewUrl: convertFileSrc(path) }
},
async pickModpackFile() {
async pickModpackFile(options) {
const result = await open({
multiple: false,
filters: [{ name: 'Modpack', extensions: ['mrpack'] }],
@@ -34,12 +34,19 @@ export function setupFilePickerProvider() {
if (!result) return null
const path = result.path ?? result
if (!path) return null
const file = await createFileFromPath(
if (options?.readFile === false) {
// Instance imports stream from the native path, keeping large packs out of JS memory.
return { path, previewUrl: '' }
}
return {
file: await createFileFromPath(
path,
'modpack.mrpack',
'application/x-modrinth-modpack+zip',
),
path,
'modpack.mrpack',
'application/x-modrinth-modpack+zip',
)
return { file, path, previewUrl: '' }
previewUrl: '',
}
},
})
}
+1
View File
@@ -104,6 +104,7 @@ TREMENDOUS_PRIVATE_KEY=none
TREMENDOUS_CAMPAIGN_ID=none
TILTIFY_CLIENT_ID=
TILTIFY_CLIENT_SECRET=
TILTIFY_WEBHOOK_SIGNING_KEY=
TILTIFY_PRIDE_26_CAMPAIGN_ID=
HCAPTCHA_SECRET=none
+1
View File
@@ -125,6 +125,7 @@ TREMENDOUS_PRIVATE_KEY=none
TREMENDOUS_CAMPAIGN_ID=none
TILTIFY_CLIENT_ID=
TILTIFY_CLIENT_SECRET=
TILTIFY_WEBHOOK_SIGNING_KEY=
TILTIFY_PRIDE_26_CAMPAIGN_ID=
HCAPTCHA_SECRET=none
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT d.mod_dependency_id as \"mod_dependency_id!\", dependent_version.mod_id dependent_project_id, d.dependency_type\n FROM dependencies d\n INNER JOIN versions dependent_version ON dependent_version.id = d.dependent_id\n WHERE d.mod_dependency_id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "mod_dependency_id!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "dependent_project_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "dependency_type",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
true,
false,
false
]
},
"hash": "7279d76dedd38306cba783844c84ff763aabf70eb8091a40c4ec9535b0d44a0c"
}
@@ -0,0 +1,2 @@
CREATE INDEX dependencies_mod_dependency_id
ON dependencies (mod_dependency_id);
+1
View File
@@ -298,6 +298,7 @@ vars! {
TILTIFY_CLIENT_ID: String = "";
TILTIFY_CLIENT_SECRET: String = "";
TILTIFY_PRIDE_26_CAMPAIGN_ID: String = "";
TILTIFY_WEBHOOK_SIGNING_KEY: String = "";
// server pinging
SERVER_PING_MAX_CONCURRENT: usize = 16usize;
+9 -1
View File
@@ -928,7 +928,15 @@ impl VersionType {
}
#[derive(
Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
Serialize,
Deserialize,
Copy,
Clone,
Debug,
PartialEq,
Eq,
Hash,
utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum DependencyType {
+58 -3
View File
@@ -1,9 +1,12 @@
use actix_web::{get, post, web};
use chrono::{DateTime, Utc};
use actix_web::{HttpRequest, get, post, web};
use base64::Engine;
use chrono::{DateTime, Duration, Utc};
use eyre::eyre;
use hmac::{Hmac, Mac};
use reqwest::Method;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashSet;
use tracing::{info, warn};
use uuid::Uuid;
@@ -134,11 +137,17 @@ impl CampaignDonation {
#[utoipa::path]
#[post("/webhook")]
pub async fn tiltify_webhook(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
payouts_queue: web::Data<PayoutsQueue>,
web::Json(raw_payload): web::Json<serde_json::Value>,
body: String,
) -> Result<(), ApiError> {
verify_tiltify_webhook_signature(&req, &body)?;
let raw_payload = serde_json::from_str::<serde_json::Value>(&body)
.wrap_internal_err_with(|| eyre!("invalid Tiltify webhook JSON"))?;
// deserialize the JSON in the request handler, not in the params,
// since if the JSON fails to deserialize then it's *our* fault,
// not the caller's.
@@ -237,6 +246,52 @@ pub async fn tiltify_webhook(
Ok(())
}
fn verify_tiltify_webhook_signature(
req: &HttpRequest,
body: &str,
) -> Result<(), ApiError> {
let signature = req
.headers()
.get("X-Tiltify-Signature")
.and_then(|x| x.to_str().ok())
.wrap_request_err("missing Tiltify webhook signature")?;
let signature = base64::engine::general_purpose::STANDARD
.decode(signature)
.wrap_request_err("invalid Tiltify webhook signature")?;
let timestamp = req
.headers()
.get("X-Tiltify-Timestamp")
.and_then(|x| x.to_str().ok())
.wrap_request_err("missing Tiltify webhook timestamp")?;
let parsed_timestamp = DateTime::parse_from_rfc3339(timestamp)
.wrap_request_err("invalid Tiltify webhook timestamp")?;
let parsed_timestamp = parsed_timestamp.with_timezone(&Utc);
let age = Utc::now().signed_duration_since(parsed_timestamp);
if age < -Duration::minutes(1) || age > Duration::minutes(1) {
return Err(ApiError::Request(eyre!(
"expired Tiltify webhook timestamp",
)));
}
if ENV.TILTIFY_WEBHOOK_SIGNING_KEY.is_empty() {
return Err(ApiError::Internal(eyre!(
"TILTIFY_WEBHOOK_SIGNING_KEY must be set"
)));
}
let mut mac: Hmac<Sha256> =
Hmac::new_from_slice(ENV.TILTIFY_WEBHOOK_SIGNING_KEY.as_bytes())
.wrap_internal_err("initializing Tiltify webhook HMAC")?;
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body.as_bytes());
mac.verify_slice(&signature)
.wrap_request_err("invalid Tiltify webhook signature")?;
Ok(())
}
#[utoipa::path]
#[get("/pride-26")]
pub async fn pride_26(
+135 -2
View File
@@ -1,5 +1,5 @@
use std::any::type_name;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::auth::checks::{filter_visible_versions, is_visible_project};
@@ -55,7 +55,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
pub fn utoipa_config(
cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
) {
cfg.service(project_get)
cfg.service(project_dependents_get)
.service(project_get)
.service(project_get_check)
.service(project_delete)
.service(project_edit)
@@ -1260,6 +1261,138 @@ pub struct DependencyInfo {
pub versions: Vec<models::projects::Version>,
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectDependentsRequest {
pub ids: Vec<ProjectId>,
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectDependentsResponse {
pub dependents: HashMap<ProjectId, HashSet<DependentInfo>>,
pub projects: HashMap<ProjectId, Project>,
}
#[derive(Serialize, Deserialize, utoipa::ToSchema, PartialEq, Eq, Hash)]
pub struct DependentInfo {
pub dependent_project_id: ProjectId,
pub dependency_type: models::projects::DependencyType,
}
#[utoipa::path]
#[post("/dependents")]
pub async fn project_dependents_get(
req: HttpRequest,
web::Json(projects): web::Json<ProjectDependentsRequest>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<ProjectDependentsResponse>, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
.ok();
let requested_project_ids = projects
.ids
.iter()
.copied()
.map(db_ids::DBProjectId::from)
.collect::<Vec<_>>();
let requested_projects = db_models::DBProject::get_many_ids(
&requested_project_ids,
&**pool,
&redis,
)
.await
.wrap_internal_err("failed to fetch requested projects")?;
let visible_requested_projects =
filter_visible_projects(requested_projects, &user_option, &pool, false)
.await
.wrap_internal_err("failed to filter requested projects")?;
let project_ids = visible_requested_projects
.iter()
.map(|project| db_ids::DBProjectId::from(project.id).0)
.collect::<Vec<_>>();
let rows = sqlx::query!(
r#"
SELECT DISTINCT d.mod_dependency_id as "mod_dependency_id!", dependent_version.mod_id dependent_project_id, d.dependency_type
FROM dependencies d
INNER JOIN versions dependent_version ON dependent_version.id = d.dependent_id
WHERE d.mod_dependency_id = ANY($1)
"#,
&project_ids,
)
.fetch(&**pool)
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("failed to fetch project dependents")?;
let dependent_project_ids = rows
.iter()
.map(|row| db_ids::DBProjectId(row.dependent_project_id))
.unique()
.collect::<Vec<_>>();
let dependent_projects = db_models::DBProject::get_many_ids(
&dependent_project_ids,
&**pool,
&redis,
)
.await
.wrap_internal_err("failed to fetch dependent projects")?;
let visible_projects =
filter_visible_projects(dependent_projects, &user_option, &pool, false)
.await
.wrap_internal_err("failed to filter dependent projects")?;
let visible_project_ids = visible_projects
.iter()
.map(|project| db_ids::DBProjectId::from(project.id))
.collect::<HashSet<_>>();
let mut dependents = visible_requested_projects
.iter()
.map(|project| project.id)
.map(|project_id| (project_id, HashSet::new()))
.collect::<HashMap<_, _>>();
for row in rows {
let dependent_project_id =
db_ids::DBProjectId(row.dependent_project_id);
if visible_project_ids.contains(&dependent_project_id) {
dependents
.entry(ProjectId::from(db_ids::DBProjectId(
row.mod_dependency_id,
)))
.or_default()
.insert(DependentInfo {
dependent_project_id: ProjectId::from(dependent_project_id),
dependency_type:
models::projects::DependencyType::from_string(
&row.dependency_type,
),
});
}
}
let projects = visible_projects
.into_iter()
.map(|project| (project.id, project))
.collect();
Ok(web::Json(ProjectDependentsResponse {
dependents,
projects,
}))
}
#[utoipa::path]
#[get("/{project_id}/dependencies")]
pub async fn dependency_list(
+1
View File
@@ -41,6 +41,7 @@ eyre = { workspace = true }
flate2 = { workspace = true }
fs4 = { workspace = true, features = ["tokio"] }
futures = { workspace = true, features = ["alloc", "async-await"] }
futures-lite = { workspace = true }
heck = { workspace = true }
hickory-resolver = { workspace = true }
indicatif = { workspace = true, optional = true }
+38 -27
View File
@@ -8,10 +8,9 @@ use crate::state::{
SideType,
};
use crate::util::fetch::{
DownloadMeta, DownloadReason, fetch, fetch_advanced, write_cached_icon,
DownloadMeta, DownloadReason, fetch, fetch_advanced, sha1_file_async,
write_cached_icon,
};
use crate::util::io;
use path_util::SafeRelativeUtf8UnixPathBuf;
use reqwest::Method;
use serde::{Deserialize, Serialize};
@@ -134,12 +133,22 @@ impl Default for CreatePackProfile {
}
}
#[derive(Clone)]
pub enum CreatePackFile {
Bytes(bytes::Bytes),
// Local packs can be larger than available memory, so keep them file-backed.
Path(PathBuf),
}
#[derive(Clone)]
pub struct CreatePack {
pub file: bytes::Bytes,
pub file: CreatePackFile,
pub description: CreatePackDescription,
}
// The hash lookup only gates the unknown-pack warning, so avoid a long blocking scan for huge local packs.
const MAX_LOCAL_FILE_HASH_LOOKUP_SIZE: u64 = 1024 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct CreatePackDescription {
pub icon: Option<PathBuf>,
@@ -176,28 +185,31 @@ pub async fn get_profile_from_pack(
.to_string_lossy()
.to_string();
let state = State::get().await?;
let file_bytes = io::read(&path).await?;
let hash =
crate::util::fetch::sha1_async(bytes::Bytes::from(file_bytes))
.await?;
let is_known_file = match CachedEntry::get_file_many(
&[&hash],
Some(CacheBehaviour::StaleWhileRevalidateSkipOffline),
&state.pool,
&state.api_semaphore,
)
.await
let is_known_file = if tokio::fs::metadata(&path).await?.len()
<= MAX_LOCAL_FILE_HASH_LOOKUP_SIZE
{
Ok(files) => !files.is_empty(),
Err(err) => {
tracing::warn!(
"Failed to check Modrinth file hash for {}: {}",
path.display(),
err
);
false
let state = State::get().await?;
let (_, hash) = sha1_file_async(&path).await?;
match CachedEntry::get_file_many(
&[&hash],
Some(CacheBehaviour::StaleWhileRevalidateSkipOffline),
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(files) => !files.is_empty(),
Err(err) => {
tracing::warn!(
"Failed to check Modrinth file hash for {}: {}",
path.display(),
err
);
false
}
}
} else {
false
};
Ok(CreatePackProfile {
@@ -380,7 +392,7 @@ pub async fn generate_pack_from_version_id(
}
Ok(CreatePack {
file,
file: CreatePackFile::Bytes(file),
description: CreatePackDescription {
icon,
override_title: Some(title),
@@ -398,9 +410,8 @@ pub async fn generate_pack_from_file(
path: PathBuf,
profile_path: String,
) -> crate::Result<CreatePack> {
let file = io::read(&path).await?;
Ok(CreatePack {
file: bytes::Bytes::from(file),
file: CreatePackFile::Path(path),
description: CreatePackDescription {
icon: None,
override_title: None,
+213 -53
View File
@@ -9,21 +9,212 @@ use crate::state::{
CacheBehaviour, CachedEntry, Profile, ProfileInstallStage, SideType,
cache_file_hash,
};
use crate::util::fetch::{
DownloadMeta, DownloadReason, fetch_mirrors, sha1_async, write,
};
use crate::util::fetch::{DownloadMeta, DownloadReason, fetch_mirrors, write};
use crate::util::io;
use crate::{State, profile};
use async_zip::base::read::seek::ZipFileReader;
use async_zip::base::read::seek::ZipFileReader as SeekZipFileReader;
use async_zip::base::read::{WithEntry, ZipEntryReader};
use async_zip::tokio::read::fs::ZipFileReader as FsZipFileReader;
use futures::StreamExt;
use path_util::SafeRelativeUtf8UnixPathBuf;
use super::install_from::{
CreatePack, CreatePackLocation, PackFormat, generate_pack_from_file,
generate_pack_from_version_id,
CreatePack, CreatePackFile, CreatePackLocation, PackFormat,
generate_pack_from_file, generate_pack_from_version_id,
};
use crate::data::ProjectType;
use std::io::{Cursor, ErrorKind};
use std::path::Path;
use tokio::io::AsyncWriteExt;
enum MrpackZipReader {
Memory(async_zip::tokio::read::seek::ZipFileReader<Cursor<bytes::Bytes>>),
// Local imports stay on disk so large .mrpacks do not have to fit in memory.
File(FsZipFileReader),
}
impl MrpackZipReader {
async fn new(file: &CreatePackFile) -> crate::Result<Self> {
match file {
CreatePackFile::Bytes(file) => Ok(Self::Memory(
SeekZipFileReader::with_tokio(Cursor::new(file.clone()))
.await
.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed to read input modpack zip".to_string(),
))
})?,
)),
CreatePackFile::Path(path) => Ok(Self::File(
FsZipFileReader::new(path).await.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed to read input modpack zip".to_string(),
))
})?,
)),
}
}
fn file(&self) -> &async_zip::ZipFile {
match self {
Self::Memory(reader) => reader.file(),
Self::File(reader) => reader.file(),
}
}
async fn read_entry_to_string(
&mut self,
index: usize,
) -> crate::Result<String> {
let mut value = String::new();
match self {
Self::Memory(reader) => {
let mut reader = reader.reader_with_entry(index).await?;
reader.read_to_string_checked(&mut value).await?;
}
Self::File(reader) => {
let mut reader = reader.reader_with_entry(index).await?;
reader.read_to_string_checked(&mut value).await?;
}
}
Ok(value)
}
async fn hash_entry(
&mut self,
index: usize,
) -> crate::Result<(u64, String)> {
match self {
Self::Memory(reader) => {
hash_zip_entry(reader.reader_with_entry(index).await?).await
}
Self::File(reader) => {
hash_zip_entry(reader.reader_with_entry(index).await?).await
}
}
}
async fn extract_entry(
&mut self,
index: usize,
path: &Path,
semaphore: &crate::util::fetch::IoSemaphore,
) -> crate::Result<(u64, String)> {
match self {
Self::Memory(reader) => {
extract_zip_entry(
reader.reader_with_entry(index).await?,
path,
semaphore,
)
.await
}
Self::File(reader) => {
extract_zip_entry(
reader.reader_with_entry(index).await?,
path,
semaphore,
)
.await
}
}
}
}
async fn hash_zip_entry<R>(
mut reader: ZipEntryReader<'_, R, WithEntry<'_>>,
) -> crate::Result<(u64, String)>
where
R: futures_lite::io::AsyncBufRead + Unpin,
{
let expected_crc32 = reader.entry().crc32();
let mut hasher = sha1_smol::Sha1::new();
let mut size = 0;
let mut buffer = vec![0; 262144];
loop {
let bytes_read =
futures_lite::io::AsyncReadExt::read(&mut reader, &mut buffer)
.await?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
size += bytes_read as u64;
}
if reader.compute_hash() != expected_crc32 {
return Err(async_zip::error::ZipError::CRC32CheckError.into());
}
Ok((size, hasher.digest().to_string()))
}
async fn extract_zip_entry<R>(
mut reader: ZipEntryReader<'_, R, WithEntry<'_>>,
path: &Path,
semaphore: &crate::util::fetch::IoSemaphore,
) -> crate::Result<(u64, String)>
where
R: futures_lite::io::AsyncBufRead + Unpin,
{
let _permit = semaphore.0.acquire().await?;
if let Some(parent) = path.parent() {
io::create_dir_all(parent).await?;
}
let parent = path.parent().ok_or_else(|| {
io::IOError::from(std::io::Error::other(
"could not get parent directory for temporary file",
))
})?;
let temp_path = tempfile::NamedTempFile::new_in(parent)
.map_err(|e| io::IOError::with_path(e, parent))?
.into_temp_path();
// Only replace the profile file after the ZIP entry has passed its CRC check.
let expected_crc32 = reader.entry().crc32();
let mut file = tokio::fs::File::create(&temp_path)
.await
.map_err(|e| io::IOError::with_path(e, &temp_path))?;
let mut hasher = sha1_smol::Sha1::new();
let mut size = 0;
let mut buffer = vec![0; 262144];
loop {
let bytes_read =
futures_lite::io::AsyncReadExt::read(&mut reader, &mut buffer)
.await?;
if bytes_read == 0 {
break;
}
file.write_all(&buffer[..bytes_read])
.await
.map_err(|e| io::IOError::with_path(e, &temp_path))?;
hasher.update(&buffer[..bytes_read]);
size += bytes_read as u64;
}
file.flush()
.await
.map_err(|e| io::IOError::with_path(e, &temp_path))?;
drop(file);
if reader.compute_hash() != expected_crc32 {
return Err(async_zip::error::ZipError::CRC32CheckError.into());
}
temp_path.persist(path).map_err(|e| {
let tempfile::PathPersistError { error, .. } = e;
io::IOError::with_path(error, path)
})?;
Ok((size, hasher.digest().to_string()))
}
/// Install a pack
/// Wrapper around install_pack_files that generates a pack creation description, and
@@ -93,15 +284,7 @@ pub async fn install_zipped_mrpack_files(
let profile_path = create_pack.description.profile_path;
let icon_exists = icon.is_some();
let reader: Cursor<&bytes::Bytes> = Cursor::new(&file);
// Create zip reader around file
let mut zip_reader =
ZipFileReader::with_tokio(reader).await.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed to read input modpack zip".to_string(),
))
})?;
let mut zip_reader = MrpackZipReader::new(&file).await?;
// Extract index of modrinth.index.json
let Some(manifest_idx) = zip_reader.file().entries().iter().position(|f| {
@@ -113,8 +296,7 @@ pub async fn install_zipped_mrpack_files(
};
let mut manifest = String::new();
let mut reader = zip_reader.reader_with_entry(manifest_idx).await?;
reader.read_to_string_checked(&mut manifest).await?;
manifest.push_str(&zip_reader.read_entry_to_string(manifest_idx).await?);
let pack: PackFormat = serde_json::from_str(&manifest)?;
@@ -151,11 +333,7 @@ pub async fn install_zipped_mrpack_files(
.collect();
for index in override_entries {
let mut file_bytes = Vec::new();
let mut entry_reader = zip_reader.reader_with_entry(index).await?;
entry_reader.read_to_end_checked(&mut file_bytes).await?;
let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?;
let (_, hash) = zip_reader.hash_entry(index).await?;
file_hashes.push(hash);
}
@@ -320,17 +498,18 @@ pub async fn install_zipped_mrpack_files(
))
})?;
let mut file_bytes = vec![];
let mut reader = zip_reader.reader_with_entry(index).await?;
reader.read_to_end_checked(&mut file_bytes).await?;
let path = profile::get_full_path(&profile_path)
.await?
.join(relative_override_file_path.as_str());
let (size, hash) = zip_reader
.extract_entry(index, &path, &state.io_semaphore)
.await?;
let file_bytes = bytes::Bytes::from(file_bytes);
cache_file_hash(
file_bytes.clone(),
crate::state::cache_file_hash_metadata(
&profile_path,
relative_override_file_path.as_str(),
None,
size,
hash,
ProjectType::get_from_parent_folder(
relative_override_file_path.as_str(),
),
@@ -338,15 +517,6 @@ pub async fn install_zipped_mrpack_files(
)
.await?;
write(
&profile::get_full_path(&profile_path)
.await?
.join(relative_override_file_path.as_str()),
&file_bytes,
&state.io_semaphore,
)
.await?;
emit_loading(
&loading_bar,
30.0 / override_file_entries_count as f64,
@@ -382,17 +552,10 @@ pub async fn install_zipped_mrpack_files(
pub async fn remove_all_related_files(
profile_path: String,
mrpack_file: bytes::Bytes,
mrpack_file: CreatePackFile,
) -> crate::Result<()> {
let reader: Cursor<&bytes::Bytes> = Cursor::new(&mrpack_file);
// Create zip reader around file
let mut zip_reader =
ZipFileReader::with_tokio(reader).await.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed to read input modpack zip".to_string(),
))
})?;
// Updates can remove files from a locally imported or downloaded pack, so share the same reader path.
let mut zip_reader = MrpackZipReader::new(&mrpack_file).await?;
// Extract index of modrinth.index.json
let Some(manifest_idx) = zip_reader.file().entries().iter().position(|f| {
@@ -403,10 +566,7 @@ pub async fn remove_all_related_files(
)));
};
let mut manifest = String::new();
let mut reader = zip_reader.reader_with_entry(manifest_idx).await?;
reader.read_to_string_checked(&mut manifest).await?;
let manifest = zip_reader.read_entry_to_string(manifest_idx).await?;
let pack: PackFormat = serde_json::from_str(&manifest)?;
+21 -1
View File
@@ -1932,10 +1932,30 @@ pub async fn cache_file_hash(
sha1_async(bytes).await?
};
cache_file_hash_metadata(
profile_path,
path,
size as u64,
hash,
project_type,
exec,
)
.await
}
pub async fn cache_file_hash_metadata(
profile_path: &str,
path: &str,
size: u64,
hash: String,
project_type: Option<ProjectType>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
// Streamed extraction already computed these values, so avoid buffering the file just to cache them.
CachedEntry::upsert_many(
&[CacheValue::FileHash(CachedFileHash {
path: format!("{profile_path}/{path}"),
size: size as u64,
size,
hash,
project_type,
})
+29 -1
View File
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::{self};
use tokio::sync::Semaphore;
use tokio::{fs::File, io::AsyncWriteExt};
use tokio::{fs::File, io::AsyncReadExt, io::AsyncWriteExt};
pub const DOWNLOAD_META_HEADER: &str = "modrinth-download-meta";
@@ -567,6 +567,34 @@ pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
Ok(hash)
}
pub async fn sha1_file_async(
path: impl AsRef<Path>,
) -> crate::Result<(u64, String)> {
let path = path.as_ref();
// Local files can be multi-gigabyte .mrpacks, so hash them without materializing bytes.
let mut file = File::open(path)
.await
.map_err(|e| IOError::with_path(e, path))?;
let mut hasher = sha1_smol::Sha1::new();
let mut size = 0;
let mut buffer = vec![0; 262144];
loop {
let bytes_read = file
.read(&mut buffer)
.await
.map_err(|e| IOError::with_path(e, path))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
size += bytes_read as u64;
}
Ok((size, hasher.digest().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -196,9 +196,11 @@ watch(
)
async function triggerFileInput() {
const picked = await filePicker.pickModpackFile()
const picked = await filePicker.pickModpackFile({
readFile: ctx.flowType !== 'instance',
})
if (picked) {
ctx.modpackFile.value = picked.file
ctx.modpackFile.value = picked.file ?? null
ctx.modpackFilePath.value = picked.path ?? null
proceedWithModpack()
}
@@ -572,7 +572,7 @@ provideInstallationSettings({
if (modpack.value.spec.platform === 'local_file') {
debug('reinstallModpack: local file, opening file picker')
const picked = await filePicker.pickModpackFile()
if (!picked) return
if (!picked?.file) return
try {
const handle = client.kyros.content_v1.uploadModpackFile(
worldId.value!,
+11 -1
View File
@@ -9,11 +9,21 @@ export interface PickedFile {
previewUrl: string
}
export interface PickedModpackFile extends Omit<PickedFile, 'file'> {
/** Only present for upload flows; native imports avoid copying huge packs into the webview heap. */
file?: File
}
export interface PickModpackFileOptions {
/** Set to false when a native path can be streamed directly by the backend. */
readFile?: boolean
}
export interface FilePickerProvider {
/** Pick an image file (for icons) */
pickImage: () => Promise<PickedFile | null>
/** Pick a .mrpack modpack file */
pickModpackFile: () => Promise<PickedFile | null>
pickModpackFile: (options?: PickModpackFileOptions) => Promise<PickedModpackFile | null>
}
export const [injectFilePicker, provideFilePicker] = createContext<FilePickerProvider>('FilePicker')