file upload

todo:
  - don't return file hash in ListingResult, solve this marking problem in general?
  - work around persisterror across filesystems
  - add proper BLOB metadata
feat/vaults
Tomáš Mládek 2022-01-18 17:05:45 +01:00
parent 1521f25132
commit d46f449e4b
No known key found for this signature in database
GPG Key ID: ED21612889E75EC5
6 changed files with 251 additions and 36 deletions

35
Cargo.lock generated
View File

@ -149,6 +149,24 @@ dependencies = [
"syn",
]
[[package]]
name = "actix-multipart"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "774bfeb11b54bf9c857a005b8ab893293da4eaff79261a66a9200dab7f5ab6e3"
dependencies = [
"actix-service",
"actix-utils",
"actix-web",
"bytes 0.5.6",
"derive_more",
"futures-util",
"httparse",
"log",
"mime",
"twoway",
]
[[package]]
name = "actix-router"
version = "0.2.7"
@ -2356,12 +2374,28 @@ dependencies = [
"trust-dns-proto",
]
[[package]]
name = "twoway"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c57ffb460d7c24cd6eda43694110189030a3d1dfe418416d9468fd1c1d290b47"
dependencies = [
"memchr",
"unchecked-index",
]
[[package]]
name = "typenum"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
[[package]]
name = "unchecked-index"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c"
[[package]]
name = "unicase"
version = "2.6.0"
@ -2416,6 +2450,7 @@ version = "0.0.16"
dependencies = [
"actix",
"actix-files",
"actix-multipart",
"actix-rt 2.5.0",
"actix-web",
"actix_derive",

View File

@ -62,6 +62,7 @@ is_executable = { version = "1.0.1", optional = true }
webbrowser = { version = "^0.5.5", optional = true }
nonempty = "0.6.0"
actix-multipart = "0.3.0"
[features]
default = ["desktop", "previews"]

View File

@ -1,24 +1,31 @@
use crate::addressing::{Address, Addressable};
use crate::database::entry::{Entry, InEntry};
use crate::database::hierarchies::{list_roots, resolve_path, UHierPath};
use crate::database::inner::models;
use crate::database::lang::Query;
use crate::database::UpEndDatabase;
use crate::previews::PreviewStore;
use crate::util::hash::{decode, encode};
use crate::util::hash::{decode, encode, Hashable};
use crate::util::jobs::JobContainer;
use actix_files::NamedFile;
use actix_multipart::Multipart;
use actix_web::error::{ErrorBadRequest, ErrorInternalServerError, ErrorNotFound};
use actix_web::http;
use actix_web::{delete, error, get, post, put, web, Either, Error, HttpResponse};
use anyhow::anyhow;
use anyhow::Result;
use futures_util::StreamExt;
use anyhow::{anyhow, Result};
use chrono::{NaiveDateTime, Utc};
use futures_util::TryStreamExt;
use log::{debug, info, trace};
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::UNIX_EPOCH;
use tempfile::NamedTempFile;
#[cfg(feature = "desktop")]
use is_executable::IsExecutable;
@ -178,19 +185,86 @@ pub async fn get_object(
#[put("/api/obj")]
pub async fn put_object(
state: web::Data<State>,
mut payload: web::Payload,
payload: Either<web::Json<InEntry>, Multipart>,
) -> Result<HttpResponse, Error> {
let body = load_body(&mut payload)
.await
.map_err(error::ErrorBadRequest)?;
let in_entry = serde_json::from_slice::<InEntry>(&body).map_err(ErrorBadRequest)?;
let entry = Entry::try_from(in_entry).map_err(ErrorInternalServerError)?;
let (result_address, entry) = match payload {
Either::A(in_entry) => {
let in_entry = in_entry.into_inner();
let entry = Entry::try_from(in_entry).map_err(ErrorInternalServerError)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
let result_address = connection
.insert_entry(entry.clone())
.map_err(ErrorInternalServerError)?;
Ok((
connection
.insert_entry(entry.clone())
.map_err(ErrorInternalServerError)?,
Some(entry),
))
}
Either::B(mut multipart) => {
if let Some(mut field) = multipart.try_next().await? {
let content_disposition = field
.content_disposition()
.ok_or_else(|| HttpResponse::BadRequest().finish())?;
let filename = content_disposition.get_filename();
let mut file = NamedTempFile::new()?;
while let Some(chunk) = field.try_next().await? {
file = web::block(move || file.write_all(&chunk).map(|_| file)).await?;
}
let path = PathBuf::from(file.path());
let hash = web::block(move || path.hash()).await?;
let address = Address::Hash(hash.clone());
let addr_str = encode(address.encode().map_err(ErrorInternalServerError)?);
let final_name = if let Some(filename) = filename {
format!("{addr_str}_{filename}")
} else {
addr_str
};
let final_path = state.upend.vault_path.join(&final_name);
file.persist(&final_path)
.map_err(ErrorInternalServerError)?;
let metadata = fs::metadata(&final_path)?;
let size = metadata.len() as i64;
let mtime = metadata
.modified()
.map(|t| {
NaiveDateTime::from_timestamp(
t.duration_since(UNIX_EPOCH).unwrap().as_secs() as i64,
0,
)
})
.ok();
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
connection
.insert_file(models::NewFile {
path: final_name,
hash: hash.0,
added: NaiveDateTime::from_timestamp(Utc::now().timestamp(), 0),
size,
mtime,
})
.map_err(ErrorInternalServerError)?;
if let Some(filename) = filename {
upend_insert_val!(&connection, address, "LBL", filename)
.map_err(ErrorInternalServerError)?;
}
Ok((address, None))
} else {
Err(ErrorBadRequest(anyhow!("wat")))
}
}
}?;
Ok(HttpResponse::Ok().json(
[(
@ -199,7 +273,7 @@ pub async fn put_object(
)]
.iter()
.cloned()
.collect::<HashMap<String, Entry>>(),
.collect::<HashMap<String, Option<Entry>>>(),
))
}
@ -376,17 +450,3 @@ pub async fn get_thumbnail(
}
Err(error::ErrorNotImplemented("Previews not enabled."))
}
const MAX_BODY_SIZE: usize = 1_000_000;
async fn load_body(payload: &mut web::Payload) -> Result<Vec<u8>> {
let mut body = web::BytesMut::new();
while let Some(chunk) = payload.next().await {
let chunk = chunk?;
if (body.len() + chunk.len()) > MAX_BODY_SIZE {
return Err(anyhow!("OVERFLOW"));
}
body.extend_from_slice(&chunk);
}
Ok(body.as_ref().to_vec())
}

View File

@ -8,21 +8,76 @@
<script lang="ts">
import mitt from "mitt";
import { navigate } from "svelte-navigator";
import type { ListingResult } from "upend/types";
import Icon from "./utils/Icon.svelte";
import IconButton from "./utils/IconButton.svelte";
let files: File[] = [];
let URLs: string[] = [];
let uploading = false;
$: visible = files.length + URLs.length > 0;
addEmitter.on("files", (ev) => {
files = ev;
});
async function upload() {
uploading = true;
try {
const responses = await Promise.all(
files.map(async (file) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/obj", {
method: "PUT",
body: formData,
});
if (!response.ok) {
throw Error(await response.text());
}
return (await response.json()) as ListingResult;
})
);
const addresses = responses.map((lr) => Object.keys(lr)).flat();
navigate(`/browse/${addresses.join(",")}`);
} catch (error) {
alert(error);
}
uploading = false;
reset();
}
function reset() {
if (!uploading) {
files = [];
URLs = [];
}
}
</script>
<div class="addmodal-container" class:visible>
<div class="addmodal">
{#each files as file}
<div>{file.name}</div>
{/each}
<div class="addmodal-container" class:visible class:uploading on:click={reset}>
<div class="addmodal" on:click|stopPropagation>
<div class="files">
{#each files as file}
<div class="file">
<div class="icon">
<Icon name="file" />
</div>
<div class="label">{file.name}</div>
</div>
{/each}
</div>
<div class="controls">
<IconButton name="upload" on:click={upload} />
</div>
</div>
</div>
@ -40,5 +95,64 @@
&.visible {
display: unset;
}
&.uploading {
cursor: progress;
.addmodal {
filter: brightness(0.5);
}
}
}
.addmodal {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: var(--background);
color: var(--foreground);
border: solid 2px var(--foreground);
border-radius: 8px;
padding: 1rem;
}
.files {
display: flex;
flex-direction: column;
gap: 1em;
padding: 0.5em;
overflow-y: auto;
max-height: 66vh;
}
.file {
display: flex;
align-items: center;
border: 1px solid var(--foreground);
border-radius: 4px;
background: var(--background-lighter);
padding: 0.5em;
.icon {
font-size: 24px;
}
.label {
flex-grow: 1;
text-align: center;
}
}
.controls {
display: flex;
justify-content: center;
font-size: 48px;
margin-top: 0.5rem;
}
</style>

View File

@ -47,7 +47,12 @@
</div>
<button class="add-button" on:click={() => fileInput.click()}>
<Icon name="plus-circle" />
<input type="file" bind:this={fileInput} on:change={onFileChange} />
<input
type="file"
multiple
bind:this={fileInput}
on:change={onFileChange}
/>
</button>
</div>

View File

@ -71,7 +71,7 @@
<div class="selector">
<Input
value={inputValue}
bind:value={inputValue}
on:input={onInput}
on:focusChange={(ev) => (inputFocused = ev.detail)}
/>