Initial commit
This commit is contained in:
commit
57771fbf0d
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
/sled
|
2384
Cargo.lock
generated
Normal file
2384
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
28
Cargo.toml
Normal file
28
Cargo.toml
Normal 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
42
docker/dev/Dockerfile
Normal 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"]
|
20
docker/dev/docker-compose.yml
Normal file
20
docker/dev/docker-compose.yml
Normal 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
|
4
docker/dev/volumes/pictrs/sled/db-0.34/conf
Normal file
4
docker/dev/volumes/pictrs/sled/db-0.34/conf
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
segment_size: 524288
|
||||||
|
use_compression: false
|
||||||
|
version: 0.34
|
||||||
|
vQÁ
|
BIN
docker/dev/volumes/pictrs/sled/db-0.34/db
Normal file
BIN
docker/dev/volumes/pictrs/sled/db-0.34/db
Normal file
Binary file not shown.
74
docker/prod/Dockerfile.amd64
Normal file
74
docker/prod/Dockerfile.amd64
Normal 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"]
|
74
docker/prod/Dockerfile.arm32v7
Normal file
74
docker/prod/Dockerfile.arm32v7
Normal 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"]
|
74
docker/prod/Dockerfile.arm64v8
Normal file
74
docker/prod/Dockerfile.arm64v8
Normal 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
76
docker/prod/deploy.sh
Executable 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
|
18
docker/prod/docker-compose.yml
Normal file
18
docker/prod/docker-compose.yml
Normal 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
43
docker/prod/manifest.sh
Executable 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
264
scss/layout.scss
Normal 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
12
src/build.rs
Normal 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
172
src/connection.rs
Normal 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
582
src/lib.rs
Normal 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
36
src/main.rs
Normal 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
154
src/middleware.rs
Normal 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
53
src/pict.rs
Normal 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
285
src/store.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.6 KiB |
22
static/file_upload.js
Normal file
22
static/file_upload.js
Normal 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
24
templates/button.rs.html
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
25
templates/button_link.rs.html
Normal file
25
templates/button_link.rs.html
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
96
templates/edit_aggregation.rs.html
Normal file
96
templates/edit_aggregation.rs.html
Normal 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>
|
||||||
|
})
|
34
templates/file_input.rs.html
Normal file
34
templates/file_input.rs.html
Normal 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>
|
21
templates/image_preview.rs.html
Normal file
21
templates/image_preview.rs.html
Normal 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
23
templates/index.rs.html
Normal 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
28
templates/layout.rs.html
Normal 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>
|
15
templates/not_found.rs.html
Normal file
15
templates/not_found.rs.html
Normal 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>
|
||||||
|
})
|
21
templates/text_area.rs.html
Normal file
21
templates/text_area.rs.html
Normal 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>
|
||||||
|
|
20
templates/text_input.rs.html
Normal file
20
templates/text_input.rs.html
Normal 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>
|
32
templates/view_aggregation.rs.html
Normal file
32
templates/view_aggregation.rs.html
Normal 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>
|
||||||
|
})
|
||||||
|
|
Loading…
Reference in a new issue