file upload
todo: - don't return file hash in ListingResult, solve this marking problem in general? - work around persisterror across filesystems - add proper BLOB metadatafeat/vaults
parent
1521f25132
commit
d46f449e4b
|
@ -149,6 +149,24 @@ dependencies = [
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "actix-router"
|
name = "actix-router"
|
||||||
version = "0.2.7"
|
version = "0.2.7"
|
||||||
|
@ -2356,12 +2374,28 @@ dependencies = [
|
||||||
"trust-dns-proto",
|
"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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
|
checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unchecked-index"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
|
@ -2416,6 +2450,7 @@ version = "0.0.16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-files",
|
"actix-files",
|
||||||
|
"actix-multipart",
|
||||||
"actix-rt 2.5.0",
|
"actix-rt 2.5.0",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"actix_derive",
|
"actix_derive",
|
||||||
|
|
|
@ -62,6 +62,7 @@ is_executable = { version = "1.0.1", optional = true }
|
||||||
webbrowser = { version = "^0.5.5", optional = true }
|
webbrowser = { version = "^0.5.5", optional = true }
|
||||||
|
|
||||||
nonempty = "0.6.0"
|
nonempty = "0.6.0"
|
||||||
|
actix-multipart = "0.3.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["desktop", "previews"]
|
default = ["desktop", "previews"]
|
||||||
|
|
118
src/routes.rs
118
src/routes.rs
|
@ -1,24 +1,31 @@
|
||||||
use crate::addressing::{Address, Addressable};
|
use crate::addressing::{Address, Addressable};
|
||||||
use crate::database::entry::{Entry, InEntry};
|
use crate::database::entry::{Entry, InEntry};
|
||||||
use crate::database::hierarchies::{list_roots, resolve_path, UHierPath};
|
use crate::database::hierarchies::{list_roots, resolve_path, UHierPath};
|
||||||
|
use crate::database::inner::models;
|
||||||
use crate::database::lang::Query;
|
use crate::database::lang::Query;
|
||||||
use crate::database::UpEndDatabase;
|
use crate::database::UpEndDatabase;
|
||||||
use crate::previews::PreviewStore;
|
use crate::previews::PreviewStore;
|
||||||
use crate::util::hash::{decode, encode};
|
use crate::util::hash::{decode, encode, Hashable};
|
||||||
use crate::util::jobs::JobContainer;
|
use crate::util::jobs::JobContainer;
|
||||||
use actix_files::NamedFile;
|
use actix_files::NamedFile;
|
||||||
|
use actix_multipart::Multipart;
|
||||||
use actix_web::error::{ErrorBadRequest, ErrorInternalServerError, ErrorNotFound};
|
use actix_web::error::{ErrorBadRequest, ErrorInternalServerError, ErrorNotFound};
|
||||||
use actix_web::http;
|
use actix_web::http;
|
||||||
use actix_web::{delete, error, get, post, put, web, Either, Error, HttpResponse};
|
use actix_web::{delete, error, get, post, put, web, Either, Error, HttpResponse};
|
||||||
use anyhow::anyhow;
|
use anyhow::{anyhow, Result};
|
||||||
use anyhow::Result;
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use futures_util::StreamExt;
|
use futures_util::TryStreamExt;
|
||||||
use log::{debug, info, trace};
|
use log::{debug, info, trace};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
use is_executable::IsExecutable;
|
use is_executable::IsExecutable;
|
||||||
|
@ -178,19 +185,86 @@ pub async fn get_object(
|
||||||
#[put("/api/obj")]
|
#[put("/api/obj")]
|
||||||
pub async fn put_object(
|
pub async fn put_object(
|
||||||
state: web::Data<State>,
|
state: web::Data<State>,
|
||||||
mut payload: web::Payload,
|
payload: Either<web::Json<InEntry>, Multipart>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let body = load_body(&mut payload)
|
let (result_address, entry) = match payload {
|
||||||
.await
|
Either::A(in_entry) => {
|
||||||
.map_err(error::ErrorBadRequest)?;
|
let in_entry = in_entry.into_inner();
|
||||||
let in_entry = serde_json::from_slice::<InEntry>(&body).map_err(ErrorBadRequest)?;
|
let entry = Entry::try_from(in_entry).map_err(ErrorInternalServerError)?;
|
||||||
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
|
Ok((
|
||||||
.insert_entry(entry.clone())
|
connection
|
||||||
.map_err(ErrorInternalServerError)?;
|
.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(
|
Ok(HttpResponse::Ok().json(
|
||||||
[(
|
[(
|
||||||
|
@ -199,7 +273,7 @@ pub async fn put_object(
|
||||||
)]
|
)]
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.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."))
|
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())
|
|
||||||
}
|
|
||||||
|
|
|
@ -8,21 +8,76 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import mitt from "mitt";
|
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 files: File[] = [];
|
||||||
let URLs: string[] = [];
|
let URLs: string[] = [];
|
||||||
|
let uploading = false;
|
||||||
|
|
||||||
$: visible = files.length + URLs.length > 0;
|
$: visible = files.length + URLs.length > 0;
|
||||||
|
|
||||||
addEmitter.on("files", (ev) => {
|
addEmitter.on("files", (ev) => {
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<div class="addmodal-container" class:visible>
|
<div class="addmodal-container" class:visible class:uploading on:click={reset}>
|
||||||
<div class="addmodal">
|
<div class="addmodal" on:click|stopPropagation>
|
||||||
{#each files as file}
|
<div class="files">
|
||||||
<div>{file.name}</div>
|
{#each files as file}
|
||||||
{/each}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -40,5 +95,64 @@
|
||||||
&.visible {
|
&.visible {
|
||||||
display: unset;
|
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>
|
</style>
|
||||||
|
|
|
@ -47,7 +47,12 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="add-button" on:click={() => fileInput.click()}>
|
<button class="add-button" on:click={() => fileInput.click()}>
|
||||||
<Icon name="plus-circle" />
|
<Icon name="plus-circle" />
|
||||||
<input type="file" bind:this={fileInput} on:change={onFileChange} />
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
bind:this={fileInput}
|
||||||
|
on:change={onFileChange}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -71,7 +71,7 @@
|
||||||
|
|
||||||
<div class="selector">
|
<div class="selector">
|
||||||
<Input
|
<Input
|
||||||
value={inputValue}
|
bind:value={inputValue}
|
||||||
on:input={onInput}
|
on:input={onInput}
|
||||||
on:focusChange={(ev) => (inputFocused = ev.detail)}
|
on:focusChange={(ev) => (inputFocused = ev.detail)}
|
||||||
/>
|
/>
|
||||||
|
|
Loading…
Reference in New Issue