add support for files

This commit is contained in:
cupcakearmy 2021-12-21 00:15:04 +01:00
parent 00fd514da5
commit e4ce767444
No known key found for this signature in database
GPG Key ID: 3235314B4D31232F
18 changed files with 263 additions and 65 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ node_modules
/.svelte /.svelte
/build /build
/functions /functions
.env

8
Cargo.lock generated
View File

@ -521,9 +521,9 @@ dependencies = [
"actix-web", "actix-web",
"bs62", "bs62",
"byte-unit", "byte-unit",
"dotenv",
"lazy_static", "lazy_static",
"memcache", "memcache",
"mime",
"ring", "ring",
"serde", "serde",
"serde_json", "serde_json",
@ -557,6 +557,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0"
[[package]]
name = "dotenv"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]] [[package]]
name = "either" name = "either"
version = "1.6.1" version = "1.6.1"

View File

@ -20,4 +20,4 @@ ring = "0.16"
bs62 = "0.1" bs62 = "0.1"
memcache = "0.16" memcache = "0.16"
byte-unit = "4" byte-unit = "4"
mime = "0.3" dotenv = "0.15"

View File

@ -97,3 +97,24 @@ fieldset {
padding: 0; padding: 0;
border: none; border: none;
} }
.box {
width: 100%;
min-height: min(calc(100vh - 30rem), 30rem);
margin: 0;
border: 2px solid var(--ui-bg-1);
resize: vertical;
outline: none;
padding: 0.5rem;
}
@media screen and (max-width: 30rem) {
.box {
min-height: calc(100vh - 25rem);
}
}
.box:hover,
.box:focus {
border-color: var(--ui-clr-primary);
}

View File

@ -1,10 +1,18 @@
export type NoteMeta = { type: 'text' | 'file' }
export type Note = { export type Note = {
contents: string contents: string
meta: NoteMeta
views?: number views?: number
expiration?: number expiration?: number
} }
export type NoteInfo = {} export type NoteInfo = {}
export type NotePublic = Pick<Note, 'contents'> export type NotePublic = Pick<Note, 'contents' | 'meta'>
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
contents: string
}
type CallOptions = { type CallOptions = {
url: string url: string
@ -32,26 +40,35 @@ async function call(options: CallOptions) {
} }
export async function create(note: Note) { export async function create(note: Note) {
const { meta, ...rest } = note
const body: NoteCreate = {
...rest,
meta: JSON.stringify(meta),
}
const data = await call({ const data = await call({
url: 'notes', url: 'notes',
method: 'post', method: 'post',
body: note, body,
}) })
return data as { id: string } return data as { id: string }
} }
export async function get(id: string) { export async function get(id: string): Promise<NotePublic> {
const data = await call({ const data = await call({
url: `notes/${id}`, url: `notes/${id}`,
method: 'delete', method: 'delete',
}) })
return data as NotePublic const { contents, meta } = data
return {
contents,
meta: JSON.parse(meta) as NoteMeta,
}
} }
export async function info(id: string) { export async function info(id: string): Promise<NoteInfo> {
const data = await call({ const data = await call({
url: `notes/${id}`, url: `notes/${id}`,
method: 'get', method: 'get',
}) })
return data as NoteInfo return data
} }

13
client/src/lib/files.ts Normal file
View File

@ -0,0 +1,13 @@
export class Files {
static toString(f: File | Blob): Promise<string> {
const reader = new window.FileReader()
reader.readAsDataURL(f)
return new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string)
})
}
static fromString(s: string): Promise<Blob> {
return fetch(s).then((r) => r.blob())
}
}

View File

