mirror of
https://github.com/SinTan1729/chhoto-url.git
synced 2025-12-06 05:24:25 -08:00
Compare commits
41 Commits
6.3.2
...
12862fbb5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12862fbb5a | ||
|
|
281c14034f | ||
|
|
c667c56a32 | ||
|
|
c12f37e9db | ||
|
|
390dbde520 | ||
|
|
732a532575 | ||
|
|
eaef52f98b | ||
|
|
1b8d9b9710 | ||
|
|
9a8c4f5f3d | ||
|
|
acb6cad149 | ||
|
|
771d2ebe1a | ||
|
|
a575700cac | ||
|
|
6dacbc086f | ||
|
|
37f5ea260b | ||
|
|
d6d4af366a | ||
|
|
08b8bbc057 | ||
|
|
8e8daa4d35 | ||
|
|
67f6df643f | ||
|
|
bf28169cac | ||
|
|
401bf1124a | ||
|
|
7e43ea4bef | ||
|
|
8b5bb4de81 | ||
|
|
6e0f4d623d | ||
|
|
a7f30a0be2 | ||
|
|
5b60d25e64 | ||
|
|
48a9a844a1 | ||
|
|
043c6efab6 | ||
|
|
64082b3bb5 | ||
|
|
897a7228bf | ||
|
|
77beace200 | ||
|
|
d5982234a0 | ||
|
|
08fe1ce768 | ||
|
|
35a5f394ea | ||
|
|
cd3d73c160 | ||
|
|
79dbc7aeba | ||
|
|
ef180831da | ||
|
|
dcc7d94870 | ||
|
|
a61b5ac156 | ||
|
|
833da9086c | ||
|
|
1c7227e5f1 | ||
|
|
351355ac9f |
4
.github/workflows/docker-release.yml
vendored
4
.github/workflows/docker-release.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
branches: ["main"]
|
||||
paths: ["actix/**", "resources/**"]
|
||||
workflow_dispatch:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -68,10 +70,10 @@ jobs:
|
||||
mv $f/chhoto-url actix/target/$f/release/
|
||||
chmod +x actix/target/$f/release/chhoto-url
|
||||
done
|
||||
mv resources/ resources-original/
|
||||
- name: Minify resources for release
|
||||
if: github.ref_type == 'tag'
|
||||
run: |
|
||||
mv resources/ resources-original/
|
||||
sudo apt update
|
||||
sudo apt install minify
|
||||
minify -rs resources-original/ -o resources/
|
||||
|
||||
35
.github/workflows/rust-tests.yml
vendored
Normal file
35
.github/workflows/rust-tests.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
paths: ["actix/**"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
paths: ["actix/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./actix
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache Dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
actix/target
|
||||
actix/.cargo
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('actix/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-
|
||||
- name: Build
|
||||
run: cargo build
|
||||
- name: Run tests
|
||||
run: cargo test
|
||||
35
.github/workflows/rust_tests.yml
vendored
35
.github/workflows/rust_tests.yml
vendored
@@ -1,35 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths: [ "actix/**" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
paths: [ "actix/**" ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./actix
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache Dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
actix/target
|
||||
actix/.cargo
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('actix/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-
|
||||
- name: Build
|
||||
run: cargo build
|
||||
- name: Run tests
|
||||
run: cargo test
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,9 +2,6 @@
|
||||
actix/target
|
||||
resources-final
|
||||
|
||||
# Ignore SQLite file
|
||||
*.sqlite
|
||||
|
||||
# Ignore irrelevant dotfiles
|
||||
.vscode/
|
||||
**/.directory
|
||||
@@ -15,3 +12,4 @@ cookie*
|
||||
|
||||
# Testing related
|
||||
custom_dir
|
||||
testing-data
|
||||
|
||||
12
CLI.md
12
CLI.md
@@ -165,7 +165,7 @@ or
|
||||
|
||||
(This route is not accessible using cookie validation.)
|
||||
|
||||
#### `/api/all`
|
||||
#### `/api/all?`
|
||||
|
||||
To get a list of all the currently available links:
|
||||
|
||||
@@ -173,6 +173,16 @@ To get a list of all the currently available links:
|
||||
curl -H "X-API-Key: <YOUR_API_KEY>" http://localhost:4567/api/all
|
||||
```
|
||||
|
||||
Supported query parameters are as follows.
|
||||
|
||||
1. `page_after`: An offset where to start pagination after. It should be a valid shortlink, or an empty response will be received.
|
||||
This is faster, and the preferred way of doing pagination.
|
||||
1. `page_size`: The size of a returned page in number of shortlinks. Default value is 10.
|
||||
1. `page_no`: Alternative way of doing pagination. This is slower, and should be used only when using `page_after` isn't viable.
|
||||
|
||||
None of the parameters are required. In absence of all of those, all shortlinks are returned. The entries should be positive integers.
|
||||
If only `page_size` is provided, the first page is returned.
|
||||
|
||||
#### `/api/del/{shortlink}`
|
||||
|
||||
To delete a link:
|
||||
|
||||
187
INSTALLATION.md
187
INSTALLATION.md
@@ -15,15 +15,19 @@ and GHCR, except the `dev` builds which are only available on GHCR. All of these
|
||||
`linux/arm64`, and `linux/arm/v7` architectures on Linux. These should also work just fine with `podman`, or any other
|
||||
container engine supporting OCI images.
|
||||
|
||||
You can use the provided compose file as a base, modifying it as needed. Run it with
|
||||
You can use the [provided compose file](./compose.yaml) as a base, modifying it as needed. Run it with
|
||||
|
||||
```
|
||||
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.
|
||||
If you're using a custom location for the `db_url`, and using WAL mode, make sure to mount a whole
|
||||
directory instead of a folder. If this is not done, there will be a low, but non-zero chance of data corruption.
|
||||
|
||||
It should be possible to run Chhoto URL with pretty much anything that supports OCI images e.g. `docker`, `podman quadlets`
|
||||
(the repo contains a sample `chhoto-url.container` file for using with `quadlets`.) etc. Official
|
||||
support is only provided for `docker` and `podman`, but it should be trivial to convert the `compose.yaml` file to other formats. If you need help,
|
||||
feel free to open a discussion.
|
||||
|
||||
## Building and running with docker
|
||||
|
||||
@@ -59,64 +63,127 @@ docker run -p 4567:4567
|
||||
touch ./urls.sqlite
|
||||
docker run -p 4567:4567 \
|
||||
-e password="password" \
|
||||
-v ./urls.sqlite:/urls.sqlite \
|
||||
-e db_url=/urls.sqlite \
|
||||
-v ./data:/data \
|
||||
-e db_url=/data/urls.sqlite \
|
||||
-d chhoto-url:latest
|
||||
```
|
||||
|
||||
1.b Further, set the URL of your website (optional)
|
||||
_Note: All of this pretty much works exactly the same if you replace `docker` with `podman`. In fact,
|
||||
that's what I use for testing. A sample file for podman quadlets is provided at
|
||||
[`chhoto-url.container`](./chhoto-url.container)_
|
||||
|
||||
```
|
||||
touch ./urls.sqlite
|
||||
docker run -p 4567:4567 \
|
||||
-e password="password" \
|
||||
-v ./urls.sqlite:/urls.sqlite \
|
||||
-e db_url=/urls.sqlite \
|
||||
-e site_url="https://www.example.com" \
|
||||
-d chhoto-url:latest
|
||||
```
|
||||
## Configuration options
|
||||
|
||||
1.c Further, set an API key to activate JSON result mode (optional)
|
||||
All the configuration is done using environmental variables. Here's a link of all supported ones. Please take
|
||||
a look at the ones marked with a `#` as those are important, especially [`use_wal_mode`](#use_wal_mode-).
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
### `db_url` \#
|
||||
|
||||
Location for the database file. Take a look at [`use_wal_mode`](#use_wal_mode-) before you change it. Defaults to
|
||||
`urls.sqlite`. It is highly recommended that you mount a named volume or directory at a location like `/data` and
|
||||
use something like `/data/urls.sqlite` as `db_url`.
|
||||
(Of course, the actual names being used don't really matter.)
|
||||
|
||||
### `password` \#
|
||||
|
||||
Provide a secure password. If kept empty, anyone can access the website. Note that password is not encrypted in
|
||||
transport, so it's recommended to use a reverse proxy like `caddy` or `nginx`.
|
||||
|
||||
### `api_key`
|
||||
|
||||
Provide a secure API key. It'll be checked at start for security. If the API key is considered weak, a strong API
|
||||
key will be generated and printed in the logs, but the weak one will be used for the time being.
|
||||
|
||||
Example Linux command for generating a secure API key: `tr -dc A-Za-z0-9 </dev/urandom | head -c 128`.
|
||||
|
||||
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. If you
|
||||
intend to have more than a few thousand shortlinks, it's strongly recommended that you
|
||||
use the UID `slug_style` with a `slug_length` of 16 or more.
|
||||
If no API key is provided, the website will still work, but it'll be a significantly worse experience if you try
|
||||
to use Chhoto URL from the CLI.
|
||||
|
||||
### `use_wal_mode` \#
|
||||
|
||||
If set to `True`, enables [`WAL` journal mode](https://sqlite.org/wal.html). Any other value is ignored.
|
||||
It's highly recommended that you enable it, but make sure that you mount either a whole directory, or a named
|
||||
volume, and have the database inside it. DO NOT mount a single file, as there will be a small chance of partial
|
||||
data loss in that case.
|
||||
|
||||
If this is enabled, there'll be a significant boost in performance under high load, since write will no longer block reads.
|
||||
Also, automated backups of the database will be enabled. Otherwise, `DELETE` journal mode is used by default, along with
|
||||
[`EXTRA` synchronous](https://sqlite.org/pragma.html#pragma_synchronous) pragma. In `WAL` mode, `FULL` synchronous pragma is
|
||||
used instead.
|
||||
|
||||
In both cases, we have full ACID compliance, but it does cost a bit of performance. If you expect to see high throughput (in the
|
||||
order of hundreds of read/writes per second), take a look at the `ensure_acid` configuration option.
|
||||
|
||||
### `ensure_acid`
|
||||
|
||||
By default, the database is
|
||||
[ACID (i.e. Atomic, Consistent, Isolated, and Durable)](https://www.slingacademy.com/article/acid-properties-in-sqlite-why-they-matter).
|
||||
If you'd like to let go of durability for an increase in throughput, set this to `False`. Any other value will be ignored.
|
||||
|
||||
This is done by setting the [synchronous pragma](https://sqlite.org/pragma.html#pragma_synchronous) to `FULL` in `WAL`
|
||||
[journal mode](https://sqlite.org/pragma.html#pragma_journal_mode), and to `EXTRA` in `DELETE` journal mode.
|
||||
|
||||
_Note: There might be partial data loss only in case of system failure or power loss. Durability is maintained across application
|
||||
crashes. If you do have data loss, you should only lose the data stored after the last sync with the database file. So, under normal
|
||||
loads, you shouldn't lose any data anyway. But this is a real thing that can technically happen._
|
||||
|
||||
### `redirect_method` \#
|
||||
|
||||
Sets which redirection is used when a shortlink is resolved.
|
||||
|
||||
Can be set to `TEMPORARY` or `PERMANENT`, which will enable Temporary 307 or Permanent 308 redirects. Any other value
|
||||
will be ignored, and a default of `PERMANENT` will be used.
|
||||
|
||||
### `slug_style`
|
||||
|
||||
Sets the style of slug used when auto-generating shortlinks.
|
||||
|
||||
Can be set to either `Pair` or `UID`. Any other value will be ignored, and a default value of `Pair` will be used.
|
||||
In pair mode, adjective-name pairs are used for auto-generated links e.g. `gifted-ramanujan`. In UID mode, a randomly
|
||||
generated slug is used.
|
||||
|
||||
### `slug_length`
|
||||
|
||||
If UID slugs are enabled, the length of the slug can be set using this. A minimum of 4 is supported, and it defaults to 16.
|
||||
If you intend to have more than a few thousand shortlinks, it's strongly recommended that you use the UID `slug_style` with
|
||||
a `slug_length` of 16 or more.
|
||||
|
||||
### `try_longer_slug`
|
||||
|
||||
If you do choose to use a short UID despite anticipating collisions, it's recommended that you set this to `True`.
|
||||
In the event of a collision, this variable will result in a single retry attempt using a UID four digits longer than
|
||||
`slug_length`. It has no effect for adjective-name slugs.
|
||||
|
||||
_Note: If not set, one retry will be attempted, just like adjective-name slugs. But it would use the same slug length._
|
||||
|
||||
### `listen_address`
|
||||
|
||||
The address Chhoto URL will bind to. Defaults to `0.0.0.0`.
|
||||
|
||||
You can change the listening address and port by setting the `listen_address` and `port` variables.
|
||||
Take a look at [this page](https://docs.rs/actix-web/4.11.0/actix_web/struct.HttpServer.html#method.bind)
|
||||
for supported values and potential consequences. Changing `listen_address` is not recommended if
|
||||
using docker.
|
||||
|
||||
If you want to use capital letters in the shortlink, set the `allow_capital_letters` variable
|
||||
to `True`. This will also allow capital letters in UID slugs, if it is enabled.
|
||||
### `port`
|
||||
|
||||
If you do choose to use a short UID despite anticipating collisions, set `try_longer_slug` to `True`.
|
||||
In the event of a collision, this variable will result in a single retry attempt using
|
||||
a UID four digits longer than `slug_length`. It has no effect for adjective-name slugs.
|
||||
The port Chhoto URL will listen to. Defaults to `4567`.
|
||||
|
||||
Although it's unlikely, it's possible that your database is mangled after some update.
|
||||
For mission critical use cases, it's recommended to keep regular versioned backups of
|
||||
the database, and sticking to a minor release tag e.g. 5.8. You can either bind mount a file
|
||||
for the database as described in 1.a above, or take a backup of the docker volume.
|
||||
### `allow_capital_letters`
|
||||
|
||||
If you want to use capital letters in the shortlink, set the `allow_capital_letters` variable to `True`. Any other
|
||||
value is ignored.
|
||||
|
||||
This will also allow capital letters in UID slugs, if those are enabled. It has no effect for adjective-name slugs.
|
||||
|
||||
### `hash_algorithm` \#
|
||||
|
||||
If you want to provided hashed password and API Key, name a supported algorithm here. For now, the supported
|
||||
values are: `Argon2`. More algorithms may be added later. Unsupported values are ignored.
|
||||
|
||||
_Note: If using a compose file, make sure to escape $ by $$._
|
||||
|
||||
_Note: It will add some latency to some of your requests and use more resources in general._
|
||||
|
||||
You can provide hashed password and API key for extra security. Note that it will add some latency
|
||||
to some of your requests and use more resources in general. The only supported algorithm for now is Argon2.
|
||||
Recommended command for hashing:
|
||||
|
||||
```bash
|
||||
@@ -125,16 +192,32 @@ echo -n <password> | argon2 <salt> -id -t 3 -m 16 -l 32 -e
|
||||
|
||||
You may also use online tools for this step.
|
||||
|
||||
### `public_mode`
|
||||
|
||||
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. If
|
||||
`public_mode` is enabled, and `public_mode_expiry_delay` is set to a positive value, submitted links
|
||||
will expire in that given time. The user can still choose a shorter expiry delay.
|
||||
To completely disable the frontend, set `disable_frontend` to `True`. If you want to serve a custom
|
||||
landing page, put all your site related files, along with an `index.html` file in a directory, and
|
||||
set `custom_landing_directory` to the path of the directory. If using docker, you need to first
|
||||
links. Listing existing links or deleting links will need admin access using the password. Any other values are
|
||||
ignored.
|
||||
|
||||
### `public_mode_expiry_delay`
|
||||
|
||||
If `public_mode` is enabled, and `public_mode_expiry_delay` is set to a positive value, submitted links
|
||||
will expire in that given time (in seconds). The user can still choose a shorter expiry delay.
|
||||
|
||||
It will have no effect for a logged in user i.e. the admin.
|
||||
|
||||
### `disable_frontend`
|
||||
|
||||
Set this to `True` to completely disable the frontend.
|
||||
|
||||
### `custom_landing_directory`
|
||||
|
||||
If you want to serve a custom landing page, put all your site related files, along with a valid `index.html` file in a
|
||||
directory, and set this to the path of the directory. If using docker, you need to first
|
||||
mount the directory inside the container. The admin page will then be located at `/admin/manage`.
|
||||
|
||||
By default, the server sends no Cache-Control headers. You can set custom `cache_control_header`
|
||||
### `cache_control_header`
|
||||
|
||||
By default, the server sends no Cache-Control headers. You can set custom headers here
|
||||
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
|
||||
|
||||
50
Makefile
50
Makefile
@@ -1,26 +1,23 @@
|
||||
# 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
|
||||
|
||||
.PHONY: clean test setup build-dev docker-local docker-stop docker-test build-release docker-release tag audit
|
||||
.PHONY: clean test setup build podman-build podman-stop podman-test build-release tag audit
|
||||
|
||||
setup:
|
||||
# cargo install cross
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
# docker buildx create --use --platform=linux/arm64,linux/amd64,linux/arm/v7 --name multi-platform-builder
|
||||
docker buildx inspect --bootstrap
|
||||
podman buildx inspect --bootstrap
|
||||
|
||||
build-dev:
|
||||
build:
|
||||
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.alpine .
|
||||
podman-build: build
|
||||
podman build --tag chhoto-url --build-arg TARGETARCH=amd64 -f Dockerfile.alpine .
|
||||
|
||||
docker-stop:
|
||||
docker ps -q --filter "name=chhoto-url" | xargs -r docker stop
|
||||
docker ps -aq --filter "name=chhoto-url" | xargs -r docker rm
|
||||
podman-stop:
|
||||
podman ps -q --filter "name=chhoto-url" | xargs -r podman stop
|
||||
podman ps -aq --filter "name=chhoto-url" | xargs -r podman rm
|
||||
|
||||
test: audit
|
||||
cargo test --release --locked --manifest-path=actix/Cargo.toml --target x86_64-unknown-linux-musl
|
||||
@@ -28,17 +25,9 @@ test: audit
|
||||
audit:
|
||||
cargo audit --file actix/Cargo.lock
|
||||
|
||||
docker-test: docker-local docker-stop test
|
||||
docker run -t -p ${port}:${port} --name chhoto-url --env-file ./.env -v "${db_file}:${db_url}" -d chhoto-url
|
||||
docker logs chhoto-url -f
|
||||
|
||||
docker-dev: test build-dev
|
||||
docker build --push --tag ghcr.io/${github_username}/chhoto-url:dev --build-arg TARGETARCH=amd64 -f Dockerfile.alpine .
|
||||
|
||||
# build-release: test
|
||||
# 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
|
||||
podman-test: test podman-build podman-stop
|
||||
podman run -t -p ${port}:${port} --name chhoto-url --env-file ./.env -v "${db_dir}:/data" -d chhoto-url
|
||||
podman logs chhoto-url -f
|
||||
|
||||
conf_tag := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)"$$/\1/p')
|
||||
last_tag := $(shell git tag -l | tail -1)
|
||||
@@ -56,21 +45,6 @@ else
|
||||
false;
|
||||
endif
|
||||
|
||||
# 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: tag build-release
|
||||
# minify -rsi resources/
|
||||
# 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.alpine .
|
||||
# docker buildx build --push --tag ghcr.io/${github_username}/chhoto-url:${v_major} --tag ghcr.io/${github_username}/chhoto-url:${v_minor} \
|
||||
# --tag ghcr.io/${github_username}/chhoto-url:${v_patch} --tag ghcr.io/${github_username}/chhoto-url:latest \
|
||||
# --platform linux/amd64,linux/arm64,linux/arm/v7 -f Dockerfile.scratch .
|
||||
# git restore resources/
|
||||
|
||||
clean:
|
||||
docker ps -q --filter "name=chhoto-url" | xargs -r docker stop
|
||||
docker ps -aq --filter "name=chhoto-url" | xargs -r docker rm
|
||||
clean: podman-stop
|
||||
cargo clean --manifest-path=actix/Cargo.toml
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
|
||||
[](https://github.com/SinTan1729/chhoto-url/actions/workflows/rust_tests.yml)
|
||||
[](https://github.com/SinTan1729/chhoto-url/actions/workflows/rust-tests.yml)
|
||||
[](https://hub.docker.com/r/sintan1729/chhoto-url)
|
||||
[](https://github.com/SinTan1729)
|
||||
[](https://github.com/SinTan1729/chhoto-url/releases/latest)
|
||||
@@ -71,9 +71,10 @@ Password: `chhoto-url-demo-pass`
|
||||
time for public instances, which might be useful.
|
||||
- Allows setting the URL of your website, in case you want to conveniently
|
||||
generate short links locally.
|
||||
- Links are stored in an SQLite database.
|
||||
- Links are stored in an SQLite database, which is configured to be ACID by default.
|
||||
Options are available for tuning the database to the user's liking.
|
||||
- Available as a Docker container with a provided compose file.
|
||||
- Backend written in Rust using [Actix](https://actix.rs/), and frontend
|
||||
- Backend written in Rust using [Actix Web](https://actix.rs/), and frontend
|
||||
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.
|
||||
@@ -116,6 +117,7 @@ Password: `chhoto-url-demo-pass`
|
||||
- It started as a fork of [`simply-shorten`](https://gitlab.com/draganczukp/simply-shorten).
|
||||
- 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).
|
||||
- It is highly recommended that you [enable WAL mode](./INSTALLATION.md/#use_wal_mode-).
|
||||
- Although it's unlikely, it's possible that your database is mangled after some update. For mission critical use cases,
|
||||
it's recommended to keep regular versioned backups of the database, and sticking to a minor release tag e.g. 5.8.
|
||||
- If you intend to have more than a few thousand short links, it's strongly recommended that you use the UID `slug_style`
|
||||
|
||||
585
actix/Cargo.lock
generated
585
actix/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
|
||||
[package]
|
||||
name = "chhoto-url"
|
||||
version = "6.3.2"
|
||||
version = "6.5.2"
|
||||
edition = "2021"
|
||||
authors = ["Sayantan Santra <sayantan[dot]santra689[at]gmail[dot]com"]
|
||||
license = "mit"
|
||||
@@ -35,8 +35,8 @@ rand = "0.9.0"
|
||||
passwords = "3.1.16"
|
||||
actix-session = { version = "0.11.0", features = [ "cookie-session" ] }
|
||||
nanoid = "0.4.0"
|
||||
serde = { version = "1.0.197", features = [ "derive", "rc" ] }
|
||||
serde_json = "1.0.115"
|
||||
serde = { version = "1.0.197", features = [ "derive" ] }
|
||||
argon2 = "0.5.3"
|
||||
chrono = "0.4.41"
|
||||
tokio = "1.44.2"
|
||||
|
||||
@@ -4,31 +4,79 @@
|
||||
use actix_session::Session;
|
||||
use actix_web::HttpRequest;
|
||||
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
|
||||
use log::{info, warn};
|
||||
use log::{debug, warn};
|
||||
use passwords::PasswordGenerator;
|
||||
use std::time::SystemTime;
|
||||
use std::{rc::Rc, time::SystemTime};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::services::JSONResponse;
|
||||
|
||||
// If the api_key environment variable exists
|
||||
pub fn is_api_ok(http: HttpRequest, config: &Config) -> JSONResponse {
|
||||
// If the api_key environment variable exists
|
||||
if config.api_key.is_some() {
|
||||
// If the header exists
|
||||
if let Some(header) = get_api_header(&http) {
|
||||
// If the header is correct
|
||||
if is_key_valid(header, config) {
|
||||
JSONResponse {
|
||||
success: true,
|
||||
error: false,
|
||||
reason: "Correct API key".to_string(),
|
||||
}
|
||||
} else {
|
||||
JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "Incorrect API key".to_string(),
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
JSONResponse {
|
||||
success: false,
|
||||
error: false,
|
||||
reason: "No valid authentication was found".to_string(),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If the API key isn't set, but an API Key header is provided
|
||||
if get_api_header(&http).is_some() {
|
||||
JSONResponse {
|
||||
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(),
|
||||
}
|
||||
} else {
|
||||
JSONResponse {
|
||||
success: false,
|
||||
error: false,
|
||||
reason: "".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate API key
|
||||
pub fn validate_key(key: String, config: &Config) -> bool {
|
||||
pub fn is_key_valid(key: &str, config: &Config) -> bool {
|
||||
if let Some(api_key) = &config.api_key {
|
||||
// Check if API Key is hashed using Argon2. More algorithms maybe added later.
|
||||
let authorized = if config.hash_algorithm.is_some() {
|
||||
info!("Using Argon2 hash for API key validation.");
|
||||
debug!("Using Argon2 hash for API key validation.");
|
||||
let hash = PasswordHash::new(api_key).expect("The provided password hash is invalid.");
|
||||
Argon2::default()
|
||||
.verify_password(key.as_bytes(), &hash)
|
||||
.is_ok()
|
||||
} else {
|
||||
// If hashing is not enabled, use the plaintext API key for matching
|
||||
api_key == &key
|
||||
api_key == key
|
||||
};
|
||||
if !authorized {
|
||||
warn!("Incorrect API key was provided when connecting to Chhoto URL.");
|
||||
false
|
||||
} else {
|
||||
info!("Server accessed with API key.");
|
||||
debug!("Server accessed with API key.");
|
||||
true
|
||||
}
|
||||
} else {
|
||||
@@ -54,28 +102,28 @@ pub fn gen_key() -> String {
|
||||
}
|
||||
|
||||
// Check if the API key header exists
|
||||
pub fn api_header(req: &HttpRequest) -> Option<&str> {
|
||||
pub fn get_api_header(req: &HttpRequest) -> Option<&str> {
|
||||
req.headers().get("X-API-Key")?.to_str().ok()
|
||||
}
|
||||
|
||||
// Validate a session
|
||||
pub fn validate(session: Session, config: &Config) -> bool {
|
||||
pub fn is_session_valid(session: Session, config: &Config) -> bool {
|
||||
// If there's no password provided, just return true
|
||||
if config.password.is_none() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Ok(token) = session.get::<String>("chhoto-url-auth") {
|
||||
check(token)
|
||||
is_token_valid(token.as_deref())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Check a token cryptographically
|
||||
fn check(token: Option<String>) -> bool {
|
||||
fn is_token_valid(token: Option<&str>) -> bool {
|
||||
if let Some(token_body) = token {
|
||||
let token_parts: Vec<&str> = token_body.split(';').collect();
|
||||
let token_parts: Rc<[&str]> = token_body.split(';').collect();
|
||||
if token_parts.len() < 2 {
|
||||
false
|
||||
} else {
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct Config {
|
||||
pub try_longer_slug: bool,
|
||||
pub allow_capital_letters: bool,
|
||||
pub custom_landing_directory: Option<String>,
|
||||
pub use_wal_mode: bool,
|
||||
pub ensure_acid: bool,
|
||||
}
|
||||
|
||||
pub fn read() -> Config {
|
||||
@@ -156,13 +158,33 @@ pub fn read() -> Config {
|
||||
if slug_style == "UID" {
|
||||
info!("Using UID slugs with length {slug_length}.");
|
||||
if try_longer_slug {
|
||||
info!("Will retry with a longer slug upon collision.")
|
||||
};
|
||||
info!("Will retry with a longer slug upon collision.");
|
||||
}
|
||||
} else {
|
||||
info!("Using adjective-noun pair slugs.");
|
||||
}
|
||||
|
||||
let allow_capital_letters = var("allow_capital_letters").is_ok_and(|s| s.trim() == "True");
|
||||
if allow_capital_letters {
|
||||
info!("Capital letters will be allowed in links.");
|
||||
} else {
|
||||
info!("Capital letters won't be allowed in links.");
|
||||
}
|
||||
|
||||
let use_wal_mode = var("use_wal_mode").is_ok_and(|s| s.trim() == "True");
|
||||
if use_wal_mode {
|
||||
info!("Using WAL journaling mode for database.");
|
||||
} else {
|
||||
warn!("Using DELETE journaling mode for database. WAL mode is recommended.");
|
||||
}
|
||||
let ensure_acid = !var("ensure_acid").is_ok_and(|s| s.trim() == "False");
|
||||
if ensure_acid {
|
||||
let synchronous = if use_wal_mode { "FULL" } else { "EXTRA" };
|
||||
info!("Ensuring ACID compliance, using synchronous pragma: {synchronous}.");
|
||||
} else {
|
||||
let synchronous = if use_wal_mode { "NORMAL" } else { "FULL" };
|
||||
info!("Not ensuring ACID compliance, using synchronous pragma: {synchronous}.")
|
||||
}
|
||||
|
||||
let custom_landing_directory = var("custom_landing_directory")
|
||||
.ok()
|
||||
@@ -170,7 +192,7 @@ pub fn read() -> Config {
|
||||
.filter(|s| !s.is_empty())
|
||||
.inspect(|s| {
|
||||
info!("Custom landing directory is set to {s}.");
|
||||
info!("The dashboard will be available at /admin/manage/")
|
||||
info!("The dashboard will be available at /admin/manage/");
|
||||
});
|
||||
|
||||
Config {
|
||||
@@ -191,5 +213,7 @@ pub fn read() -> Config {
|
||||
try_longer_slug,
|
||||
allow_capital_letters,
|
||||
custom_landing_directory,
|
||||
use_wal_mode,
|
||||
ensure_acid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use log::info;
|
||||
use rusqlite::{Connection, Error};
|
||||
use log::{error, info};
|
||||
use rusqlite::{fallible_iterator::FallibleIterator, Connection};
|
||||
use serde::Serialize;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::services::ChhotoError::{self, ClientError, ServerError};
|
||||
|
||||
// Struct for encoding a DB row
|
||||
#[derive(Serialize)]
|
||||
@@ -14,70 +17,117 @@ pub struct DBRow {
|
||||
expiry_time: i64,
|
||||
}
|
||||
|
||||
// Find a single URL
|
||||
pub fn find_url(
|
||||
shortlink: &str,
|
||||
db: &Connection,
|
||||
needhits: bool,
|
||||
) -> (Option<String>, Option<i64>, Option<i64>) {
|
||||
// Find a single URL for /api/expand
|
||||
pub fn find_url(shortlink: &str, db: &Connection) -> Result<(String, i64, i64), ChhotoError> {
|
||||
// Long link, hits, expiry time
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let query = if needhits {
|
||||
"SELECT long_url, hits, expiry_time FROM urls WHERE short_url = ?1 AND (expiry_time > ?2 OR expiry_time = 0)"
|
||||
} else {
|
||||
"SELECT long_url FROM urls WHERE short_url = ?1 AND (expiry_time > ?2 OR expiry_time = 0)"
|
||||
let query = "SELECT long_url, hits, expiry_time FROM urls
|
||||
WHERE short_url = ?1
|
||||
AND (expiry_time = 0 OR expiry_time > ?2)";
|
||||
let Ok(mut statement) = db.prepare_cached(query) else {
|
||||
error!("Error preparing SQL statement for find_url.");
|
||||
return Err(ServerError);
|
||||
};
|
||||
let mut statement = db
|
||||
.prepare_cached(query)
|
||||
.expect("Error preparing SQL statement for find_url.");
|
||||
statement
|
||||
.query_row((shortlink, now), |row| {
|
||||
let longlink = row.get("long_url").ok();
|
||||
let hits = row.get("hits").ok();
|
||||
let expiry_time = row.get("expiry_time").ok();
|
||||
Ok((longlink, hits, expiry_time))
|
||||
Ok((
|
||||
row.get("long_url")?,
|
||||
row.get("hits")?,
|
||||
row.get("expiry_time")?,
|
||||
))
|
||||
})
|
||||
.map_err(|_| ChhotoError::ClientError {
|
||||
reason: "The shortlink does not exist on the server!".to_string(),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// Get all URLs in DB
|
||||
pub fn getall(db: &Connection) -> Vec<DBRow> {
|
||||
pub fn getall(
|
||||
db: &Connection,
|
||||
page_after: Option<&str>,
|
||||
page_no: Option<i64>,
|
||||
page_size: Option<i64>,
|
||||
) -> Rc<[DBRow]> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let mut statement = db
|
||||
.prepare_cached(
|
||||
"SELECT * FROM urls WHERE expiry_time > ?1 OR expiry_time = 0 ORDER BY id ASC",
|
||||
)
|
||||
.expect("Error preparing SQL statement for getall.");
|
||||
let query = if page_after.is_some() {
|
||||
"SELECT short_url, long_url, hits, expiry_time FROM (
|
||||
SELECT t.id, t.short_url, t.long_url, t.hits, t.expiry_time FROM urls AS t
|
||||
JOIN urls AS u ON u.short_url = ?1
|
||||
WHERE t.id < u.id AND (t.expiry_time = 0 OR t.expiry_time > ?2)
|
||||
ORDER BY t.id DESC LIMIT ?3
|
||||
) ORDER BY id ASC"
|
||||
} else if page_no.is_some() {
|
||||
"SELECT short_url, long_url, hits, expiry_time FROM (
|
||||
SELECT id, short_url, long_url, hits, expiry_time FROM urls
|
||||
WHERE expiry_time= 0 OR expiry_time > ?1
|
||||
ORDER BY id DESC LIMIT ?2 OFFSET ?3
|
||||
) ORDER BY id ASC"
|
||||
} else if page_size.is_some() {
|
||||
"SELECT short_url, long_url, hits, expiry_time FROM (
|
||||
SELECT id, short_url, long_url, hits, expiry_time FROM urls
|
||||
WHERE expiry_time = 0 OR expiry_time > ?1
|
||||
ORDER BY id DESC LIMIT ?2
|
||||
) ORDER BY id ASC"
|
||||
} else {
|
||||
"SELECT short_url, long_url, hits, expiry_time
|
||||
FROM urls WHERE expiry_time = 0 OR expiry_time > ?1
|
||||
ORDER BY id ASC"
|
||||
};
|
||||
let Ok(mut statement) = db.prepare_cached(query) else {
|
||||
error!("Error preparing SQL statement for getall.");
|
||||
return [].into();
|
||||
};
|
||||
|
||||
let mut data = statement
|
||||
.query([now])
|
||||
.expect("Error executing query for getall.");
|
||||
let raw_data = if let Some(pos) = page_after {
|
||||
let size = page_size.unwrap_or(10);
|
||||
statement.query((pos, now, size))
|
||||
} else if let Some(num) = page_no {
|
||||
let size = page_size.unwrap_or(10);
|
||||
statement.query((now, size, (num - 1) * size))
|
||||
} else if let Some(size) = page_size {
|
||||
statement.query((now, size))
|
||||
} else {
|
||||
statement.query([now])
|
||||
};
|
||||
|
||||
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."),
|
||||
expiry_time: row.get("expiry_time").unwrap_or_default(),
|
||||
};
|
||||
links.push(row_struct);
|
||||
}
|
||||
let Ok(data) = raw_data else {
|
||||
error!("Error running SQL statement for getall: {query}");
|
||||
return [].into();
|
||||
};
|
||||
|
||||
let links: Rc<[DBRow]> = data
|
||||
.map(|row| {
|
||||
Ok(DBRow {
|
||||
shortlink: row.get("short_url")?,
|
||||
longlink: row.get("long_url")?,
|
||||
hits: row.get("hits")?,
|
||||
expiry_time: row.get("expiry_time")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.unwrap_or_else(|err| {
|
||||
error!("Error processing fetched rows: {err}");
|
||||
[].into()
|
||||
});
|
||||
|
||||
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],
|
||||
)
|
||||
.expect("Error updating hit count.");
|
||||
// Add a hit when site is visited during link resolution
|
||||
pub fn find_and_add_hit(shortlink: &str, db: &Connection) -> Result<String, ()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let Ok(mut statement) = db.prepare_cached(
|
||||
"UPDATE urls
|
||||
SET hits = hits + 1
|
||||
WHERE short_url = ?1 AND (expiry_time = 0 OR expiry_time > ?2)
|
||||
RETURNING long_url",
|
||||
) else {
|
||||
error!("Error preparing SQL statement for add_hit.");
|
||||
return Err(());
|
||||
};
|
||||
statement
|
||||
.query_one((shortlink, now), |row| row.get("long_url"))
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
// Insert a new link
|
||||
@@ -86,7 +136,7 @@ pub fn add_link(
|
||||
longlink: &str,
|
||||
expiry_delay: i64,
|
||||
db: &Connection,
|
||||
) -> Result<i64, Error> {
|
||||
) -> Result<i64, ChhotoError> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let expiry_time = if expiry_delay == 0 {
|
||||
0
|
||||
@@ -94,26 +144,27 @@ pub fn add_link(
|
||||
now + expiry_delay
|
||||
};
|
||||
|
||||
let result = db
|
||||
.execute(
|
||||
"INSERT INTO urls (long_url, short_url, hits, expiry_time) VALUES (?1, ?2, 0, ?3)",
|
||||
(longlink, shortlink, expiry_time),
|
||||
)
|
||||
.map(|_| expiry_time);
|
||||
if result.is_err() {
|
||||
let updated = db.execute(
|
||||
"UPDATE urls
|
||||
SET long_url = ?1, short_url = ?2, hits = 0, expiry_time = ?3
|
||||
WHERE short_url = ?2 AND ?4 >= expiry_time AND expiry_time > 0",
|
||||
(longlink, shortlink, expiry_time, now),
|
||||
);
|
||||
if updated == Ok(0) || updated.is_err() {
|
||||
// Zero rows returned means no updates
|
||||
return result;
|
||||
let Ok(mut statement) = db.prepare_cached(
|
||||
"INSERT INTO urls
|
||||
(long_url, short_url, hits, expiry_time)
|
||||
VALUES (?1, ?2, 0, ?3)
|
||||
ON CONFLICT(short_url) DO UPDATE
|
||||
SET long_url = ?1, hits = 0, expiry_time = ?3
|
||||
WHERE short_url = ?2 AND expiry_time <= ?4 AND expiry_time > 0",
|
||||
) else {
|
||||
error!("Error preparing SQL statement for add_link.");
|
||||
return Err(ServerError);
|
||||
};
|
||||
match statement.execute((longlink, shortlink, expiry_time, now)) {
|
||||
Ok(1) => Ok(expiry_time),
|
||||
Ok(_) => Err(ClientError {
|
||||
reason: "Short URL is already in use!".to_string(),
|
||||
}),
|
||||
Err(e) => {
|
||||
error!("There was some error while adding the link ({shortlink}, {longlink}, {expiry_delay}): {e}");
|
||||
Err(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(expiry_time)
|
||||
|
||||
}
|
||||
|
||||
// Edit an existing link
|
||||
@@ -122,57 +173,83 @@ pub fn edit_link(
|
||||
longlink: &str,
|
||||
reset_hits: bool,
|
||||
db: &Connection,
|
||||
) -> Result<usize, Error> {
|
||||
) -> Result<usize, ()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let statement = if reset_hits {
|
||||
"UPDATE urls SET long_url = ?1, hits = 0 WHERE short_url = ?2 AND (expiry_time = 0 OR ?3 < expiry_time)"
|
||||
let query = if reset_hits {
|
||||
"UPDATE urls
|
||||
SET long_url = ?1, hits = 0
|
||||
WHERE short_url = ?2 AND (expiry_time = 0 OR expiry_time > ?3)"
|
||||
} else {
|
||||
"UPDATE urls SET long_url = ?1 WHERE short_url = ?2 AND (expiry_time = 0 OR ?3 < expiry_time)"
|
||||
"UPDATE urls
|
||||
SET long_url = ?1
|
||||
WHERE short_url = ?2 AND (expiry_time = 0 OR expiry_time > ?3)"
|
||||
};
|
||||
db.execute(statement, (longlink, shortlink, now))
|
||||
let Ok(mut statement) = db.prepare_cached(query) else {
|
||||
error!("Error preparing SQL statement for edit_link.");
|
||||
return Err(());
|
||||
};
|
||||
|
||||
statement
|
||||
.execute((longlink, shortlink, now))
|
||||
.inspect_err(|err| {
|
||||
error!(
|
||||
"Got an error while editing link ({shortlink}, {longlink}, {reset_hits}): {err}"
|
||||
);
|
||||
})
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
// Clean expired links
|
||||
pub fn cleanup(db: &Connection) {
|
||||
pub fn cleanup(db: &Connection, use_wal_mode: bool) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
info!("Starting database cleanup.");
|
||||
|
||||
let mut statement = db
|
||||
.prepare_cached("SELECT short_url FROM urls WHERE ?1 >= expiry_time AND expiry_time > 0")
|
||||
.prepare_cached("DELETE FROM urls WHERE ?1 >= expiry_time AND expiry_time > 0")
|
||||
.expect("Error preparing SQL statement for cleanup.");
|
||||
statement
|
||||
.execute([now])
|
||||
.inspect(|&u| match u {
|
||||
0 => (),
|
||||
1 => info!("1 link was deleted."),
|
||||
_ => info!("{u} links were deleted."),
|
||||
})
|
||||
.expect("Error cleaning expired links.");
|
||||
|
||||
let mut data = statement
|
||||
.query([now])
|
||||
.expect("Error executing query for cleanup.");
|
||||
|
||||
while let Some(row) = data.next().expect("Error reading fetched rows.") {
|
||||
let shortlink: String = row
|
||||
.get("short_url")
|
||||
.expect("Error reading shortlink off a row.");
|
||||
info!("Expired link marked for deletion: {shortlink}");
|
||||
if use_wal_mode {
|
||||
let mut pragma_statement = db
|
||||
.prepare_cached("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
.expect("Error preparing SQL statement for pragma: wal_checkpoint.");
|
||||
pragma_statement
|
||||
.query_one([], |row| row.get::<usize, isize>(1))
|
||||
.ok()
|
||||
.filter(|&v| v != -1)
|
||||
.expect("Unable to create WAL checkpoint.");
|
||||
}
|
||||
|
||||
db.execute(
|
||||
"DELETE FROM urls WHERE ?1 >= expiry_time AND expiry_time > 0",
|
||||
[now],
|
||||
)
|
||||
.inspect(|&u| match u {
|
||||
0 => (),
|
||||
1 => info!("1 link was deleted."),
|
||||
_ => info!("{u} links were deleted."),
|
||||
})
|
||||
.expect("Error cleaning expired links.");
|
||||
let mut pragma_statement = db
|
||||
.prepare_cached("PRAGMA optimize")
|
||||
.expect("Error preparing SQL statement for pragma: optimize.");
|
||||
pragma_statement
|
||||
.execute([])
|
||||
.expect("Unable to optimize database.");
|
||||
info!("Optimized database.")
|
||||
}
|
||||
|
||||
// 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
|
||||
// Delete an existing link
|
||||
pub fn delete_link(shortlink: &str, db: &Connection) -> Result<(), ChhotoError> {
|
||||
let Ok(mut statement) = db.prepare_cached("DELETE FROM urls WHERE short_url = ?1") else {
|
||||
error!("Error preparing SQL statement for delete_link.");
|
||||
return Err(ServerError);
|
||||
};
|
||||
match statement.execute([shortlink]) {
|
||||
Ok(delta) if delta > 0 => Ok(()),
|
||||
_ => Err(ClientError {
|
||||
reason: "The shortlink was not found, and could not be deleted.".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_db(path: String) -> Connection {
|
||||
pub fn open_db(path: &str, use_wal_mode: bool, ensure_acid: bool) -> Connection {
|
||||
// Set current user_version. Should be incremented on change of schema.
|
||||
let user_version = 1;
|
||||
|
||||
@@ -181,7 +258,7 @@ pub fn open_db(path: String) -> Connection {
|
||||
// It would be 0 if table does not exist, and 1 if it does
|
||||
let table_exists: usize = db
|
||||
.query_row_and_then(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'urls'",
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'urls'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -195,7 +272,7 @@ pub fn open_db(path: String) -> Connection {
|
||||
short_url TEXT NOT NULL,
|
||||
hits INTEGER NOT NULL,
|
||||
expiry_time INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
)",
|
||||
// expiry_time is added later during migration 1
|
||||
[],
|
||||
)
|
||||
@@ -236,7 +313,28 @@ pub fn open_db(path: String) -> Connection {
|
||||
|
||||
// Set the user version
|
||||
db.pragma_update(None, "user_version", user_version)
|
||||
.expect("Unable to set user_version.");
|
||||
.expect("Unable to set pragma: user_version.");
|
||||
// Set WAL mode if specified
|
||||
let (journal_mode, synchronous) = match (use_wal_mode, ensure_acid) {
|
||||
(true, false) => ("WAL", "NORMAL"),
|
||||
(true, true) => ("WAL", "FULL"),
|
||||
(false, false) => ("DELETE", "FULL"),
|
||||
(false, true) => ("DELETE", "EXTRA"),
|
||||
};
|
||||
db.pragma_update(None, "journal_mode", journal_mode)
|
||||
.expect("Unable to set pragma: journal_mode.");
|
||||
db.pragma_update(None, "synchronous", synchronous)
|
||||
.expect("Unable to set pragma: synchronous.");
|
||||
// Set some further optimizations and run vacuum
|
||||
db.pragma_update(None, "temp_store", "memory")
|
||||
.expect("Unable to set pragma: temp_store.");
|
||||
db.pragma_update(None, "journal_size_limit", "8388608")
|
||||
.expect("Unable to set pragma: journal_size_limit.");
|
||||
db.pragma_update(None, "mmap_size", "16777216")
|
||||
.expect("Unable to set pragma: mmap_size.");
|
||||
db.execute("VACUUM", []).expect("Unable to vacuum database");
|
||||
db.execute("PRAGMA optimize=0x10002", [])
|
||||
.expect("Error running pragma optimize.");
|
||||
|
||||
db
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use actix_web::{
|
||||
};
|
||||
use log::info;
|
||||
use rusqlite::Connection;
|
||||
pub(crate) use std::io::Result;
|
||||
use std::{fs, io::Result};
|
||||
use tokio::{spawn, time};
|
||||
|
||||
// Import modules
|
||||
@@ -34,7 +34,13 @@ struct AppState {
|
||||
#[actix_web::main]
|
||||
async fn main() -> Result<()> {
|
||||
env_logger::builder()
|
||||
.parse_filters("warn,chhoto_url=info,actix_session::middleware=error")
|
||||
.parse_filters(
|
||||
std::env::var("RUST_LOG")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("warn,chhoto_url=info,actix_session::middleware=error".to_string())
|
||||
.as_str(),
|
||||
)
|
||||
.format(|buf, record| {
|
||||
use chrono::Local;
|
||||
use env_logger::fmt::style::{AnsiColor, Style};
|
||||
@@ -72,14 +78,26 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
// Do periodic cleanup
|
||||
let db_location_clone = conf.db_location.clone();
|
||||
let db_location = conf.db_location.clone();
|
||||
// Create backups if WAL mode is being used
|
||||
if conf.use_wal_mode {
|
||||
info!("Creating database backups.");
|
||||
if fs::exists(format!("{db_location}.bak1")).ok() == Some(true) {
|
||||
fs::rename(format!("{db_location}.bak1"), format!("{db_location}.bak2"))
|
||||
.expect("Error creating backups.");
|
||||
}
|
||||
if fs::exists(&db_location).ok() == Some(true) {
|
||||
fs::copy(&db_location, format!("{db_location}.bak1")).expect("Error creating backups.");
|
||||
}
|
||||
}
|
||||
|
||||
info!("Starting cleanup service, will run once every hour.");
|
||||
spawn(async move {
|
||||
let db = database::open_db(db_location_clone);
|
||||
let db = database::open_db(&db_location, conf.use_wal_mode, conf.ensure_acid);
|
||||
let mut interval = time::interval(time::Duration::from_secs(3600));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
database::cleanup(&db);
|
||||
database::cleanup(&db, conf.use_wal_mode);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -100,7 +118,7 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
// Maintain a single instance of database throughout
|
||||
.app_data(web::Data::new(AppState {
|
||||
db: database::open_db(conf_clone.db_location.clone()),
|
||||
db: database::open_db(&conf.db_location, conf.use_wal_mode, conf.ensure_acid),
|
||||
config: conf_clone.clone(),
|
||||
}))
|
||||
.wrap(if let Some(header) = &conf.cache_control_header {
|
||||
|
||||
@@ -11,23 +11,30 @@ use actix_web::{
|
||||
Either, HttpRequest, HttpResponse, Responder,
|
||||
};
|
||||
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
|
||||
use log::{info, warn};
|
||||
use serde::Serialize;
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::{auth, database};
|
||||
use crate::{auth::validate, utils};
|
||||
use crate::{auth::is_session_valid, utils};
|
||||
use ChhotoError::{ClientError, ServerError};
|
||||
|
||||
// Store the version number
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// Error types
|
||||
pub enum ChhotoError {
|
||||
ServerError,
|
||||
ClientError { reason: String },
|
||||
}
|
||||
|
||||
// Define JSON struct for returning success/error data
|
||||
#[derive(Serialize)]
|
||||
struct Response {
|
||||
success: bool,
|
||||
error: bool,
|
||||
reason: String,
|
||||
pub struct JSONResponse {
|
||||
pub success: bool,
|
||||
pub error: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
// Define JSON struct for returning backend config
|
||||
@@ -62,6 +69,14 @@ struct LinkInfo {
|
||||
expiry_time: i64,
|
||||
}
|
||||
|
||||
// Struct for query params in /api/all
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetReqParams {
|
||||
pub page_after: Option<String>,
|
||||
pub page_no: Option<i64>,
|
||||
pub page_size: Option<i64>,
|
||||
}
|
||||
|
||||
// Define the routes
|
||||
|
||||
// Add new links
|
||||
@@ -74,53 +89,64 @@ pub async fn add_link(
|
||||
) -> HttpResponse {
|
||||
let config = &data.config;
|
||||
// Call is_api_ok() function, pass HttpRequest
|
||||
let result = utils::is_api_ok(http, config);
|
||||
let result = auth::is_api_ok(http, config);
|
||||
// If success, add new link
|
||||
if result.success {
|
||||
let (success, reply, expiry_time) = utils::add_link(req, &data.db, config, false);
|
||||
if success {
|
||||
let site_url = config.site_url.clone();
|
||||
let shorturl = if let Some(url) = site_url {
|
||||
format!("{url}/{reply}")
|
||||
} else {
|
||||
let protocol = if config.port == 443 { "https" } else { "http" };
|
||||
let port_text = if [80, 443].contains(&config.port) {
|
||||
String::new()
|
||||
match utils::add_link(&req, &data.db, config, false) {
|
||||
Ok((shorturl, expiry_time)) => {
|
||||
let site_url = config.site_url.clone();
|
||||
let shorturl = if let Some(url) = site_url {
|
||||
format!("{url}/{shorturl}")
|
||||
} else {
|
||||
format!(":{}", config.port)
|
||||
let protocol = if config.port == 443 { "https" } else { "http" };
|
||||
let port_text = if [80, 443].contains(&config.port) {
|
||||
String::new()
|
||||
} else {
|
||||
format!(":{}", config.port)
|
||||
};
|
||||
format!("{protocol}://localhost{port_text}/{shorturl}")
|
||||
};
|
||||
format!("{protocol}://localhost{port_text}/{reply}")
|
||||
};
|
||||
let response = CreatedURL {
|
||||
success: true,
|
||||
error: false,
|
||||
shorturl,
|
||||
expiry_time,
|
||||
};
|
||||
HttpResponse::Created().json(response)
|
||||
} else {
|
||||
let response = Response {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: reply,
|
||||
};
|
||||
HttpResponse::Conflict().json(response)
|
||||
let response = CreatedURL {
|
||||
success: true,
|
||||
error: false,
|
||||
shorturl,
|
||||
expiry_time,
|
||||
};
|
||||
HttpResponse::Created().json(response)
|
||||
}
|
||||
Err(ServerError) => {
|
||||
let response = JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "Something went wrong when adding the link.".to_string(),
|
||||
};
|
||||
HttpResponse::InternalServerError().json(response)
|
||||
}
|
||||
Err(ClientError { reason }) => {
|
||||
let response = JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason,
|
||||
};
|
||||
HttpResponse::Conflict().json(response)
|
||||
}
|
||||
}
|
||||
} else if result.error {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
// If password authentication or public mode is used - keeps backwards compatibility
|
||||
} else {
|
||||
let (success, reply, _) = if auth::validate(session, config) {
|
||||
utils::add_link(req, &data.db, config, false)
|
||||
let result = if auth::is_session_valid(session, config) {
|
||||
utils::add_link(&req, &data.db, config, false)
|
||||
} else if config.public_mode {
|
||||
utils::add_link(req, &data.db, config, true)
|
||||
utils::add_link(&req, &data.db, config, true)
|
||||
} else {
|
||||
return HttpResponse::Unauthorized().body("Not logged in!");
|
||||
};
|
||||
if success {
|
||||
HttpResponse::Created().body(reply)
|
||||
} else {
|
||||
HttpResponse::Conflict().body(reply)
|
||||
match result {
|
||||
Ok((shorturl, _)) => HttpResponse::Created().body(shorturl),
|
||||
Err(ServerError) => HttpResponse::InternalServerError()
|
||||
.body("Something went wrong when adding the link.".to_string()),
|
||||
Err(ClientError { reason }) => HttpResponse::Conflict().body(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,19 +156,20 @@ pub async fn add_link(
|
||||
pub async fn getall(
|
||||
data: web::Data<AppState>,
|
||||
session: Session,
|
||||
params: web::Query<GetReqParams>,
|
||||
http: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
let config = &data.config;
|
||||
// Call is_api_ok() function, pass HttpRequest
|
||||
let result = utils::is_api_ok(http, config);
|
||||
let result = auth::is_api_ok(http, config);
|
||||
// If success, return all links
|
||||
if result.success {
|
||||
HttpResponse::Ok().body(utils::getall(&data.db))
|
||||
HttpResponse::Ok().body(utils::getall(&data.db, params.into_inner()))
|
||||
} else if result.error {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
// If password authentication is used - keeps backwards compatibility
|
||||
} else if auth::validate(session, config) {
|
||||
HttpResponse::Ok().body(utils::getall(&data.db))
|
||||
} else if auth::is_session_valid(session, config) {
|
||||
HttpResponse::Ok().body(utils::getall(&data.db, params.into_inner()))
|
||||
} else {
|
||||
HttpResponse::Unauthorized().body("Not logged in!")
|
||||
}
|
||||
@@ -151,27 +178,35 @@ pub async fn getall(
|
||||
// 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, &data.config);
|
||||
let result = auth::is_api_ok(http, &data.config);
|
||||
if result.success {
|
||||
let (longurl, hits, expiry_time) =
|
||||
utils::get_longurl(req, &data.db, true, data.config.allow_capital_letters);
|
||||
if let Some(longlink) = longurl {
|
||||
let body = LinkInfo {
|
||||
success: true,
|
||||
error: false,
|
||||
longurl: longlink,
|
||||
hits: hits.expect("Error getting hit count for existing shortlink."),
|
||||
expiry_time: expiry_time
|
||||
.expect("Error getting expiry time 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::BadRequest().json(body)
|
||||
match database::find_url(&req, &data.db) {
|
||||
Ok((longurl, hits, expiry_time)) => {
|
||||
let body = LinkInfo {
|
||||
success: true,
|
||||
error: false,
|
||||
longurl,
|
||||
hits,
|
||||
expiry_time,
|
||||
};
|
||||
HttpResponse::Ok().json(body)
|
||||
}
|
||||
Err(ServerError) => {
|
||||
let body = JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "Something went wrong when finding the link.".to_string(),
|
||||
};
|
||||
HttpResponse::BadRequest().json(body)
|
||||
}
|
||||
Err(ClientError { reason }) => {
|
||||
let body = JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason,
|
||||
};
|
||||
HttpResponse::BadRequest().json(body)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
@@ -187,26 +222,33 @@ pub async fn edit_link(
|
||||
http: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
let config = &data.config;
|
||||
let result = utils::is_api_ok(http, config);
|
||||
if result.success || validate(session, config) {
|
||||
if let Some((server_error, error_msg)) = utils::edit_link(req, &data.db, config) {
|
||||
let body = Response {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: error_msg,
|
||||
};
|
||||
if server_error {
|
||||
let result = auth::is_api_ok(http, config);
|
||||
if result.success || is_session_valid(session, config) {
|
||||
match utils::edit_link(&req, &data.db, config) {
|
||||
Ok(()) => {
|
||||
let body = JSONResponse {
|
||||
success: true,
|
||||
error: false,
|
||||
reason: String::from("Edit was successful."),
|
||||
};
|
||||
HttpResponse::Created().json(body)
|
||||
}
|
||||
Err(ServerError) => {
|
||||
let body = JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "Something went wrong when editing the link.".to_string(),
|
||||
};
|
||||
HttpResponse::InternalServerError().json(body)
|
||||
} else {
|
||||
}
|
||||
Err(ClientError { reason }) => {
|
||||
let body = JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason,
|
||||
};
|
||||
HttpResponse::BadRequest().json(body)
|
||||
}
|
||||
} else {
|
||||
let body = Response {
|
||||
success: true,
|
||||
error: false,
|
||||
reason: String::from("Edit was successful."),
|
||||
};
|
||||
HttpResponse::Created().json(body)
|
||||
}
|
||||
} else {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
@@ -241,8 +283,8 @@ pub async fn whoami(
|
||||
http: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
let config = &data.config;
|
||||
let result = utils::is_api_ok(http, config);
|
||||
let acting_user = if result.success || validate(session, config) {
|
||||
let result = auth::is_api_ok(http, config);
|
||||
let acting_user = if result.success || is_session_valid(session, config) {
|
||||
"admin"
|
||||
} else if config.public_mode {
|
||||
"public"
|
||||
@@ -260,8 +302,8 @@ pub async fn getconfig(
|
||||
http: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
let config = &data.config;
|
||||
let result = utils::is_api_ok(http, config);
|
||||
if result.success || validate(session, config) || data.config.public_mode {
|
||||
let result = auth::is_api_ok(http, config);
|
||||
if result.success || is_session_valid(session, config) || data.config.public_mode {
|
||||
let backend_config = BackendConfig {
|
||||
version: VERSION.to_string(),
|
||||
allow_capital_letters: config.allow_capital_letters,
|
||||
@@ -292,16 +334,8 @@ 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,
|
||||
data.config.allow_capital_letters,
|
||||
)
|
||||
.0
|
||||
{
|
||||
database::add_hit(shortlink.as_str(), &data.db);
|
||||
let shortlink_str = shortlink.as_str();
|
||||
if let Ok(longlink) = database::find_and_add_hit(shortlink_str, &data.db) {
|
||||
if data.config.use_temp_redirect {
|
||||
Either::Left(Redirect::to(longlink))
|
||||
} else {
|
||||
@@ -325,7 +359,7 @@ pub async fn login(req: String, session: Session, data: web::Data<AppState>) ->
|
||||
// Check if password is hashed using Argon2. More algorithms maybe added later.
|
||||
let authorized = if let Some(password) = &config.password {
|
||||
if config.hash_algorithm.is_some() {
|
||||
info!("Using Argon2 hash for password validation.");
|
||||
debug!("Using Argon2 hash for password validation.");
|
||||
let hash = PasswordHash::new(password).expect("The provided password hash is invalid.");
|
||||
Some(
|
||||
Argon2::default()
|
||||
@@ -343,7 +377,7 @@ pub async fn login(req: String, session: Session, data: web::Data<AppState>) ->
|
||||
if let Some(valid_pass) = authorized {
|
||||
if !valid_pass {
|
||||
warn!("Failed login attempt!");
|
||||
let response = Response {
|
||||
let response = JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "Wrong password!".to_string(),
|
||||
@@ -356,7 +390,7 @@ pub async fn login(req: String, session: Session, data: web::Data<AppState>) ->
|
||||
.insert("chhoto-url-auth", auth::gen_token())
|
||||
.expect("Error inserting auth token.");
|
||||
|
||||
let response = Response {
|
||||
let response = JSONResponse {
|
||||
success: true,
|
||||
error: false,
|
||||
reason: "Correct password!".to_string(),
|
||||
@@ -403,37 +437,40 @@ pub async fn delete_link(
|
||||
) -> HttpResponse {
|
||||
let config = &data.config;
|
||||
// Call is_api_ok() function, pass HttpRequest
|
||||
let result = utils::is_api_ok(http, config);
|
||||
let result = auth::is_api_ok(http, config);
|
||||
// If success, delete shortlink
|
||||
if result.success {
|
||||
if utils::delete_link(
|
||||
shortlink.to_string(),
|
||||
&data.db,
|
||||
data.config.allow_capital_letters,
|
||||
) {
|
||||
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)
|
||||
match utils::delete_link(&shortlink, &data.db, data.config.allow_capital_letters) {
|
||||
Ok(()) => {
|
||||
let response = JSONResponse {
|
||||
success: true,
|
||||
error: false,
|
||||
reason: format!("Deleted {shortlink}"),
|
||||
};
|
||||
HttpResponse::Ok().json(response)
|
||||
}
|
||||
Err(ServerError) => {
|
||||
let response = JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "Something went wrong when deleting the link.".to_string(),
|
||||
};
|
||||
HttpResponse::InternalServerError().json(response)
|
||||
}
|
||||
Err(ClientError { reason }) => {
|
||||
let response = JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason,
|
||||
};
|
||||
HttpResponse::NotFound().json(response)
|
||||
}
|
||||
}
|
||||
} else if result.error {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
// If "pass" is true - keeps backwards compatibility
|
||||
} else if auth::validate(session, config) {
|
||||
if utils::delete_link(
|
||||
shortlink.to_string(),
|
||||
&data.db,
|
||||
data.config.allow_capital_letters,
|
||||
) {
|
||||
// If using password - keeps backwards compatibility
|
||||
} else if auth::is_session_valid(session, config) {
|
||||
if utils::delete_link(&shortlink, &data.db, data.config.allow_capital_letters).is_ok() {
|
||||
HttpResponse::Ok().body(format!("Deleted {shortlink}"))
|
||||
} else {
|
||||
HttpResponse::NotFound().body("Not found!")
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use actix_http::{Request, StatusCode};
|
||||
use actix_service::Service;
|
||||
use actix_web::test;
|
||||
use actix_web::{body::to_bytes, dev::ServiceResponse, web::Bytes, App, Error};
|
||||
use actix_web::{body::to_bytes, dev::ServiceResponse, test, web::Bytes, App, Error};
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use std::{fmt::Display, fs, thread::sleep, time::Duration};
|
||||
use std::{fmt::Display, fs, rc::Rc, thread::sleep, time::Duration};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -63,6 +62,8 @@ fn default_config(test: &str) -> config::Config {
|
||||
try_longer_slug: false,
|
||||
allow_capital_letters: false,
|
||||
custom_landing_directory: None,
|
||||
use_wal_mode: true,
|
||||
ensure_acid: false,
|
||||
};
|
||||
conf
|
||||
}
|
||||
@@ -75,7 +76,11 @@ async fn create_app(
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(AppState {
|
||||
db: database::open_db(format!("/tmp/chhoto-url-test-{test}.sqlite")),
|
||||
db: database::open_db(
|
||||
format!("/tmp/chhoto-url-test-{test}.sqlite").as_str(),
|
||||
conf.use_wal_mode,
|
||||
conf.ensure_acid,
|
||||
),
|
||||
config: conf.clone(),
|
||||
}))
|
||||
.service(services::siteurl)
|
||||
@@ -285,13 +290,13 @@ async fn data_fetching_all() {
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/api/all")
|
||||
.insert_header(("X-API-Key", api_key))
|
||||
.insert_header(("X-API-Key", api_key.clone()))
|
||||
.to_request();
|
||||
|
||||
let resp = test::call_service(&app, req).await;
|
||||
assert!(resp.status().is_success());
|
||||
let body = to_bytes(resp.into_body()).await.unwrap();
|
||||
let reply_chunks: Vec<URLData> = serde_json::from_str(body.as_str()).unwrap();
|
||||
let reply_chunks: Rc<[URLData]> = serde_json::from_str(body.as_str()).unwrap();
|
||||
assert_eq!(reply_chunks.len(), 2);
|
||||
assert_eq!(reply_chunks[0].shortlink, "test1");
|
||||
assert_eq!(reply_chunks[1].shortlink, "test3");
|
||||
@@ -302,6 +307,30 @@ async fn data_fetching_all() {
|
||||
assert_ne!(reply_chunks[0].expiry_time, 0);
|
||||
assert_ne!(reply_chunks[1].expiry_time, 0);
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/api/all?page_no=2&page_size=1")
|
||||
.insert_header(("X-API-Key", api_key.clone()))
|
||||
.to_request();
|
||||
|
||||
let resp = test::call_service(&app, req).await;
|
||||
assert!(resp.status().is_success());
|
||||
let body = to_bytes(resp.into_body()).await.unwrap();
|
||||
let reply_chunks: Rc<[URLData]> = serde_json::from_str(body.as_str()).unwrap();
|
||||
assert_eq!(reply_chunks.len(), 1);
|
||||
assert_eq!(reply_chunks[0].shortlink, "test1");
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/api/all?page_after=test3&page_size=1")
|
||||
.insert_header(("X-API-Key", api_key))
|
||||
.to_request();
|
||||
|
||||
let resp = test::call_service(&app, req).await;
|
||||
assert!(resp.status().is_success());
|
||||
let body = to_bytes(resp.into_body()).await.unwrap();
|
||||
let reply_chunks: Rc<[URLData]> = serde_json::from_str(body.as_str()).unwrap();
|
||||
assert_eq!(reply_chunks.len(), 1);
|
||||
assert_eq!(reply_chunks[0].shortlink, "test1");
|
||||
|
||||
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use log::error;
|
||||
use nanoid::nanoid;
|
||||
use rand::seq::IndexedRandom;
|
||||
use regex::Regex;
|
||||
use rusqlite::{ffi::SQLITE_CONSTRAINT_UNIQUE, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use rusqlite::Connection;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{auth, config::Config, database};
|
||||
use crate::{
|
||||
config::Config,
|
||||
database,
|
||||
services::{
|
||||
ChhotoError::{self, ClientError, ServerError},
|
||||
GetReqParams,
|
||||
},
|
||||
};
|
||||
|
||||
// Struct for reading link pairs sent during API call for new link
|
||||
#[derive(Deserialize)]
|
||||
@@ -28,85 +35,8 @@ struct EditURLRequest {
|
||||
reset_hits: bool,
|
||||
}
|
||||
|
||||
// Define JSON struct for error 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, config: &Config) -> Response {
|
||||
// If the api_key environment variable exists
|
||||
if config.api_key.is_some() {
|
||||
// If the header exists
|
||||
if let Some(header) = auth::api_header(&http) {
|
||||
// If the header is correct
|
||||
if auth::validate_key(header.to_string(), config) {
|
||||
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: "No valid authentication was found".to_string(),
|
||||
pass: true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 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,
|
||||
allow_capital_letters: bool,
|
||||
) -> (Option<String>, Option<i64>, Option<i64>) {
|
||||
// Long link, hits, expiry time
|
||||
if validate_link(&shortlink, allow_capital_letters) {
|
||||
database::find_url(shortlink.as_str(), db, needhits)
|
||||
} else {
|
||||
(None, None, None)
|
||||
}
|
||||
}
|
||||
|
||||
// Only have a-z, 0-9, - and _ as valid characters in a shortlink
|
||||
fn validate_link(link: &str, allow_capital_letters: bool) -> bool {
|
||||
fn is_link_valid(link: &str, allow_capital_letters: bool) -> bool {
|
||||
let re = if allow_capital_letters {
|
||||
Regex::new("^[A-Za-z0-9-_]+$").expect("Regex generation failed.")
|
||||
} else {
|
||||
@@ -116,24 +46,29 @@ fn validate_link(link: &str, allow_capital_letters: bool) -> bool {
|
||||
}
|
||||
|
||||
// Request the DB for all URLs
|
||||
pub fn getall(db: &Connection) -> String {
|
||||
let links = database::getall(db);
|
||||
pub fn getall(db: &Connection, params: GetReqParams) -> String {
|
||||
let page_after = params.page_after.filter(|s| !s.is_empty());
|
||||
let page_no = params.page_no.filter(|&n| n > 0);
|
||||
let page_size = params.page_size.filter(|&n| n > 0);
|
||||
let links = database::getall(db, page_after.as_deref(), page_no, page_size);
|
||||
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,
|
||||
req: &str,
|
||||
db: &Connection,
|
||||
config: &Config,
|
||||
using_public_mode: bool,
|
||||
) -> (bool, String, i64) {
|
||||
// Success status, response string, expiry time
|
||||
) -> Result<(String, i64), ChhotoError> {
|
||||
// Ok : shortlink, expiry_time
|
||||
let mut chunks: NewURLRequest;
|
||||
if let Ok(json) = serde_json::from_str(&req) {
|
||||
if let Ok(json) = serde_json::from_str(req) {
|
||||
chunks = json;
|
||||
} else {
|
||||
return (false, String::from("Invalid request!"), 0);
|
||||
return Err(ClientError {
|
||||
reason: "Invalid request!".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let style = &config.slug_style;
|
||||
@@ -159,80 +94,85 @@ pub fn add_link(
|
||||
chunks.expiry_delay = chunks.expiry_delay.min(157784760);
|
||||
chunks.expiry_delay = chunks.expiry_delay.max(0);
|
||||
|
||||
if validate_link(chunks.shortlink.as_str(), allow_capital_letters) {
|
||||
if !shortlink_provided || is_link_valid(chunks.shortlink.as_str(), allow_capital_letters) {
|
||||
match database::add_link(&chunks.shortlink, &chunks.longlink, chunks.expiry_delay, db) {
|
||||
Ok(expiry_time) => (true, chunks.shortlink, expiry_time),
|
||||
Err(error) => {
|
||||
if error.sqlite_error().map(|err| err.extended_code)
|
||||
== Some(SQLITE_CONSTRAINT_UNIQUE)
|
||||
{
|
||||
if shortlink_provided {
|
||||
(false, String::from("Short URL is already in use!"), 0)
|
||||
} else if config.slug_style == "UID" && config.try_longer_slug {
|
||||
// Optionally, retry with a longer slug length
|
||||
chunks.shortlink = gen_link(style, len + 4, allow_capital_letters);
|
||||
match database::add_link(
|
||||
&chunks.shortlink,
|
||||
&chunks.longlink,
|
||||
chunks.expiry_delay,
|
||||
db,
|
||||
) {
|
||||
Ok(expiry_time) => (true, chunks.shortlink, expiry_time),
|
||||
Err(_) => (false, String::from("Something went very wrong!"), 0),
|
||||
}
|
||||
} else {
|
||||
(false, String::from("Something went wrong!"), 0)
|
||||
}
|
||||
Ok(expiry_time) => Ok((chunks.shortlink, expiry_time)),
|
||||
Err(ClientError { reason }) => {
|
||||
if shortlink_provided {
|
||||
Err(ClientError { reason })
|
||||
} else {
|
||||
// This should be super rare
|
||||
(false, String::from("Something went extremely wrong!"), 0)
|
||||
// Optionally, retry with a longer slug length
|
||||
let retry_len = if config.slug_style == "UID" && config.try_longer_slug {
|
||||
len + 4
|
||||
} else {
|
||||
len
|
||||
};
|
||||
chunks.shortlink = gen_link(style, retry_len, allow_capital_letters);
|
||||
match database::add_link(
|
||||
&chunks.shortlink,
|
||||
&chunks.longlink,
|
||||
chunks.expiry_delay,
|
||||
db,
|
||||
) {
|
||||
Ok(expiry_time) => Ok((chunks.shortlink, expiry_time)),
|
||||
Err(_) => {
|
||||
error!("Something went wrong while adding a generated link.");
|
||||
Err(ServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(ServerError) => Err(ServerError),
|
||||
}
|
||||
} else {
|
||||
(false, String::from("Short URL is not valid!"), 0)
|
||||
Err(ClientError {
|
||||
reason: "Short URL is not valid!".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Make checks and then request the DB to edit an URL entry
|
||||
pub fn edit_link(req: String, db: &Connection, config: &Config) -> Option<(bool, String)> {
|
||||
// None means success
|
||||
// The boolean is true when it's a server error and false when it's a client error
|
||||
// The string is the error message
|
||||
|
||||
pub fn edit_link(req: &str, db: &Connection, config: &Config) -> Result<(), ChhotoError> {
|
||||
let chunks: EditURLRequest;
|
||||
if let Ok(json) = serde_json::from_str(&req) {
|
||||
if let Ok(json) = serde_json::from_str(req) {
|
||||
chunks = json;
|
||||
} else {
|
||||
return Some((false, String::from("Malformed request!")));
|
||||
return Err(ClientError {
|
||||
reason: "Malformed request!".to_string(),
|
||||
});
|
||||
}
|
||||
if !validate_link(&chunks.shortlink, config.allow_capital_letters) {
|
||||
return Some((false, String::from("Invalid shortlink!")));
|
||||
if !is_link_valid(&chunks.shortlink, config.allow_capital_letters) {
|
||||
return Err(ClientError {
|
||||
reason: "Invalid shortlink!".to_string(),
|
||||
});
|
||||
}
|
||||
let result = database::edit_link(&chunks.shortlink, &chunks.longlink, chunks.reset_hits, db);
|
||||
if Ok(0) == result {
|
||||
match result {
|
||||
// Zero rows returned means no updates
|
||||
Some((
|
||||
false,
|
||||
"The short link was not found, and could not be edited.".to_string(),
|
||||
))
|
||||
} else if result.is_ok() {
|
||||
None
|
||||
} else {
|
||||
Some((true, String::from("Something went wrong!"))) // Should not really happen
|
||||
Ok(0) => Err(ClientError {
|
||||
reason: "The shortlink was not found, and could not be edited.".to_string(),
|
||||
}),
|
||||
Ok(_) => Ok(()),
|
||||
Err(()) => Err(ServerError),
|
||||
}
|
||||
}
|
||||
// Check if link, and request DB to delete it if exists
|
||||
pub fn delete_link(shortlink: String, db: &Connection, allow_capital_letters: bool) -> bool {
|
||||
if validate_link(shortlink.as_str(), allow_capital_letters) {
|
||||
pub fn delete_link(
|
||||
shortlink: &str,
|
||||
db: &Connection,
|
||||
allow_capital_letters: bool,
|
||||
) -> Result<(), ChhotoError> {
|
||||
if is_link_valid(shortlink, allow_capital_letters) {
|
||||
database::delete_link(shortlink, db)
|
||||
} else {
|
||||
false
|
||||
Err(ClientError {
|
||||
reason: "The shortlink is invalid.".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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, allow_capital_letters: bool) -> String {
|
||||
fn gen_link(style: &str, len: usize, allow_capital_letters: bool) -> 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",
|
||||
|
||||
53
chhoto-url.container
Normal file
53
chhoto-url.container
Normal file
@@ -0,0 +1,53 @@
|
||||
# SPDX-FileCopyrightText: 2025 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# chhoto-url.container
|
||||
#
|
||||
# To be used with rootless quadlets. Put inside your $XDG_CONFIG_HOME/containers/systemd/
|
||||
# Take a look at README for the explanation of the configs.
|
||||
# The commented out configs are optional.
|
||||
[Unit]
|
||||
Description=Caddy
|
||||
#AssertPathIsDirectory=%h/podman/chhoto-url/data
|
||||
|
||||
[Container]
|
||||
ContainerName=chhoto-url
|
||||
Image=sintan1729/chhoto-url:latest
|
||||
PodmanArgs=--tty
|
||||
PublishPort=4567:4567
|
||||
DropCapability=ALL
|
||||
|
||||
# Environment variables
|
||||
Environment=db_url=/db/urls.sqlite
|
||||
Environment=use_wal_mode = True
|
||||
#Environment=ensure_acid = True
|
||||
#Environment=site_url=https://www.example.com
|
||||
#Environment=hash_algorithm=Argon2
|
||||
Environment=password=TopSecretPass
|
||||
Environment=port=4567
|
||||
#Environment=api_key=SECURE_API_KEY
|
||||
Environment=redirect_method=TEMPORARY
|
||||
Environment=slug_style=Pair
|
||||
#Environment=slug_length=8
|
||||
#Environment=try_longer_slug=False
|
||||
#Environment=allow_capital_letters=False
|
||||
#Environment=public_mode=Disable
|
||||
#Environment=public_mode_expiry_delay=3600
|
||||
#Environment=disable_frontend=False
|
||||
#Environment=custom_landing_directory=/custom/dir/location
|
||||
#Environment=cache_control_header=no-cache, private
|
||||
|
||||
# Volume
|
||||
Volume=db:/db
|
||||
|
||||
# Health check
|
||||
# Only enable this if using the alpine images.
|
||||
# HealthCmd=CMD-SHELL wget --no-verbose --tries=1 --spider http://chhoto-url:4567/api/whoami || exit 1
|
||||
# HealthInterval=60s
|
||||
# HealthRetries=3
|
||||
# HealthStartPeriod=10s
|
||||
# HealthTimeout=10s
|
||||
# HealthOnFailure=kill
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
21
compose.yaml
21
compose.yaml
@@ -4,6 +4,8 @@
|
||||
services:
|
||||
chhoto-url:
|
||||
image: sintan1729/chhoto-url:latest
|
||||
# You may want to check out the alpine images for extra features. The images can also be
|
||||
# pulled from ghcr.io
|
||||
restart: unless-stopped
|
||||
container_name: chhoto-url
|
||||
tty: true
|
||||
@@ -23,11 +25,17 @@ services:
|
||||
# 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.
|
||||
# Make sure that you create an empty file with the correct name
|
||||
# before starting the container if you do make any changes.
|
||||
- db_url=/db/urls.sqlite
|
||||
# Uncomment the next line to enable WAL mode. It's highly recommended.
|
||||
# Make sure that you mount a directory instead of a bare file
|
||||
# since that might have a (low, but non-zero) possibility of
|
||||
# corrupting your db since we use WAL journaling mode
|
||||
# (In fact, I'd suggest that you do that so that you can keep
|
||||
# a copy of your database.)
|
||||
- db_url=/db/urls.sqlite
|
||||
# - use_wal_mode = True
|
||||
# If you'd like to disable ACID compliance, uncomment the next line.
|
||||
# Note that there are risks. Look at the README for more.
|
||||
# - ensure_acid = False
|
||||
|
||||
# Change this if your server URL is not "http://localhost"
|
||||
# This must not be surrounded by quotes. For example:
|
||||
@@ -90,6 +98,13 @@ services:
|
||||
# You may set the TZ variable for timezone in logging, but it will only work in the alpine builds
|
||||
volumes:
|
||||
- db:/db
|
||||
# Only enable this if using the alpine images.
|
||||
# healthcheck:
|
||||
# test: wget --no-verbose --tries=1 --spider http://chhoto-url:4567/api/whoami || exit 1
|
||||
# interval: 60s
|
||||
# start_period: 10s
|
||||
# retries: 3
|
||||
# timeout: 10s
|
||||
|
||||
volumes:
|
||||
db:
|
||||
|
||||
@@ -62,6 +62,12 @@ spec:
|
||||
- name: cache_control_header
|
||||
value: {{ .Values.cache_control_header }}
|
||||
{{- end }}
|
||||
- name: use_wal_mode
|
||||
value: {{ .Values.use_wal_mode }}
|
||||
{{- if .Values.ensure_acid }}
|
||||
- name: ensure_acid
|
||||
value: {{ .Values.ensure_acid }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /db
|
||||
|
||||
@@ -27,6 +27,8 @@ disable_frontend: False
|
||||
allow_capital_letters: False
|
||||
# custom_landing_directory: "/custom/dir/location"
|
||||
# cache_control_header: "no-cache, private"
|
||||
use_wal_mode: True
|
||||
# ensure_acid: False
|
||||
|
||||
protocol: https
|
||||
fqdn: your.short.link.url.com
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
<button class="chhoto-button pure-button pure-button-primary">
|
||||
Shorten!
|
||||
</button>
|
||||
<p id="alert-box"> </p>
|
||||
<div id="alert-box"> </div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -125,7 +125,19 @@
|
||||
<p id="loading-text">Loading links table...</p>
|
||||
<table class="chhoto-table pure-table" id="table-box" hidden>
|
||||
<caption>
|
||||
Active links
|
||||
<span>Active links</span>
|
||||
<span id="pageControls" hidden="true">
|
||||
<button
|
||||
id="prevPageBtn"
|
||||
class="svg-button"
|
||||
title="Previous Page"
|
||||
></button>
|
||||
<button
|
||||
id="nextPageBtn"
|
||||
class="svg-button"
|
||||
title="Next Page"
|
||||
></button>
|
||||
</span>
|
||||
</caption>
|
||||
<br />
|
||||
<thead>
|
||||
@@ -163,7 +175,13 @@
|
||||
<p>Please enter password to access this website</p>
|
||||
<div>
|
||||
<input class="chhoto-input" type="password" id="password" />
|
||||
<button type="button" id="password-eye-button">👁</button>
|
||||
<button
|
||||
type="button"
|
||||
id="password-eye-button"
|
||||
title="Toggle Password Visibility"
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="chhoto-button pure-button pure-button-primary"
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Application state
|
||||
let VERSION = null;
|
||||
let SITE_URL = "-";
|
||||
let CONFIG = null;
|
||||
let SUBDIR = null;
|
||||
let ADMIN = false;
|
||||
let LOCAL_DATA = [];
|
||||
let CUR_PAGE = 0;
|
||||
|
||||
// Flags
|
||||
let PROCESSING_PAGE_TRANSITION = true;
|
||||
|
||||
// Buttons
|
||||
// https://svgicons.com/icon/10648/copy-outline
|
||||
@@ -20,6 +26,10 @@ SVG_DELETE_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="
|
||||
SVG_OPEN_EYE = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 9.75a2.25 2.25 0 1 0 0 4.5a2.25 2.25 0 0 0 0-4.5"/><path fill="currentColor" fill-rule="evenodd" d="M12 5.5c-2.618 0-4.972 1.051-6.668 2.353c-.85.652-1.547 1.376-2.036 2.08c-.48.692-.796 1.418-.796 2.067c0 .649.317 1.375.796 2.066c.49.705 1.186 1.429 2.036 2.08C7.028 17.45 9.382 18.5 12 18.5c2.618 0 4.972-1.051 6.668-2.353c.85-.652 1.547-1.376 2.035-2.08c.48-.692.797-1.418.797-2.067c0-.649-.317-1.375-.797-2.066c-.488-.705-1.185-1.429-2.035-2.08C16.972 6.55 14.618 5.5 12 5.5M8.25 12a3.75 3.75 0 1 1 7.5 0a3.75 3.75 0 0 1-7.5 0" clip-rule="evenodd"/></svg>`;
|
||||
// https://svgicons.com/icon/10687/eye-closed-solid
|
||||
SVG_CLOSED_EYE = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M20.53 4.53a.75.75 0 0 0-1.06-1.06l-16 16a.75.75 0 1 0 1.06 1.06l3.035-3.035C8.883 18.103 10.392 18.5 12 18.5c2.618 0 4.972-1.051 6.668-2.353c.85-.652 1.547-1.376 2.035-2.08c.48-.692.797-1.418.797-2.067c0-.649-.317-1.375-.797-2.066c-.488-.705-1.185-1.429-2.035-2.08c-.27-.208-.558-.41-.86-.601zm-5.4 5.402l-1.1 1.098a2.25 2.25 0 0 1-3 3l-1.1 1.1a3.75 3.75 0 0 0 5.197-5.197" clip-rule="evenodd"/><path fill="currentColor" d="M12.67 8.31a.26.26 0 0 0 .23-.07l1.95-1.95a.243.243 0 0 0-.104-.407A10.214 10.214 0 0 0 12 5.5c-2.618 0-4.972 1.051-6.668 2.353c-.85.652-1.547 1.376-2.036 2.08c-.48.692-.796 1.418-.796 2.067c0 .649.317 1.375.796 2.066a9.287 9.287 0 0 0 1.672 1.79a.246.246 0 0 0 .332-.017l2.94-2.94a.26.26 0 0 0 .07-.23a3.75 3.75 0 0 1 4.36-4.36"/></svg>`;
|
||||
// https://svgicons.com/icon/10926/skip-prev-outline
|
||||
SVG_PREV_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M6.75 7a.75.75 0 0 0-1.5 0v10a.75.75 0 0 0 1.5 0z"/><path fill="currentColor" fill-rule="evenodd" d="M9.393 13.253a1.584 1.584 0 0 1 0-2.505a25.76 25.76 0 0 1 7.143-3.902l.466-.165c1.023-.364 2.1.329 2.238 1.381c.34 2.59.34 5.286 0 7.876c-.138 1.052-1.215 1.745-2.238 1.381l-.466-.165a25.758 25.758 0 0 1-7.143-3.902m.918-1.32a.084.084 0 0 0 0 .133a24.257 24.257 0 0 0 6.727 3.674l.466.166c.1.035.232-.033.249-.163c.322-2.46.322-5.025 0-7.486a.194.194 0 0 0-.25-.163l-.465.166c-2.423.86-4.694 2.1-6.727 3.674" clip-rule="evenodd"/></svg>`;
|
||||
// https://svgicons.com/icon/10924/skip-next-outline
|
||||
SVG_NEXT_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M14.607 10.748c.82.634.82 1.87 0 2.505a25.758 25.758 0 0 1-7.143 3.9l-.466.166c-1.023.364-2.1-.329-2.238-1.381c-.34-2.59-.34-5.286 0-7.876c.138-1.052 1.215-1.745 2.238-1.381l.466.165a25.76 25.76 0 0 1 7.143 3.902m-.918 1.318a.084.084 0 0 0 0-.132A24.257 24.257 0 0 0 6.962 8.26l-.466-.166a.194.194 0 0 0-.249.163a29.063 29.063 0 0 0 0 7.486c.017.13.15.198.25.163l.465-.166c2.423-.86 4.694-2.1 6.727-3.674M18 6.25a.75.75 0 0 1 .75.75v10a.75.75 0 0 1-1.5 0V7a.75.75 0 0 1 .75-.75" clip-rule="evenodd"/></svg>`;
|
||||
|
||||
// in miliseconds
|
||||
const UNITS = {
|
||||
@@ -125,15 +135,26 @@ const refreshData = async () => {
|
||||
}
|
||||
showVersion();
|
||||
if (ADMIN) {
|
||||
const res = await fetch(prepSubdir("/api/all"), { cache: "no-cache" });
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
await getConfig();
|
||||
ADMIN = true;
|
||||
displayData(data.reverse());
|
||||
const params = new URLSearchParams();
|
||||
if (LOCAL_DATA.length == 0) {
|
||||
params.append("page_size", "20");
|
||||
} else {
|
||||
throw Error("There was an error getting data.");
|
||||
if (LOCAL_DATA.length <= CUR_PAGE * 10) {
|
||||
console.log("Reached the end of URLs.");
|
||||
return;
|
||||
}
|
||||
displayData();
|
||||
params.append("page_size", "10");
|
||||
params.append("page_after", LOCAL_DATA.at(-1)["shortlink"]);
|
||||
}
|
||||
const data = await pullData(params);
|
||||
await getConfig();
|
||||
ADMIN = true;
|
||||
LOCAL_DATA.push(...data.reverse());
|
||||
if (CUR_PAGE == 0) {
|
||||
displayData();
|
||||
}
|
||||
managePageControls();
|
||||
} else {
|
||||
document.getElementById("table-box").hidden = true;
|
||||
loading_text.hidden = false;
|
||||
@@ -147,16 +168,59 @@ const refreshData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const pullData = async (params) => {
|
||||
const res = await fetch(prepSubdir(`/api/all?${params}`), {
|
||||
cache: "no-cache",
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
return data;
|
||||
} else {
|
||||
throw Error("There was an error getting data.");
|
||||
}
|
||||
};
|
||||
|
||||
const gotoPrevPage = () => {
|
||||
if (PROCESSING_PAGE_TRANSITION) {
|
||||
return;
|
||||
}
|
||||
PROCESSING_PAGE_TRANSITION = true;
|
||||
if (CUR_PAGE > 0) {
|
||||
CUR_PAGE -= 1;
|
||||
}
|
||||
displayData();
|
||||
managePageControls();
|
||||
};
|
||||
|
||||
const gotoNextPage = () => {
|
||||
if (PROCESSING_PAGE_TRANSITION) {
|
||||
return;
|
||||
}
|
||||
PROCESSING_PAGE_TRANSITION = true;
|
||||
CUR_PAGE += 1;
|
||||
if (LOCAL_DATA.length <= (CUR_PAGE + 1) * 10) {
|
||||
refreshData();
|
||||
} else {
|
||||
displayData();
|
||||
managePageControls();
|
||||
}
|
||||
};
|
||||
|
||||
const updateInputBox = () => {
|
||||
if (CONFIG.allow_capital_letters) {
|
||||
const input_box = document.getElementById("shortUrl");
|
||||
input_box.pattern = "[A-Za-z0-9\-_]+";
|
||||
input_box.pattern = "[A-Za-z0-9\\\-_]+";
|
||||
input_box.title = "Only A-Z, a-z, 0-9, - and _ are allowed";
|
||||
input_box.placeholder = "Only A-Z, a-z, 0-9, - and _ are allowed";
|
||||
}
|
||||
};
|
||||
|
||||
const displayData = (data) => {
|
||||
const displayData = () => {
|
||||
if (CUR_PAGE < 0) {
|
||||
console.log("Trying to access negative numbered page.");
|
||||
return;
|
||||
}
|
||||
const data = LOCAL_DATA.slice(CUR_PAGE * 10, CUR_PAGE * 10 + 10);
|
||||
showVersion();
|
||||
const admin_button = document.getElementById("admin-button");
|
||||
admin_button.innerText = "logout";
|
||||
@@ -176,21 +240,32 @@ const displayData = (data) => {
|
||||
table_box.hidden = false;
|
||||
table.innerHTML = "";
|
||||
for (const [i, row] of data.entries()) {
|
||||
table.appendChild(TR(i + 1, row));
|
||||
table.appendChild(TR(CUR_PAGE * 10 + i + 1, row));
|
||||
}
|
||||
setTimeout(refreshExpiryTimes, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const managePageControls = () => {
|
||||
const on_first_page = CUR_PAGE == 0;
|
||||
const on_last_page = LOCAL_DATA.length <= (CUR_PAGE + 1) * 10;
|
||||
|
||||
document.getElementById("prevPageBtn").disabled = on_first_page;
|
||||
document.getElementById("nextPageBtn").disabled = on_last_page;
|
||||
document.getElementById("pageControls").hidden =
|
||||
on_first_page && on_last_page;
|
||||
PROCESSING_PAGE_TRANSITION = false;
|
||||
};
|
||||
|
||||
const showAlert = (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;
|
||||
const alertBox = document.getElementById("alert-box");
|
||||
alertBox.style.background = col;
|
||||
alertBox.innerHTML = text;
|
||||
alertBox.style.display = "block";
|
||||
controls.appendChild(alertBox);
|
||||
if (text == " ") {
|
||||
alertBox.removeAttribute("style");
|
||||
} else {
|
||||
alertBox.style.display = "block";
|
||||
}
|
||||
};
|
||||
|
||||
const refreshExpiryTimes = async () => {
|
||||
@@ -200,7 +275,7 @@ const refreshExpiryTimes = async () => {
|
||||
let expiryTimeParsed = new Date(td.getAttribute("data-time") * 1000);
|
||||
let relativeTime = formatRelativeTime(expiryTimeParsed);
|
||||
if (relativeTime == "expired") {
|
||||
td.style.color = "light-dark(red, #ff1a1a)";
|
||||
td.style.color = "light-dark(red, #a01e1e)";
|
||||
for (const btn of td.parentElement.lastChild.querySelectorAll("button")) {
|
||||
btn.disabled = true;
|
||||
}
|
||||
@@ -303,13 +378,13 @@ const copyShortUrl = async (short_link) => {
|
||||
await navigator.clipboard.writeText(full_link);
|
||||
showAlert(
|
||||
`Short URL ${link_elt} was copied to clipboard!`,
|
||||
"light-dark(green, #72ff72)",
|
||||
"light-dark(green, #1e501e)",
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
showAlert(
|
||||
`Could not copy short URL to clipboard, please do it manually: ${link_elt}`,
|
||||
"light-dark(red, #ff1a1a)",
|
||||
"light-dark(red, #a01e1e)",
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -416,7 +491,7 @@ const deleteButton = (shortUrl) => {
|
||||
btn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
if (confirm("Do you want to delete the entry " + shortUrl + "?")) {
|
||||
showAlert(" ", "black");
|
||||
showAlert(" ", "transparent");
|
||||
fetch(prepSubdir(`/api/del/${shortUrl}`), {
|
||||
method: "DELETE",
|
||||
cache: "no-cache",
|
||||
@@ -425,13 +500,21 @@ const deleteButton = (shortUrl) => {
|
||||
if (!res.ok) {
|
||||
throw new Error("Could not delete.");
|
||||
}
|
||||
await refreshData();
|
||||
LOCAL_DATA = LOCAL_DATA.filter(
|
||||
(item) => item["shortlink"] != shortUrl,
|
||||
);
|
||||
if (LOCAL_DATA.length <= CUR_PAGE * 10 && CUR_PAGE > 0) {
|
||||
CUR_PAGE -= 1;
|
||||
}
|
||||
PROCESSING_PAGE_TRANSITION = true;
|
||||
displayData();
|
||||
managePageControls();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error:", err);
|
||||
showAlert(
|
||||
"Unable to delete " + shortUrl + ". Please try again!",
|
||||
"light-dark(red, #ff1a1a)",
|
||||
"light-dark(red, #a01e1e)",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -467,14 +550,24 @@ const submitForm = () => {
|
||||
})
|
||||
.then(async (text) => {
|
||||
if (!ok) {
|
||||
showAlert(text, "light-dark(red, #ff1a1a)");
|
||||
showAlert(text, "light-dark(red, #a01e1e)");
|
||||
} else {
|
||||
await copyShortUrl(text);
|
||||
longUrl.value = "";
|
||||
shortUrl.value = "";
|
||||
expiryDelay.value = 0;
|
||||
const params = new URLSearchParams();
|
||||
params.append("page_size", 1);
|
||||
const newEntry = await pullData(params);
|
||||
LOCAL_DATA.unshift(newEntry[0]);
|
||||
if (LOCAL_DATA.length == (CUR_PAGE + 1) * 10 + 1) {
|
||||
LOCAL_DATA.pop();
|
||||
}
|
||||
CUR_PAGE = 0;
|
||||
PROCESSING_PAGE_TRANSITION = true;
|
||||
displayData();
|
||||
managePageControls();
|
||||
}
|
||||
await refreshData();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error:", err);
|
||||
@@ -513,13 +606,20 @@ const submitEdit = () => {
|
||||
})
|
||||
.then(async (text) => {
|
||||
if (!ok) {
|
||||
showAlert(text, "light-dark(red, #ff1a1a)");
|
||||
showAlert(text, "light-dark(red, #a01e1e)");
|
||||
} else {
|
||||
document.getElementById("edit-dialog").close();
|
||||
editUrlSpan.textContent = shortUrl;
|
||||
const editedIndex = LOCAL_DATA.findIndex(
|
||||
(item) => item["shortlink"] == shortUrl,
|
||||
);
|
||||
LOCAL_DATA[editedIndex]["longlink"] = longUrl;
|
||||
if (checkBox.checked) {
|
||||
LOCAL_DATA[editedIndex]["hits"] = 0;
|
||||
}
|
||||
checkBox.checked = false;
|
||||
}
|
||||
await refreshData();
|
||||
displayData();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error:", err);
|
||||
@@ -574,14 +674,15 @@ const logOut = async () => {
|
||||
if (res.ok) {
|
||||
document.getElementById("version-number").hidden = true;
|
||||
document.getElementById("admin-button").hidden = true;
|
||||
showAlert(" ", "black");
|
||||
showAlert(" ", "transparent");
|
||||
ADMIN = false;
|
||||
VERSION = null;
|
||||
LOCAL_DATA = [];
|
||||
await refreshData();
|
||||
} else {
|
||||
showAlert(
|
||||
`Logout failed. Please try again!`,
|
||||
"light-dark(red, #ff1a1a)",
|
||||
"light-dark(red, #a01e1e)",
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -644,6 +745,17 @@ refreshData()
|
||||
document.getElementById("password").focus();
|
||||
};
|
||||
|
||||
const prevPageBtn = document.getElementById("prevPageBtn");
|
||||
prevPageBtn.innerHTML = SVG_PREV_BUTTON;
|
||||
prevPageBtn.onclick = () => {
|
||||
gotoPrevPage();
|
||||
};
|
||||
const nextPageBtn = document.getElementById("nextPageBtn");
|
||||
nextPageBtn.innerHTML = SVG_NEXT_BUTTON;
|
||||
nextPageBtn.onclick = () => {
|
||||
gotoNextPage();
|
||||
};
|
||||
|
||||
const qrCodeDialog = document.getElementById("qr-code-dialog");
|
||||
document.getElementById("qr-close").onclick = () => {
|
||||
qrCodeDialog.close();
|
||||
|
||||
@@ -19,7 +19,7 @@ body {
|
||||
background-color: light-dark(white, #181a1b);
|
||||
}
|
||||
.container {
|
||||
max-width: 75em;
|
||||
max-width: 80em;
|
||||
margin: 1em auto auto;
|
||||
}
|
||||
|
||||
@@ -78,9 +78,18 @@ a {
|
||||
.chhoto-table tr td div {
|
||||
max-height: 4.5em;
|
||||
line-height: 1.5em;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow: auto;
|
||||
}
|
||||
.chhoto-table tr td[label="Long URL"] div {
|
||||
word-break: break-all;
|
||||
}
|
||||
.chhoto-table tr td[name="numColumn"] div {
|
||||
word-break: normal;
|
||||
}
|
||||
.chhoto-table tr td[name="hitsColumn"] div {
|
||||
word-break: normal;
|
||||
}
|
||||
.chhoto-table tr:nth-child(even) {
|
||||
background-color: light-dark(#f2f2f2, #080a0b);
|
||||
}
|
||||
@@ -98,11 +107,11 @@ a {
|
||||
.chhoto-table th,
|
||||
.chhoto-table td {
|
||||
border-left: none;
|
||||
max-width: 42em;
|
||||
max-width: 36em;
|
||||
}
|
||||
|
||||
#short-url-header {
|
||||
min-width: 8em;
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
th[name="hitsColumn"],
|
||||
@@ -122,7 +131,8 @@ td[name="actions"] div {
|
||||
text-align: center;
|
||||
min-width: 8em;
|
||||
}
|
||||
td[name="actions"] div button {
|
||||
td[name="actions"] div button,
|
||||
.pure-table caption button.svg-button {
|
||||
aspect-ratio: 1;
|
||||
border-style: none;
|
||||
cursor: pointer;
|
||||
@@ -130,10 +140,15 @@ td[name="actions"] div button {
|
||||
vertical-align: middle;
|
||||
background-color: transparent;
|
||||
}
|
||||
td[name="actions"] div button.svg-button svg {
|
||||
td[name="actions"] div button.svg-button svg,
|
||||
.pure-table caption button.svg-button svg {
|
||||
height: 1.3em;
|
||||
}
|
||||
|
||||
.pure-table caption span {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
form input[name="shortUrl"]::placeholder {
|
||||
text-transform: none;
|
||||
}
|
||||
@@ -183,6 +198,13 @@ div[name="links-div"] {
|
||||
background-color: light-dark(#dd1a1a, #901010);
|
||||
}
|
||||
|
||||
#alert-box {
|
||||
padding: 0.5em;
|
||||
border-radius: 0.3em;
|
||||
margin-top: 0.2em;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
#qr-code-dialog {
|
||||
background-color: white;
|
||||
border-color: grey;
|
||||
@@ -245,7 +267,7 @@ div[name="links-div"] {
|
||||
/* Settings for mobile devices */
|
||||
@media (pointer: none), (pointer: coarse) {
|
||||
.container {
|
||||
max-width: 100vw;
|
||||
max-width: 98vw;
|
||||
}
|
||||
.pure-form input.chhoto-input {
|
||||
width: 98%;
|
||||
@@ -265,7 +287,7 @@ div[name="links-div"] {
|
||||
.chhoto-table td {
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
width: 98vw;
|
||||
width: 97vw;
|
||||
padding: 0.1em;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user