Skip to content

Commit f4130c7

Browse files
committed
remove container name to fix reload issue
1 parent b676771 commit f4130c7

File tree

16 files changed

+1626
-135
lines changed

16 files changed

+1626
-135
lines changed

Cargo.lock

Lines changed: 444 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ chrono = { version = "0.4", default-features = false, features = ["std", "clock"
1515
anyhow = "1.0"
1616
thiserror = "1.0"
1717
hrt-shared = { path = "../shared" }
18+
lopdf = "0.39"

crates/server/src/api.rs

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode};
44
use axum::response::{IntoResponse, Response};
55
use axum::Json;
66
use chrono::Utc;
7+
use lopdf::Document;
78
use serde_json::{json, Value};
89

910
use hrt_shared::convert::convert_hormone;
1011
use hrt_shared::types::Hormone;
1112

1213
use crate::storage::{
13-
content_type_from_ext, delete_photo, read_json, read_photo, read_yaml, save_photo,
14-
write_json_atomic, write_yaml, DATA_FILE_PATH, SETTINGS_FILE_PATH,
14+
content_type_from_ext, delete_bloodtest_pdf as delete_bloodtest_pdf_file, delete_photo,
15+
read_bloodtest_pdf as read_bloodtest_pdf_file, read_json, read_photo, read_yaml,
16+
save_bloodtest_pdf, save_photo, write_json_atomic, write_yaml, DATA_FILE_PATH,
17+
SETTINGS_FILE_PATH,
1518
};
1619

1720
pub async fn get_data() -> Response {
@@ -214,6 +217,111 @@ pub async fn delete_dosage_photo(Path((entry_id, filename)): Path<(String, Strin
214217
}
215218
}
216219

220+
pub async fn upload_bloodtest_pdf(mut multipart: Multipart) -> Response {
221+
let settings = match read_yaml::<Value>(SETTINGS_FILE_PATH).await {
222+
Ok(Some(value)) if value.is_object() => value,
223+
_ => json!({}),
224+
};
225+
let pdf_password = pdf_password_from_settings(&settings);
226+
227+
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
228+
while let Ok(Some(field)) = multipart.next_field().await {
229+
let name = field.name().unwrap_or("");
230+
if name != "file" && name != "files" && name != "pdf" && name != "pdfs" {
231+
continue;
232+
}
233+
let filename = field.file_name().map(|s| s.to_string());
234+
let content_type = field.content_type().map(|s| s.to_string());
235+
let bytes = match field.bytes().await {
236+
Ok(bytes) => bytes,
237+
Err(_) => continue,
238+
};
239+
if bytes.is_empty() {
240+
continue;
241+
}
242+
let ext = ext_from_name_or_type(filename.as_deref(), content_type.as_deref());
243+
if ext != "pdf" {
244+
continue;
245+
}
246+
let stored_name = format!("{}_{}.{}", Utc::now().timestamp_millis(), files.len(), ext);
247+
files.push((stored_name, bytes.to_vec()));
248+
}
249+
250+
if files.is_empty() {
251+
return json_error("no files", StatusCode::BAD_REQUEST);
252+
}
253+
254+
let mut payload = Vec::new();
255+
for (filename, bytes) in files {
256+
if save_bloodtest_pdf(&filename, &bytes).await.is_err() {
257+
continue;
258+
}
259+
let extraction = extract_pdf_text(&bytes, pdf_password.as_deref());
260+
let (text, extract_error) = match extraction {
261+
Ok(text) => {
262+
let trimmed = text.trim();
263+
if trimmed.is_empty() {
264+
(None, Some("no extractable text found".to_string()))
265+
} else {
266+
let capped: String = text.chars().take(120_000).collect();
267+
(Some(capped), None)
268+
}
269+
}
270+
Err(err) => (None, Some(err)),
271+
};
272+
payload.push(json!({
273+
"filename": filename,
274+
"text": text,
275+
"extractError": extract_error,
276+
}));
277+
}
278+
279+
Json(json!({ "files": payload })).into_response()
280+
}
281+
282+
pub async fn get_bloodtest_pdf(Path(filename): Path<String>) -> Response {
283+
if !is_safe_storage_name(&filename) {
284+
return Response::builder()
285+
.status(StatusCode::NOT_FOUND)
286+
.body("Not found".into())
287+
.unwrap();
288+
}
289+
290+
let data = match read_bloodtest_pdf_file(&filename).await {
291+
Ok(Some(bytes)) => bytes,
292+
Ok(None) => {
293+
return Response::builder()
294+
.status(StatusCode::NOT_FOUND)
295+
.body("Not found".into())
296+
.unwrap()
297+
}
298+
Err(_) => {
299+
return Response::builder()
300+
.status(StatusCode::INTERNAL_SERVER_ERROR)
301+
.body("Error".into())
302+
.unwrap()
303+
}
304+
};
305+
306+
let ext = filename.split('.').last().unwrap_or("");
307+
let mut headers = HeaderMap::new();
308+
if let Ok(value) = HeaderValue::from_str(content_type_from_ext(ext)) {
309+
headers.insert("Content-Type", value);
310+
}
311+
(StatusCode::OK, headers, data).into_response()
312+
}
313+
314+
pub async fn delete_bloodtest_pdf(Path(filename): Path<String>) -> Response {
315+
if !is_safe_storage_name(&filename) {
316+
return json_error("invalid filename", StatusCode::BAD_REQUEST);
317+
}
318+
319+
match delete_bloodtest_pdf_file(&filename).await {
320+
Ok(_) => Json(json!({ "success": true })).into_response(),
321+
Err(_) => json_error("delete failed", StatusCode::INTERNAL_SERVER_ERROR),
322+
}
323+
}
324+
217325
fn ext_from_name_or_type(name: Option<&str>, content_type: Option<&str>) -> String {
218326
if let Some(name) = name {
219327
if let Some(ext) = name.split('.').last() {
@@ -224,6 +332,7 @@ fn ext_from_name_or_type(name: Option<&str>, content_type: Option<&str>) -> Stri
224332
}
225333

226334
match content_type.unwrap_or("") {
335+
"application/pdf" => "pdf".to_string(),
227336
"image/jpeg" => "jpg".to_string(),
228337
"image/png" => "png".to_string(),
229338
"image/webp" => "webp".to_string(),
@@ -232,6 +341,48 @@ fn ext_from_name_or_type(name: Option<&str>, content_type: Option<&str>) -> Stri
232341
}
233342
}
234343

344+
fn pdf_password_from_settings(settings: &Value) -> Option<String> {
345+
settings
346+
.get("pdfPassword")
347+
.and_then(|value| value.as_str())
348+
.map(|value| value.trim().to_string())
349+
.filter(|value| !value.is_empty())
350+
}
351+
352+
fn extract_pdf_text(bytes: &[u8], password: Option<&str>) -> Result<String, String> {
353+
let mut document = match password.filter(|value| !value.trim().is_empty()) {
354+
Some(password) => Document::load_mem_with_password(bytes, password)
355+
.or_else(|_| Document::load_mem(bytes))
356+
.map_err(|err| err.to_string())?,
357+
None => Document::load_mem(bytes).map_err(|err| err.to_string())?,
358+
};
359+
360+
if document.is_encrypted() && document.encryption_state.is_none() {
361+
if let Some(password) = password.filter(|value| !value.trim().is_empty()) {
362+
document.decrypt(password).map_err(|err| err.to_string())?;
363+
}
364+
}
365+
366+
let pages = document.get_pages();
367+
if pages.is_empty() {
368+
return Ok(String::new());
369+
}
370+
let page_numbers: Vec<u32> = pages.keys().cloned().collect();
371+
document
372+
.extract_text(&page_numbers)
373+
.map_err(|err| err.to_string())
374+
}
375+
376+
fn is_safe_storage_name(value: &str) -> bool {
377+
let trimmed = value.trim();
378+
if trimmed.is_empty() || trimmed.contains("..") {
379+
return false;
380+
}
381+
trimmed
382+
.bytes()
383+
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'.' || ch == b'_' || ch == b'-')
384+
}
385+
235386
fn json_error(message: &str, status: StatusCode) -> Response {
236387
(status, Json(json!({ "error": message }))).into_response()
237388
}

crates/server/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ async fn main() {
3838
"/api/dosage-photo/:entry_id/:filename",
3939
get(api::get_dosage_photo).delete(api::delete_dosage_photo),
4040
)
41+
.route("/api/bloodtest-pdf", post(api::upload_bloodtest_pdf))
42+
.route(
43+
"/api/bloodtest-pdf/:filename",
44+
get(api::get_bloodtest_pdf).delete(api::delete_bloodtest_pdf),
45+
)
4146
.layer(cors);
4247

4348
let addr = std::env::var("HRT_SERVER_ADDR").unwrap_or_else(|_| "127.0.0.1:4200".to_string());

crates/server/src/storage.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use tokio::io::AsyncWriteExt;
77
pub const DATA_FILE_PATH: &str = "data/hrt-data.json";
88
pub const SETTINGS_FILE_PATH: &str = "data/hrt-settings.yaml";
99
pub const PHOTOS_DIR: &str = "data/dosage-photos";
10+
pub const BLOODTEST_PDFS_DIR: &str = "data/bloodtest-pdfs";
1011

1112
#[derive(thiserror::Error, Debug)]
1213
pub enum StorageError {
@@ -115,8 +116,35 @@ pub async fn delete_photo(entry_id: &str, filename: &str) -> Result<bool, Storag
115116
}
116117
}
117118

119+
pub async fn save_bloodtest_pdf(filename: &str, bytes: &[u8]) -> Result<PathBuf, StorageError> {
120+
let dir = Path::new(BLOODTEST_PDFS_DIR);
121+
fs::create_dir_all(dir).await?;
122+
let path = dir.join(filename);
123+
fs::write(&path, bytes).await?;
124+
Ok(path)
125+
}
126+
127+
pub async fn read_bloodtest_pdf(filename: &str) -> Result<Option<Vec<u8>>, StorageError> {
128+
let path = Path::new(BLOODTEST_PDFS_DIR).join(filename);
129+
match fs::read(path).await {
130+
Ok(bytes) => Ok(Some(bytes)),
131+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
132+
Err(err) => Err(err.into()),
133+
}
134+
}
135+
136+
pub async fn delete_bloodtest_pdf(filename: &str) -> Result<bool, StorageError> {
137+
let path = Path::new(BLOODTEST_PDFS_DIR).join(filename);
138+
match fs::remove_file(path).await {
139+
Ok(()) => Ok(true),
140+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
141+
Err(err) => Err(err.into()),
142+
}
143+
}
144+
118145
pub fn content_type_from_ext(ext: &str) -> &'static str {
119146
match ext.to_lowercase().as_str() {
147+
"pdf" => "application/pdf",
120148
"jpg" | "jpeg" => "image/jpeg",
121149
"png" => "image/png",
122150
"webp" => "image/webp",

crates/shared/src/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ pub struct BloodTest {
305305
pub notes: Option<String>,
306306
#[serde(skip_serializing_if = "Option::is_none")]
307307
pub estrogenType: Option<EstrogenType>,
308+
#[serde(skip_serializing_if = "Option::is_none")]
309+
pub pdfFiles: Option<Vec<String>>,
308310
}
309311

310312
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -370,6 +372,8 @@ pub struct Settings {
370372
pub displayInjectableInIU: Option<bool>,
371373
#[serde(skip_serializing_if = "Option::is_none")]
372374
pub braSizeSystem: Option<String>,
375+
#[serde(skip_serializing_if = "Option::is_none")]
376+
pub pdfPassword: Option<String>,
373377
}
374378

375379
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]

crates/web/src/pages/backup.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub fn BackupPage() -> impl IntoView {
1616
let settings = store.settings;
1717

1818
let ics_secret = create_rw_signal(settings.get().icsSecret.unwrap_or_default());
19+
let pdf_password = create_rw_signal(settings.get().pdfPassword.unwrap_or_default());
1920
let blood_test_interval_months = create_rw_signal(
2021
settings
2122
.get()
@@ -48,16 +49,23 @@ pub fn BackupPage() -> impl IntoView {
4849
let on_save_settings = {
4950
let store = store.clone();
5051
let ics_secret = ics_secret;
52+
let pdf_password = pdf_password;
5153
let blood_test_interval_months = blood_test_interval_months;
5254
move |_: leptos::ev::MouseEvent| {
5355
let secret = ics_secret.get();
56+
let password = pdf_password.get();
5457
let interval = parse_decimal(&blood_test_interval_months.get());
5558
store.settings.update(|s| {
5659
s.icsSecret = if secret.trim().is_empty() {
5760
None
5861
} else {
5962
Some(secret)
6063
};
64+
s.pdfPassword = if password.trim().is_empty() {
65+
None
66+
} else {
67+
Some(password)
68+
};
6169
s.bloodTestIntervalMonths = interval;
6270
});
6371
store.mark_dirty();
@@ -242,6 +250,13 @@ pub fn BackupPage() -> impl IntoView {
242250
on:input=move |ev| ics_secret.set(event_target_value(&ev))
243251
prop:value=move || ics_secret.get()
244252
/>
253+
<label>"PDF password (optional)"</label>
254+
<input
255+
type="password"
256+
placeholder="password for encrypted lab PDFs"
257+
on:input=move |ev| pdf_password.set(event_target_value(&ev))
258+
prop:value=move || pdf_password.get()
259+
/>
245260
<label>"Estradiol display unit"</label>
246261
<select
247262
on:change={

0 commit comments

Comments
 (0)