Initial commit

This commit is contained in:
asonix 2020-12-08 15:59:55 -06:00
commit 57771fbf0d
35 changed files with 4763 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
/sled

2384
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "pict-rs-aggregator"
version = "0.1.0"
authors = ["asonix <asonix@asonix.dog>"]
edition = "2018"
build = "src/build.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = { version = "3.3.2", features = ["rustls"] }
anyhow = "1.0"
bcrypt = "0.9"
env_logger = "0.8.2"
futures = "0.3"
mime = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sled = { version = "0.34.6", features = ["zstd"] }
structopt = "0.3.21"
thiserror = "1.0"
url = { version = "2.2", features = ["serde"] }
uuid = { version = "0.8.1", features = ["serde", "v4"] }
[build-dependencies]
anyhow = "1.0"
dotenv = "0.15.0"
ructe = { version = "0.13.0", features = ["sass", "mime03"] }

42
docker/dev/Dockerfile Normal file
View File

@ -0,0 +1,42 @@
# Build
FROM ekidd/rust-musl-builder:1.48.0 as rust
ARG OUT_DIR=/tmp
# Cache deps
WORKDIR /app
RUN sudo chown -R rust:rust .
RUN USER=root cargo new server
WORKDIR /app/server
COPY Cargo.toml Cargo.lock ./
RUN sudo chown -R rust:rust .
RUN mkdir -p ./src/bin ./scss ./templates ./static \
&& touch ./scss/layout.scss \
&& echo 'fn main() { println!("Dummy") }' > ./src/bin/main.rs
COPY src/build.rs ./src/build.rs
RUN cargo build --release
RUN rm -rf ./src ./scss ./templates
COPY src ./src/
COPY scss ./scss/
COPY templates ./templates/
COPY static ./static/
# Build for release
RUN cargo build --release --frozen
FROM alpine:3.12
# Copy resources
COPY --from=rust /app/server/target/x86_64-unknown-linux-musl/release/picture-aggregator /app/picture-aggregator
RUN addgroup -g 1000 pictrs
RUN adduser -D -s /bin/sh -u 1000 -G pictrs pictrs
RUN chown pictrs:pictrs /app/picture-aggregator
USER pictrs
EXPOSE 8080
CMD ["/app/picture-aggregator"]

View File

@ -0,0 +1,20 @@
version: '3.3'
services:
pictrs:
image: asonix/pictrs:v0.2.6-r0
user: root
restart: always
volumes:
- ./volumes/pictrs:/mnt
picture-aggregator:
build:
context: ../../
dockerfile: docker/dev/Dockerfile
user: root
environment:
- PICTRS_AGGREGATOR_UPSTREAM=http://pictrs:8080
ports:
- "127.0.0.1:8082:8082"
restart: always

View File

@ -0,0 +1,4 @@
segment_size: 524288
use_compression: false
version: 0.34
v

Binary file not shown.

View File

@ -0,0 +1,74 @@
FROM rustembedded/cross:x86_64-unknown-linux-musl AS x86_64-builder
ARG UID=991
ARG GID=991
ENV TOOLCHAIN=stable
ENV TARGET=x86_64-unknown-linux-musl
ENV TOOL=x86_64-linux-musl
RUN \
apt-get update && \
apt-get upgrade -y
RUN \
addgroup --gid "${GID}" build && \
adduser \
--disabled-password \
--gecos "" \
--ingroup build \
--uid "${UID}" \
--home /opt/build \
build
ADD https://sh.rustup.rs /opt/build/rustup.sh
RUN \
chown -R build:build /opt/build
USER build
WORKDIR /opt/build
ENV PATH=/opt/build/.cargo/bin:/usr/local/musl/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN \
chmod +x rustup.sh && \
./rustup.sh --default-toolchain $TOOLCHAIN --profile minimal -y && \
rustup target add $TARGET
FROM x86_64-builder as builder
ARG TAG=main
ARG REPOSITORY=https://git.asonix.dog/asonix/pict-rs-aggregator
ARG BINARY=pict-rs-aggregator
RUN \
git clone -b $TAG $REPOSITORY repo
WORKDIR /opt/build/repo
RUN \
cargo build --release --target $TARGET && \
$TOOL-strip target/$TARGET/release/$BINARY
FROM amd64/alpine:3.12
ARG UID=991
ARG GID=991
ARG BINARY=pict-rs-aggregator
COPY --from=builder /opt/build/repo/target/x86_64-unknown-linux-musl/release/$BINARY /usr/bin/$BINARY
RUN \
apk add tini && \
addgroup -g $GID pictrs && \
adduser -D -G pictrs -u $UID -g "" -h /opt/pictrs pictrs
RUN \
chown -R pictrs:pictrs /mnt
VOLUME /mnt
WORKDIR /opt/pictrs
USER pictrs
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/bin/pict-rs-aggregator"]

View File

@ -0,0 +1,74 @@
FROM rustembedded/cross:arm-unknown-linux-musleabihf AS arm32v7-builder
ARG UID=991
ARG GID=991
ENV TOOLCHAIN=stable
ENV TARGET=arm-unknown-linux-musleabihf
ENV TOOL=arm-linux-musleabihf
RUN \
apt-get update && \
apt-get upgrade -y
RUN \
addgroup --gid "${GID}" build && \
adduser \
--disabled-password \
--gecos "" \
--ingroup build \
--uid "${UID}" \
--home /opt/build \
build
ADD https://sh.rustup.rs /opt/build/rustup.sh
RUN \
chown -R build:build /opt/build
USER build
WORKDIR /opt/build
ENV PATH=/opt/build/.cargo/bin:/usr/local/musl/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN \
chmod +x rustup.sh && \
./rustup.sh --default-toolchain $TOOLCHAIN --profile minimal -y && \
rustup target add $TARGET
FROM arm32v7-builder as builder
ARG TAG=main
ARG REPOSITORY=https://git.asonix.dog/asonix/pict-rs-aggregator
ARG BINARY=pict-rs-aggregator
RUN \
git clone -b $TAG $REPOSITORY repo
WORKDIR /opt/build/repo
RUN \
cargo build --release --target $TARGET && \
$TOOL-strip target/$TARGET/release/$BINARY
FROM arm32v7/alpine:3.12
ARG UID=991
ARG GID=991
ARG BINARY=pict-rs-aggregator
COPY --from=builder /opt/build/repo/target/arm-unknown-linux-musleabihf/release/$BINARY /usr/bin/$BINARY
RUN \
apk add tini && \
addgroup -g $GID pictrs && \
adduser -D -G pictrs -u $UID -g "" -h /opt/pictrs pictrs
RUN \
chown -R pictrs:pictrs /mnt
VOLUME /mnt
WORKDIR /opt/pictrs
USER pictrs
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/bin/pict-rs-aggregator"]

View File

