Skip to content
Open
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
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ RUN cargo install cargo-chef
WORKDIR /app

FROM chef AS planner
COPY ./pentaract .
WORKDIR /app
COPY pentaract/Cargo.toml .
COPY pentaract/Cargo.lock .
COPY pentaract/src ./src

RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
Expand Down
19 changes: 9 additions & 10 deletions pentaract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,19 @@ name = "pentaract"
version = "0.1.0"
edition = "2021"

[profile.release]
strip = true
codegen-units = 1

[dependencies]
# routing
axum = { version = "0.6.20", features = ["headers", "tracing", "multipart"]}
axum = { version = "0.6.20", features = ["headers", "tracing", "multipart"] }
mime_guess = "2.0.4"
tower = { version = "0.4.13", features = ["limit"], default-features = false}
tower-http = { version = "0.4.4", features = ["fs", "trace", "cors"], default_features = false }
tower = { version = "0.4.13", features = ["limit"], default-features = false }
tower-http = { version = "0.4.4", features = ["fs", "trace", "cors"], default-features = false }

# serialization/deserialization
serde = { version = "1.0.189", features = ["derive"] }

# auth
pwhash = "1.0.0"
jsonwebtoken = { version = "9", default-features = false }
jsonwebtoken = { version = "9", default-features = false, features = ["use_pem"] }

# async
tokio = { version = "1.33.0", features = ["full"] }
Expand All @@ -28,10 +24,13 @@ futures = "0.3.29"

# logging
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"]}
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }

# others
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid"] }
thiserror = "1.0.50"
uuid = { version = "1.5.0", features = ["serde", "v4"] }
reqwest = { version = "0.11.22", features = ["multipart", "json"] }

# HTTP client (без native-tls — ок для musl)
reqwest = { version = "0.11.22", default-features = false, features = ["multipart", "json", "rustls-tls"] }
percent-encoding = "2.3"
48 changes: 35 additions & 13 deletions pentaract/src/common/telegram_api/bot_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use reqwest::multipart;
use uuid::Uuid;

use crate::{
common::types::ChatId, errors::PentaractResult,
common::types::ChatId,
errors::{PentaractError, PentaractResult},
services::storage_workers_scheduler::StorageWorkersScheduler,
};

