Initial commit
This commit is contained in:
commit
57771fbf0d
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
/sled
|
File diff suppressed because it is too large
Load Diff
|
@ -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"] }
|
|
@ -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"]
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
segment_size: 524288
|
||||
use_compression: false
|
||||
version: 0.34
|
||||
vQÁ
|
Binary file not shown.
|
@ -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"]
|
|
@ -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"]
|
|
@ -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"]
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(())
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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(())
|
||||
}
|
|
@ -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(())
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 5.6 KiB |
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
})
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
})
|
|
@ -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>
|
|
@ -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>
|
||||
})
|
|
@ -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>
|
||||
|
|
@ -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>
|
|
@ -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 New Issue