@ -0,0 +1,74 @@
FROM rustembedded/cross:aarch64-unknown-linux-musl AS aarch64-builder
ARG UID=991
ARG GID=991
ENV TOOLCHAIN=stable
ENV TARGET=aarch64-unknown-linux-musl
ENV TOOL=aarch64-linux-musl
RUN \
apt-get update && \
apt-get upgrade -y
RUN \
addgroup --gid "${GID}" build && \
adduser \
--disabled-password \
--gecos "" \
--ingroup build \
--uid "${UID}" \
--home /opt/build \
build
ADD https://sh.rustup.rs /opt/build/rustup.sh
RUN \
chown -R build:build /opt/build
USER build
WORKDIR /opt/build
ENV PATH=/opt/build/.cargo/bin:/usr/local/musl/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN \
chmod +x rustup.sh && \
./rustup.sh --default-toolchain $TOOLCHAIN --profile minimal -y && \
rustup target add $TARGET
FROM aarch64-builder as builder
ARG TAG=main
ARG REPOSITORY=https://git.asonix.dog/asonix/pict-rs-aggregator
ARG BINARY=pict-rs-aggregator
RUN \
git clone -b $TAG $REPOSITORY repo
WORKDIR /opt/build/repo
RUN \
cargo build --release --target $TARGET && \
$TOOL-strip target/$TARGET/release/$BINARY
FROM arm64v8/alpine:3.12
ARG UID=991
ARG GID=991
ARG BINARY=pict-rs-aggregator
COPY --from=builder /opt/build/repo/target/aarch64-unknown-linux-musl/release/$BINARY /usr/bin/$BINARY
RUN \
apk add tini && \
addgroup -g $GID pictrs && \
adduser -D -G pictrs -u $UID -g "" -h /opt/pictrs pictrs
RUN \
chown -R pictrs:pictrs /mnt
VOLUME /mnt
WORKDIR /opt/pictrs
USER pictrs
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/bin/pict-rs-aggregator"]

76
docker/prod/deploy.sh Executable file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
function require() {
if [ "$1" = "" ]; then
echo "input '$2' required"
print_help
exit 1
fi
}
function print_help() {
echo "deploy.sh"
echo ""
echo "Usage:"
echo " deploy.sh [tag]"
echo ""
echo "Args:"
echo " tag: The git tag to be applied to the repository and docker build"
echo " branch: The git branch to use for tagging and publishing"
}
function build_image() {
tag=$1
arch=$2
docker build \
--pull \
--build-arg TAG=$tag \
-t asonix/pictrs-aggregator:$arch-$tag \
-t asonix/pictrs-aggregator:$arch-latest \
-f Dockerfile.$arch \
.
docker push asonix/pictrs-aggregator:$arch-$tag
docker push asonix/pictrs-aggregator:$arch-latest
}
# Creating the new tag
new_tag="$1"
branch="$2"
require "$new_tag" "tag"
require "$branch" "branch"
if ! docker run --rm -it arm64v8/alpine:3.11 /bin/sh -c 'echo "docker is configured correctly"'
then
echo "docker is not configured to run on qemu-emulated architectures, fixing will require sudo"
sudo docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
fi
set -xe
git checkout $branch
# Changing the docker-compose prod
sed -i "s/asonix\/pictrs-aggregator:.*/asonix\/pictrs-aggregator:$new_tag/" docker-compose.yml
git add ../prod/docker-compose.yml
# The commit
git commit -m"Version $new_tag"
git tag $new_tag
# Push
git push origin $new_tag
git push
# Build for arm64v8, arm32v7, and amd64
build_image $new_tag arm64v8
build_image $new_tag arm32v7
build_image $new_tag amd64
# Build for other archs
# TODO
./manifest.sh asonix/pictrs-aggregator $new_tag
./manifest.sh asonix/pictrs-aggregator latest

View File

@ -0,0 +1,18 @@
version: '3.3'
services:
pictrs:
image: asonix/pictrs:latest
restart: always
volumes:
- ./volumes/pictrs:/mnt
pictrs-aggregator:
image: asonix/pictrs-aggregator:v0.2.3
ports:
- "8082:8082"
restart: always
environment:
- PICTRS_AGGREGATOR_UPSTREAM=http://pictrs:8080
volumes:
- ./volumes/aggregator:/opt/pictrs

43
docker/prod/manifest.sh Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
function require() {
if [ "$1" = "" ]; then
echo "input '$2' required"
print_help
exit 1
fi
}
function print_help() {
echo "deploy.sh"
echo ""
echo "Usage:"
echo " manifest.sh [repository] [tag]"
echo ""
echo "Args:"
echo " repository: The docker repository hosting the images"
echo " tag: The git tag to be applied to the image manifest"
}
repo=$1
new_tag=$2
require "$repo" "repository"
require "$new_tag" "tag"
set -xe
docker manifest create $repo:$new_tag \
-a $repo:arm64v8-$new_tag \
-a $repo:arm32v7-$new_tag \
-a $repo:amd64-$new_tag
docker manifest annotate $repo:$new_tag \
$repo:arm64v8-$new_tag --os linux --arch arm64 --variant v8
docker manifest annotate $repo:$new_tag \
$repo:arm32v7-$new_tag --os linux --arch arm --variant v7
docker manifest annotate $repo:$new_tag \
$repo:amd64-$new_tag --os linux --arch amd64
docker manifest push $repo:$new_tag --purge

264
scss/layout.scss Normal file
View File

