@@ -4,14 +4,17 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode};
44use axum:: response:: { IntoResponse , Response } ;
55use axum:: Json ;
66use chrono:: Utc ;
7+ use lopdf:: Document ;
78use serde_json:: { json, Value } ;
89
910use hrt_shared:: convert:: convert_hormone;
1011use hrt_shared:: types:: Hormone ;
1112
1213use 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
1720pub 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+
217325fn 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+
235386fn json_error ( message : & str , status : StatusCode ) -> Response {
236387 ( status, Json ( json ! ( { "error" : message } ) ) ) . into_response ( )
237388}
0 commit comments