@ -0,0 +1,69 @@
<script lang="ts">
import type { FileDTO } from '$lib/api'
import { Files } from '$lib/files'
import { createEventDispatcher } from 'svelte'
export let label: string = ''
let files: File[] = []
const dispatch = createEventDispatcher<{ file: string }>()
async function onInput(e: Event) {
const input = e.target as HTMLInputElement
if (input.files.length) {
files = Array.from(input.files)
const data: FileDTO[] = await Promise.all(
files.map(async (file) => ({
name: file.name,
type: file.type,
size: file.size,
contents: await Files.toString(file),
}))
)
console.debug(
'files',
data.map((d) => d.contents.length)
)
dispatch('file', JSON.stringify(data))
} else {
dispatch('file', '')
}
}
</script>
<label>
<small>
{label}
</small>
<input type="file" on:change={onInput} multiple />
<div class="box">
{#if files.length}
<div>
<b>Selected Files</b>
{#each files as file}
<div class="file">
{file.name}
</div>
{/each}
</div>
{:else}
<div>
<b>No Files Selected</b>
</div>
{/if}
</div>
</label>
<style>
input {
display: none;
}
.box {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,69 @@
<script lang="ts">
import type { FileDTO, NotePublic } from '$lib/api'
import { Files } from '$lib/files'
import copy from 'copy-to-clipboard'
import { saveAs } from 'file-saver'
import prettyBytes from 'pretty-bytes'
import Button from './Button.svelte'
export let note: NotePublic
let files: FileDTO[] = []
$: if (note.meta.type === 'file') {
files = JSON.parse(note.contents) as FileDTO[]
}
$: download = () => {
for (const file of files) {
downloadFile(file)
}
}
async function downloadFile(file: FileDTO) {
const f = new File([await Files.fromString(file.contents)], file.name, {
type: file.type,
})
saveAs(f)
}
</script>
<p class="error-text">you will <b>not</b> get the chance to see the note again.</p>
{#if note.meta.type === 'text'}
<div class="note" data-testid="note-result">
{note.contents}
</div>
<Button on:click={() => copy(note.contents)}>copy to clipboard</Button>
{:else}
{#each files as file}
<div class="note file" data-testid="note-result">
<b on:click={() => downloadFile(file)}> {file.name}</b>
<small> {file.type} {prettyBytes(file.size)}</small>
</div>
{/each}
<Button on:click={download}>download all</Button>
{/if}
<style>
.note {
width: 100%;
margin: 0;
padding: 0;
border: 2px solid var(--ui-bg-1);
outline: none;
padding: 0.5rem;
white-space: pre;
overflow: auto;
margin-bottom: 0.5rem;
}
.note b {
cursor: pointer;
}
.note.file {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@ -4,7 +4,7 @@
export let color = true export let color = true
</script> </script>
<div> <div {...$$restProps}>
<label class="switch"> <label class="switch">
<small>{label}</small> <small>{label}</small>
<input type="checkbox" bind:checked={value} /> <input type="checkbox" bind:checked={value} />

View File

@ -7,28 +7,5 @@
<small> <small>
{label} {label}
</small> </small>
<textarea {...$$restProps} bind:value /> <textarea class="box" {...$$restProps} bind:value />
</label> </label>
<style>
textarea {
width: 100%;
min-height: min(calc(100vh - 30rem), 30rem);
margin: 0;
border: 2px solid var(--ui-bg-1);
resize: vertical;
outline: none;
padding: 0.5rem;
}
@media screen and (max-width: 30rem) {
textarea {
min-height: calc(100vh - 25rem);
}
}
textarea:hover,
textarea:focus {
border-color: var(--ui-clr-primary);
}
</style>

View File

@ -4,17 +4,20 @@
import { getKeyFromString, encrypt, Hex, getRandomBytes } from '$lib/crypto' import { getKeyFromString, encrypt, Hex, getRandomBytes } from '$lib/crypto'
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import FileUpload from '$lib/ui/FileUpload.svelte'
import Switch from '$lib/ui/Switch.svelte' import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte' import TextArea from '$lib/ui/TextArea.svelte'
import TextInput from '$lib/ui/TextInput.svelte' import TextInput from '$lib/ui/TextInput.svelte'
let note: Note = { let note: Note = {
contents: '', contents: '',
meta: { type: 'text' },
views: 1, views: 1,
expiration: 60, expiration: 60,
} }
let result: { password: string; id: string } | null = null let result: { password: string; id: string } | null = null
let advanced = false let advanced = false
let file = false
let type = false let type = false
let message = '' let message = ''
let loading = false let loading = false
@ -31,6 +34,8 @@
message = 'the note will expire and be destroyed after ' + fraction message = 'the note will expire and be destroyed after ' + fraction
} }
$: note.meta.type = file ? 'file' : 'text'
async function submit() { async function submit() {
try { try {
error = null error = null
@ -39,6 +44,7 @@
const key = await getKeyFromString(password) const key = await getKeyFromString(password)
const data: Note = { const data: Note = {
contents: await encrypt(note.contents, key), contents: await encrypt(note.contents, key),
meta: note.meta,
} }
// @ts-ignore // @ts-ignore
if (type) data.expiration = parseInt(note.expiration) if (type) data.expiration = parseInt(note.expiration)
@ -89,15 +95,21 @@
{:else} {:else}
<form on:submit|preventDefault={submit}> <form on:submit|preventDefault={submit}>
<fieldset disabled={loading}> <fieldset disabled={loading}>
<TextArea {#if file}
label="note" <FileUpload label="file" on:file={(f) => (note.contents = f.detail)} />
bind:value={note.contents} {:else}
placeholder="..." <TextArea
data-testid="input-note" label="note"
/> bind:value={note.contents}
placeholder="..."
data-testid="input-note"
/>
{/if}
<div class="bottom"> <div class="bottom">
<Switch class="file" label="file" bind:value={file} />
<Switch label="advanced" bind:value={advanced} /> <Switch label="advanced" bind:value={advanced} />
<div class="grow" />
<Button type="submit" data-testid="button-create">create</Button> <Button type="submit" data-testid="button-create">create</Button>
</div> </div>
@ -152,11 +164,19 @@
<style> <style>
.bottom { .bottom {
display: flex; display: flex;
justify-content: space-between; /* justify-content: space-between; */
align-items: flex-end; align-items: flex-end;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.bottom :global(.file) {
margin-right: 0.5rem;
}
.grow {
flex: 1;
}
.middle-switch { .middle-switch {
margin: 0 1rem; margin: 0 1rem;
} }

View File

@ -7,13 +7,12 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'
import copy from 'copy-to-clipboard'
import type { NotePublic } from '$lib/api' import type { NotePublic } from '$lib/api'
import { info, get } from '$lib/api' import { get, info } from '$lib/api'
import { decrypt, getKeyFromString } from '$lib/crypto' import { decrypt, getKeyFromString } from '$lib/crypto'
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import ShowNote from '$lib/ui/ShowNote.svelte'
import { onMount } from 'svelte'
export let id: string export let id: string
@ -29,7 +28,6 @@
loading = true loading = true
error = null error = null
password = window.location.hash.slice(1) password = window.location.hash.slice(1)
console.log(password)
await info(id) await info(id)
exists = true exists = true
} catch { } catch {
@ -61,12 +59,7 @@
note was not found or was already deleted. note was not found or was already deleted.
</p> </p>
{:else if note && !error} {:else if note && !error}
<p class="error-text">you will not get the chance to see the note again.</p> <ShowNote {note} />
<div class="note" data-testid="note-result">
{note.contents}
</div>
<br />
<Button on:click={() => copy(note.contents)}>copy to clipboard</Button>
{:else} {:else}
<form on:submit|preventDefault={show}> <form on:submit|preventDefault={show}>
<fieldset> <fieldset>
@ -86,16 +79,3 @@
{#if loading} {#if loading}
<p>loading...</p> <p>loading...</p>
{/if} {/if}
<style>
.note {
width: 100%;
margin: 0;
padding: 0;
border: 2px solid var(--ui-bg-1);
outline: none;
padding: 0.5rem;
white-space: pre;
overflow: auto;
}
</style>

View File

@ -6,7 +6,8 @@ version: '3.7'
services: services:
memcached: memcached:
image: memcached:1-alpine image: memcached:1-alpine
entrypoint: memcached -m 128 restart: unless-stopped
entrypoint: memcached -m 128M -I 4M
ports: ports:
- 11211:11211 - 11211:11211

View File

@ -9,5 +9,9 @@
"devDependencies": { "devDependencies": {
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"npm-run-all": "^4.1.5" "npm-run-all": "^4.1.5"
},
"dependencies": {
"file-saver": "^2.0.5",
"pretty-bytes": "^5.6.0"
} }
} }

15
pnpm-lock.yaml generated
View File

@ -1,8 +1,14 @@
lockfileVersion: 5.3 lockfileVersion: 5.3
specifiers: specifiers:
file-saver: ^2.0.5
http-proxy: ^1.18.1 http-proxy: ^1.18.1
npm-run-all: ^4.1.5 npm-run-all: ^4.1.5
pretty-bytes: ^5.6.0
dependencies:
file-saver: 2.0.5
pretty-bytes: 5.6.0
devDependencies: devDependencies:
http-proxy: 1.18.1 http-proxy: 1.18.1
@ -122,6 +128,10 @@ packages:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
dev: true dev: true
/file-saver/2.0.5:
resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
dev: false
/follow-redirects/1.14.6: /follow-redirects/1.14.6:
resolution: {integrity: sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==} resolution: {integrity: sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@ -357,6 +367,11 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
dev: true dev: true
/pretty-bytes/5.6.0:
resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
engines: {node: '>=6'}
dev: false
/read-pkg/3.0.0: /read-pkg/3.0.0:
resolution: {integrity: sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=} resolution: {integrity: sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=}
engines: {node: '>=4'} engines: {node: '>=4'}

View File

@ -1,4 +1,5 @@
use actix_web::{middleware, web, App, HttpServer}; use actix_web::{middleware, web, App, HttpServer};
use dotenv::dotenv;
#[macro_use] #[macro_use]
extern crate lazy_static; extern crate lazy_static;
@ -10,6 +11,7 @@ mod store;
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
dotenv().ok();
return HttpServer::new(|| { return HttpServer::new(|| {
App::new() App::new()
// .configure(|cfg: &mut web::ServiceConfig| { // .configure(|cfg: &mut web::ServiceConfig| {

View File

@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct Note { pub struct Note {
pub meta: String,
pub contents: String, pub contents: String,
pub views: Option<u8>, pub views: Option<u8>,
pub expiration: Option<u64>, pub expiration: Option<u64>,
@ -14,6 +15,7 @@ pub struct NoteInfo {}
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct NotePublic { pub struct NotePublic {
pub meta: String,
pub contents: String, pub contents: String,
} }

View File

@ -96,6 +96,7 @@ async fn delete(path: web::Path<NotePath>) -> impl Responder {
} }
return HttpResponse::Ok().json(NotePublic { return HttpResponse::Ok().json(NotePublic {
contents: changed.contents, contents: changed.contents,
meta: changed.meta,
}); });
} }
} }