@ -0,0 +1,264 @@
* {
box-sizing: border-box;
}
body {
color: #fff;
background-color: #333;
font-family: sans-serif;
margin: 0;
}
section {
max-width: 700px;
margin: auto;
margin-bottom: 32px;
background-color: #fff;
color: #333;
border: 1px solid #e5e5e5;
border-radius: 3px;
}
.content-group {
padding: 16px 32px;
border-bottom: 1px solid #e5e5e5;
&.even {
padding: 32px;
}
&:last-child {
border-bottom: none;
}
}
article .content-group {
&:last-child {
border-bottom: 1px solid #e5e5e5;
}
}
.title {
max-width: 700px;
margin: auto;
h1 {
font-weight: 500;
}
}
.subtitle {
margin: 0;
}
h3 {
margin: 0;
}
a {
&,
&:focus,
&:active {
color: #c92a60;
}
&:hover {
color: #9d2a60;
}
}
.button {
position: relative;
overflow: hidden;
padding: 8px 16px;
margin: 0 8px;
display: inline-block;
cursor: pointer;
border-radius: 3px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
&, & * {
user-select: none;
}
&.outline {
border: 1px solid #c92a60;
background-color: #fff;
color: #c92a60;
&:hover {
border-color: #9d2a60;
background-color: #fff;
color: #9d2a60;
}
}
&.plain {
border: 1px solid #e5e5e5;
background-color: #f5f5f5;
color: #222;
&:hover {
border-color: #e0e0e0;
background-color: #f0f0f0;
}
}
&.submit {
border: 1px solid #9d2a60;
background-color: #c92a60;
color: #fff;
&:hover {
border-color: #4a1a31;
background-color: #aa2452;
}
}
.action {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: 0;
padding: 0;
font-size: 20px;
cursor: pointer;
opacity: 0;
filter: alpha(opacity=0);
width: 100%;
border: 1px solid;
}
}
.button-space {
margin: 0 -8px;
padding-top: 16px;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
.input-wrapper {
width: 300px;
padding: 8px 0;
label {
display: block;
}
.input-title {
padding: 4px 0;
font-weight: 500;
}
textarea.input {
resize: vertical;
}
.input {
width: 100%;
padding: 8px 12px;
border: 1px solid #e5e5e5;
background-color: #fff;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1) inset;
border-radius: 3px;
}
.input:hover,
.input:focus {
box-shadow: 0px 0px 5px #c92a603b inset;
}
.input:focus {
border: 1px solid #c92a60;
}
}
.edit-row {
display: flex;
flex-direction: row-reverse;
.edit-item {
width: 100%;
flex: 1;
}
.edit-item + .edit-item {
padding-right: 32px;
}
}
.image-box {
max-width: 100%;
background-color: #f5f5f5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
border-radius: 3px;
img {
display: block;
width: 100%;
border-radius: 3px;
}
}
.image-meta {
padding: 8px 0 0;
.image-title {
font-weight: 500;
font-size: 18px;
padding: 8px 0;
}
.image-description {
background-color: #f5f5f5;
border: 1px solid #e5e5e5;
border-radius: 3px;
padding: 8px;
}
}
@media (max-width: 700px) {
section {
border-radius: 0;
}
.content-group {
&, &.even {
padding: 16px;
}
}
.input-wrapper {
width: 100%;
}
.button-space {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.button {
margin: 4px 8px;
flex: 1;
min-width: 150px;
text-align: center;
}
.title {
padding: 0 16px;
}
.edit-row {
flex-direction: column;
.edit-item + .edit-item {
padding-right: 0;
}
}
}

12
src/build.rs Normal file
View File

@ -0,0 +1,12 @@
use ructe::Ructe;
fn main() -> Result<(), anyhow::Error> {
dotenv::dotenv().ok();
let mut ructe = Ructe::from_env()?;
let mut statics = ructe.statics()?;
statics.add_sass_file("scss/layout.scss")?;
statics.add_files("static")?;
ructe.compile_templates("templates")?;
Ok(())
}

172
src/connection.rs Normal file
View File

@ -0,0 +1,172 @@
use crate::pict::{Extension, Images};
use actix_web::{
body::BodyStream, client::Client, http::StatusCode, web, HttpRequest, HttpResponse,
ResponseError,
};
use url::Url;
pub(crate) static VALID_SIZES: &[u16] = &[80, 160, 320, 640, 1080, 2160];
pub(crate) struct Connection {
upstream: Url,
client: Client,
}
impl Connection {
pub(crate) fn new(upstream: Url, client: Client) -> Self {
Connection { upstream, client }
}
pub(crate) async fn thumbnail(
&self,
size: u16,
file: &str,
extension: Extension,
req: &HttpRequest,
) -> Result<HttpResponse, actix_web::Error> {
if !VALID_SIZES.contains(&size) {
return Err(SizeError(size).into());
}
self.proxy(self.thumbnail_url(size, file, extension), req)
.await
}
pub(crate) async fn image(
&self,
file: &str,
req: &HttpRequest,
) -> Result<HttpResponse, actix_web::Error> {
self.proxy(self.image_url(file), req).await
}
pub(crate) async fn upload(
&self,
req: &HttpRequest,
body: web::Payload,
) -> Result<Images, UploadError> {
let client_request = self.client.request_from(self.upload_url(), req.head());
let client_request = if let Some(addr) = req.head().peer_addr {
client_request.header("X-Forwareded-For", addr.to_string())
} else {
client_request
};
let mut res = client_request
.send_stream(body)
.await
.map_err(|_| UploadError::Request)?;
let images = res.json::<Images>().await.map_err(|_| UploadError::Json)?;
Ok(images)
}
pub(crate) async fn delete(&self, file: &str, token: &str) -> Result<(), UploadError> {
let res = self
.client
.delete(self.delete_url(file, token))
.send()
.await
.map_err(|_| UploadError::Request)?;
if !res.status().is_success() {
return Err(UploadError::Status);
}
Ok(())
}
fn upload_url(&self) -> String {
let mut url = self.upstream.clone();
url.set_path("/image");
url.to_string()
}
fn thumbnail_url(&self, size: u16, file: &str, extension: Extension) -> String {
let mut url = self.upstream.clone();
url.set_path(&format!("/image/process.{}", extension));
url.set_query(Some(&format!("src={}&thumbnail={}", file, size)));
url.to_string()
}
fn image_url(&self, file: &str) -> String {
let mut url = self.upstream.clone();
url.set_path(&format!("/image/original/{}", file,));
url.to_string()
}
fn delete_url(&self, file: &str, token: &str) -> String {
let mut url = self.upstream.clone();
url.set_path(&format!("/image/delete/{}/{}", token, file));
url.to_string()
}
async fn proxy(
&self,
url: String,
req: &HttpRequest,
) -> Result<HttpResponse, actix_web::Error> {
let client_request = self.client.request_from(url, req.head());
let client_request = if let Some(addr) = req.head().peer_addr {
client_request.header("X-Forwarded-For", addr.to_string())
} else {
client_request
};
let res = client_request.no_decompress().send().await?;
let mut client_res = HttpResponse::build(res.status());
for (name, value) in res.headers().iter().filter(|(h, _)| *h != "connection") {
client_res.header(name.clone(), value.clone());
}
Ok(client_res.body(BodyStream::new(res)))
}
}
#[derive(Clone, Debug, thiserror::Error)]
pub(crate) enum UploadError {
#[error("There was an error uploading the image")]
Request,
#[error("There was an error parsing the image response")]
Json,
#[error("Request returned bad HTTP status")]
Status,
}
impl ResponseError for UploadError {
fn status_code(&self) -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code())
.content_type(mime::TEXT_PLAIN.essence_str())
.body(self.to_string())
}
}
#[derive(Clone, Debug, thiserror::Error)]
#[error("The requested size is invalid, {0}")]
struct SizeError(u16);
impl ResponseError for SizeError {
fn status_code(&self) -> StatusCode {
StatusCode::BAD_REQUEST
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code())
.content_type(mime::TEXT_PLAIN.essence_str())
.body(self.to_string())
}
}

582
src/lib.rs Normal file
View File

