Compare commits
233 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
828019998e | ||
|
|
49d910fb3c | ||
|
|
c521ad1120 | ||
|
|
d42a738861 | ||
|
|
e3eaf5aba8 | ||
|
|
3b48ce7b5e | ||
|
|
5363a1b056 | ||
|
|
0d58e626a4 | ||
|
|
e8faf660f4 | ||
|
|
67695da86b | ||
|
|
d50c183c9c | ||
|
|
90b04b1f21 | ||
|
|
babf3d8911 | ||
|
|
1ae00eb3a8 | ||
|
|
6f419c7b3d | ||
|
|
c557b8b262 | ||
|
|
a63222a71a | ||
|
|
86cea6278f | ||
|
|
f283991740 | ||
|
|
1775f71347 | ||
|
|
0b1224f8e5 | ||
|
|
1047763285 | ||
|
|
fc785c3eef | ||
|
|
17d0df943b | ||
|
|
7b52bd60da | ||
|
|
db8417d919 | ||
|
|
af1685bb70 | ||
|
|
a5621acfe4 | ||
|
|
1be89db43b | ||
|
|
a60853fd21 | ||
|
|
2b9fafe440 | ||
|
|
f952cb88a0 | ||
|
|
9eec252fe2 | ||
|
|
f8f4dae457 | ||
|
|
16bc211f9f | ||
|
|
cca5bcfa1a | ||
|
|
cba667ded8 | ||
|
|
1d9a8c202d | ||
|
|
eb4f05a87b | ||
|
|
5183279cab | ||
|
|
f1c1642976 | ||
|
|
eed3c2292a | ||
|
|
4fb8d0cb5c | ||
|
|
9a0cdec646 | ||
|
|
818dadb84f | ||
|
|
247cfb0476 | ||
|
|
6347a89725 | ||
|
|
9ddf043c17 | ||
|
|
a1f8700664 | ||
|
|
aab7a9b3d1 | ||
|
|
1ef5d539d5 | ||
|
|
5c2886f651 | ||
|
|
2c56c68637 | ||
|
|
756d675f06 | ||
|
|
e6eed2dd70 | ||
|
|
37a5300015 | ||
|
|
66d94634d9 | ||
|
|
03f5529c30 | ||
|
|
f772475d96 | ||
|
|
8b8ceca313 | ||
|
|
201d0b319f | ||
|
|
733ef6ea67 | ||
|
|
cf5909c888 | ||
|
|
dcb3144b22 | ||
|
|
e0c61bdb93 | ||
|
|
06f7a33d5d | ||
|
|
514e905299 | ||
|
|
3688692c7a | ||
|
|
a7cf0cdf30 | ||
|
|
35880f4d1e | ||
|
|
0d0da1141b | ||
|
|
4a8b62446c | ||
|
|
855145d4d7 | ||
|
|
59f679a1c2 | ||
|
|
5213a9df2e | ||
|
|
3e6f482533 | ||
|
|
56ab16aa4e | ||
|
|
066fa9c80a | ||
|
|
9fc8634704 | ||
|
|
5bbaad3001 | ||
|
|
892959d49d | ||
|
|
9948ce713c | ||
|
|
70d9d828c5 | ||
|
|
114a97a273 | ||
|
|
96495b037d | ||
|
|
69fc25a264 | ||
|
|
f19f3249cc | ||
|
|
2cf0e5d2de | ||
|
|
de9bc130d5 | ||
|
|
8ff4c3f24f | ||
|
|
eab1c9bc73 | ||
|
|
0b50a7c261 | ||
|
|
e55c6f82b4 | ||
|
|
6992d27390 | ||
|
|
39e4d2df74 | ||
|
|
41b7e37819 | ||
|
|
e67e0a88cd | ||
|
|
00ade1af40 | ||
|
|
b1632c4c87 | ||
|
|
3445d5366a | ||
|
|
5fb8587628 | ||
|
|
6cdacda510 | ||
|
|
ed2be0e883 | ||
|
|
0fce881654 | ||
|
|
168cff94a2 | ||
|
|
b1c4142296 | ||
|
|
75912e8f9d | ||
|
|
5ac822d5f9 | ||
|
|
233247e154 | ||
|
|
108abc2b30 | ||
|
|
4a158bbea6 | ||
|
|
51c6817487 | ||
|
|
0192c26fd0 | ||
|
|
8b815e1bbb | ||
|
|
3ad05f1e63 | ||
|
|
931b4a95e4 | ||
|
|
cdc3508a0c | ||
|
|
e742c0ab5e | ||
|
|
231fd3c8ca | ||
|
|
86ec787d96 | ||
|
|
30c0b8b50a | ||
|
|
62ae71f4ca | ||
|
|
e9bb9d0b65 | ||
|
|
ca14c02e70 | ||
|
|
0469f9b933 | ||
|
|
f27984a63f | ||
|
|
7ad874a1ff | ||
|
|
604e95aa9c | ||
|
|
917be6ade4 | ||
|
|
2594051a54 | ||
|
|
38b817fdf8 | ||
|
|
a9168e3459 | ||
|
|
d48b664c0a | ||
|
|
a0f0eb5280 | ||
|
|
34518affaf | ||
|
|
4f80b1b522 | ||
|
|
f38abdf1fb | ||
|
|
f2b5e1ab6d | ||
|
|
99b5298cd8 | ||
|
|
6659452c51 | ||
|
|
3441d3ae90 | ||
|
|
f6060eb649 | ||
|
|
6c7ca8d0ac | ||
|
|
599b013fc9 | ||
|
|
841f877ee8 | ||
|
|
088cd594a5 | ||
|
|
f9e642275a | ||
|
|
9a520c122e | ||
|
|
3a712f812a | ||
|
|
753b73c4a3 | ||
|
|
fbcb088260 | ||
|
|
f526e7ec5b | ||
|
|
5d8dd6fb63 | ||
|
|
5e4db14ea2 | ||
|
|
c76b39dc16 | ||
|
|
731cb41646 | ||
|
|
0cfa674029 | ||
|
|
1f18766f79 | ||
|
|
dfefff2703 | ||
|
|
6d3d220cff | ||
|
|
629e66a57c | ||
|
|
ce76f04f35 | ||
|
|
db5d1f72bd | ||
|
|
e54aa3b33b | ||
|
|
82559d38fd | ||
|
|
2baa481040 | ||
|
|
ffb4846239 | ||
|
|
5bd174d287 | ||
|
|
9221c3e371 | ||
|
|
c5cfba85f9 | ||
|
|
d6dcd2f18d | ||
|
|
d278021e1b | ||
|
|
8dbb6e9bd6 | ||
|
|
cde3fb4c89 | ||
|
|
f3d5e2cf50 | ||
|
|
fd74a941d9 | ||
|
|
2cab341e8b | ||
|
|
2a2ed7e41a | ||
|
|
5a5a1bc775 | ||
|
|
b66086be38 | ||
|
|
3be2862e9f | ||
|
|
b0603f62b4 | ||
|
|
9d5bc2d0fd | ||
|
|
94af81b802 | ||
|
|
03154fd010 | ||
|
|
4c394c8004 | ||
|
|
9d46546e44 | ||
|
|
c07bb5c25f | ||
|
|
85f150b543 | ||
|
|
d235f1aea7 | ||
|
|
13d613093b | ||
|
|
71ac19028e | ||
|
|
d8ee2ce658 | ||
|
|
5ecd29926d | ||
|
|
aa097ad982 | ||
|
|
bf0944a5fd | ||
|
|
6693941985 | ||
|
|
0bfaa49e7b | ||
|
|
a32f00c36e | ||
|
|
8b1af0a169 | ||
|
|
9fdb634d71 | ||
|
|
a5574683a6 | ||
|
|
d1ac769fc1 | ||
|
|
8ba8472940 | ||
|
|
f331370767 | ||
|
|
cb8f6f3e1d | ||
|
|
4d3e52cc95 | ||
|
|
8f0a6f0fc6 | ||
|
|
15ff8819a7 | ||
|
|
501f7f1d65 | ||
|
|
bd47f3c74b | ||
|
|
31212ab252 | ||
|
|
deff47db2c | ||
|
|
95a8263797 | ||
|
|
0227c5f783 | ||
|
|
4a8385955b | ||
|
|
1f9cf1d777 | ||
|
|
2a85189155 | ||
|
|
caa6c58fd2 | ||
|
|
583700cdbf | ||
|
|
432328b97e | ||
|
|
bcf30049a2 | ||
|
|
bfd7e111d9 | ||
|
|
0bd8fbe96c | ||
|
|
0c80a0ac21 | ||
|
|
f6255566b0 | ||
|
|
57d390a129 | ||
|
|
acb67fdcf6 | ||
|
|
9e8a9395a5 | ||
|
|
c77763c1c2 | ||
|
|
79cca4bc26 | ||
|
|
1ee7ebe847 | ||
|
|
ac82396584 |
6
.gitattributes
vendored
@@ -1,6 +0,0 @@
|
||||
#
|
||||
# https://help.github.com/articles/dealing-with-line-endings/
|
||||
#
|
||||
# These are explicitly windows files and should use crlf
|
||||
*.bat text eol=crlf
|
||||
|
||||
44
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'bug'
|
||||
assignees: 'SinTan1729'
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Which version of Chhoto-URL are you experiencing the problem on?**
|
||||
e.g. v5.x.x
|
||||
|
||||
**Can you reproduce the issue in the latest version?**
|
||||
Yes/No
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'feature-request'
|
||||
assignees: 'SinTan1729'
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
7
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
# Ignore cargo build outputs
|
||||
# Ignore build outputs
|
||||
actix/target
|
||||
|
||||
# Ignore SQLite file
|
||||
@@ -6,4 +6,7 @@ urls.sqlite
|
||||
|
||||
# Ignore irrelevant dotfiles
|
||||
.vscode/
|
||||
.directory
|
||||
**/.directory
|
||||
.env
|
||||
cookie*
|
||||
.idea/
|
||||
|
||||
41
Dockerfile
@@ -1,25 +1,30 @@
|
||||
FROM rust:1 as build
|
||||
RUN cargo install cargo-build-deps
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
RUN cargo new --bin simply-shorten
|
||||
WORKDIR /simply-shorten
|
||||
|
||||
COPY ./actix/Cargo.toml .
|
||||
COPY ./actix/Cargo.lock .
|
||||
|
||||
RUN cargo build-deps --release
|
||||
FROM lukemathwalker/cargo-chef:latest-rust-slim AS chef
|
||||
WORKDIR /chhoto-url
|
||||
|
||||
FROM chef AS planner
|
||||
COPY ./actix/Cargo.toml ./actix/Cargo.lock ./
|
||||
COPY ./actix/src ./src
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
RUN cargo build --release
|
||||
FROM chef AS builder
|
||||
ARG target=x86_64-unknown-linux-musl
|
||||
RUN apt-get update && apt-get install -y musl-tools
|
||||
RUN rustup target add $target
|
||||
|
||||
FROM frolvlad/alpine-glibc:latest
|
||||
COPY --from=planner /chhoto-url/recipe.json recipe.json
|
||||
# Build dependencies - this is the caching Docker layer
|
||||
RUN cargo chef cook --release --target=$target --recipe-path recipe.json
|
||||
|
||||
RUN apk add sqlite-libs
|
||||
COPY ./actix/Cargo.toml ./actix/Cargo.lock ./
|
||||
COPY ./actix/src ./src
|
||||
# Build application
|
||||
RUN cargo build --release --target=$target --locked --bin chhoto-url
|
||||
RUN cp /chhoto-url/target/$target/release/chhoto-url /chhoto-url/release
|
||||
|
||||
WORKDIR /opt
|
||||
|
||||
COPY --from=build /simply-shorten/target/release/simply-shorten /opt/simply-shorten
|
||||
COPY ./actix/resources /opt/resources
|
||||
|
||||
CMD ["./simply-shorten"]
|
||||
FROM scratch
|
||||
COPY --from=builder /chhoto-url/release /chhoto-url
|
||||
COPY ./resources /resources
|
||||
ENTRYPOINT ["/chhoto-url"]
|
||||
|
||||
18
Dockerfile.multiarch
Normal file
@@ -0,0 +1,18 @@
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
FROM scratch AS builder-amd64
|
||||
COPY ./actix/target/x86_64-unknown-linux-musl/release/chhoto-url /chhoto-url
|
||||
|
||||
FROM scratch AS builder-arm64
|
||||
COPY ./actix/target/aarch64-unknown-linux-musl/release/chhoto-url /chhoto-url
|
||||
|
||||
FROM scratch AS builder-arm
|
||||
COPY ./actix/target/armv7-unknown-linux-musleabihf/release/chhoto-url /chhoto-url
|
||||
|
||||
ARG TARGETARCH
|
||||
FROM builder-$TARGETARCH
|
||||
COPY ./resources /resources
|
||||
|
||||
ENTRYPOINT ["/chhoto-url"]
|
||||
|
||||
52
Makefile
Normal file
@@ -0,0 +1,52 @@
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# .env file has the variables $DOCKER_USERNAME and $PASSWORD defined
|
||||
include .env
|
||||
|
||||
setup:
|
||||
cargo install cross
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
docker buildx create --use --platform=linux/arm64,linux/amd64 --name multi-platform-builder
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
build-dev:
|
||||
cargo build --release --locked --manifest-path=actix/Cargo.toml --target x86_64-unknown-linux-musl
|
||||
|
||||
docker-local: build-dev
|
||||
docker build --tag chhoto-url --build-arg TARGETARCH=amd64 -f Dockerfile.multiarch .
|
||||
|
||||
docker-stop:
|
||||
docker ps -q --filter "name=chhoto-url" | xargs -r docker stop
|
||||
docker ps -aq --filter "name=chhoto-url" | xargs -r docker rm
|
||||
|
||||
docker-test: docker-local docker-stop
|
||||
docker run -p ${PORT}:${PORT} --name chhoto-url -e password="${PASSWORD}" -e public_mode="${PUBLIC_MODE}" \
|
||||
-e site_url="${SITE_URL}" -e db_url="${DB_URL}" -e redirect_method="${REDIRECT_METHOD}" -e port="${PORT}"\
|
||||
-e slug_style="${SLUG_STYLE}" -e slug_length="${SLUG_LENGTH}" -e cache_control_header="${CACHE_CONTROL_HEADER}"\
|
||||
-e api_key="${API_KEY}"\
|
||||
-d chhoto-url
|
||||
docker logs chhoto-url -f
|
||||
|
||||
docker-dev: build-dev
|
||||
docker build --push --tag ${DOCKER_USERNAME}/chhoto-url:dev --build-arg TARGETARCH=amd64 -f Dockerfile.multiarch .
|
||||
|
||||
build-release:
|
||||
cross build --release --locked --manifest-path=actix/Cargo.toml --target aarch64-unknown-linux-musl
|
||||
cross build --release --locked --manifest-path=actix/Cargo.toml --target armv7-unknown-linux-musleabihf
|
||||
cross build --release --locked --manifest-path=actix/Cargo.toml --target x86_64-unknown-linux-musl
|
||||
|
||||
V_PATCH := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)"$$/\1/p')
|
||||
V_MINOR := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)\..+"$$/\1/p')
|
||||
V_MAJOR := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)\..+\..+"$$/\1/p')
|
||||
docker-release: build-release
|
||||
docker buildx build --push --tag ${DOCKER_USERNAME}/chhoto-url:${V_MAJOR} --tag ${DOCKER_USERNAME}/chhoto-url:${V_MINOR} \
|
||||
--tag ${DOCKER_USERNAME}/chhoto-url:${V_PATCH} --tag ${DOCKER_USERNAME}/chhoto-url:latest \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 -f Dockerfile.multiarch .
|
||||
|
||||
clean:
|
||||
docker ps -q --filter "name=chhoto-url" | xargs -r docker stop
|
||||
docker ps -aq --filter "name=chhoto-url" | xargs -r docker rm
|
||||
cargo clean --manifest-path=actix/Cargo.toml
|
||||
|
||||
.PHONY: build-dev docker-local docker-stop build-release
|
||||
199
README.md
@@ -1,33 +1,53 @@
|
||||
[](https://hub.docker.com/r/sintan1729/simply-shorten)
|
||||
[](https://github.com/SinTan1729)
|
||||

|
||||
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
|
||||
#  <span style="font-size:42px">Simply Shorten</span>
|
||||
[](https://hub.docker.com/r/sintan1729/chhoto-url)
|
||||
[](https://github.com/SinTan1729)
|
||||
[](https://github.com/SinTan1729/chhoto-url/releases/latest)
|
||||

|
||||

|
||||
[](https://spdx.org/licenses/MIT.html)
|
||||
|
||||
#  <span style="font-size:42px">Chhoto URL</span>
|
||||
|
||||
# What is it?
|
||||
A simple selfhosted URL shortener with no unnecessary features.
|
||||
A simple selfhosted URL shortener with no unnecessary features. Simplicity
|
||||
and speed are the main foci of this project. The docker image is ~6 MB (compressed),
|
||||
and it uses <5 MB of RAM under regular use.
|
||||
|
||||
Don't worry if you see no activity for a long time. I consider this project
|
||||
to be complete, not dead. I'm unlikely to add any new features, but I will try
|
||||
and fix every bug you report.
|
||||
and fix every bug you report. I will also try to keep it updated in terms of
|
||||
security vulnerabilities.
|
||||
|
||||
If you feel like a feature is missing, please let me know by creating an issue
|
||||
using the "feature request" template.
|
||||
|
||||
## But why another URL shortener?
|
||||
I've looked at a couple popular URL shorteners, however they either have
|
||||
unnecessary features, or they didn't have all the features I wanted.
|
||||
Most URL shorteners are either bloated with unnecessary features, or are a pain to set up.
|
||||
Even fewer are written with simplicity and lightness in mind. When I saw the `simply-shorten`
|
||||
project (linked below), I really liked the idea but thought that it missed some features. Also,
|
||||
I didn't like the fact that a simple app like this had a ~200 MB docker image (mostly due to the
|
||||
included java runtime). So, I decided to rewrite it in Rust and add some features to it that I
|
||||
thought were essential (e.g. hit counting).
|
||||
|
||||
## What does the name mean?
|
||||
Chhoto (ছোট, [IPA](https://en.wikipedia.org/wiki/Help:IPA/Bengali): /tʃʰoʈo/) is the Bangla word
|
||||
for small. URL means, well... URL. So the name simply means Small URL.
|
||||
|
||||
# Features
|
||||
- Shortens URLs of any length to a fixed length, randomly generated string.
|
||||
- Shortens URLs of any length to a randomly generated link.
|
||||
- (Optional) Allows you to specify the shortened URL instead of the generated
|
||||
one (Missing in a surprising number of alternatives).
|
||||
- Opening the fixed length URL in your browser will instantly redirect you
|
||||
to the correct long URL (you'd think that's a standard feature, but
|
||||
apparently it's not).
|
||||
- Provides a simple API for adding new short links.
|
||||
one. (It's surprisingly missing in a surprising number of alternatives.)
|
||||
- Opening the shortened URL in your browser will instantly redirect you
|
||||
to the correct long URL. (So no stupid redirecting pages.)
|
||||
- Super lightweight and snappy. (The docker image is only ~6MB and RAM uasge
|
||||
stays under 5MB under normal use.)
|
||||
- Counts number of hits for each short link in a privacy respecting way
|
||||
i.e. only the hit is recorded, and nothing else.
|
||||
- Has a mobile friendly UI, and automatic dark mode.
|
||||
- Has a public mode, where anyone can add links without authentication. Deleting
|
||||
or listing available links will need admin access using the password.
|
||||
- Allows setting the URL of your website, in case you want to conveniently
|
||||
generate short links locally.
|
||||
- Links are stored in an SQLite database.
|
||||
@@ -36,7 +56,7 @@ unnecessary features, or they didn't have all the features I wanted.
|
||||
written in plain HTML and vanilla JS, using [Pure CSS](https://purecss.io/)
|
||||
for styling.
|
||||
- Uses very basic authentication using a provided password. It's not encrypted in transport.
|
||||
I recommend using something like [Nginx Proxy Manager](https://nginxproxymanager.com/) to
|
||||
I recommend using a reverse proxy such as [caddy](https://caddyserver.com/) to
|
||||
encrypt the connection by SSL.
|
||||
|
||||
# Bloat that will not be implemented
|
||||
@@ -51,8 +71,11 @@ not needed here.
|
||||
- Paywalls or messages begging for donations. If you want to support me (for
|
||||
whatever reason), you can message me through GitHub issues.
|
||||
|
||||
# Screenshot
|
||||

|
||||
# Screenshots
|
||||
<p align="middle">
|
||||
<img src="screenshot-desktop.webp" height="250" alt="desktop screenshot" />
|
||||
<img src="screenshot-mobile.webp" height="250" alt="mobile screenshot" />
|
||||
</p>
|
||||
|
||||
# Usage
|
||||
## Using `docker compose` (Recommended method)
|
||||
@@ -65,42 +88,25 @@ docker compose up -d
|
||||
If you're using a custom location for the `db_url`, make sure to make that file
|
||||
before running the docker image, as otherwise a directory will be created in its
|
||||
place, resulting in possibly unwanted behavior.
|
||||
## Building from source
|
||||
Clone this repository
|
||||
```
|
||||
git clone https://github.com/SinTan1729/simply-shorten
|
||||
```
|
||||
|
||||
### 2. Set environment variables
|
||||
```bash
|
||||
# Required for authentication
|
||||
export password=<api password>
|
||||
# Sets where the database exists. Can be local or remote (optional)
|
||||
export db_url=<url> # Default: './urls.sqlite'
|
||||
# Sets the url of website, so that it displays that even when accessed
|
||||
# locally (optional, defaults to hostname you're accessing it on)
|
||||
export site_url=<url>
|
||||
```
|
||||
|
||||
### 3. Build and run it
|
||||
```
|
||||
cd actix
|
||||
cargo run
|
||||
```
|
||||
You can optionally set the port the server listens on by appending `--port=[port]`.
|
||||
### 4. Navigate to `http://localhost:4567` in your browser, add links as you wish.
|
||||
|
||||
## Running with docker
|
||||
## Building and running with docker
|
||||
### `docker run` method
|
||||
0. (Only if you really want to) Build the image
|
||||
0. (Only if you really want to) Build the image for the default `x86_64-unknown-linux-musl` target:
|
||||
```
|
||||
docker build . -t simply-shorten:latest
|
||||
docker build . -t chhoto-url
|
||||
```
|
||||
For building on `arm64` or `arm/v7`, use the following:
|
||||
```
|
||||
docker build . -t chhoto-url --build-arg target=<desired-target>
|
||||
```
|
||||
Make sure that the desired target is a `musl` one, since the docker image is built from `scratch`.
|
||||
For cross-compilation, take a look at the `Makefile`. It builds and pushes for `linux/amd64`, `linux/aarch64`
|
||||
and `linux/arm/v7` architectures. For any other architectures, open a discussion, and I'll try to help you out.
|
||||
1. Run the image
|
||||
```
|
||||
docker run -p 4567:4567
|
||||
-e password="password"
|
||||
-d simply-shorten:latest
|
||||
-d chhoto-url:latest
|
||||
```
|
||||
1.a Make the database file available to host (optional)
|
||||
```
|
||||
@@ -109,7 +115,7 @@ docker run -p 4567:4567 \
|
||||
-e password="password" \
|
||||
-v ./urls.sqlite:/urls.sqlite \
|
||||
-e db_url=/urls.sqlite \
|
||||
-d simply-shorten:latest
|
||||
-d chhoto-url:latest
|
||||
```
|
||||
1.b Further, set the URL of your website (optional)
|
||||
```
|
||||
@@ -119,11 +125,100 @@ docker run -p 4567:4567 \
|
||||
-v ./urls.sqlite:/urls.sqlite \
|
||||
-e db_url=/urls.sqlite \
|
||||
-e site_url="https://www.example.com" \
|
||||
-d simply-shorten:latest
|
||||
-d chhoto-url:latest
|
||||
```
|
||||
1.c Further, set an API key to activate JSON result mode (optional)
|
||||
|
||||
```
|
||||
docker run -p 4567:4567 \
|
||||
-e password="password" \
|
||||
-e api_key="SECURE_API_KEY" \
|
||||
-v ./urls.sqlite:/urls.sqlite \
|
||||
-e db_url=/urls.sqlite \
|
||||
-e site_url="https://www.example.com" \
|
||||
-d chhoto-url:latest
|
||||
```
|
||||
|
||||
You can also set the redirect method to Permanent 308 (default) or Temporary 307 by setting
|
||||
the `redirect_method` variable to `TEMPORARY` or `PERMANENT` (it's matched exactly).
|
||||
You can set the redirect method to Permanent 308 (default) or Temporary 307 by setting
|
||||
the `redirect_method` variable to `TEMPORARY` or `PERMANENT` (it's matched exactly). By
|
||||
default, the auto-generated links are adjective-name pairs. You can use UIDs by setting
|
||||
the `slug_style` variable to `UID`. You can also set the length of those slug by setting
|
||||
the `slug_length` variable. It defaults to 8, and a minimum of 4 is supported.
|
||||
|
||||
To enable public mode, set `public_mode` to `Enable`. With this, anyone will be able to add
|
||||
links. Listing existing links or deleting links will need admin access using the password.
|
||||
|
||||
By default, the server sends no Cache-Control headers. You can set custom `cache_control_header`
|
||||
to send your desired headers. It must be a comma separated list of valid
|
||||
[RFC 7234 §5.2](https://datatracker.ietf.org/doc/html/rfc7234#section-5.2) headers. For example,
|
||||
you can set it to `no-cache, private` to disable caching. It might help during testing if
|
||||
served through a proxy.
|
||||
|
||||
## Instructions for CLI usage
|
||||
The application can be used from the terminal using something like `curl`. In all the examples
|
||||
below, replace `http://localhost:4567` with where your instance of `chhoto-url` is accessible.
|
||||
|
||||
You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and
|
||||
get the siteurl using `curl http://localhost:4567/api/siteurl`. These routes are accessible without any authentication.
|
||||
|
||||
### API key validation
|
||||
**This is required for programs that rely on a JSON response from Chhoto URL**
|
||||
|
||||
In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie
|
||||
validation (see section above). If the API key is insecure, a warning will be outputted along with a generated API key which may be used.
|
||||
|
||||
Example Linux command for generating a secure API key: `tr -dc A-Za-z0-9 </dev/urandom | head -c 128`
|
||||
|
||||
To add a link:
|
||||
``` bash
|
||||
curl -X POST -H "X-API-Key: <YOUR_API_KEY>" -d '{"shortlink":"<shortlink>", "longlink":"<longlink>"}' http://localhost:4567/api/new
|
||||
```
|
||||
Send an empty `<shortlink>` if you want it to be auto-generated. The server will reply with the generated shortlink.
|
||||
|
||||
To get information about a single shortlink:
|
||||
``` bash
|
||||
curl -H "X-API-Key: <YOUR_API_KEY>" -d '<shortlink>' http://localhost:4567/api/expand
|
||||
```
|
||||
(This route is not accessible using cookie validation.)
|
||||
|
||||
To get a list of all the currently available links:
|
||||
``` bash
|
||||
curl -H "X-API-Key: <YOUR_API_KEY>" http://localhost:4567/api/all
|
||||
```
|
||||
|
||||
To delete a link:
|
||||
``` bash
|
||||
curl -X DELETE -H "X-API-Key: <YOUR_API_KEY>" http://localhost:4567/api/del/<shortlink>
|
||||
```
|
||||
Where `<shortlink>` is name of the shortened link you would like to delete. For example, if the shortened link is
|
||||
`http://localhost:4567/example`, `<shortlink>` would be `example`.
|
||||
|
||||
The server will output when the instance is accessed over API, when an incorrect API key is received, etc.
|
||||
|
||||
### Cookie validation
|
||||
If you have set up a password, first do the following to get an authentication cookie and store it in a file.
|
||||
```bash
|
||||
curl -X POST -d "<your-password>" -c cookie.txt http://localhost:4567/api/login
|
||||
```
|
||||
You should receive "Correct password!" if the provided password was correct. For any subsequent
|
||||
request, please add `-b cookie.txt` to provide authentication.
|
||||
|
||||
To add a link, do
|
||||
```bash
|
||||
curl -X POST -d '{"shortlink":"<shortlink>", "longlink":"<longlink>"}' http://localhost:4567/api/new
|
||||
```
|
||||
Send an empty `<shortlink>` if you want it to be auto-generated. The server will reply with the generated shortlink.
|
||||
|
||||
To get a list of all the currently available links as `json`, do
|
||||
```bash
|
||||
curl http://localhost:4567/api/all
|
||||
```
|
||||
|
||||
To delete a link, do
|
||||
```bash
|
||||
curl -X DELETE http://localhost:4567/api/del/<shortlink>
|
||||
```
|
||||
The server will send a confirmation.
|
||||
|
||||
## Disable authentication
|
||||
If you do not define a password environment variable when starting the docker image, authentication
|
||||
@@ -135,6 +230,8 @@ pointing to illegal content. Since there are no logs, it's impossible to prove
|
||||
that those links aren't created by you.
|
||||
|
||||
## Notes
|
||||
- It started as a fork of [this project](https://gitlab.com/draganczukp/simply-shorten).
|
||||
- It started as a fork of [`simply-shorten`](https://gitlab.com/draganczukp/simply-shorten).
|
||||
- There's an (unofficial) extension maintained by for shortening URLs easily using Chhoto URL.
|
||||
[You can take a look at it here.](https://github.com/SolninjaA/Chhoto-URL-Extension)
|
||||
- The list of adjectives and names used for random short url generation is a modified
|
||||
version of [this list used by docker](https://github.com/moby/moby/blob/master/pkg/namesgenerator/names-generator.go).
|
||||
version of [this list used by docker](https://github.com/moby/moby/blob/master/pkg/namesgenerator/names-generator.go).
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[Dolphin]
|
||||
Timestamp=2023,4,2,17,52,37.922
|
||||
Version=4
|
||||
1569
actix/Cargo.lock
generated
@@ -1,24 +1,40 @@
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
[package]
|
||||
name = "simply-shorten"
|
||||
version = "4.3.3"
|
||||
name = "chhoto-url"
|
||||
version = "5.6.3"
|
||||
edition = "2021"
|
||||
authors = ["Sayantan Santra <sayantan[dot]santra689[at]gmail[dot]com"]
|
||||
license = "MIT"
|
||||
license = "mit"
|
||||
description = "A simple selfhosted URL shortener with no unnecessary features."
|
||||
homepage = "https://github.com/SinTan1729/simply-shorten"
|
||||
documentation = "https://github.com/SinTan1729/simply-shorten"
|
||||
repository = "https://github.com/SinTan1729/simply-shorten"
|
||||
homepage = "https://github.com/SinTan1729/chhoto-url"
|
||||
documentation = "https://github.com/SinTan1729/chhoto-url"
|
||||
repository = "https://github.com/SinTan1729/chhoto-url"
|
||||
readme = "README.md"
|
||||
keywords = ["docker", "rust", "self-hosted", "url-shortener", "webapp", "shortener", "link-shortener", "actix-web"]
|
||||
keywords = [
|
||||
"docker",
|
||||
"rust",
|
||||
"self-hosted",
|
||||
"url-shortener",
|
||||
"webapp",
|
||||
"shortener",
|
||||
"link-shortener",
|
||||
"actix-web",
|
||||
]
|
||||
categories = ["web-programming"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4"
|
||||
actix-files = "0.6.2"
|
||||
rusqlite = "0.29.0"
|
||||
regex = "1.7.3"
|
||||
rand = "0.8.5"
|
||||
actix-session = {version = "0.7.2", features = ["cookie-session"]}
|
||||
env_logger = "0.10.0"
|
||||
actix-web = "4.5.1"
|
||||
actix-files = "0.6.5"
|
||||
rusqlite = { version = "0.34.0", features = ["bundled"] }
|
||||
regex = "1.10.3"
|
||||
rand = "0.9.0"
|
||||
passwords = "3.1.16"
|
||||
actix-session = { version = "0.10.0", features = ["cookie-session"] }
|
||||
env_logger = "0.11.1"
|
||||
nanoid = "0.4.0"
|
||||
serde_json = "1.0.115"
|
||||
serde = { version = "1.0.197", features = [ "derive" ] }
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Error 404</title>
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
</head>
|
||||
|
||||
<style>
|
||||
#quote {
|
||||
text-indent: 4em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body style="text-align: center;">
|
||||
<h1>Error 404!</h1>
|
||||
<div style="display: inline-block; text-align:left;">
|
||||
<p>You step in the stream,</p>
|
||||
<p>but the water has moved on.</p>
|
||||
<p>The page is not here.</p>
|
||||
<p id="quote"> — Cass Whittington</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,207 +0,0 @@
|
||||
const getSiteUrl = async () => await fetch("/api/siteurl")
|
||||
.then(res => res.text())
|
||||
.then(text => {
|
||||
if (text == "unset") {
|
||||
return window.location.host;
|
||||
}
|
||||
else {
|
||||
return text;
|
||||
}
|
||||
});
|
||||
|
||||
const refreshData = async () => {
|
||||
let reply = await fetch("/api/all").then(res => res.text());
|
||||
if (reply == "logged_out") {
|
||||
console.log("logged_out");
|
||||
document.getElementById("container").style.filter = "blur(2px)"
|
||||
document.getElementById("login-dialog").showModal();
|
||||
document.getElementById("password").focus();
|
||||
} else {
|
||||
data = reply
|
||||
.split("\n")
|
||||
.filter(line => line !== "")
|
||||
.map(line => line.split(","))
|
||||
.map(arr => ({
|
||||
short: arr[0],
|
||||
long: arr[1],
|
||||
hits: arr[2]
|
||||
}));
|
||||
|
||||
displayData(data);
|
||||
}
|
||||
};
|
||||
|
||||
const displayData = async (data) => {
|
||||
let site = await getSiteUrl();
|
||||
table_box = document.querySelector(".pure-table");
|
||||
loading_text = document.getElementsByName("loading-text")[0];
|
||||
|
||||
if (data.length == 0) {
|
||||
table_box.style.visibility = "hidden";
|
||||
loading_text.style.display = "block";
|
||||
loading_text.innerHTML = "No active links.";
|
||||
}
|
||||
else {
|
||||
loading_text.style.display = "none";
|
||||
const table = document.querySelector("#url-table");
|
||||
if (!window.isSecureContext) {
|
||||
const shortUrlHeader = document.getElementById("short-url-header");
|
||||
shortUrlHeader.innerHTML = "Short URL<br>(right click and copy)";
|
||||
}
|
||||
table_box.style.visibility = "visible";
|
||||
table.innerHTML = ''; // Clear
|
||||
data.forEach(tr => table.appendChild(TR(tr, site)));
|
||||
}
|
||||
};
|
||||
|
||||
const showAlert = async (text, col) => {
|
||||
document.getElementById("alert-box")?.remove();
|
||||
const controls = document.querySelector(".pure-controls");
|
||||
const alertBox = document.createElement("p");
|
||||
alertBox.id = "alert-box";
|
||||
alertBox.style.color = col;
|
||||
alertBox.innerHTML = text;
|
||||
controls.appendChild(alertBox);
|
||||
};
|
||||
|
||||
const TR = (row, site) => {
|
||||
const tr = document.createElement("tr");
|
||||
const longTD = TD(A_LONG(row.long), "Long URL");
|
||||
var shortTD = null;
|
||||
if (window.isSecureContext) {
|
||||
shortTD = TD(A_SHORT(row.short, site), "Short URL");
|
||||
}
|
||||
else {
|
||||
shortTD = TD(A_SHORT_INSECURE(row.short, site), "Short URL");
|
||||
}
|
||||
hitsTD = TD(row.hits);
|
||||
hitsTD.setAttribute("label", "Hits");
|
||||
const btn = deleteButton(row.short);
|
||||
|
||||
tr.appendChild(shortTD);
|
||||
tr.appendChild(longTD);
|
||||
tr.appendChild(hitsTD);
|
||||
tr.appendChild(btn);
|
||||
|
||||
return tr;
|
||||
};
|
||||
|
||||
const copyShortUrl = async (link) => {
|
||||
const site = await getSiteUrl();
|
||||
try {
|
||||
navigator.clipboard.writeText(`${site}/${link}`);
|
||||
showAlert(`Short URL ${link} was copied to clipboard!`, "green");
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
showAlert("Could not copy short URL to clipboard, please do it manually.", "red");
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const addProtocol = (input) => {
|
||||
var url = input.value.trim();
|
||||
if (url != "" && !~url.indexOf("://") && !~url.indexOf("magnet:")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
input.value = url;
|
||||
return input
|
||||
}
|
||||
|
||||
const A_LONG = (s) => `<a href='${s}'>${s}</a>`;
|
||||
const A_SHORT = (s, t) => `<a href="javascript:copyShortUrl('${s}');">${s}</a>`;
|
||||
const A_SHORT_INSECURE = (s, t) => `<a href="${t}/${s}">${s}</a>`;
|
||||
|
||||
const deleteButton = (shortUrl) => {
|
||||
const td = document.createElement("td");
|
||||
const btn = document.createElement("button");
|
||||
|
||||
btn.innerHTML = "×";
|
||||
|
||||
btn.onclick = e => {
|
||||
e.preventDefault();
|
||||
if (confirm("Do you want to delete the entry " + shortUrl + "?")) {
|
||||
document.getElementById("alert-box")?.remove();
|
||||
showAlert(" ", "black");
|
||||
fetch(`/api/del/${shortUrl}`, {
|
||||
method: "DELETE"
|
||||
}).then(_ => refreshData());
|
||||
}
|
||||
};
|
||||
td.setAttribute("name", "deleteBtn");
|
||||
td.setAttribute("label", "Delete");
|
||||
td.appendChild(btn);
|
||||
return td;
|
||||
};
|
||||
|
||||
const TD = (s, u) => {
|
||||
const td = document.createElement("td");
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = s;
|
||||
td.appendChild(div);
|
||||
td.setAttribute("label", u);
|
||||
return td;
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
const form = document.forms.namedItem("new-url-form");
|
||||
const longUrl = form.elements["longUrl"];
|
||||
const shortUrl = form.elements["shortUrl"];
|
||||
|
||||
const url = `/api/new`;
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
body: `${longUrl.value};${shortUrl.value}`
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
showAlert("Short URL is not valid or it's already in use!", "red");
|
||||
return "error";
|
||||
}
|
||||
else {
|
||||
return res.text();
|
||||
}
|
||||
}).then(text => {
|
||||
if (text != "error") {
|
||||
copyShortUrl(text);
|
||||
longUrl.value = "";
|
||||
shortUrl.value = "";
|
||||
refreshData();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submitLogin = () => {
|
||||
const password = document.getElementById("password");
|
||||
fetch("/api/login", {
|
||||
method: "POST",
|
||||
body: password.value
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
document.getElementById("container").style.filter = "blur(0px)"
|
||||
document.getElementById("login-dialog").remove();
|
||||
refreshData();
|
||||
} else {
|
||||
const wrongPassBox = document.getElementById("wrong-pass");
|
||||
wrongPassBox.innerHTML = "Wrong password!";
|
||||
wrongPassBox.style.color = "red";
|
||||
password.focus();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await refreshData();
|
||||
|
||||
const form = document.forms.namedItem("new-url-form");
|
||||
form.onsubmit = e => {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}
|
||||
|
||||
const login_form = document.forms.namedItem("login-form");
|
||||
login_form.onsubmit = e => {
|
||||
e.preventDefault();
|
||||
submitLogin();
|
||||
}
|
||||
})();
|
||||
@@ -1,100 +0,0 @@
|
||||
.container {
|
||||
max-width: 950px;
|
||||
margin: 20px auto auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table tr td div {
|
||||
max-height: 75px;
|
||||
line-height: 25px;
|
||||
word-wrap: break-word;
|
||||
max-width: 575px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
td[name="deleteBtn"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td[name="deleteBtn"] button {
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
form input[name="shortUrl"] {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
form input[name="shortUrl"]::placeholder {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
div[name="github-link"] {
|
||||
position: absolute;
|
||||
right: 0.5%;
|
||||
top: 0.5%;
|
||||
}
|
||||
|
||||
.pure-table {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.pure-table caption {
|
||||
font-size: 22px;
|
||||
text-align: left;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
#logo {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
#password {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
dialog form {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Settings for mobile devices */
|
||||
@media (pointer:none),
|
||||
(pointer:coarse) {
|
||||
table tr {
|
||||
border-bottom: 1px solid #999;
|
||||
}
|
||||
|
||||
table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table td {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
table td::before {
|
||||
content: attr(label);
|
||||
font-weight: bold;
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table tr td div {
|
||||
width: 63vw
|
||||
}
|
||||
|
||||
.pure-table caption {
|
||||
padding-top: 0px;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,75 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use actix_session::Session;
|
||||
use actix_web::HttpRequest;
|
||||
use std::{env, time::SystemTime};
|
||||
|
||||
// API key generation and scoring
|
||||
use passwords::{analyzer, scorer, PasswordGenerator};
|
||||
|
||||
// Validate API key
|
||||
pub fn validate_key(key: String) -> bool {
|
||||
if let Ok(api_key) = env::var("api_key") {
|
||||
if api_key != key {
|
||||
eprintln!("Incorrect API key was provided when connecting to Chhoto URL.");
|
||||
false
|
||||
} else {
|
||||
eprintln!("Server accessed with API key.");
|
||||
true
|
||||
}
|
||||
} else {
|
||||
eprintln!("API was accessed with API key validation but no API key was specified. Set the 'api_key' environment variable.");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate an API key if the user doesn't specify a secure key
|
||||
// Called in main.rs
|
||||
pub fn gen_key() -> String {
|
||||
let key = PasswordGenerator {
|
||||
length: 128,
|
||||
numbers: true,
|
||||
lowercase_letters: true,
|
||||
uppercase_letters: true,
|
||||
symbols: false,
|
||||
spaces: false,
|
||||
exclude_similar_characters: false,
|
||||
strict: true,
|
||||
};
|
||||
key.generate_one().unwrap()
|
||||
}
|
||||
|
||||
// Check if the API key header exists
|
||||
pub fn api_header(req: &HttpRequest) -> Option<&str> {
|
||||
req.headers().get("X-API-Key")?.to_str().ok()
|
||||
}
|
||||
|
||||
// Determine whether the inputted API key is sufficiently secure
|
||||
pub fn is_key_secure() -> bool {
|
||||
let score = scorer::score(&analyzer::analyze(env::var("api_key").unwrap()));
|
||||
score >= 90.0
|
||||
}
|
||||
|
||||
// Validate a given password
|
||||
pub fn validate(session: Session) -> bool {
|
||||
// If there's no password provided, just return true
|
||||
if env::var("password").is_err() {
|
||||
if env::var("password")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.is_none()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let token = session.get::<String>("session-token");
|
||||
token.is_ok() && check(token.unwrap())
|
||||
if let Ok(token) = session.get::<String>("chhoto-url-auth") {
|
||||
check(token)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Check a token cryptographically
|
||||
fn check(token: Option<String>) -> bool {
|
||||
if let Some(token_body) = token {
|
||||
let token_parts: Vec<&str> = token_body.split(';').collect();
|
||||
@@ -23,15 +82,16 @@ fn check(token: Option<String>) -> bool {
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("Time went backwards!")
|
||||
.as_secs();
|
||||
token_text == "session-token" && time_now < token_time + 1209600 // There are 1209600 seconds in 14 days
|
||||
token_text == "chhoto-url-auth" && time_now < token_time + 1209600 // There are 1209600 seconds in 14 days
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new cryptographic token
|
||||
pub fn gen_token() -> String {
|
||||
let token_text = String::from("session-token");
|
||||
let token_text = String::from("chhoto-url-auth");
|
||||
let time = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("Time went backwards!")
|
||||
|
||||
@@ -1,46 +1,72 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
pub fn find_url(shortlink: &str, db: &Connection) -> String {
|
||||
let mut statement = db
|
||||
.prepare_cached("SELECT long_url FROM urls WHERE short_url = ?1")
|
||||
.unwrap();
|
||||
|
||||
let links = statement
|
||||
.query_map([shortlink], |row| row.get("long_url"))
|
||||
.unwrap();
|
||||
|
||||
let mut longlink = String::new();
|
||||
for link in links {
|
||||
longlink = link.unwrap();
|
||||
}
|
||||
|
||||
longlink
|
||||
// Struct for encoding a DB row
|
||||
#[derive(Serialize)]
|
||||
pub struct DBRow {
|
||||
shortlink: String,
|
||||
longlink: String,
|
||||
hits: i64,
|
||||
}
|
||||
|
||||
pub fn getall(db: &Connection) -> Vec<String> {
|
||||
let mut statement = db.prepare_cached("SELECT * FROM urls").unwrap();
|
||||
// Find a single URL
|
||||
pub fn find_url(shortlink: &str, db: &Connection, needhits: bool) -> (Option<String>, Option<i64>) {
|
||||
let query = if needhits {
|
||||
"SELECT long_url,hits FROM urls WHERE short_url = ?1"
|
||||
} else {
|
||||
"SELECT long_url FROM urls WHERE short_url = ?1"
|
||||
};
|
||||
let mut statement = db
|
||||
.prepare_cached(query)
|
||||
.expect("Error preparing SQL statement for find_url.");
|
||||
|
||||
let mut data = statement.query([]).unwrap();
|
||||
let longlink = statement
|
||||
.query_row([shortlink], |row| row.get("long_url"))
|
||||
.ok();
|
||||
let hits = statement.query_row([shortlink], |row| row.get("hits")).ok();
|
||||
(longlink, hits)
|
||||
}
|
||||
|
||||
let mut links: Vec<String> = Vec::new();
|
||||
while let Some(row) = data.next().unwrap() {
|
||||
let short_url: String = row.get("short_url").unwrap();
|
||||
let long_url: String = row.get("long_url").unwrap();
|
||||
let hits: i64 = row.get("hits").unwrap();
|
||||
links.push(format!("{short_url},{long_url},{hits}"));
|
||||
// Get all URLs in DB
|
||||
pub fn getall(db: &Connection) -> Vec<DBRow> {
|
||||
let mut statement = db
|
||||
.prepare_cached("SELECT * FROM urls ORDER BY id ASC")
|
||||
.expect("Error preparing SQL statement for getall.");
|
||||
|
||||
let mut data = statement
|
||||
.query([])
|
||||
.expect("Error executing query for getall.");
|
||||
|
||||
let mut links: Vec<DBRow> = Vec::new();
|
||||
while let Some(row) = data.next().expect("Error reading fetched rows.") {
|
||||
let row_struct = DBRow {
|
||||
shortlink: row
|
||||
.get("short_url")
|
||||
.expect("Error reading shortlink from row."),
|
||||
longlink: row
|
||||
.get("long_url")
|
||||
.expect("Error reading shortlink from row."),
|
||||
hits: row.get("hits").expect("Error reading shortlink from row."),
|
||||
};
|
||||
links.push(row_struct);
|
||||
}
|
||||
|
||||
links
|
||||
}
|
||||
|
||||
// Add a hit when site is visited
|
||||
pub fn add_hit(shortlink: &str, db: &Connection) {
|
||||
db.execute(
|
||||
"UPDATE urls SET hits = hits + 1 WHERE short_url = ?1",
|
||||
[shortlink],
|
||||
)
|
||||
.unwrap();
|
||||
.expect("Error updating hit count.");
|
||||
}
|
||||
|
||||
// Insert a new link
|
||||
pub fn add_link(shortlink: String, longlink: String, db: &Connection) -> bool {
|
||||
db.execute(
|
||||
"INSERT INTO urls (long_url, short_url, hits) VALUES (?1, ?2, ?3)",
|
||||
@@ -49,11 +75,16 @@ pub fn add_link(shortlink: String, longlink: String, db: &Connection) -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn delete_link(shortlink: String, db: &Connection) {
|
||||
db.execute("DELETE FROM urls WHERE short_url = ?1", [shortlink])
|
||||
.unwrap();
|
||||
// Delete and existing link
|
||||
pub fn delete_link(shortlink: String, db: &Connection) -> bool {
|
||||
if let Ok(delta) = db.execute("DELETE FROM urls WHERE short_url = ?1", [shortlink]) {
|
||||
delta > 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Open the DB, and create schema if missing
|
||||
pub fn open_db(path: String) -> Connection {
|
||||
let db = Connection::open(path).expect("Unable to open database!");
|
||||
// Create table if it doesn't exist
|
||||
@@ -66,6 +97,7 @@ pub fn open_db(path: String) -> Connection {
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
.expect("Unable to initialize empty database.");
|
||||
|
||||
db
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
use actix_files::{Files, NamedFile};
|
||||
use actix_session::{storage::CookieSessionStore, Session, SessionMiddleware};
|
||||
use actix_web::{
|
||||
cookie::Key,
|
||||
delete, get,
|
||||
http::StatusCode,
|
||||
middleware, post,
|
||||
web::{self, Redirect},
|
||||
App, HttpResponse, HttpServer, Responder,
|
||||
};
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use actix_files::Files;
|
||||
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
|
||||
use actix_web::{cookie::Key, middleware, web, App, HttpServer};
|
||||
use rusqlite::Connection;
|
||||
use std::env;
|
||||
use std::{env, io::Result};
|
||||
|
||||
// Import modules
|
||||
mod auth;
|
||||
mod database;
|
||||
mod services;
|
||||
mod utils;
|
||||
|
||||
// This struct represents state
|
||||
@@ -19,133 +18,101 @@ struct AppState {
|
||||
db: Connection,
|
||||
}
|
||||
|
||||
// Define the routes
|
||||
|
||||
// Add new links
|
||||
#[post("/api/new")]
|
||||
async fn add_link(req: String, data: web::Data<AppState>, session: Session) -> HttpResponse {
|
||||
if auth::validate(session) {
|
||||
let out = utils::add_link(req, &data.db);
|
||||
if out.0 {
|
||||
HttpResponse::Ok().body(out.1)
|
||||
} else {
|
||||
HttpResponse::BadRequest().body(out.1)
|
||||
}
|
||||
} else {
|
||||
HttpResponse::Forbidden().body("logged_out")
|
||||
}
|
||||
}
|
||||
|
||||
// Return all active links
|
||||
#[get("/api/all")]
|
||||
async fn getall(data: web::Data<AppState>, session: Session) -> HttpResponse {
|
||||
if auth::validate(session) {
|
||||
HttpResponse::Ok().body(utils::getall(&data.db))
|
||||
} else {
|
||||
HttpResponse::Forbidden().body("logged_out")
|
||||
}
|
||||
}
|
||||
|
||||
// Get the site URL
|
||||
#[get("/api/siteurl")]
|
||||
async fn siteurl(session: Session) -> HttpResponse {
|
||||
if auth::validate(session) {
|
||||
let site_url = env::var("site_url").unwrap_or(String::from("unset"));
|
||||
HttpResponse::Ok().body(site_url)
|
||||
} else {
|
||||
HttpResponse::Forbidden().body("logged_out")
|
||||
}
|
||||
}
|
||||
|
||||
// 404 error page
|
||||
async fn error404() -> impl Responder {
|
||||
NamedFile::open_async("./resources/static/404.html")
|
||||
.await
|
||||
.customize()
|
||||
.with_status(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
// Handle a given shortlink
|
||||
#[get("/{shortlink}")]
|
||||
async fn link_handler(shortlink: web::Path<String>, data: web::Data<AppState>) -> impl Responder {
|
||||
let shortlink_str = shortlink.to_string();
|
||||
let longlink = utils::get_longurl(shortlink_str, &data.db);
|
||||
if longlink.is_empty() {
|
||||
Redirect::to("/err/404")
|
||||
} else {
|
||||
let redirect_method = env::var("redirect_method").unwrap_or(String::from("PERMANENT"));
|
||||
database::add_hit(shortlink.as_str(), &data.db);
|
||||
if redirect_method == "TEMPORARY" {
|
||||
Redirect::to(longlink)
|
||||
} else {
|
||||
// Defaults to permanent redirection
|
||||
Redirect::to(longlink).permanent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle login
|
||||
#[post("/api/login")]
|
||||
async fn login(req: String, session: Session) -> HttpResponse {
|
||||
if req == env::var("password").unwrap_or(req.clone()) {
|
||||
// If no password was provided, match any password
|
||||
session.insert("session-token", auth::gen_token()).unwrap();
|
||||
HttpResponse::Ok().body("Correct password!")
|
||||
} else {
|
||||
eprintln!("Failed login attempt!");
|
||||
HttpResponse::Forbidden().body("Wrong password!")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a given shortlink
|
||||
#[delete("/api/del/{shortlink}")]
|
||||
async fn delete_link(
|
||||
shortlink: web::Path<String>,
|
||||
data: web::Data<AppState>,
|
||||
session: Session,
|
||||
) -> HttpResponse {
|
||||
if auth::validate(session) {
|
||||
database::delete_link(shortlink.to_string(), &data.db);
|
||||
HttpResponse::Ok().body("")
|
||||
} else {
|
||||
HttpResponse::Forbidden().body("Wrong password!")
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
async fn main() -> Result<()> {
|
||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or("warn"));
|
||||
|
||||
// Generate session key in runtime so that restart invalidates older logins
|
||||
let secret_key = Key::generate();
|
||||
let db_location = env::var("db_url").unwrap_or(String::from("/urls.sqlite"));
|
||||
|
||||
let db_location = env::var("db_url")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(String::from("urls.sqlite"));
|
||||
|
||||
// Get the port environment variable
|
||||
let port = env::var("port")
|
||||
.unwrap_or(String::from("4567"))
|
||||
.parse::<u16>()
|
||||
.expect("Supplied port is not an integer");
|
||||
|
||||
let cache_control_header = env::var("cache_control_header")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
|
||||
// If an API key is set, check the security
|
||||
if let Ok(key) = env::var("api_key") {
|
||||
if !auth::is_key_secure() {
|
||||
eprintln!("WARN: API key is insecure! Please change it. Current key is: {}. Generated secure key which you may use: {}", key, auth::gen_key())
|
||||
} else {
|
||||
eprintln!("Secure API key was provided.")
|
||||
}
|
||||
}
|
||||
|
||||
// If the site_url env variable exists
|
||||
if let Some(site_url) = env::var("site_url").ok().filter(|s| !s.trim().is_empty()) {
|
||||
// Get first and last characters of the site_url
|
||||
let mut chars = site_url.chars();
|
||||
let first = chars.next();
|
||||
let last = chars.next_back();
|
||||
let url = chars.as_str();
|
||||
// If the site_url is encapsulated by quotes (i.e. invalid)
|
||||
if first == Option::from('"') || first == Option::from('\'') && first == last {
|
||||
// Set the site_url without the quotes
|
||||
env::set_var("site_url", url);
|
||||
eprintln!("WARN: The site_url environment variable is encapsulated by quotes. Automatically adjusting to {}", url);
|
||||
|
||||
// Tell the user what URI the server will respond with
|
||||
eprintln!("INFO: Public URI is: {url}:{port}.")
|
||||
} else {
|
||||
// No issues
|
||||
eprintln!("INFO: Configured Site URL is: {site_url}.");
|
||||
|
||||
// Tell the user what URI the server will respond with
|
||||
eprintln!("INFO: Public URI is: {site_url}:{port}.")
|
||||
}
|
||||
} else {
|
||||
// Site URL is not configured
|
||||
eprintln!("WARN: The site_url environment variable is not configured. Defaulting to http://localhost");
|
||||
eprintln!("INFO: Public URI is: http://localhost:{port}.")
|
||||
}
|
||||
|
||||
// Tell the user that the server has started, and where it is listening to, rather than simply outputting nothing
|
||||
eprintln!("Server has started at 0.0.0.0 on port {port}.");
|
||||
|
||||
// Actually start the server
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(SessionMiddleware::new(
|
||||
CookieSessionStore::default(),
|
||||
secret_key.clone(),
|
||||
))
|
||||
// Maintain a single instance of database throughout
|
||||
.app_data(web::Data::new(AppState {
|
||||
db: database::open_db(env::var("db_url").unwrap_or(db_location.clone())),
|
||||
}))
|
||||
.wrap(middleware::Logger::default())
|
||||
.wrap(middleware::Compress::default())
|
||||
.service(link_handler)
|
||||
.service(getall)
|
||||
.service(siteurl)
|
||||
.service(add_link)
|
||||
.service(delete_link)
|
||||
.service(login)
|
||||
.wrap(
|
||||
SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
|
||||
.cookie_same_site(actix_web::cookie::SameSite::Strict)
|
||||
.cookie_secure(false)
|
||||
.build(),
|
||||
)
|
||||
// Maintain a single instance of database throughout
|
||||
.app_data(web::Data::new(AppState {
|
||||
db: database::open_db(db_location.clone()),
|
||||
}))
|
||||
.wrap(if let Some(header) = &cache_control_header {
|
||||
middleware::DefaultHeaders::new().add(("Cache-Control", header.to_owned()))
|
||||
} else {
|
||||
middleware::DefaultHeaders::new()
|
||||
})
|
||||
.service(services::link_handler)
|
||||
.service(services::getall)
|
||||
.service(services::siteurl)
|
||||
.service(services::version)
|
||||
.service(services::add_link)
|
||||
.service(services::delete_link)
|
||||
.service(services::login)
|
||||
.service(services::logout)
|
||||
.service(services::expand)
|
||||
.service(Files::new("/", "./resources/").index_file("index.html"))
|
||||
.default_service(web::get().to(error404))
|
||||
.default_service(actix_web::web::get().to(services::error404))
|
||||
})
|
||||
// Hardcode the port the server listens to. Allows for more intuitive Docker Compose port management
|
||||
.bind(("0.0.0.0", port))?
|
||||
.run()
|
||||
.await
|
||||
|
||||
322
actix/src/services.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use actix_files::NamedFile;
|
||||
use actix_session::Session;
|
||||
use actix_web::{
|
||||
delete, get,
|
||||
http::StatusCode,
|
||||
post,
|
||||
web::{self, Redirect},
|
||||
Either, HttpRequest, HttpResponse, Responder,
|
||||
};
|
||||
use std::env;
|
||||
// Serialize JSON data
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth;
|
||||
use crate::database;
|
||||
use crate::utils;
|
||||
use crate::AppState;
|
||||
|
||||
// Store the version number
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// Define JSON struct for returning JSON data
|
||||
#[derive(Serialize)]
|
||||
struct Response {
|
||||
success: bool,
|
||||
error: bool,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
// Needed to return the short URL to make it easier for programs leveraging the API
|
||||
#[derive(Serialize)]
|
||||
struct CreatedURL {
|
||||
success: bool,
|
||||
error: bool,
|
||||
shorturl: String,
|
||||
}
|
||||
|
||||
// Struct for returning information about a shortlink
|
||||
#[derive(Serialize)]
|
||||
struct LinkInfo {
|
||||
success: bool,
|
||||
error: bool,
|
||||
longurl: String,
|
||||
hits: i64,
|
||||
}
|
||||
|
||||
// Define the routes
|
||||
|
||||
// Add new links
|
||||
#[post("/api/new")]
|
||||
pub async fn add_link(
|
||||
req: String,
|
||||
data: web::Data<AppState>,
|
||||
session: Session,
|
||||
http: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
// Call is_api_ok() function, pass HttpRequest
|
||||
let result = utils::is_api_ok(http);
|
||||
// If success, add new link
|
||||
if result.success {
|
||||
let out = utils::add_link(req, &data.db);
|
||||
if out.0 {
|
||||
let port = env::var("port")
|
||||
.unwrap_or(String::from("4567"))
|
||||
.parse::<u16>()
|
||||
.expect("Supplied port is not an integer");
|
||||
let mut url = format!(
|
||||
"{}:{}",
|
||||
env::var("site_url")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(String::from("http://localhost")),
|
||||
port
|
||||
);
|
||||
// If the port is 80, remove the port from the returned URL (better for copying and pasting)
|
||||
// Return http://
|
||||
if port == 80 {
|
||||
url = env::var("site_url")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(String::from("http://localhost"));
|
||||
}
|
||||
// If the port is 443, remove the port from the returned URL (better for copying and pasting)
|
||||
// Return https://
|
||||
if port == 443 {
|
||||
url = env::var("site_url")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(String::from("https://localhost"));
|
||||
}
|
||||
let response = CreatedURL {
|
||||
success: true,
|
||||
error: false,
|
||||
shorturl: format!("{}/{}", url, out.1),
|
||||
};
|
||||
HttpResponse::Created().json(response)
|
||||
} else {
|
||||
let response = Response {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: out.1,
|
||||
};
|
||||
HttpResponse::Conflict().json(response)
|
||||
}
|
||||
} else if result.error {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
// If password authentication or public mode is used - keeps backwards compatibility
|
||||
} else if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) {
|
||||
let out = utils::add_link(req, &data.db);
|
||||
if out.0 {
|
||||
HttpResponse::Created().body(out.1)
|
||||
} else {
|
||||
HttpResponse::Conflict().body(out.1)
|
||||
}
|
||||
} else {
|
||||
HttpResponse::Unauthorized().body("Not logged in!")
|
||||
}
|
||||
}
|
||||
|
||||
// Return all active links
|
||||
#[get("/api/all")]
|
||||
pub async fn getall(
|
||||
data: web::Data<AppState>,
|
||||
session: Session,
|
||||
http: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
// Call is_api_ok() function, pass HttpRequest
|
||||
let result = utils::is_api_ok(http);
|
||||
// If success, return all links
|
||||
if result.success {
|
||||
HttpResponse::Ok().body(utils::getall(&data.db))
|
||||
} else if result.error {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
// If password authentication is used - keeps backwards compatibility
|
||||
} else if auth::validate(session) {
|
||||
HttpResponse::Ok().body(utils::getall(&data.db))
|
||||
} else {
|
||||
let body = if env::var("public_mode") == Ok(String::from("Enable")) {
|
||||
"Using public mode."
|
||||
} else {
|
||||
"Not logged in!"
|
||||
};
|
||||
HttpResponse::Unauthorized().body(body)
|
||||
}
|
||||
}
|
||||
|
||||
// Get information about a single shortlink
|
||||
#[post("/api/expand")]
|
||||
pub async fn expand(req: String, data: web::Data<AppState>, http: HttpRequest) -> HttpResponse {
|
||||
let result = utils::is_api_ok(http);
|
||||
if result.success {
|
||||
let linkinfo = utils::get_longurl(req, &data.db, true);
|
||||
if let Some(longlink) = linkinfo.0 {
|
||||
let body = LinkInfo {
|
||||
success: true,
|
||||
error: false,
|
||||
longurl: longlink,
|
||||
hits: linkinfo
|
||||
.1
|
||||
.expect("Error getting hit count for existing shortlink."),
|
||||
};
|
||||
HttpResponse::Ok().json(body)
|
||||
} else {
|
||||
let body = Response {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "The shortlink does not exist on the server.".to_string(),
|
||||
};
|
||||
HttpResponse::Unauthorized().json(body)
|
||||
}
|
||||
} else {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the site URL
|
||||
#[get("/api/siteurl")]
|
||||
pub async fn siteurl() -> HttpResponse {
|
||||
let site_url = env::var("site_url")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(String::from("unset"));
|
||||
HttpResponse::Ok().body(site_url)
|
||||
}
|
||||
|
||||
// Get the version number
|
||||
#[get("/api/version")]
|
||||
pub async fn version() -> HttpResponse {
|
||||
HttpResponse::Ok().body(VERSION)
|
||||
}
|
||||
|
||||
// 404 error page
|
||||
pub async fn error404() -> impl Responder {
|
||||
NamedFile::open_async("./resources/static/404.html")
|
||||
.await
|
||||
.customize()
|
||||
.with_status(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
// Handle a given shortlink
|
||||
#[get("/{shortlink}")]
|
||||
pub async fn link_handler(
|
||||
shortlink: web::Path<String>,
|
||||
data: web::Data<AppState>,
|
||||
) -> impl Responder {
|
||||
let shortlink_str = shortlink.to_string();
|
||||
if let Some(longlink) = utils::get_longurl(shortlink_str, &data.db, false).0 {
|
||||
let redirect_method = env::var("redirect_method").unwrap_or(String::from("PERMANENT"));
|
||||
database::add_hit(shortlink.as_str(), &data.db);
|
||||
if redirect_method == "TEMPORARY" {
|
||||
Either::Left(Redirect::to(longlink))
|
||||
} else {
|
||||
// Defaults to permanent redirection
|
||||
Either::Left(Redirect::to(longlink).permanent())
|
||||
}
|
||||
} else {
|
||||
Either::Right(
|
||||
NamedFile::open_async("./resources/static/404.html")
|
||||
.await
|
||||
.customize()
|
||||
.with_status(StatusCode::NOT_FOUND),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle login
|
||||
#[post("/api/login")]
|
||||
pub async fn login(req: String, session: Session) -> HttpResponse {
|
||||
// Keep this function backwards compatible
|
||||
if env::var("api_key").is_ok() {
|
||||
if let Ok(password) = env::var("password") {
|
||||
if password != req {
|
||||
eprintln!("Failed login attempt!");
|
||||
let response = Response {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "Wrong password!".to_string(),
|
||||
};
|
||||
return HttpResponse::Unauthorized().json(response);
|
||||
}
|
||||
}
|
||||
// Return Ok if no password was set on the server side
|
||||
session
|
||||
.insert("chhoto-url-auth", auth::gen_token())
|
||||
.expect("Error inserting auth token.");
|
||||
|
||||
let response = Response {
|
||||
success: true,
|
||||
error: false,
|
||||
reason: "Correct password!".to_string(),
|
||||
};
|
||||
HttpResponse::Ok().json(response)
|
||||
} else {
|
||||
if let Ok(password) = env::var("password") {
|
||||
if password != req {
|
||||
eprintln!("Failed login attempt!");
|
||||
return HttpResponse::Unauthorized().body("Wrong password!");
|
||||
}
|
||||
}
|
||||
// Return Ok if no password was set on the server side
|
||||
session
|
||||
.insert("chhoto-url-auth", auth::gen_token())
|
||||
.expect("Error inserting auth token.");
|
||||
|
||||
HttpResponse::Ok().body("Correct password!")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle logout
|
||||
// There's no reason to be calling this route with an API key, so it is not necessary to check if the api_key env variable is set.
|
||||
#[delete("/api/logout")]
|
||||
pub async fn logout(session: Session) -> HttpResponse {
|
||||
if session.remove("chhoto-url-auth").is_some() {
|
||||
HttpResponse::Ok().body("Logged out!")
|
||||
} else {
|
||||
HttpResponse::Unauthorized().body("You don't seem to be logged in.")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a given shortlink
|
||||
#[delete("/api/del/{shortlink}")]
|
||||
pub async fn delete_link(
|
||||
shortlink: web::Path<String>,
|
||||
data: web::Data<AppState>,
|
||||
session: Session,
|
||||
http: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
// Call is_api_ok() function, pass HttpRequest
|
||||
let result = utils::is_api_ok(http);
|
||||
// If success, delete shortlink
|
||||
if result.success {
|
||||
if utils::delete_link(shortlink.to_string(), &data.db) {
|
||||
let response = Response {
|
||||
success: true,
|
||||
error: false,
|
||||
reason: format!("Deleted {}", shortlink),
|
||||
};
|
||||
HttpResponse::Ok().json(response)
|
||||
} else {
|
||||
let response = Response {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "The short link was not found, and could not be deleted.".to_string(),
|
||||
};
|
||||
HttpResponse::NotFound().json(response)
|
||||
}
|
||||
} else if result.error {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
// If "pass" is true - keeps backwards compatibility
|
||||
} else if auth::validate(session) {
|
||||
if utils::delete_link(shortlink.to_string(), &data.db) {
|
||||
HttpResponse::Ok().body(format!("Deleted {shortlink}"))
|
||||
} else {
|
||||
HttpResponse::NotFound().body("Not found!")
|
||||
}
|
||||
} else {
|
||||
HttpResponse::Unauthorized().body("Not logged in!")
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,154 @@
|
||||
use crate::database;
|
||||
use rand::seq::SliceRandom;
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::{auth, database};
|
||||
use actix_web::HttpRequest;
|
||||
use nanoid::nanoid;
|
||||
use rand::seq::IndexedRandom;
|
||||
use regex::Regex;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
pub fn get_longurl(shortlink: String, db: &Connection) -> String {
|
||||
if validate_link(&shortlink) {
|
||||
database::find_url(shortlink.as_str(), db)
|
||||
// Struct for reading link pairs sent during API call
|
||||
#[derive(Deserialize)]
|
||||
struct URLPair {
|
||||
shortlink: String,
|
||||
longlink: String,
|
||||
}
|
||||
|
||||
// Define JSON struct for response
|
||||
#[derive(Serialize)]
|
||||
pub struct Response {
|
||||
pub(crate) success: bool,
|
||||
pub(crate) error: bool,
|
||||
reason: String,
|
||||
pass: bool,
|
||||
}
|
||||
|
||||
// If the api_key environment variable exists
|
||||
pub fn is_api_ok(http: HttpRequest) -> Response {
|
||||
// If the api_key environment variable exists
|
||||
if env::var("api_key").is_ok() {
|
||||
// If the header exists
|
||||
if let Some(header) = auth::api_header(&http) {
|
||||
// If the header is correct
|
||||
if auth::validate_key(header.to_string()) {
|
||||
Response {
|
||||
success: true,
|
||||
error: false,
|
||||
reason: "Correct API key".to_string(),
|
||||
pass: false,
|
||||
}
|
||||
} else {
|
||||
Response {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "Incorrect API key".to_string(),
|
||||
pass: false,
|
||||
}
|
||||
}
|
||||
// The header may not exist when the user logs in through the web interface, so allow a request with no header.
|
||||
// Further authentication checks will be conducted in services.rs
|
||||
} else {
|
||||
// Due to the implementation of this result in services.rs, this JSON object will not be outputted.
|
||||
Response {
|
||||
success: false,
|
||||
error: false,
|
||||
reason: "X-API-Key header was not found".to_string(),
|
||||
pass: true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
// If the API key isn't set, but an API Key header is provided
|
||||
if auth::api_header(&http).is_some() {
|
||||
Response {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".to_string(),
|
||||
pass: false
|
||||
}
|
||||
} else {
|
||||
Response {
|
||||
success: false,
|
||||
error: false,
|
||||
reason: "".to_string(),
|
||||
pass: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request the DB for searching an URL
|
||||
pub fn get_longurl(shortlink: String, db: &Connection, needhits: bool) -> (Option<String>, Option<i64>) {
|
||||
if validate_link(&shortlink) {
|
||||
database::find_url(shortlink.as_str(), db, needhits)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
|
||||
// Only have a-z, 0-9, - and _ as valid characters in a shortlink
|
||||
fn validate_link(link: &str) -> bool {
|
||||
let re = Regex::new("[a-z0-9-_]+").unwrap();
|
||||
let re = Regex::new("^[a-z0-9-_]+$").expect("Regex generation failed.");
|
||||
re.is_match(link)
|
||||
}
|
||||
|
||||
// Request the DB for all URLs
|
||||
pub fn getall(db: &Connection) -> String {
|
||||
let links = database::getall(db);
|
||||
links.join("\n")
|
||||
serde_json::to_string(&links).expect("Failure during creation of json from db.")
|
||||
}
|
||||
|
||||
// Make checks and then request the DB to add a new URL entry
|
||||
pub fn add_link(req: String, db: &Connection) -> (bool, String) {
|
||||
let chunks: Vec<&str> = req.split(';').collect();
|
||||
let longlink = String::from(chunks[0]);
|
||||
|
||||
let mut shortlink;
|
||||
if chunks.len() > 1 {
|
||||
shortlink = chunks[1].to_string().to_lowercase();
|
||||
if shortlink.is_empty() {
|
||||
shortlink = random_name();
|
||||
}
|
||||
let mut chunks: URLPair;
|
||||
if let Ok(json) = serde_json::from_str(&req) {
|
||||
chunks = json;
|
||||
} else {
|
||||
shortlink = random_name();
|
||||
// shorturl should always be supplied, even if empty
|
||||
return (false, String::from("Invalid request!"));
|
||||
}
|
||||
|
||||
if validate_link(shortlink.as_str()) && get_longurl(shortlink.clone(), db).is_empty() {
|
||||
let style = env::var("slug_style").unwrap_or(String::from("Pair"));
|
||||
let mut len = env::var("slug_length")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(8);
|
||||
if len < 4 {
|
||||
len = 4;
|
||||
}
|
||||
|
||||
if chunks.shortlink.is_empty() {
|
||||
chunks.shortlink = gen_link(style, len);
|
||||
}
|
||||
|
||||
if validate_link(chunks.shortlink.as_str())
|
||||
&& get_longurl(chunks.shortlink.clone(), db, false).0.is_none()
|
||||
{
|
||||
(
|
||||
database::add_link(shortlink.clone(), longlink, db),
|
||||
shortlink,
|
||||
database::add_link(chunks.shortlink.clone(), chunks.longlink, db),
|
||||
chunks.shortlink,
|
||||
)
|
||||
} else {
|
||||
(false, String::from("shortUrl not valid or already in use"))
|
||||
(
|
||||
false,
|
||||
String::from("Short URL not valid or already in use!"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn random_name() -> String {
|
||||
// Check if link, and request DB to delete it if exists
|
||||
pub fn delete_link(shortlink: String, db: &Connection) -> bool {
|
||||
if validate_link(shortlink.as_str()) {
|
||||
database::delete_link(shortlink, db)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a random link using either adjective-name pair (default) of a slug or a-z, 0-9
|
||||
fn gen_link(style: String, len: usize) -> String {
|
||||
#[rustfmt::skip]
|
||||
static ADJECTIVES: [&str; 108] = ["admiring", "adoring", "affectionate", "agitated", "amazing", "angry", "awesome", "beautiful",
|
||||
"blissful", "bold", "boring", "brave", "busy", "charming", "clever", "compassionate", "competent", "condescending", "confident", "cool",
|
||||
@@ -77,9 +180,21 @@ fn random_name() -> String {
|
||||
"taussig", "tesla", "tharp", "thompson", "torvalds", "tu", "turing", "varahamihira", "vaughan", "vaughn", "villani", "visvesvaraya", "volhard",
|
||||
"wescoff", "weierstrass", "wilbur", "wiles", "williams", "williamson", "wilson", "wing", "wozniak", "wright", "wu", "yalow", "yonath", "zhukovsky"];
|
||||
|
||||
format!(
|
||||
"{0}-{1}",
|
||||
NAMES.choose(&mut rand::thread_rng()).unwrap(),
|
||||
ADJECTIVES.choose(&mut rand::thread_rng()).unwrap()
|
||||
)
|
||||
#[rustfmt::skip]
|
||||
static CHARS: [char; 36] = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
|
||||
'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||
|
||||
if style == "UID" {
|
||||
nanoid!(len, &CHARS)
|
||||
} else {
|
||||
format!(
|
||||
"{0}-{1}",
|
||||
ADJECTIVES
|
||||
.choose(&mut rand::rng())
|
||||
.expect("Error choosing random adjective."),
|
||||
NAMES
|
||||
.choose(&mut rand::rng())
|
||||
.expect("Error choosing random name.")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
75
compose.yaml
@@ -1,35 +1,72 @@
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
services:
|
||||
simply-shorten:
|
||||
image: sintan1729/simply-shorten:latest
|
||||
chhoto-url:
|
||||
image: sintan1729/chhoto-url:latest
|
||||
restart: unless-stopped
|
||||
container_name: simply-shorten
|
||||
container_name: chhoto-url
|
||||
# You may enable the next two options if you want, but it may break the program if the db is bind
|
||||
# mounted from the system. It does add extra security, but I don't know enough about docker
|
||||
# to help in case it breaks something.
|
||||
# read_only: true
|
||||
# cap_drop:
|
||||
# - ALL
|
||||
ports:
|
||||
# If you changed the "port" environment variable, adjust accordingly
|
||||
# The number AFTER the colon should match the "port" variable and the number
|
||||
# before the colon is the port where you would access the container from outside.
|
||||
- 4567:4567
|
||||
environment:
|
||||
# Change if you want to mount the database somewhere else
|
||||
# Change if you want to mount the database somewhere else.
|
||||
# In this case, you can get rid of the db volume below
|
||||
# and instead do a mount manually by specifying the location
|
||||
# - db_url=/urls.sqlite
|
||||
# and instead do a mount manually by specifying the location.
|
||||
# Make sure that you create an empty file with the correct name
|
||||
# before starting the container if you do make any changes.
|
||||
# (In fact, I'd suggest that you do that so that you can keep
|
||||
# a copy of your database.)
|
||||
- db_url=/db/urls.sqlite
|
||||
|
||||
# Change it in case you want to set the website name
|
||||
# displayed in front of the shorturls, defaults to
|
||||
# the hostname you're accessing it from
|
||||
# Change this if your server URL is not "http://localhost"
|
||||
# This must not be surrounded by quotes. For example:
|
||||
# site_url="https://www.example.com" incorrect
|
||||
# site_url=https://www.example.com correct
|
||||
# This is important to ensure Chhoto URL outputs the shortened link with the correct URL.
|
||||
# - site_url=https://www.example.com
|
||||
|
||||
- password=$3CuReP4S$W0rD
|
||||
# Change this if you are running Chhoto URL on a port which is not 4567.
|
||||
# This is important to ensure Chhoto URL outputs the shortened link with the correct port.
|
||||
# - port=4567
|
||||
|
||||
# Pass the redirect method, if needed TEMPORARY and PERMANENT
|
||||
# are accepted values, defaults to PERMANENT
|
||||
- password=TopSecretPass
|
||||
|
||||
# This needs to be set in order to use programs that use the JSON interface of Chhoto URL.
|
||||
# You will get a warning if this is insecure, and a generated value will be output
|
||||
# You may use that value if you can't think of a secure key
|
||||
# - api_key=SECURE_API_KEY
|
||||
|
||||
# Pass the redirect method, if needed. TEMPORARY and PERMANENT
|
||||
# are accepted values, defaults to PERMANENT.
|
||||
# - redirect_method=TEMPORARY
|
||||
|
||||
# By default, the auto-generated pairs are adjective-name pairs.
|
||||
# If you want UIDs, please change slug_style to UID.
|
||||
# Supported values for slug_style are Pair and UID.
|
||||
# The length is 8 by default, and a minimum of 4 is allowed.
|
||||
# - slug_style=Pair
|
||||
# - slug_length=8
|
||||
|
||||
# In case you want to provide public access to adding links (and not
|
||||
# delete, or listing), change the following option to Enable.
|
||||
# - public_mode=Disable
|
||||
|
||||
# By default, the server sends no Cache-Control headers. You can supply a
|
||||
# comma separated list of valid header as per RFC 7234 §5.2 to send those
|
||||
# headers instead.
|
||||
# - cache_control_header=no-cache, private
|
||||
volumes:
|
||||
- db:/urls.sqlite
|
||||
networks:
|
||||
- proxy
|
||||
- db:/db
|
||||
|
||||
volumes:
|
||||
db:
|
||||
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
BIN
resources/assets/Montserrat-VF.woff2
Normal file
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,24 +1,27 @@
|
||||
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
|
||||
<title>Simply Shorten</title>
|
||||
<meta name="description" content="A simple selfhosted URL shortener with no unnecessary features.">
|
||||
<meta name="keywords" content="url shortener, link shortener, self hosted, open source">
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
|
||||
<link rel="icon" type="image/png" href="assets/favicon-32.png" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="assets/favicon-196.png" sizes="196x196">
|
||||
<title>Chhoto URL</title>
|
||||
<meta name="description" content="A simple selfhosted URL shortener with no unnecessary features." />
|
||||
<meta name="keywords" content="url shortener, link shortener, self hosted, open source" />
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="assets/favicon-32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="assets/favicon-196.png" sizes="196x196" />
|
||||
|
||||
<script src="static/script.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.1/build/pure-min.css"
|
||||
integrity="sha384-oAOxQR6DkCoMliIh8yFnu25d7Eq/PHS21PClpwjOTeU2jRSq11vu66rf90/cZr47" crossorigin="anonymous">
|
||||
<link rel="stylesheet" type="text/css" target="_blank" href="static/styles.css">
|
||||
integrity="sha384-oAOxQR6DkCoMliIh8yFnu25d7Eq/PHS21PClpwjOTeU2jRSq11vu66rf90/cZr47" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" type="text/css" target="_blank" href="static/styles.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -26,7 +29,7 @@
|
||||
<div class="container" id="container">
|
||||
<form class="pure-form pure-form-aligned" name="new-url-form">
|
||||
<fieldset>
|
||||
<legend id="logo"><img src="assets/favicon-32.png" width="26px" alt="logo"> Simply Shorten</legend>
|
||||
<legend id="logo"><img src="assets/favicon-32.png" width="26px" alt="logo"> Chhoto URL</legend>
|
||||
<div class="pure-control-group">
|
||||
<label for="longUrl">Long URL</label>
|
||||
<input type="url" name="longUrl" id="longUrl" placeholder="Please enter a valid URL"
|
||||
@@ -35,24 +38,24 @@
|
||||
<div class=" pure-control-group">
|
||||
<label for="shortUrl">Short URL (optional)</label>
|
||||
<input type="text" name="shortUrl" id="shortUrl" placeholder="Only a-z, 0-9, - and _ are allowed"
|
||||
pattern="[A-Za-z0-9_-]+" />
|
||||
pattern="[a-z0-9\-_]+" title="Only a-z, 0-9, - and _ are allowed" autocapitalize="off"/>
|
||||
</div>
|
||||
<div class="pure-controls">
|
||||
<div class="pure-controls" id="controls">
|
||||
<button class="pure-button pure-button-primary">Shorten!</button>
|
||||
<p id="alert-box"> </p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<p name="loading-text">Loading links table...</p>
|
||||
<table class="pure-table">
|
||||
<p id="loading-text">Loading links table...</p>
|
||||
<table class="pure-table" id="table-box" hidden>
|
||||
<caption>Active links</caption>
|
||||
<br>
|
||||
<br />
|
||||
<thead>
|
||||
<tr>
|
||||
<td id="short-url-header">Short URL<br>(click to copy)</td>
|
||||
<td id="short-url-header">Short URL (click to copy)</td>
|
||||
<td>Long URL</td>
|
||||
<td>Hits</td>
|
||||
<td name="hitsColumn">Hits</td>
|
||||
<td name="deleteBtn">×</td>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -62,19 +65,23 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div name="github-link">
|
||||
<a href="https://github.com/SinTan1729/simply-shorten" target="_blank" rel="noopener noreferrer">Source Code</a>
|
||||
<div name="links-div">
|
||||
<a id="admin-button" href="javascript:getLogin()" hidden>login</a>
|
||||
|
||||
<a id="version-number" href="https://github.com/SinTan1729/chhoto-url" target="_blank" rel="noopener noreferrer"
|
||||
hidden>Source Code</a>
|
||||
<!-- The version number would be inserted here -->
|
||||
</div>
|
||||
|
||||
<dialog id="login-dialog">
|
||||
<form class="pure-form" name="login-form">
|
||||
<p>Please enter password to access this website</p>
|
||||
<input type="password" id="password" />
|
||||
<button class="pure-button pure-button-primary" value="default">Submit</button>
|
||||
<p id="wrong-pass"> </p>
|
||||
<button class="pure-button pure-button-primary" value="default">Log in</button>
|
||||
<p id="wrong-pass" hidden>Wrong password!</p>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
42
resources/static/404.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Error 404</title>
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
|
||||
</head>
|
||||
|
||||
<style>
|
||||
body {
|
||||
text-align: center;
|
||||
}
|
||||
#quote {
|
||||
text-indent: 4em;
|
||||
}
|
||||
|
||||
/* Settings for mobile devices */
|
||||
@media (pointer:none),
|
||||
(pointer:coarse) {
|
||||
body {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<h1>Error 404!</h1>
|
||||
<div style="display: inline-block; text-align:left;">
|
||||
<p>You step in the stream,</p>
|
||||
<p>But the water has moved on.</p>
|
||||
<p>The page is not here.</p>
|
||||
<p id="quote"> — Cass Whittington</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
268
resources/static/script.js
Normal file
@@ -0,0 +1,268 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const prepSubdir = (link) => {
|
||||
let thisPage = new URL(window.location.href);
|
||||
let subdir = thisPage.pathname;
|
||||
let out = (subdir + link).replace('//', '/');
|
||||
console.log(out);
|
||||
return (subdir + link).replace('//', '/');
|
||||
}
|
||||
|
||||
const getSiteUrl = async () => {
|
||||
let url = await fetch(prepSubdir("/api/siteurl"))
|
||||
.then(res => res.text());
|
||||
if (url == "unset") {
|
||||
return window.location.host.replace(/\/$/, '');
|
||||
}
|
||||
else {
|
||||
return url.replace(/\/$/, '').replace(/^"/, '').replace(/"$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
const getVersion = async () => {
|
||||
let ver = await fetch(prepSubdir("/api/version"))
|
||||
.then(res => res.text());
|
||||
return ver;
|
||||
}
|
||||
|
||||
const showVersion = async () => {
|
||||
let version = await getVersion();
|
||||
link = document.getElementById("version-number");
|
||||
link.innerText = "v" + version;
|
||||
link.href = "https://github.com/SinTan1729/chhoto-url/releases/tag/" + version;
|
||||
link.hidden = false;
|
||||
}
|
||||
|
||||
const getLogin = async () => {
|
||||
document.getElementById("container").style.filter = "blur(2px)";
|
||||
document.getElementById("login-dialog").showModal();
|
||||
document.getElementById("password").focus();
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
let res = await fetch(prepSubdir("/api/all"));
|
||||
if (!res.ok) {
|
||||
let errorMsg = await res.text();
|
||||
document.getElementById("url-table").innerHTML = '';
|
||||
console.log(errorMsg);
|
||||
if (errorMsg == "Using public mode.") {
|
||||
document.getElementById("admin-button").hidden = false;
|
||||
loading_text = document.getElementById("loading-text");
|
||||
loading_text.hidden = true;
|
||||
showVersion();
|
||||
} else {
|
||||
getLogin();
|
||||
}
|
||||
} else {
|
||||
let data = await res.json();
|
||||
displayData(data.reverse());
|
||||
}
|
||||
}
|
||||
|
||||
const displayData = async (data) => {
|
||||
showVersion();
|
||||
let site = await getSiteUrl();
|
||||
admin_button = document.getElementById("admin-button");
|
||||
admin_button.innerText = "logout";
|
||||
admin_button.href = "javascript:logOut()";
|
||||
admin_button.hidden = false;
|
||||
|
||||
table_box = document.getElementById("table-box");
|
||||
loading_text = document.getElementById("loading-text");
|
||||
const table = document.getElementById("url-table");
|
||||
|
||||
if (data.length == 0) {
|
||||
table_box.hidden = true;
|
||||
loading_text.innerHTML = "No active links.";
|
||||
loading_text.hidden = false;
|
||||
}
|
||||
else {
|
||||
loading_text.hidden = true;
|
||||
if (!window.isSecureContext) {
|
||||
const shortUrlHeader = document.getElementById("short-url-header");
|
||||
shortUrlHeader.innerHTML = "Short URL<br>(right click and copy)";
|
||||
}
|
||||
table_box.hidden = false;
|
||||
table.innerHTML = '';
|
||||
data.forEach(tr => table.appendChild(TR(tr, site)));
|
||||
}
|
||||
}
|
||||
|
||||
const showAlert = async (text, col) => {
|
||||
document.getElementById("alert-box")?.remove();
|
||||
const controls = document.getElementById("controls");
|
||||
const alertBox = document.createElement("p");
|
||||
alertBox.id = "alert-box";
|
||||
alertBox.style.color = col;
|
||||
alertBox.innerHTML = text;
|
||||
controls.appendChild(alertBox);
|
||||
}
|
||||
|
||||
const TR = (row, site) => {
|
||||
const tr = document.createElement("tr");
|
||||
const longTD = TD(A_LONG(row["longlink"]), "Long URL");
|
||||
var shortTD = null;
|
||||
var isSafari = /Safari/.test(navigator.userAgent) && /Apple Computer/.test(navigator.vendor);
|
||||
// For now, we disable copying on WebKit due to a possible bug. Manual copying is enabled instead.
|
||||
// Take a look at https://github.com/SinTan1729/chhoto-url/issues/36
|
||||
if (window.isSecureContext && !(isSafari)) {
|
||||
shortTD = TD(A_SHORT(row["shortlink"], site), "Short URL");
|
||||
}
|
||||
else {
|
||||
shortTD = TD(A_SHORT_INSECURE(row["shortlink"], site), "Short URL");
|
||||
}
|
||||
let hitsTD = TD(row["hits"]);
|
||||
hitsTD.setAttribute("label", "Hits");
|
||||
hitsTD.setAttribute("name", "hitsColumn");
|
||||
const btn = deleteButton(row["shortlink"]);
|
||||
|
||||
tr.appendChild(shortTD);
|
||||
tr.appendChild(longTD);
|
||||
tr.appendChild(hitsTD);
|
||||
tr.appendChild(btn);
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
const copyShortUrl = async (link) => {
|
||||
const site = await getSiteUrl();
|
||||
try {
|
||||
navigator.clipboard.writeText(`${site}/${link}`);
|
||||
showAlert(`Short URL ${link} was copied to clipboard!`, "light-dark(green, #72ff72)");
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
showAlert(`Could not copy short URL to clipboard, please do it manually: <a href=${site}/${link}>${site}/${link}</a>`, "light-dark(red, #ff1a1a)");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const addProtocol = (input) => {
|
||||
var url = input.value.trim();
|
||||
if (url != "" && !~url.indexOf("://") && !~url.indexOf("magnet:")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
input.value = url;
|
||||
return input;
|
||||
}
|
||||
|
||||
const A_LONG = (s) => `<a href='${s}'>${s}</a>`;
|
||||
const A_SHORT = (s, t) => `<a href="javascript:copyShortUrl('${s}');">${s}</a>`;
|
||||
const A_SHORT_INSECURE = (s, t) => `<a href="${t}/${s}">${s}</a>`;
|
||||
|
||||
const deleteButton = (shortUrl) => {
|
||||
const td = document.createElement("td");
|
||||
const div = document.createElement("div");
|
||||
const btn = document.createElement("button");
|
||||
|
||||
btn.innerHTML = "×";
|
||||
|
||||
btn.onclick = e => {
|
||||
e.preventDefault();
|
||||
if (confirm("Do you want to delete the entry " + shortUrl + "?")) {
|
||||
document.getElementById("alert-box")?.remove();
|
||||
showAlert(" ", "black");
|
||||
fetch(prepSubdir(`/api/del/${shortUrl}`), {
|
||||
method: "DELETE"
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
console.log("Deleted " + shortUrl);
|
||||
} else {
|
||||
console.log("Unable to delete " + shortUrl);
|
||||
}
|
||||
refreshData();
|
||||
});
|
||||
}
|
||||
};
|
||||
td.setAttribute("name", "deleteBtn");
|
||||
td.setAttribute("label", "Delete");
|
||||
div.appendChild(btn);
|
||||
td.appendChild(div);
|
||||
return td;
|
||||
}
|
||||
|
||||
const TD = (s, u) => {
|
||||
const td = document.createElement("td");
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = s;
|
||||
td.appendChild(div);
|
||||
td.setAttribute("label", u);
|
||||
return td;
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
const form = document.forms.namedItem("new-url-form");
|
||||
const data = {
|
||||
"longlink": form.elements["longUrl"].value,
|
||||
"shortlink": form.elements["shortUrl"].value,
|
||||
};
|
||||
|
||||
const url = prepSubdir("/api/new");
|
||||
let ok = false;
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(res => {
|
||||
ok = res.ok;
|
||||
return res.text();
|
||||
})
|
||||
.then(text => {
|
||||
if (!ok) {
|
||||
showAlert(text, "light-dark(red, #ff1a1a)");
|
||||
}
|
||||
else {
|
||||
copyShortUrl(text);
|
||||
longUrl.value = "";
|
||||
shortUrl.value = "";
|
||||
refreshData();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submitLogin = () => {
|
||||
const password = document.getElementById("password");
|
||||
fetch(prepSubdir("/api/login"), {
|
||||
method: "POST",
|
||||
body: password.value
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
document.getElementById("container").style.filter = "blur(0px)"
|
||||
document.getElementById("login-dialog").close();
|
||||
password.value = '';
|
||||
document.getElementById("wrong-pass").hidden = true;
|
||||
refreshData();
|
||||
} else {
|
||||
document.getElementById("wrong-pass").hidden = false;
|
||||
password.focus();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const logOut = async () => {
|
||||
let reply = await fetch(prepSubdir("/api/logout"), {method: "DELETE"}).then(res => res.text());
|
||||
console.log(reply);
|
||||
document.getElementById("table-box").hidden = true;
|
||||
document.getElementById("loading-text").hidden = false;
|
||||
refreshData();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await refreshData();
|
||||
|
||||
const form = document.forms.namedItem("new-url-form");
|
||||
form.onsubmit = e => {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}
|
||||
|
||||
const login_form = document.forms.namedItem("login-form");
|
||||
login_form.onsubmit = e => {
|
||||
e.preventDefault();
|
||||
submitLogin();
|
||||
}
|
||||
})()
|
||||
183
resources/static/styles.css
Normal file
@@ -0,0 +1,183 @@
|
||||
/* SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> */
|
||||
/* SPDX-License-Identifier: MIT */
|
||||
|
||||
@font-face {
|
||||
font-family: Montserrat;
|
||||
src: url('/assets/Montserrat-VF.woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
color: light-dark(black, #e8e6e3);
|
||||
background-color: light-dark(white, #181a1b);
|
||||
}
|
||||
|
||||
.pure-button {
|
||||
background-color: light-dark(#0078e7, #0060b9);
|
||||
}
|
||||
|
||||
input {
|
||||
border-color: light-dark(#cccccc, #3e4446) !important;
|
||||
box-shadow: light-dark(#dddddd, #2b2f31) 0px 1px 3px inset !important;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: light-dark(#757575, #636061);
|
||||
}
|
||||
|
||||
legend {
|
||||
color: light-dark(#333333, #c8c3bc) !important;
|
||||
border-bottom-color: light-dark(#e5e5e5 ,#373c3e) !important;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: Montserrat;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: light-dark(blue, #3391ff);
|
||||
}
|
||||
|
||||
table tr td div {
|
||||
max-height: 75px;
|
||||
line-height: 25px;
|
||||
word-wrap: break-word;
|
||||
max-width: 575px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pure-table {
|
||||
border-color: light-dark(black, #867d6e);
|
||||
}
|
||||
|
||||
.pure-table caption {
|
||||
color: light-dark(black, #e8e6e3);
|
||||
}
|
||||
|
||||
.pure-table thead {
|
||||
color: light-dark(black, #e8e6e3);
|
||||
background-color: light-dark(#e0e0e0, #2a2d2f);
|
||||
}
|
||||
|
||||
.pure-table td {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
td[name="hitsColumn"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
td[name="deleteBtn"] div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
td[name="deleteBtn"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td[name="deleteBtn"] div button {
|
||||
border-radius: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-style: solid;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
form input[name="shortUrl"]::placeholder {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
div[name="links-div"] {
|
||||
position: absolute;
|
||||
right: 0.5%;
|
||||
top: 0.5%;
|
||||
}
|
||||
|
||||
.pure-table {
|
||||
width: 98%;
|
||||
}
|
||||
|
||||
.pure-table caption {
|
||||
font-size: 22px;
|
||||
text-align: left;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
#logo {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
#password {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#login-dialog {
|
||||
border-radius: 10px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
#login-dialog form {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#wrong-pass {
|
||||
color: light-dark(red, #ff1a1a);
|
||||
}
|
||||
|
||||
/* Settings for mobile devices */
|
||||
@media (pointer:none),
|
||||
(pointer:coarse) {
|
||||
.container {
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.pure-control-group input {
|
||||
width: 98%;
|
||||
}
|
||||
|
||||
table tr {
|
||||
border-bottom: 1px solid #999;
|
||||
}
|
||||
|
||||
table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table td {
|
||||
display: flex;
|
||||
justify-content: left !important;
|
||||
width: 98vw;
|
||||
padding: .5em .1em !important;
|
||||
}
|
||||
|
||||
table td::before {
|
||||
content: attr(label);
|
||||
font-weight: bold;
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pure-table caption {
|
||||
padding-top: 0px;
|
||||
}
|
||||
}
|
||||
BIN
screenshot-desktop.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
screenshot-mobile.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
screenshot.png
|
Before Width: | Height: | Size: 43 KiB |