Expand All @@ -27,15 +28,19 @@ impl<'t> TelegramBotApi<'t> {
chat_id: ChatId,
storage_id: Uuid,
) -> PentaractResult<UploadSchema> {
let chat_id = {
// inserting 100 between minus sign and chat id
// cause telegram devs are complete retards and it works this way only
//
// https://stackoverflow.com/a/65965402/12255756
tracing::debug!(
"[TELEGRAM API] Uploading chunk: chat_id={}, file_size={}",
chat_id,
file.len()
);

let n = chat_id.abs().checked_ilog10().unwrap_or(0) + 1;
chat_id - (100 * ChatId::from(10).pow(n))
};
if chat_id < 0 && chat_id > -10000000000 {
tracing::info!(
"[TELEGRAM API] Using regular group (chat_id={}). If bot can't find the chat, \
make sure the bot is added and has permissions.",
chat_id
);
}

let token = self.scheduler.get_token(storage_id).await?;
let url = self.build_url("", "sendDocument", token);
Expand All @@ -51,10 +56,27 @@ impl<'t> TelegramBotApi<'t> {
.send()
.await?;

match response.error_for_status() {
// https://stackoverflow.com/a/32679930/12255756
Ok(r) => Ok(r.json::<UploadBodySchema>().await?.result.document),
Err(e) => Err(e.into()),
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unable to read error body".to_string());
tracing::error!(
"[TELEGRAM API] Upload failed: status={}, response={}",
status,
error_text
);
return Err(PentaractError::TelegramAPIError(format!(
"Status {}: {}",
status,
error_text
)));
}

match response.json::<UploadBodySchema>().await {
Ok(body) => Ok(body.result.document),
Err(e) => {
tracing::error!("[TELEGRAM API] Failed to parse response: {}", e);
Err(e.into())
}
}
}

Expand Down
22 changes: 22 additions & 0 deletions pentaract/src/repositories/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ impl<'d> AccessRepository<'d> {
) -> PentaractResult<()> {
let id = Uuid::new_v4();

tracing::debug!(
"[ACCESS REPO] Attempting to grant access: storage_id={}, user_email={}, access_type={:?}",
storage_id,
grant_access.user_email,
grant_access.access_type
);

let result = sqlx::query(
format!(
"
Expand Down Expand Up @@ -54,13 +61,28 @@ impl<'d> AccessRepository<'d> {
}
})?;

tracing::debug!(
"[ACCESS REPO] Query affected {} rows",
result.rows_affected()
);

if result.rows_affected() == 0 {
tracing::error!(
"[ACCESS REPO] User with email \"{}\" not found in users table",
grant_access.user_email
);
return Err(PentaractError::DoesNotExist(format!(
"user with email \"{}\"",
grant_access.user_email
)));
}

tracing::debug!(
"[ACCESS REPO] Successfully granted access to user {} for storage {}",
grant_access.user_email,
storage_id
);

Ok(())
}

Expand Down
21 changes: 17 additions & 4 deletions pentaract/src/repositories/storages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,19 @@ impl<'d> StoragesRepository<'d> {
}

pub async fn list_by_user_id(&self, user_id: Uuid) -> PentaractResult<Vec<StorageWithInfo>> {
sqlx::query_as(
tracing::debug!(
"[STORAGES REPO] Fetching storages for user_id={}",
user_id
);

let result = sqlx::query_as(
format!(
"
SELECT s.*, COUNT(f.id) AS files_amount, COALESCE(SUM(f.size), 0)::BigInt as size
FROM {TABLE} s
JOIN {ACCESS_TABLE} a ON s.id = a.storage_id
LEFT JOIN {FILES_TABLE} f ON s.id = f.storage_id
WHERE a.user_id = $1 AND (f.path NOT LIKE '%/' OR f.path IS NULL)
LEFT JOIN {FILES_TABLE} f ON s.id = f.storage_id AND f.path NOT LIKE '%/'
WHERE a.user_id = $1
GROUP by s.id
"
)
Expand All @@ -62,7 +67,15 @@ impl<'d> StoragesRepository<'d> {
.bind(user_id)
.fetch_all(self.db)
.await
.map_err(|e| map_not_found(e, "storages"))
.map_err(|e| map_not_found(e, "storages"))?;

tracing::debug!(
"[STORAGES REPO] Found {} storages for user_id={}",
result.len(),
user_id
);

Ok(result)
}

pub async fn get_by_id(&self, id: Uuid) -> PentaractResult<Storage> {
Expand Down
17 changes: 15 additions & 2 deletions pentaract/src/routers/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use axum::{
routing::{get, post},
Extension, Json, Router,
};
use percent_encoding::percent_decode_str;
use reqwest::header;
use tokio_util::bytes::Bytes;
use uuid::Uuid;
Expand Down Expand Up @@ -100,7 +101,13 @@ impl FilesRouter {
file = Some(data);
filename = Some(field_filename);
}
"path" => path = Some(String::from_utf8(data.to_vec()).unwrap()),
"path" => {
let raw_path = String::from_utf8(data.to_vec()).unwrap();
let decoded = percent_decode_str(&raw_path)
.decode_utf8()
.unwrap_or(std::borrow::Cow::Borrowed(&raw_path));
path = Some(decoded.to_string());
}
// don't give a fuck about other fields
_ => (),
}
Expand Down Expand Up @@ -143,7 +150,13 @@ impl FilesRouter {
.get("path")
.map(|path| String::from_utf8(path.to_vec()).map_err(|_| "Path cannot be parsed"))
.unwrap_or(Err("Path is required"))
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_owned()))?;
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_owned()))
.map(|raw_path| {
percent_decode_str(&raw_path)
.decode_utf8()
.unwrap_or(std::borrow::Cow::Borrowed(&raw_path))
.to_string()
})?;

let file = body_parts
.get("file")
Expand Down
4 changes: 4 additions & 0 deletions pentaract/src/routers/storages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ impl StoragesRouter {
.list(&user)
.await
.map(|s| StoragesListSchema::new(s))?;
tracing::debug!(
"[STORAGES ROUTER] Returning {} storages to client",
storages.storages.len()
);
Ok::<_, (StatusCode, String)>(Json(storages))
}

Expand Down
37 changes: 33 additions & 4 deletions pentaract/src/services/storages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,51 @@ impl<'d> StoragesService<'d> {
// creating storage
let in_model = InStorage::new(in_schema.name, in_schema.chat_id);
let storage = self.repo.create(in_model).await?;

tracing::debug!(
"[STORAGES SERVICE] Created storage id={}, name={}, chat_id={}",
storage.id,
storage.name,
storage.chat_id
);

// setting user as the storage admin
let access_schema = GrantAccess::new(user.email.clone(), AccessType::A);
let result = self
.access_repo
.create_or_update(storage.id, access_schema)
.await;
if result.is_err() {
// fallback
self.repo.delete_storage(storage.id).await?

match &result {
Ok(_) => {
tracing::debug!(
"[STORAGES SERVICE] Successfully granted access to user {} for storage {}",
user.email,
storage.id
);
}
Err(e) => {
tracing::error!(
"[STORAGES SERVICE] Failed to grant access to user {} for storage {}: {:?}. Rolling back storage creation.",
user.email,
storage.id,
e
);
// fallback
let _ = self.repo.delete_storage(storage.id).await;
}
}
result.map(|_| storage)
}

pub async fn list(&self, user: &AuthUser) -> PentaractResult<Vec<StorageWithInfo>> {
self.repo.list_by_user_id(user.id).await
let storages = self.repo.list_by_user_id(user.id).await?;
tracing::debug!(
"[STORAGES SERVICE] Listed {} storages for user_id={}",
storages.len(),
user.id
);
Ok(storages)
}

pub async fn get(&self, id: Uuid, user: &AuthUser) -> PentaractResult<Storage> {
Expand Down
11 changes: 8 additions & 3 deletions ui/src/pages/Storages/StorageCreateForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ const StorageCreateForm = () => {
let err = null

if (value > 0) {
err = 'Chat id must be a valid negative integer'
err = 'Chat id must be a negative integer'
} else if (value === '') {
err = 'Chat id is required and must be a valid negative integer'
err = 'Chat id is required'
}
// No additional validation - accept any negative number
// Both regular groups (-XXXXXXXXX) and supergroups (-100XXXXXXXXXX) are valid

setChatIdErr(err)
}
Expand Down Expand Up @@ -108,7 +110,10 @@ const StorageCreateForm = () => {
type="number"
variant="standard"
onChange={validateChatId}
helperText={chatIdErr}
helperText={
chatIdErr() ||
'Get chat ID via @userinfobot or @getidsbot. Use the ID exactly as provided.'
}
error={typeof chatIdErr() === 'string'}
fullWidth
required
Expand Down
2 changes: 2 additions & 0 deletions ui/src/pages/Storages/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const Storages = () => {

onMount(async () => {
const storagesSchema = await API.storages.listStorages()
console.log('[STORAGES] Received from API:', storagesSchema)
console.log('[STORAGES] Number of storages:', storagesSchema.storages?.length || 0)
setStorages(storagesSchema.storages)
})

Expand Down