@ -0,0 +1,582 @@
use actix_web::{
client::Client,
http::{
header::{CacheControl, CacheDirective, ContentType, LastModified, LOCATION},
StatusCode,
},
web, HttpRequest, HttpResponse, ResponseError, Scope,
};
use sled::Db;
use std::{io::Cursor, net::SocketAddr, time::SystemTime};
use structopt::StructOpt;
use url::Url;
use uuid::Uuid;
include!(concat!(env!("OUT_DIR"), "/templates.rs"));
const HOURS: u32 = 60 * 60;
const DAYS: u32 = 24 * HOURS;
mod connection;
mod middleware;
mod pict;
mod store;
mod ui;
use self::{connection::Connection, middleware::ValidToken, store::Store};
#[derive(Clone, Debug, StructOpt)]
pub struct Config {
#[structopt(
short,
long,
env = "PICTRS_AGGREGATOR_ADDR",
default_value = "0.0.0.0:8082",
help = "The address and port the server binds to"
)]
addr: SocketAddr,
#[structopt(
short,
long,
env = "PICTRS_AGGREGATOR_UPSTREAM",
default_value = "http://localhost:8080",
help = "The url of the upstream pict-rs server"
)]
upstream: Url,
}
pub fn accept() -> &'static str {
"image/png,image/jpeg,image/webp,.jpg,.jpeg,.png,.webp"
}
impl Config {
pub fn bind_address(&self) -> SocketAddr {
self.addr
}
}
#[derive(Clone)]
pub struct State {
upstream: Url,
scope: String,
store: Store,
db: Db,
}
impl State {
fn scoped(&self, s: &str) -> String {
if self.scope == "" && s == "" {
"/".to_string()
} else if s == "" {
self.scope.clone()
} else if self.scope == "" {
format!("/{}", s)
} else {
format!("{}/{}", self.scope, s)
}
}
fn create_aggregation_path(&self) -> String {
self.scoped("")
}
fn edit_aggregation_path(&self, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}?token={}", id, token.token))
}
fn update_aggregation_path(&self, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}?token={}", id, token.token))
}
fn delete_aggregation_path(&self, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}/delete?token={}", id, token.token))
}
fn public_aggregation_path(&self, id: Uuid) -> String {
self.scoped(&format!("{}", id))
}
fn create_entry_path(&self, aggregation_id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!("{}/entry?token={}", aggregation_id, token.token))
}
fn update_entry_path(&self, aggregation_id: Uuid, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!(
"{}/entry/{}?token={}",
aggregation_id, id, token.token
))
}
fn delete_entry_path(&self, aggregation_id: Uuid, id: Uuid, token: &ValidToken) -> String {
self.scoped(&format!(
"{}/entry/{}/delete?token={}",
aggregation_id, id, token.token
))
}
fn statics_path(&self, file: &str) -> String {
self.scoped(&format!("static/{}", file))
}
fn thumbnail_path(&self, entry: &Entry, size: u16, extension: pict::Extension) -> String {
self.scoped(&format!(
"image/thumbnail.{}?src={}&size={}",
extension, entry.filename, size
))
}
fn srcset(&self, entry: &Entry, extension: pict::Extension) -> String {
connection::VALID_SIZES
.iter()
.map(|size| format!("{} {}w", self.thumbnail_path(entry, *size, extension), size,))
.collect::<Vec<String>>()
.join(", ")
}
fn image_path(&self, entry: &Entry) -> String {
self.scoped(&format!("image/full/{}", entry.filename))
}
}
pub fn state(config: Config, scope: &str, db: Db) -> Result<State, sled::Error> {
Ok(State {
upstream: config.upstream,
scope: scope.to_string(),
store: Store::new(&db)?,
db,
})
}
pub fn service(client: Client, state: State) -> Scope {
web::scope(&state.scoped(""))
.data(Connection::new(state.upstream.clone(), client))
.data(state)
.service(web::resource("/static/{filename}").route(web::get().to(static_files)))
.service(web::resource("404").route(web::get().to(not_found)))
.service(
web::scope("image")
.service(web::resource("/thumbnail.{extension}").route(web::get().to(thumbnail)))
.service(web::resource("/full/{filename}").route(web::get().to(image))),
)
.service(
web::resource("")
.route(web::get().to(index))
.route(web::post().to(create_aggregation)),
)
.service(
web::scope("/{aggregation}")
.wrap(middleware::Verify)
.service(
web::resource("")
.route(web::get().to(aggregation))
.route(web::post().to(update_aggregation)),
)
.service(web::resource("/delete").route(web::get().to(delete_aggregation)))
.service(
web::scope("/entry")
.service(web::resource("").route(web::post().to(upload)))
.service(
web::scope("/{entry}")
.service(web::resource("").route(web::post().to(update_entry)))
.service(
web::resource("/delete").route(web::get().to(delete_entry)),
),
),
),
)
}
fn to_edit_page(id: Uuid, token: &ValidToken, state: &State) -> HttpResponse {
HttpResponse::SeeOther()
.header(LOCATION, state.edit_aggregation_path(id, token))
.finish()
}
fn to_404(state: &State) -> HttpResponse {
HttpResponse::MovedPermanently()
.header(LOCATION, state.create_aggregation_path())
.finish()
}
fn to_home(state: &State) -> HttpResponse {
HttpResponse::SeeOther()
.header(LOCATION, state.create_aggregation_path())
.finish()
}
async fn static_files(filename: web::Path<String>, state: web::Data<State>) -> HttpResponse {
let filename = filename.into_inner();
if let Some(data) = self::templates::statics::StaticFile::get(&filename) {
return HttpResponse::Ok()
.set(LastModified(SystemTime::now().into()))
.set(CacheControl(vec![
CacheDirective::Public,
CacheDirective::MaxAge(365 * DAYS),
CacheDirective::Extension("immutable".to_owned(), None),
]))
.set(ContentType(data.mime.clone()))
.body(data.content);
}
to_404(&state)
}
async fn not_found(state: web::Data<State>) -> Result<HttpResponse, Error> {
let mut cursor = Cursor::new(vec![]);
self::templates::not_found(&mut cursor, &state)?;
Ok(HttpResponse::NotFound()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
#[derive(Debug, thiserror::Error)]
enum Error {
#[error("{0}")]
Render(#[from] std::io::Error),
#[error("{0}")]
Store(#[from] self::store::Error),
#[error("{0}")]
Upload(#[from] self::connection::UploadError),
#[error("{0}")]
UploadString(String),
}
impl ResponseError for Error {
fn status_code(&self) -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR
}
fn error_response(&self) -> HttpResponse {
match self {
Self::Store(self::store::Error::NotFound) => HttpResponse::MovedPermanently()
.header(LOCATION, "/404")
.finish(),
_ => HttpResponse::build(self.status_code())
.content_type(mime::TEXT_PLAIN.essence_str())
.body(format!("{}", self)),
}
}
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct Aggregation {
title: String,
description: String,
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct Entry {
title: String,
description: String,
filename: String,
delete_token: String,
}
#[derive(Clone, serde::Deserialize)]
pub struct Token {
token: Uuid,
}
impl Token {
fn hash(&self) -> Result<TokenStorage, bcrypt::BcryptError> {
use bcrypt::{hash, DEFAULT_COST};
Ok(TokenStorage {
token: hash(self.token.as_bytes(), DEFAULT_COST)?,
})
}
}
#[derive(serde::Deserialize, serde::Serialize)]
struct TokenStorage {
token: String,
}
impl TokenStorage {
fn verify(&self, token: &Token) -> Result<bool, bcrypt::BcryptError> {
bcrypt::verify(&token.token.as_bytes(), &self.token)
}
}
#[derive(serde::Deserialize)]
struct AggregationPath {
aggregation: Uuid,
}
impl AggregationPath {
fn key(&self) -> String {
format!("{}", self.aggregation)
}
fn entry_range(&self) -> std::ops::Range<Vec<u8>> {
let base = format!("{}/entry/", self.aggregation).as_bytes().to_vec();
let mut start = base.clone();
let mut end = base;
start.push(0x0);
end.push(0xff);
start..end
}
fn token_key(&self) -> String {
format!("{}/token", self.aggregation)
}
}
#[derive(serde::Deserialize)]
struct EntryPath {
aggregation: Uuid,
entry: Uuid,
}
impl EntryPath {
fn key(&self) -> String {
format!("{}/entry/{}", self.aggregation, self.entry)
}
}
async fn upload(
req: HttpRequest,
pl: web::Payload,
path: web::Path<AggregationPath>,
token: ValidToken,
conn: web::Data<Connection>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let images = conn.upload(&req, pl).await?;
if images.is_err() {
return Err(Error::UploadString(images.message().to_owned()));
}
let image = images
.files()
.next()
.ok_or(Error::UploadString("Missing file".to_owned()))?;
let entry = Entry {
title: String::new(),
description: String::new(),
filename: image.file().to_owned(),
delete_token: image.delete_token().to_owned(),
};
let entry_path = EntryPath {
aggregation: path.aggregation,
entry: Uuid::new_v4(),
};
store::CreateEntry {
entry_path: &entry_path,
entry: &entry,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(path.aggregation, &token, &state))
}
#[derive(serde::Deserialize)]
struct ImagePath {
filename: String,
}
async fn image(
req: HttpRequest,
path: web::Path<ImagePath>,
conn: web::Data<Connection>,
) -> Result<HttpResponse, actix_web::Error> {
conn.image(&path.filename, &req).await
}
#[derive(serde::Deserialize)]
struct ThumbnailPath {
extension: pict::Extension,
}
#[derive(serde::Deserialize)]
struct ThumbnailQuery {
src: String,
size: u16,
}
async fn thumbnail(
req: HttpRequest,
path: web::Path<ThumbnailPath>,
query: web::Query<ThumbnailQuery>,
conn: web::Data<Connection>,
) -> Result<HttpResponse, actix_web::Error> {
conn.thumbnail(query.size, &query.src, path.extension, &req)
.await
}
async fn index(state: web::Data<State>) -> Result<HttpResponse, Error> {
let mut cursor = Cursor::new(vec![]);
self::templates::index(&mut cursor, &state)?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn aggregation(
path: web::Path<AggregationPath>,
token: Option<ValidToken>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
match token {
Some(token) => edit_aggregation(path, token, state).await,
None => view_aggregation(path, state).await,
}
}
async fn view_aggregation(
path: web::Path<AggregationPath>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let aggregation = state.store.aggregation(&path).await?;
let entries = state.store.entries(path.entry_range()).await?;
let mut cursor = Cursor::new(vec![]);
self::templates::view_aggregation(&mut cursor, &aggregation, &entries, &state)?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn edit_aggregation(
path: web::Path<AggregationPath>,
token: ValidToken,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let aggregation = state.store.aggregation(&path).await?;
let entries = state.store.entries(path.entry_range()).await?;
let mut cursor = Cursor::new(vec![]);
self::templates::edit_aggregation(
&mut cursor,
&aggregation,
path.aggregation,
&entries,
&token,
&state,
)?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn create_aggregation(
aggregation: web::Form<Aggregation>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let aggregation_id = Uuid::new_v4();
let aggregation_path = AggregationPath {
aggregation: aggregation_id,
};
let token = Token {
token: Uuid::new_v4(),
};
store::CreateAggregation {
aggregation_path: &aggregation_path,
aggregation: &aggregation,
token: &token,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(
aggregation_path.aggregation,
&ValidToken { token: token.token },
&state,
))
}
async fn update_aggregation(
path: web::Path<AggregationPath>,
form: web::Form<Aggregation>,
token: ValidToken,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
store::UpdateAggregation {
aggregation_path: &path,
aggregation: &form,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(path.aggregation, &token, &state))
}
async fn update_entry(
entry_path: web::Path<EntryPath>,
entry: web::Form<Entry>,
token: ValidToken,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
store::UpdateEntry {
entry_path: &entry_path,
entry: &entry,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(entry_path.aggregation, &token, &state))
}
async fn delete_entry(
entry_path: web::Path<EntryPath>,
token: ValidToken,
conn: web::Data<Connection>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let entry = state.store.entry(&entry_path).await?;
conn.delete(&entry.filename, &entry.delete_token).await?;
store::DeleteEntry {
entry_path: &entry_path,
}
.exec(&state.store)
.await?;
Ok(to_edit_page(entry_path.aggregation, &token, &state))
}
async fn delete_aggregation(
path: web::Path<AggregationPath>,
_token: ValidToken,
conn: web::Data<Connection>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let entries = state.store.entries(path.entry_range()).await?;
let future_vec = entries
.iter()
.map(|(_, entry)| conn.delete(&entry.filename, &entry.delete_token))
.collect::<Vec<_>>();
futures::future::try_join_all(future_vec).await?;
store::DeleteAggregation {
aggregation_path: &path,
}
.exec(&state.store)
.await?;
Ok(to_home(&state))
}

36
src/main.rs Normal file
View File

@ -0,0 +1,36 @@
use actix_web::{
client::Client,
middleware::{Compress, Logger},
App, HttpServer,
};
use structopt::StructOpt;
#[actix_web::main]
async fn main() -> Result<(), anyhow::Error> {
if std::env::var("RUST_LOG").is_err() {
std::env::set_var("RUST_LOG", "info,pict_rs_aggregator=info,actix_web=info");
}
env_logger::init();
let db = sled::open("sled/db-0-34")?;
let config = pict_rs_aggregator::Config::from_args();
let bind_address = config.bind_address();
let state = pict_rs_aggregator::state(config, "", db)?;
HttpServer::new(move || {
let client = Client::builder()
.header("User-Agent", "pict_rs_aggregator-v0.1.0")
.finish();
App::new()
.wrap(Logger::default())
.wrap(Compress::default())
.service(pict_rs_aggregator::service(client, state.clone()))
})
.bind(bind_address)?
.run()
.await?;
Ok(())
}

154
src/middleware.rs Normal file
View File

@ -0,0 +1,154 @@
use actix_web::{
dev::{Payload, Service, ServiceRequest, Transform},
http::StatusCode,
web, FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError,
};
use futures::{
channel::oneshot,
future::{ok, LocalBoxFuture, Ready},
};
use std::task::{Context, Poll};
use uuid::Uuid;
pub(crate) struct Verify;
pub(crate) struct VerifyMiddleware<S>(S);
pub struct ValidToken {
pub(crate) token: Uuid,
}
impl FromRequest for ValidToken {
type Error = TokenError;
type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
type Config = ();
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let res = req
.extensions_mut()
.remove::<oneshot::Receiver<Self>>()
.ok_or(TokenError);
Box::pin(async move { res?.await.map_err(|_| TokenError) })
}
}
#[derive(Clone, Debug, thiserror::Error)]
#[error("Invalid token")]
pub struct TokenError;
#[derive(Clone, Debug, thiserror::Error)]
#[error("Invalid token")]
struct VerifyError;
impl ResponseError for TokenError {
fn status_code(&self) -> StatusCode {
StatusCode::UNAUTHORIZED
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code())
.content_type(mime::TEXT_PLAIN.essence_str())
.body(self.to_string())
}
}
impl ResponseError for VerifyError {
fn status_code(&self) -> StatusCode {
StatusCode::UNAUTHORIZED
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code())
.content_type(mime::TEXT_PLAIN.essence_str())
.body(self.to_string())
}
}
impl<S> Transform<S> for Verify
where
S: Service<Request = ServiceRequest, Error = actix_web::Error>,
S::Future: 'static,
{
type Request = S::Request;
type Response = S::Response;
type Error = S::Error;
type InitError = ();
type Transform = VerifyMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(VerifyMiddleware(service))
}
}
impl<S> Service for VerifyMiddleware<S>
where
S: Service<Request = ServiceRequest, Error = actix_web::Error>,
S::Future: 'static,
{
type Request = S::Request;
type Response = S::Response;
type Error = S::Error;
type Future = LocalBoxFuture<'static, Result<S::Response, S::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.0.poll_ready(cx)
}
fn call(&mut self, req: S::Request) -> Self::Future {
let (req, pl) = req.into_parts();
let state_fut = web::Data::<crate::State>::extract(&req);
let token_fut = Option::<web::Query<crate::Token>>::extract(&req);
let path_fut = web::Path::<crate::AggregationPath>::extract(&req);
let req = ServiceRequest::from_parts(req, pl)
.map_err(|_| VerifyError)
.unwrap();
let (tx, rx) = oneshot::channel();
req.extensions_mut().insert(rx);
let service_fut = self.0.call(req);
Box::pin(async move {
let state = state_fut.await?;
let path = path_fut.await?;
let token = token_fut.await?;
if let Some(token) = token {
let token = token.into_inner();
if verify(&path, token.clone(), &state).await.is_ok() {
tx.send(ValidToken { token: token.token })
.map_err(|_| VerifyError)?;
} else {
drop(tx);
}
} else {
drop(tx);
}
service_fut.await
})
}
}
async fn verify(
path: &crate::AggregationPath,
token: crate::Token,
state: &crate::State,
) -> Result<(), VerifyError> {
let token_storage = state.store.token(path).await.map_err(|_| VerifyError)?;
let verified = web::block(move || token_storage.verify(&token))
.await
.map_err(|_| VerifyError)?;
if !verified {
return Err(VerifyError);
}
Ok(())
}

53
src/pict.rs Normal file
View File

@ -0,0 +1,53 @@
#[derive(Clone, Copy, serde::Deserialize)]
pub(crate) enum Extension {
#[serde(rename = "jpg")]
Jpg,
#[serde(rename = "webp")]
Webp,
}
impl std::fmt::Display for Extension {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Jpg => write!(f, "jpg"),
Self::Webp => write!(f, "webp"),
}
}
}
#[derive(serde::Deserialize)]
pub(crate) struct Image {
file: String,
delete_token: String,
}
impl Image {
pub(crate) fn file(&self) -> &str {
&self.file
}
pub(crate) fn delete_token(&self) -> &str {
&self.delete_token
}
}
#[derive(serde::Deserialize)]
pub(crate) struct Images {
msg: String,
files: Option<Vec<Image>>,
}
impl Images {
pub(crate) fn is_err(&self) -> bool {
self.files.is_none()
}
pub(crate) fn message(&self) -> &str {
&self.msg
}
pub(crate) fn files(&self) -> impl Iterator<Item = &Image> {
self.files.iter().flat_map(|v| v.iter())
}
}

285
src/store.rs Normal file
View File

@ -0,0 +1,285 @@
use crate::{Aggregation, AggregationPath, Entry, EntryPath, Token};
use actix_web::web;
use sled::{Db, Tree};
use std::ops::Range;
use uuid::Uuid;
#[derive(Clone)]
pub(crate) struct Store {
tree: Tree,
}
pub(crate) struct CreateAggregation<'a> {
pub(crate) aggregation_path: &'a AggregationPath,
pub(crate) aggregation: &'a Aggregation,
pub(crate) token: &'a Token,
}
pub(crate) struct UpdateAggregation<'a> {
pub(crate) aggregation_path: &'a AggregationPath,
pub(crate) aggregation: &'a Aggregation,
}
pub(crate) struct DeleteAggregation<'a> {
pub(crate) aggregation_path: &'a AggregationPath,
}
pub(crate) struct CreateEntry<'a> {
pub(crate) entry_path: &'a EntryPath,
pub(crate) entry: &'a Entry,
}
pub(crate) struct UpdateEntry<'a> {
pub(crate) entry_path: &'a EntryPath,
pub(crate) entry: &'a Entry,
}
pub(crate) struct DeleteEntry<'a> {
pub(crate) entry_path: &'a EntryPath,
}
impl<'a> CreateAggregation<'a> {
pub(crate) async fn exec(self, store: &Store) -> Result<(), Error> {
store.create_aggregation(self).await
}
}
impl<'a> UpdateAggregation<'a> {
pub(crate) async fn exec(self, store: &Store) -> Result<(), Error> {
store.update_aggregation(self).await
}
}
impl<'a> DeleteAggregation<'a> {
pub(crate) async fn exec(self, store: &Store) -> Result<(), Error> {
store.delete_aggregation(self).await
}
}
impl<'a> CreateEntry<'a> {
pub(crate) async fn exec(self, store: &Store) -> Result<(), Error> {
store.create_entry(self).await
}
}
impl<'a> UpdateEntry<'a> {
pub(crate) async fn exec(self, store: &Store) -> Result<(), Error> {
store.update_entry(self).await
}
}
impl<'a> DeleteEntry<'a> {
pub(crate) async fn exec(self, store: &Store) -> Result<(), Error> {
store.delete_entry(self).await
}
}
impl Store {
pub(crate) fn new(db: &Db) -> Result<Self, sled::Error> {
Ok(Store {
tree: db.open_tree("aggregations")?,
})
}
async fn create_aggregation(&self, config: CreateAggregation<'_>) -> Result<(), Error> {
let aggregation_key = config.aggregation_path.key();
let aggregation_value = serde_json::to_string(&config.aggregation)?;
let token_key = config.aggregation_path.token_key();
let token2 = config.token.clone();
let token_value = serde_json::to_string(&web::block(move || token2.hash()).await?)?;
let tree = self.tree.clone();
web::block(move || {
tree.transaction(move |tree| {
tree.insert(aggregation_key.as_bytes(), aggregation_value.as_bytes())?;
tree.insert(token_key.as_bytes(), token_value.as_bytes())?;
Ok(())
})
})
.await?;
Ok(())
}
async fn update_aggregation(&self, config: UpdateAggregation<'_>) -> Result<(), Error> {
let aggregation_key = config.aggregation_path.key();
let aggregation_value = serde_json::to_string(&config.aggregation)?;
let tree = self.tree.clone();
web::block(move || tree.insert(aggregation_key.as_bytes(), aggregation_value.as_bytes()))
.await?;
Ok(())
}
async fn delete_aggregation(&self, config: DeleteAggregation<'_>) -> Result<(), Error> {
let entry_range = config.aggregation_path.entry_range();
let token_key = config.aggregation_path.token_key();
let aggregation_key = config.aggregation_path.key();
let tree = self.tree.clone();
web::block(move || {
let entries = tree
.range(entry_range)
.keys()
.collect::<Result<Vec<_>, _>>()?;
tree.transaction(move |tree| {
for key in &entries {
tree.remove(key)?;
}
tree.remove(token_key.as_bytes())?;
tree.remove(aggregation_key.as_bytes())?;
Ok(())
})?;
Ok(()) as Result<(), Error>
})
.await?;
Ok(())
}
async fn create_entry(&self, config: CreateEntry<'_>) -> Result<(), Error> {
let entry_key = config.entry_path.key();
let entry_value = serde_json::to_string(&config.entry)?;
let tree = self.tree.clone();
web::block(move || tree.insert(entry_key.as_bytes(), entry_value.as_bytes())).await?;
Ok(())
}
async fn update_entry(&self, config: UpdateEntry<'_>) -> Result<(), Error> {
let entry_key = config.entry_path.key();
let entry_value = serde_json::to_string(&config.entry)?;
let tree = self.tree.clone();
web::block(move || tree.insert(entry_key.as_bytes(), entry_value.as_bytes())).await?;
Ok(())
}
async fn delete_entry(&self, config: DeleteEntry<'_>) -> Result<(), Error> {
let entry_key = config.entry_path.key();
let tree = self.tree.clone();
web::block(move || tree.remove(entry_key)).await?;
Ok(())
}
pub(crate) async fn aggregation(
&self,
path: &AggregationPath,
) -> Result<crate::Aggregation, Error> {
let aggregation_key = path.key();
let tree = self.tree.clone();
let opt = web::block(move || tree.get(aggregation_key.as_bytes())).await?;
match opt {
Some(a) => {
let aggregation = serde_json::from_slice(&a)?;
Ok(aggregation)
}
None => Err(Error::NotFound),
}
}
pub(crate) async fn entry(&self, path: &EntryPath) -> Result<crate::Entry, Error> {
let entry_key = path.key();
let tree = self.tree.clone();
let opt = web::block(move || tree.get(entry_key.as_bytes())).await?;
match opt {
Some(e) => {
let entry = serde_json::from_slice(&e)?;
Ok(entry)
}
None => Err(Error::NotFound),
}
}
pub(crate) async fn entries(&self, range: Range<Vec<u8>>) -> Result<Vec<(Uuid, Entry)>, Error> {
let tree = self.tree.clone();
let v = web::block(move || {
tree.range(range)
.map(|res| match res {
Err(e) => Err(e.into()),
Ok((k, v)) => {
let string_key = String::from_utf8_lossy(&k);
let entry_uuid = string_key.split(|b| b == '/').rev().next().unwrap();
let uuid: Uuid = entry_uuid.parse()?;
let string_value = String::from_utf8_lossy(&v);
let entry: Entry = serde_json::from_str(&string_value)?;
Ok((uuid, entry))
}
})
.collect::<Result<Vec<_>, Error>>()
})
.await?;
Ok(v)
}
pub(crate) async fn token(&self, path: &AggregationPath) -> Result<crate::TokenStorage, Error> {
let token_key = path.token_key();
let tree = self.tree.clone();
let token_opt = web::block(move || tree.get(token_key.as_bytes())).await?;
let token = match token_opt {
Some(token_ivec) => serde_json::from_slice(&token_ivec)?,
None => return Err(Error::NotFound),
};
Ok(token)
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error("{0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
Uuid(#[from] uuid::Error),
#[error("{0}")]
Sled(#[from] sled::Error),
#[error("{0}")]
Transaction(#[from] sled::transaction::TransactionError),
#[error("{0}")]
Bcrypt(#[from] bcrypt::BcryptError),
#[error("Panic in blocking operation")]
Blocking,
#[error("Item is not found")]
NotFound,
}
impl<E> From<actix_web::error::BlockingError<E>> for Error
where
E: std::fmt::Debug,
Error: From<E>,
{
fn from(err: actix_web::error::BlockingError<E>) -> Self {
match err {
actix_web::error::BlockingError::Error(e) => e.into(),
_ => Error::Blocking,
}
}
}

5
src/ui.rs Normal file
View File

@ -0,0 +1,5 @@
pub enum ButtonKind {
Submit,
Outline,
Plain,
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

22
static/file_upload.js Normal file
View File

@ -0,0 +1,22 @@
(function(fn) {
if (document.readyState === "complete" || document.readyState === "interactive") {
setTimeout(fn, 1);
} else {
document.addEventListener("DOMContentLoaded", fn);
}
})(function() {
var container = document.getElementById("file-input-container");
var text = container.getElementsByTagName("span")[0];
var input = container.getElementsByTagName("input")[0];
if (!text || !input) {
console.err("Error fetching file upload");
return;
}
input.addEventListener("change", function(event) {
if (event.target.files && event.target.files.length === 1) {
console.log(event.target.files[0]);
text.textContent = "Selected: " + event.target.files[0].name;
}
});
});

24
templates/button.rs.html Normal file
View File

@ -0,0 +1,24 @@
@use crate::ui::ButtonKind;
@(text: &str, kind: ButtonKind)
@match kind {
ButtonKind::Submit => {
<div class="button submit">
<span>@text</span>
<button class="action" type="submit">@text</button>
</div>
}
ButtonKind::Plain => {
<div class="button plain">
<span>@text</span>
<button class="action">@text</button>
</div>
}
ButtonKind::Outline => {
<div class="button outline">
<span>@text</span>
<button class="action">@text</button>
</div>
}
}

View File

@ -0,0 +1,25 @@
@use crate::ui::ButtonKind;
@(text: &str, href: &str, kind: ButtonKind)
@match kind {
ButtonKind::Submit => {
<div class="button submit">
<span>@text</span>
<a class="action" href="@href">@text</a>
</div>
}
ButtonKind::Plain => {
<div class="button plain">
<span>@text</span>
<a class="action" href="@href">@text</a>
</div>
}
ButtonKind::Outline => {
<div class="button outline">
<span>@text</span>
<a class="action" href="@href">@text</a>
</div>
}
}

View File

@ -0,0 +1,96 @@
@use crate::{ui::ButtonKind, Aggregation, Entry, State, ValidToken};
@use super::{button, button_link, image_preview, file_input, layout, text_area, text_input, statics::file_upload_js};
@use uuid::Uuid;
@(aggregation: &Aggregation, aggregation_id: Uuid, entries: &[(Uuid, Entry)], token: &ValidToken, state: &State)
@:layout(state, "Edit Aggregation", None, {
<script
src="@state.statics_path(&file_upload_js.name)"
type="text/javascript"
>
</script>
}, {
<section>
<article class="content-group">
<h3>Share Aggregation</h3>
</article>
<article class="content-group">
<a
href="@state.public_aggregation_path(aggregation_id)"
target="_blank"
rel="noopen noreferer"
>
Public Link
</a>
</article>
</section>
<section>
<article class="content-group">
<h3>Edit Aggregation</h3>
</article>
<article class="content-group">
<p class="subtitle"><a href="@state.edit_aggregation_path(aggregation_id, token)">Do not lose this link</a></p>
</article>
<article class="content-group">
<form method="POST" action="@state.update_aggregation_path(aggregation_id, token)">
@:text_input("title", Some("Title"), Some(&aggregation.title))
@:text_area("description", Some("Description"), Some(&aggregation.description))
<div class="button-space">
@:button("Update Aggregation", ButtonKind::Submit)
@:button_link("Delete Aggregation", &state.delete_aggregation_path(aggregation_id, token), ButtonKind::Outline)
</div>
</form>
</article>
<ul>
@for (id, entry) in entries {
<li class="content-group">
<article>
<div class="edit-row">
<div class="edit-item">
@:image_preview(entry, state)
<div class="image-meta">
<div class="image-title">@entry.title</div>
<div class="image-description">@entry.description</div>
</div>
</div>
<div class="edit-item">
<form method="POST" action="@state.update_entry_path(aggregation_id, *id, token)">
@:text_input("title", Some("Title"), Some(&entry.title))
@:text_area("description", Some("Description"), Some(&entry.description))
<input type="hidden" name="filename" value="@entry.filename" />
<input type="hidden" name="delete_token" value="@entry.delete_token" />
<div class="button-space">
@:button("Update Image", ButtonKind::Submit)
@:button_link("Delete Image", &state.delete_entry_path(aggregation_id, *id, token), ButtonKind::Outline)
</div>
</form>
</div>
</div>
</article>
</li>
}
</ul>
</section>
<section>
<article>
<form
method="POST"
action="@state.create_entry_path(aggregation_id, token)"
enctype="multipart/form-data"
>
<div class="content-group">
<h3><legend>Add Image</legend></h3>
</div>
<div class="content-group" id="file-input-container">
<div class="button-space">
@:file_input("images[]", Some("Select Image"), Some(crate::accept()), false)
</div>
<div class="button-space">
@:button("Upload", ButtonKind::Submit)
</div>
</div>
</form>
</article>
</section>
})

View File

@ -0,0 +1,34 @@
@(name: &str, title: Option<&str>, accept: Option<&str>, multiple: bool)
<div class="button plain">
@match (title, accept, multiple) {
(Some(title), Some(accept), true) => {
<span>@title</span>
<input class="action" type="file" name="@name" accept="@accept" multiple />
}
(Some(title), Some(accept), false) => {
<span>@title</span>
<input class="action" type="file" name="@name" accept="@accept" />
}
(Some(title), None, true) => {
<span>@title</span>
<input class="action" type="file" name="@name" multiple />
}
(Some(title), None, false) => {
<span>@title</span>
<input class="action" type="file" name="@name" />
}
(None, Some(accept), true) => {
<input class="action" type="file" name="@name" accept="@accept" multiple />
}
(None, Some(accept), false) => {
<input class="action" type="file" name="@name" accept="@accept" />
}
(None, None, true) => {
<input class="action" type="file" name="@name" multiple />
}
(None, None, false) => {
<input class="action" type="file" name="@name" />
}
}
</div>

View File

@ -0,0 +1,21 @@
@use crate::{pict::Extension, Entry, State};
@(entry: &Entry, state: &State)
<div class="image-box">
<picture>
<source
type="image/webp"
srcset="@state.srcset(entry, Extension::Webp)"
/>
<source
type="image/jpeg"
srcset="@state.srcset(entry, Extension::Jpg)"
/>
<img
src="@state.image_path(entry)"
title="@entry.title"
alt="@entry.description"
/>
</picture>
</div>

23
templates/index.rs.html Normal file
View File

@ -0,0 +1,23 @@
@use crate::{ui::ButtonKind, State};
@use super::{layout, text_area, text_input, button};
@(state: &State)
@:layout(state, "Aggregation", None, {}, {
<section>
<article>
<form method="POST" action="@state.create_aggregation_path()">
<div class="content-group">
<h3><legend>Create Aggregation</legend></h3>
</div>
<div class="content-group">
@:text_input("title", Some("Title"), None)
@:text_area("description", Some("Description"), None)
</div>
<div class="content-group">
@:button("Create Aggregation", ButtonKind::Submit)
</div>
</form>
</article>
</section>
})

28
templates/layout.rs.html Normal file
View File

@ -0,0 +1,28 @@
@use crate::State;
@use super::statics::{layout_css, favicon_ico};
@(state: &State, title: &str, description: Option<&str>, head: Content, body: Content)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>@title</title>
<link rel="stylesheet" href="@state.statics_path(&layout_css.name)" type="text/css" />
<meta property="og:title" content="@title" />
@if let Some(description) = description {
<meta property="og:description" content="@description" />
} else {
<meta property="og:description" content="Aggregate and share images" />
}
<meta property="og:type" content="website" />
<link rel="shortcut icon" type="image/png" href="@state.statics_path(&favicon_ico.name)">
@:head()
</head>
<body>
<div class="title"><h1>pict-rs aggregator</h1></div>
@:body()
</body>
</html>

View File

@ -0,0 +1,15 @@
@use crate::State;
@use super::layout;
@(state: &State)
@:layout(state, "Not Found", None, {}, {
<section>
<article class="content-group">
<h3>Not Found</h3>
</article>
<article class="content-group">
<p><a href="@state.create_aggregation_path()">Return Home</a></p>
</article>
</section>
})

View File

@ -0,0 +1,21 @@
@(name: &str, title: Option<&str>, value: Option<&str>)
<div class="input-wrapper">
@if let Some(title) = title {
<label for="@name">
<div class="input-title">@title</div>
@if let Some(value) = value {
<textarea class="input" name="@name">@value</textarea>
} else {
<textarea class="input" name="@name"></textarea>
}
</label>
} else {
@if let Some(value) = value {
<textarea class="input" name="@name">@value</textarea>
} else {
<textarea class="input" name="@name"></textarea>
}
}
</div>

View File

@ -0,0 +1,20 @@
@(name: &str, title: Option<&str>, value: Option<&str>)
<div class="input-wrapper">
@if let Some(title) = title {
<label for="@name">
<div class="input-title">@title</div>
@if let Some(value) = value {
<input type="text" class="input" name="@name" value="@value" />
} else {
<input type="text" class="input" name="@name" />
}
</label>
} else {
@if let Some(value) = value {
<input type="text" class="input" name="@name" value="@value" />
} else {
<input type="text" class="input" name="@name" />
}
}
</div>

View File

@ -0,0 +1,32 @@
@use crate::{Aggregation, Entry, State};
@use super::{layout, image_preview};
@use uuid::Uuid;
@(aggregation: &Aggregation, entries: &[(Uuid, Entry)], state: &State)
@:layout(state, "Aggregation", None, {}, {
<section>
<article>
<div class="content-group">
<h3>@aggregation.title</h3>
</div>
<div class="content-group">
<p class="subtitle">@aggregation.description</p>
</div>
</article>
<ul>
@for (_, entry) in entries {
<li class="content-group even">
<article>
@:image_preview(entry, state)
<div class="image-meta">
<div class="image-title">@entry.title</div>
<div class="image-description">@entry.description</div>
</div>
</article>
</li>
}
</ul>
</section>
})