Compare commits

...

41 Commits

Author SHA1 Message Date
SinTan1729
12862fbb5a build: Bumped version to 6.5.2 2025-12-01 20:47:32 -06:00
SinTan1729
281c14034f chg: Retry once for all generated links
Would improve experience for large databases.
2025-11-25 16:56:15 -06:00
SinTan1729
c667c56a32 build: Bumped version to 6.5.1 2025-11-03 17:52:26 -06:00
SinTan1729
c12f37e9db chg: Add tiny bit of margin on mobile view 2025-10-30 09:55:15 -05:00
SinTan1729
390dbde520 build: Bumped version to 6.5.0 2025-10-28 18:00:56 -05:00
SinTan1729
732a532575 chg: Alert box is now a box, instead of colored text 2025-10-28 17:54:51 -05:00
SinTan1729
eaef52f98b fix: Consolidate two structs 2025-10-28 16:41:29 -05:00
SinTan1729
1b8d9b9710 chg: Renamed some structs 2025-10-28 01:15:33 -05:00
SinTan1729
9a8c4f5f3d fix: Some small typos 2025-10-28 00:31:21 -05:00
Sayantan Santra
acb6cad149 chg: Some code cleanup and semantic changes (#100)
* new: Periodically do wal checkpoints during cleanup

* docs: Added spdx header in quadlet

* chg: More sensible names for auth functions

* chg: Use Results in edit, add, and delete routes

This would increase reabability and make more semantic sense.

* chg: Moved is_api_ok to auth

It makes more sense to keep it there

* fix: Changed comments and changed a function name to make semantic sense

* chg: find_url and find_and_add_hit now use Result

* chg: Some reorganizing

* fix: Do not use expect unless absolutely necessary

Basically, unless there's some unrecoverable error, or something that's
guaranteed not to happen, do not use expect.
2025-10-27 23:54:52 -05:00
SinTan1729
771d2ebe1a build: Bumped version to 6.4.1 2025-10-23 15:04:34 -05:00
SinTan1729
a575700cac chg: Enable ACID by default 2025-10-23 15:00:34 -05:00
SinTan1729
6dacbc086f docs: Explain WAL mode and ACID a little bit 2025-10-23 04:11:04 -05:00
SinTan1729
37f5ea260b docs: Added new configs to the helm chart 2025-10-22 19:26:53 -05:00
SinTan1729
d6d4af366a build: Bumped version to 6.4.0 2025-10-22 00:06:03 -05:00
SinTan1729
08b8bbc057 docs: Added a podman quadlet example config file 2025-10-22 00:01:06 -05:00
SinTan1729
8e8daa4d35 new: Added config option for ensuring ACID
This is disabled by default.
2025-10-21 23:47:29 -05:00
SinTan1729
67f6df643f docs: Major rewrite of docs to match current state
Also, the organization should hopefully be better for people to
understand.
2025-10-21 23:29:51 -05:00
SinTan1729
bf28169cac chg: Clean up the imports a little 2025-10-15 23:49:25 -05:00
SinTan1729
401bf1124a build: Run tests on pushes to non-main branches 2025-10-15 00:10:26 -05:00
SinTan1729
7e43ea4bef build: Updated deps 2025-10-15 00:06:24 -05:00
SinTan1729
8b5bb4de81 chg: Some optimizations by using &str and Rc instead of String and Vec 2025-10-14 15:54:08 -05:00
SinTan1729
6e0f4d623d build: Cleaned up the Makefile 2025-10-11 15:18:57 -05:00
SinTan1729
a7f30a0be2 build: Switched the testing to podman 2025-10-10 19:31:27 -05:00
SinTan1729
5b60d25e64 build: Auto build dev on pushes to main 2025-10-10 17:24:10 -05:00
SinTan1729
48a9a844a1 new: The frontend now works properly with the pagination
There is quite a bit of caching for performance improvements.
This should lower the number of API calls made. We're only caching text,
so this shouldn't cause any issues in any modern browser. (Modern
meaning anything built in the last decade or so.)
2025-10-10 17:11:43 -05:00
SinTan1729
043c6efab6 new: Create database backups before opening 2025-10-09 14:22:08 -05:00
SinTan1729
64082b3bb5 fix: Run optimize with recommended mask at start, and call VACUUM properly 2025-10-09 12:22:59 -05:00
SinTan1729
897a7228bf fix: Simplified logic flow to minimize number of transactions
Should improve performance under stress.
2025-10-08 23:22:10 -05:00
SinTan1729
77beace200 fix: More consistent and legible SQL indentation 2025-10-08 22:41:03 -05:00
Sayantan Santra
d5982234a0 new: Added support for pagination of returned data (#98)
* new: Added support for pagination in the backend

No page no means all results are returned. Page no starts at 1, so <=0
values are ignored. Floating point values will results in 400 errors.
Same rules are held for page size.
If page no is provided, but no page size is provided, a default value of
10 is used.

* fix: Ignore empty RUST_LOG entries

* fix: Ordering of pages

* chg: Always keep row name in left of comparison

Improves readability

* new: Alternative cursor based pagination

Also, made all queries more explicit, and hence intentional.

* test: Added tests for both types of pagination
2025-10-08 17:03:21 -05:00
SinTan1729
08fe1ce768 chg: Make WAL mode an option
There's a low chance of corruption for older installations otherwise, I
don't want that.
2025-10-07 01:25:55 -05:00
SinTan1729
35a5f394ea chg: Do not print which expired links were deleted
This is consistent with the no logging policy.
2025-10-07 00:24:26 -05:00
SinTan1729
cd3d73c160 chg: API key access and hashing info now uses DEBUG loglevel
Also, loglevel is now configurable by the RUST_LOG variable.
2025-10-05 20:07:55 -05:00
SinTan1729
79dbc7aeba chg: Some small layout adjustments in the WebUI
The longurl should now break at any place.
2025-10-04 18:41:01 -05:00
SinTan1729
ef180831da chg: A semantic change, does not change functionality 2025-10-04 15:52:14 -05:00
SinTan1729
dcc7d94870 docs: Use directory for mounting 2025-10-03 23:20:09 -05:00
SinTan1729
a61b5ac156 fix: Use cached statement as much as possible 2025-10-03 20:32:10 -05:00
SinTan1729
833da9086c build: Fix the step for moving original resources before minification 2025-10-03 14:54:21 -05:00
SinTan1729
1c7227e5f1 fix: Some database optimizations, may help in #97
Noticed significant performance improvements
2025-10-03 14:46:13 -05:00
SinTan1729
351355ac9f docs: Fix name of Actix Web 2025-09-21 17:57:23 -05:00
24 changed files with 1301 additions and 919 deletions

View File

@@ -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
View 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

View File

@@ -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
View File

@@ -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
View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -1,7 +1,7 @@
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
<!-- SPDX-License-Identifier: MIT -->
[![github-tests-badge](https://github.com/SinTan1729/chhoto-url/actions/workflows/rust_tests.yml/badge.svg)](https://github.com/SinTan1729/chhoto-url/actions/workflows/rust_tests.yml)
[![github-tests-badge](https://github.com/SinTan1729/chhoto-url/actions/workflows/rust-tests.yml/badge.svg)](https://github.com/SinTan1729/chhoto-url/actions/workflows/rust-tests.yml)
[![docker-pulls-badge](https://img.shields.io/docker/pulls/sintan1729/chhoto-url)](https://hub.docker.com/r/sintan1729/chhoto-url)
[![maintainer-badge](https://img.shields.io/badge/maintainer-SinTan1729-blue)](https://github.com/SinTan1729)
[![latest-release-badge](https://img.shields.io/github/v/release/SinTan1729/chhoto-url?label=latest%20release)](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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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!")

View File

@@ -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"));
}

View File

@@ -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
View 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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -117,7 +117,7 @@
<button class="chhoto-button pure-button pure-button-primary">
Shorten!
</button>
<p id="alert-box">&nbsp;</p>
<div id="alert-box">&nbsp;</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">&#x1F441;</button>
<button
type="button"
id="password-eye-button"
title="Toggle Password Visibility"
>
&#x1F441;
</button>
</div>
<button
class="chhoto-button pure-button pure-button-primary"

View File

@@ -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 == "&nbsp;") {
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("&nbsp;", "black");
showAlert("&nbsp;", "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("&nbsp;", "black");
showAlert("&nbsp;", "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();

View File

@@ -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;
}