Compare commits

...

101 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
SinTan1729
20a1ac287d build: Bumped version to 6.3.2 2025-09-17 19:16:29 -05:00
SinTan1729
d3800328c1 new: Show button action on hover 2025-09-17 19:11:59 -05:00
Upa
83cac33388 Always open links in new tabs (#95)
For the main chhoto url app page -  with a long list of links , the expected behaviour for opening on links is for them to be opened in a new tab / window. this behaviour is in line with other url shorteners in the market.
2025-09-17 19:05:25 -05:00
SinTan1729
5d47b58f91 fix: Properly trim most env vars before checking 2025-09-17 16:51:21 -05:00
Diogo Correia
d4f1414b37 feat: add address env var for bind address (#94)
* feat: add address env var for bind address

Closes #93

* chg: Renamed variable and added a check

* docs: Added info about listen_address in INSTALLATION.md

* fix: Match variable name for cleaner code

---------

Co-authored-by: SinTan1729 <sayantan.santra689@gmail.com>
2025-09-16 20:25:15 -05:00
SinTan1729
77fd9a8567 docs: Link to wiktionary for pronunciation 2025-09-16 15:22:31 -05:00
SinTan1729
d6e6360ed9 docs: Some fixing and cleanup 2025-09-13 12:15:32 -05:00
SinTan1729
358db69d72 docs: Some words 2025-09-03 02:27:52 -05:00
SinTan1729
dd7bd0c628 build: Bumped version to 6.3.1 2025-09-01 13:42:40 -05:00
SinTan1729
eeb3420c87 fix: Positioning of the eye button after wrong password, and qr logo link for custom homepage 2025-09-01 00:17:32 -05:00
SinTan1729
a23da3d20a docs: Fix typo in edit 2025-08-31 14:30:39 -05:00
SinTan1729
c695644dcd build: Bumped version to 6.3.0 2025-08-31 14:03:11 -05:00
SinTan1729
b7ce945799 docs: Updated docs to match the current state 2025-08-31 13:57:42 -05:00
SinTan1729
3fe78d09b5 fix: Small bugs in the js and cleanup 2025-08-30 21:07:33 -05:00
SinTan1729
ea56e0e9aa new: whoami api endpoint for getting user role
It's implemented because many have firewall rules and rate limiting
based on 4xx responses. So they should be minimized as much as possible.
2025-08-30 19:27:04 -05:00
SinTan1729
2862975c28 new: Download button for QR code 2025-08-29 20:16:59 -05:00
SinTan1729
0ea6a5cbf9 chg: The QR code can now be downloaded 2025-08-29 15:23:05 -05:00
SinTan1729
871c95a837 fix: Some minor changes and cleaning 2025-08-29 12:12:04 -05:00
SinTan1729
b15e6e7731 docs: Added links to the svgs
The licensing is fine.
2025-08-29 11:03:54 -05:00
SinTan1729
19fb92b445 fix: Some UI quirks on mobile 2025-08-29 10:59:33 -05:00
SinTan1729
dd8712019a new: Added a toggle for password visibility 2025-08-28 22:31:14 -05:00
SinTan1729
a1ac8bb7c3 new: The qr code now has a log in the middle 2025-08-28 21:39:26 -05:00
SinTan1729
906982e514 fix: Positioning of QR in mobile 2025-08-28 19:51:15 -05:00
SinTan1729
2d1fe51bc2 new: Ability to show QR codes, fixes #54, fixes #68, fixes #52 2025-08-28 18:47:00 -05:00
SinTan1729
0d7fa73dbb fix: Disable actions on expired link 2025-08-28 17:37:22 -05:00
Sayantan Santra
35dc14d474 new: Support for editing existing links (#87)
* new: Backend for edit route works

* chg: More optmized login for add_link

Cleanup is not run anymore. Instead, UPDATE is used.

* new: Prompt before logging out

* chg: Improved edit logic

No useless cleanup

* test: Added a new test for link editing

And cleaned up some other test code as well.

* fix: Updated table styles to account for extremely long urls (#89)

* new: Some UI regarding edit

* chg: Use svg in button icons

* fix: Properly handle submitting form (only UI, api request needs to be handled)

* fix: Most of the UI quirks

* new: Set ID per row. Should be useful for smarter refreshes.

* new: Working mvp

* new: Cancel button for edit dialog

---------

Co-authored-by: Upa <upamanyudas@hotmail.com>
2025-08-28 17:24:59 -05:00
Upa
139a2e3032 fix: Updated table styles to account for extremely long urls (#89) 2025-08-18 00:03:06 -05:00
SinTan1729
167f234ac9 new: Prompt before logging out 2025-08-15 14:43:02 -05:00
SinTan1729
e9cd2812c4 chg: More optmized login for add_link
Cleanup is not run anymore. Instead, UPDATE is used.
2025-08-15 14:35:24 -05:00
SinTan1729
360d40ce30 build: Bumped version to 6.2.13 2025-08-15 00:36:49 -05:00
SinTan1729
4f606e813c fix: CVE-2025-55159 2025-08-15 00:27:52 -05:00
Amir H. Moayeri
3f08298257 Fix incorrect relative URL and ensure correct protocol in copy function (#86)
* fix: incorrect relative link on copying short URL

* refactor: centralize SITE_URL handling and protocol check

* chg: Use regex to comply with RFC 2396

* fix: Do not match ports

---------

Co-authored-by: SinTan1729 <sayantan.santra689@gmail.com>
2025-08-11 15:10:58 -05:00
SinTan1729
c918fa2317 build: Bumped version to 6.2.12 2025-08-05 21:48:20 -05:00
SinTan1729
991914b1c0 fix: Align text to left for link buttons
Longer shortlinks look nicer when broken into several lines.
2025-07-24 20:54:25 -05:00
SinTan1729
0e579ec44b build: Bumped version to 6.2.11 2025-07-24 01:45:03 -05:00
SinTan1729
65b3da2979 fix: All px are now em in styles 2025-07-24 01:44:28 -05:00
SinTan1729
30b9f6beb2 chg: Use em whenever it makes more sense 2025-07-23 23:24:29 -05:00
SinTan1729
cef641f71f chg: Use em for styles in mobile view 2025-07-23 23:06:19 -05:00
SinTan1729
ae8ec4455f fix: The position of tooltip for mobile view 2025-07-23 18:14:20 -05:00
SinTan1729
fae845e1d7 fix: Location for tooltips when table cell heights can change 2025-07-23 17:53:25 -05:00
SinTan1729
c6025519bd fix: Optimize the svg size
Used https://jakearchibald.github.io/svgomg/
2025-07-22 23:56:36 -05:00
SinTan1729
8aa4697025 fix: Clean up CSS in 404 page 2025-07-22 22:59:59 -05:00
SinTan1729
7ef6b5fb10 build: Added cargo audit step 2025-07-22 21:07:19 -05:00
SinTan1729
0d135e6574 build: Use release builds for dev images 2025-07-22 15:02:52 -05:00
SinTan1729
b89b7ed69f chg: Don't add unnecessary gap above table in mobile view 2025-07-22 00:04:44 -05:00
SinTan1729
34076ec69e fix: Align link button with :before properly
This is very minor but annoying when you notice it.
2025-07-21 23:46:26 -05:00
SinTan1729
682889f246 build: Bumped version to 6.2.10 2025-07-21 23:08:56 -05:00
SinTan1729
060bee7b1d fix: No padding for link buttons 2025-07-21 23:08:22 -05:00
SinTan1729
95fb879ba4 chg: Do the same thing for the admin-button
That is, turn it into an actual button.
2025-07-21 22:57:01 -05:00
SinTan1729
6631b1f053 chg: Use button instead of link for shortlinks
I've been told that it's more idiomatic.
Idk much about frontend. :(
2025-07-21 22:53:40 -05:00
SinTan1729
0595cd4bc2 fix: No dotted border on the last entry in mobile view 2025-07-21 14:59:03 -05:00
SinTan1729
3ed1317039 fix: Make sure that the short column has minimum width 2025-07-21 02:11:54 -05:00
SinTan1729
72e06cbdcd fix: Adjust table td sizes in light of the addition of the # col 2025-07-21 01:57:50 -05:00
SinTan1729
3cd39b4c25 new: Show index in the links table
I'll hide it in the mobile view, until and unless I come up with
a way to make it look nice there.
2025-07-21 01:46:40 -05:00
SinTan1729
a5f8dfd57a fix: Centering of the X button 2025-07-21 00:24:32 -05:00
SinTan1729
95ae288d59 chg: Use a different glyph for the X button 2025-07-21 00:24:22 -05:00
SinTan1729
b0dc457224 fix: Do not allow closing login dialog by pressing esc 2025-07-20 17:45:53 -05:00
SinTan1729
7ae2497432 fix: Don't build twice for testing 2025-07-20 17:27:24 -05:00
SinTan1729
e7b15cb356 chg: Use co-cache for fetching
This is necessary due to the nature of how displaying links work.
2025-07-20 17:27:13 -05:00
Sayantan Santra
f5c164a470 docs: Added links to maintaners' profiles 2025-07-20 15:53:42 -05:00
29 changed files with 2221 additions and 1174 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

75
CLI.md
View File

@@ -22,10 +22,21 @@ validation (see section above). If the API key is insecure, a warning will be ou
Example Linux command for generating a secure API key: `tr -dc A-Za-z0-9 </dev/urandom | head -c 128`
For each response, the response code will be `200`, `401`, `400`, `500`, or `404`, depending on the context. The routes are as follows.
#### `/api/new`
To add a link:
```bash
curl -X POST -H "X-API-Key: <YOUR_API_KEY>" -d '{"shortlink":"<shortlink>", "longlink":"<longlink>", "expiry_delay": <expiry_delay>}' http://localhost:4567/api/new
curl -X POST \
-H "X-API-Key: <YOUR_API_KEY>" \
-d '{ \
"shortlink":"<shortlink>", \
"longlink":"<longlink>", \
"expiry_delay": <expiry_delay> \
}' \
http://localhost:4567/api/new
```
An empty or missing `<shortlink>` will result in it being auto-generated.
@@ -52,10 +63,13 @@ or
}
```
#### `/api/getconfig`
To get the config for the backend:
```bash
curl -H "X-API-Key: <YOUR_API_KEY>" -d '<shortlink>' http://localhost:4567/api/getconfig
curl -H "X-API-Key: <YOUR_API_KEY>" \
-d '<shortlink>' http://localhost:4567/api/getconfig
```
(This would work without authentication in public mode.)
@@ -82,10 +96,49 @@ The server will reply in the following format.
}
```
To get information about a single shortlink:
#### `/api/whoami`
To get the current user role:
```bash
curl -H "X-API-Key: <YOUR_API_KEY>" -d '<shortlink>' http://localhost:4567/api/expand
curl -H "X-API-Key: <YOUR_API_KEY>" http://localhost:4567/api/whoami
```
The server will reply with `admin` if admin access is granted, `public` if admin access is not granted but public mode is enabled,
and `nobody` if no access is granted.
#### `/api/edit`
To edit an existing short link:
```bash
curl -X PUT \
-H "X-API-Key: <YOUR_API_KEY>" \
-d '{ \
"shortlink":"<shortlink>", \
"longlink":"<longlink>", \
"reset_hits": <bool> \
}' \
http://localhost:4567/api/edit
```
The server will reply in the following format.
```json
{
"success": true/false,
"error": false/true,
"reason": "<reason"
}
```
#### `/api/expand`
To get information about a single short link:
```bash
curl -H "X-API-Key: <YOUR_API_KEY>" \
-d '<shortlink>' http://localhost:4567/api/expand
```
The server will reply in the following format.
@@ -112,12 +165,26 @@ or
(This route is not accessible using cookie validation.)
#### `/api/all?`
To get a list of all the currently available links:
```bash
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:
```bash

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,59 +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.
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.
### `use_wal_mode` \#
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.
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.
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.
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`.
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.
### `port`
The port Chhoto URL will listen to. Defaults to `4567`.
### `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
@@ -120,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,41 +1,33 @@
# 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
.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:
cargo test --manifest-path=actix/Cargo.toml
test: audit
cargo test --release --locked --manifest-path=actix/Cargo.toml --target x86_64-unknown-linux-musl
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
audit:
cargo audit --file actix/Cargo.lock
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)
@@ -53,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)
@@ -35,7 +35,7 @@ thought were essential (e.g. hit counting).
## What does the name mean?
Chhoto (ছোট, [IPA](https://en.wikipedia.org/wiki/Help:IPA/Bengali): /tʃʰoʈo/) is the Bangla word
Chhoto (ছোট, [pronunciation](https://en.wiktionary.org/wiki/ছোট)) is the Bangla word
for small. URL means, well... URL. So the name simply means Small URL.
# Demo
@@ -46,7 +46,7 @@ Password: `chhoto-url-demo-pass`
#### Note:
- The database is cleared every 15 minutes, so don't use it for anything other than testing.
- If you host a public instance of Chhoto URL, please let know, and I'll add it to the README.
- If you host a public instance of Chhoto URL, please let me know, and I'll add it to the README.
# Features
@@ -60,6 +60,8 @@ Password: `chhoto-url-demo-pass`
stays under 5MB under normal use.)
- Counts number of hits for each short link in a privacy respecting way
i.e. only the hit is recorded, and nothing else.
- Short links can be edited after creation.
- QR codes can be generated for easy sharing.
- Supports operation using API key, and lets the user provide hashed password and API key.
- Has a mobile friendly UI, and automatic dark mode.
- Can serve a custom landing page, if needed.
@@ -69,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.
@@ -114,7 +117,8 @@ 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 shortlinks, it's strongly recommended that you use the UID `slug_style`
with a `slug_length` of 16 or more.
- If you intend to have more than a few thousand short links, it's strongly recommended that you use the UID `slug_style`
with a `slug_length` of 16 or more. Otherwise, generating new links will start to fail after a while.

View File

@@ -12,20 +12,20 @@ in the respective repos.
## Browser extension
There's an unofficial browser extension maintained by @SolninjaA for shortening URLs easily using Chhoto URL.
There's an unofficial browser extension maintained by [@SolninjaA](https://github.com/SolninjaA) for shortening URLs easily using Chhoto URL.
[You can take a look at it here.](https://github.com/SolninjaA/Chhoto-URL-Extension)
## Raycast extension
There's an unofficial Raycast extension maintained by @paranoidPhantom for shortening URLs efficiently using Chhoto URL.
There's an unofficial Raycast extension maintained by [@paranoidPhantom](https://github.com/paranoidPhantom) for shortening URLs efficiently using Chhoto URL.
[You can get it from the Raycast extension store.](https://www.raycast.com/andrei_hudalla/chhoto)
## FreeBSD port
There's an unofficial FreeBSD port maintained by @jcpsantiago for installing Chhoto URL.
There's an unofficial FreeBSD port maintained by [@jcpsantiago](https://github.com/jcpsantiago) for installing Chhoto URL.
[You can take a look at it here.](https://tangled.sh/@jcpsantiago.xyz/freebsd-ports/tree/main/www/chhoto-url)
Feel free to discuss any issues or suggestions in [#56](https://github.com/SinTan1729/chhoto-url/discussions/56).
## NixOS Package
There's an unoffical NixOS package maintained by @Defelo for Chhoto URL.
There's an unoffical NixOS package maintained by [@Defelo](https://github.com/Defelo) for Chhoto URL.
[You can take a look at it here.](https://search.nixos.org/packages?query=chhoto-url)

770
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.2.9"
version = "6.5.2"
edition = "2021"
authors = ["Sayantan Santra <sayantan[dot]santra689[at]gmail[dot]com"]
license = "mit"
@@ -33,10 +33,10 @@ rusqlite = { version = "0.37.0", features = [ "bundled" ] }
regex = "1.10.3"
rand = "0.9.0"
passwords = "3.1.16"
actix-session = { version = "0.10.0", features = [ "cookie-session" ] }
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

@@ -10,6 +10,7 @@ use crate::auth;
// Struct for storing config read form env vars that might be accessed more than once
#[derive(Clone)]
pub struct Config {
pub listen_address: String,
pub port: u16,
pub db_location: String,
pub cache_control_header: Option<String>,
@@ -26,15 +27,26 @@ 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 {
let db_location = var("db_url")
.ok()
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or(String::from("urls.sqlite"));
info!("DB Location is set to: {db_location}");
// Get the address environment variable
let listen_address = var("listen_address")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or(String::from("0.0.0.0"));
info!("Listening address is set to {listen_address}.");
// Get the port environment variable
let port = var("port")
.unwrap_or(String::from("4567"))
@@ -45,7 +57,8 @@ pub fn read() -> Config {
let cache_control_header = var("cache_control_header")
.ok()
.inspect(|h| info!("Using \"{h}\" as Cache-Control header."))
.filter(|s| !s.trim().is_empty());
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let disable_frontend = var("disable_frontend").is_ok_and(|s| s.trim() == "True");
if disable_frontend {
@@ -94,7 +107,10 @@ pub fn read() -> Config {
.inspect(|h| info!("Will use {h} hashes for password verification."));
// If the site_url env variable exists
let site_url = if let Some(provided_url) = var("site_url").ok().filter(|s| !s.trim().is_empty())
let site_url = if let Some(provided_url) = var("site_url")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
// Get first and last characters of the site_url
let mut chars = provided_url.chars();
@@ -128,7 +144,8 @@ pub fn read() -> Config {
let slug_style = var("slug_style")
.ok()
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or(String::from("Pair"));
let slug_length = var("slug_length")
.ok()
@@ -141,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()
@@ -155,10 +192,11 @@ 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 {
listen_address,
port,
db_location,
cache_control_header,
@@ -175,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,9 +136,7 @@ pub fn add_link(
longlink: &str,
expiry_delay: i64,
db: &Connection,
) -> Result<i64, Error> {
cleanup(db); // So that expired links don't block new ones from being added
) -> Result<i64, ChhotoError> {
let now = chrono::Utc::now().timestamp();
let expiry_time = if expiry_delay == 0 {
0
@@ -96,54 +144,112 @@ pub fn add_link(
now + expiry_delay
};
db.execute(
"INSERT INTO urls (long_url, short_url, hits, expiry_time) VALUES (?1, ?2, ?3, ?4)",
(longlink, shortlink, 0, expiry_time),
)
.map(|_| expiry_time)
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)
}
}
}
// Edit an existing link
pub fn edit_link(
shortlink: &str,
longlink: &str,
reset_hits: bool,
db: &Connection,
) -> Result<usize, ()> {
let now = chrono::Utc::now().timestamp();
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 expiry_time > ?3)"
};
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;
@@ -152,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),
)
@@ -166,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
[],
)
@@ -207,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};
@@ -66,17 +72,32 @@ async fn main() -> Result<()> {
let conf = config::read();
// Tell the user that the server has started, and where it is listening to, rather than simply outputting nothing
info!("Server has started at 0.0.0.0 on port {}.", conf.port);
info!(
"Server has started listening to {} on port {}.",
conf.listen_address, conf.port
);
// 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);
}
});
@@ -97,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 {
@@ -106,6 +127,7 @@ async fn main() -> Result<()> {
middleware::DefaultHeaders::new()
})
.service(services::link_handler)
.service(services::edit_link)
.service(services::getall)
.service(services::siteurl)
.service(services::version)
@@ -114,7 +136,8 @@ async fn main() -> Result<()> {
.service(services::delete_link)
.service(services::login)
.service(services::logout)
.service(services::expand);
.service(services::expand)
.service(services::whoami);
if !conf.disable_frontend {
if let Some(dir) = &conf.custom_landing_directory {
@@ -130,7 +153,7 @@ async fn main() -> Result<()> {
app.default_service(actix_web::web::get().to(services::error404))
})
// Hardcode the port the server listens to. Allows for more intuitive Docker Compose port management
.bind(("0.0.0.0", conf.port))?
.bind((conf.listen_address, conf.port))?
.run()
.await
}

View File

@@ -6,31 +6,38 @@ use actix_session::Session;
use actix_web::{
delete, get,
http::StatusCode,
post,
post, put,
web::{self, Redirect},
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");
// Define JSON struct for returning JSON data
#[derive(Serialize)]
struct Response {
success: bool,
error: bool,
reason: String,
// Error types
pub enum ChhotoError {
ServerError,
ClientError { reason: String },
}
// Defin JSON struct for returning backend config
// Define JSON struct for returning success/error data
#[derive(Serialize)]
pub struct JSONResponse {
pub success: bool,
pub error: bool,
pub reason: String,
}
// Define JSON struct for returning backend config
#[derive(Serialize)]
struct BackendConfig {
version: String,
@@ -52,7 +59,7 @@ struct CreatedURL {
expiry_time: i64,
}
// Struct for returning information about a shortlink
// Struct for returning information about a shortlink in expand
#[derive(Serialize)]
struct LinkInfo {
success: bool,
@@ -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,53 +156,99 @@ 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 {
let body = if config.public_mode {
format!("Using public mode. {}", config.public_mode_expiry_delay)
} else {
String::from("Not logged in!")
};
HttpResponse::Unauthorized().body(body)
HttpResponse::Unauthorized().body("Not logged in!")
}
}
// 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)
}
}
// Get information about a single shortlink
#[put("/api/edit")]
pub async fn edit_link(
req: String,
session: Session,
data: web::Data<AppState>,
http: HttpRequest,
) -> HttpResponse {
let config = &data.config;
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)
}
Err(ClientError { reason }) => {
let body = JSONResponse {
success: false,
error: true,
reason,
};
HttpResponse::BadRequest().json(body)
}
}
} else {
HttpResponse::Unauthorized().json(result)
@@ -203,6 +275,25 @@ pub async fn version() -> HttpResponse {
HttpResponse::Ok().body(format!("Chhoto URL v{VERSION}"))
}
// Get the user's current role
#[get("/api/whoami")]
pub async fn whoami(
data: web::Data<AppState>,
session: Session,
http: HttpRequest,
) -> HttpResponse {
let config = &data.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"
} else {
"nobody"
};
HttpResponse::Ok().body(acting_user)
}
// Get some useful backend config
#[get("/api/getconfig")]
pub async fn getconfig(
@@ -211,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,
@@ -243,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 {
@@ -276,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()
@@ -294,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(),
@@ -307,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(),
@@ -354,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::*;
@@ -34,6 +33,8 @@ struct CreatedURL {
shorturl: String,
#[serde(default)]
longurl: String,
#[serde(default)]
hits: i64,
}
#[derive(Deserialize)]
@@ -44,6 +45,7 @@ struct BackendConfig {
fn default_config(test: &str) -> config::Config {
let conf = config::Config {
listen_address: String::from("0.0.0.0"),
port: 4567,
db_location: format!("/tmp/chhoto-url-test-{test}.sqlite"),
cache_control_header: None,
@@ -60,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
}
@@ -72,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)
@@ -81,7 +89,9 @@ async fn create_app(
.service(services::add_link)
.service(services::getall)
.service(services::link_handler)
.service(services::edit_link)
.service(services::delete_link)
.service(services::whoami)
.service(services::expand),
)
.await;
@@ -96,7 +106,7 @@ async fn add_link<T: Service<Request, Response = ServiceResponse, Error = Error>
) -> (StatusCode, CreatedURL) {
let req = test::TestRequest::post().uri("/api/new")
.insert_header(("X-API-Key", api_key))
.set_payload(format!("{{\"shortlink\": \"{shortlink}\", \"longlink\": \"https://example-{shortlink}.com\", \"expiry_delay\": {expiry_delay}}}"))
.set_payload(format!("{{\"shortlink\":\"{shortlink}\",\"longlink\":\"https://example-{shortlink}.com\",\"expiry_delay\":{expiry_delay}}}"))
.to_request();
let resp = test::call_service(&app, req).await;
@@ -107,6 +117,39 @@ async fn add_link<T: Service<Request, Response = ServiceResponse, Error = Error>
(status, url)
}
async fn expand<T: Service<Request, Response = ServiceResponse, Error = Error>, S: Display>(
app: T,
api_key: &str,
shortlink: S,
) -> (StatusCode, CreatedURL) {
let req = test::TestRequest::post()
.uri("/api/expand")
.insert_header(("X-API-Key", api_key))
.set_payload(shortlink.to_string())
.to_request();
let resp = test::call_service(&app, req).await;
let status = resp.status();
let body = to_bytes(resp.into_body()).await.unwrap();
let url: CreatedURL = serde_json::from_str(body.as_str()).unwrap();
(status, url)
}
async fn edit_link<T: Service<Request, Response = ServiceResponse, Error = Error>>(
app: T,
api_key: &str,
shortlink: &str,
reset_hits: bool,
) -> StatusCode {
let req = test::TestRequest::put()
.uri("/api/edit")
.insert_header(("X-API-Key", api_key))
.set_payload(format!("{{\"shortlink\":\"{shortlink}\",\"longlink\":\"https://edited-{shortlink}.com\",\"reset_hits\":{reset_hits}}}"))
.to_request();
let resp = test::call_service(&app, req).await;
resp.status()
}
//
// The tests start here
//
@@ -122,6 +165,18 @@ async fn basic_site_config() {
let body = to_bytes(resp.into_body()).await.unwrap();
assert_eq!(body.as_str(), conf.site_url.unwrap());
let req = test::TestRequest::get().uri("/api/whoami").to_request();
let resp = test::call_service(&app, req).await;
let body = to_bytes(resp.into_body()).await.unwrap();
assert_eq!(body.as_str(), "nobody");
let req = test::TestRequest::get()
.uri("/api/whoami")
.insert_header(("X-API-Key", conf.api_key.clone().unwrap()))
.to_request();
let resp = test::call_service(&app, req).await;
let body = to_bytes(resp.into_body()).await.unwrap();
assert_eq!(body.as_str(), "admin");
let req = test::TestRequest::get().uri("/api/version").to_request();
let resp = test::call_service(&app, req).await;
let body = to_bytes(resp.into_body()).await.unwrap();
@@ -235,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");
@@ -252,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"));
}
@@ -383,16 +462,55 @@ async fn link_expiry() {
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_client_error());
let req = test::TestRequest::post()
.uri("/api/expand")
.insert_header(("X-API-Key", api_key.clone()))
.set_payload("test1")
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_client_error());
let (status, _) = expand(&app, &api_key, "test1").await;
assert!(status.is_client_error());
// We should be able to add it again right away
let (status, _) = add_link(&app, &api_key, "test1", 10).await;
assert!(status.is_success());
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn link_editing() {
let test = "link-editing";
let conf = default_config(test);
let app = create_app(&conf, test).await;
let api_key = conf.api_key.clone().unwrap();
let (status, _) = add_link(&app, &api_key, "test1", 0).await;
assert!(status.is_success());
let (status, _) = add_link(&app, &api_key, "test2", 1).await;
assert!(status.is_success());
let req = test::TestRequest::get().uri("/test2").to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_redirection());
let status = edit_link(&app, &api_key, "test2", false).await;
assert!(status.is_success());
let (status, reply) = expand(&app, &api_key, "test2").await;
assert!(status.is_success());
assert_eq!(reply.longurl, "https://edited-test2.com");
assert_eq!(reply.hits, 1);
let req = test::TestRequest::get().uri("/test1").to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_redirection());
let status = edit_link(&app, &api_key, "test1", true).await;
assert!(status.is_success());
let (status, reply) = expand(&app, &api_key, "test1").await;
assert!(status.is_success());
assert_eq!(reply.longurl, "https://edited-test1.com");
assert_eq!(reply.hits, 0);
let one_second = Duration::from_secs(1);
sleep(one_second);
let status = edit_link(&app, &api_key, "test2", true).await;
assert!(status.is_client_error());
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}

View File

@@ -1,18 +1,25 @@
// 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
// Struct for reading link pairs sent during API call for new link
#[derive(Deserialize)]
struct URLPair {
struct NewURLRequest {
#[serde(default)]
shortlink: String,
longlink: String,
@@ -20,85 +27,16 @@ struct URLPair {
expiry_delay: i64,
}
// Define JSON struct for response
#[derive(Serialize)]
pub struct Response {
pub(crate) success: bool,
pub(crate) error: bool,
reason: String,
pass: bool,
}
// If the api_key environment variable exists
pub fn is_api_ok(http: HttpRequest, 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(
// Struct for reading link pairs sent during API call for editing link
#[derive(Deserialize)]
struct EditURLRequest {
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)
}
longlink: String,
reset_hits: bool,
}
// 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 {
@@ -108,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
let mut chunks: URLPair;
if let Ok(json) = serde_json::from_str(&req) {
) -> Result<(String, i64), ChhotoError> {
// Ok : shortlink, expiry_time
let mut chunks: NewURLRequest;
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;
@@ -151,52 +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: &str, db: &Connection, config: &Config) -> Result<(), ChhotoError> {
let chunks: EditURLRequest;
if let Ok(json) = serde_json::from_str(req) {
chunks = json;
} else {
return Err(ClientError {
reason: "Malformed request!".to_string(),
});
}
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);
match result {
// Zero rows returned means no updates
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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -47,6 +47,10 @@
sizes="196x196"
/>
<script
src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"
async
></script>
<script src="static/script.js" defer></script>
<link
rel="stylesheet"
@@ -67,7 +71,7 @@
<form class="pure-form pure-form-aligned" name="new-url-form">
<fieldset>
<legend id="logo">
<img src="assets/favicon.svg" width="26px" alt="logo" /> Chhoto URL
<img src="assets/favicon.svg" alt="chhoto-url-logo" /> Chhoto URL
</legend>
<div class="pure-control-group">
<label for="longUrl">Long URL</label>
@@ -113,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>
@@ -121,16 +125,29 @@
<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>
<tr>
<th id="short-url-header">Short URL (click to copy)</th>
<th name="numColumn">#</th>
<th id="short-url-header">Short URL</th>
<th>Long URL</th>
<th name="hitsColumn">Hits</th>
<th name="expiryColumn">Expiry</th>
<th name="deleteBtn">&times;</th>
<th name="actions">Actions</th>
</tr>
</thead>
<tbody id="url-table">
@@ -140,7 +157,7 @@
</div>
<div name="links-div">
<a id="admin-button" href="#" hidden>login</a>
<button class="linkButton" id="admin-button" hidden>login</button>
&nbsp;
<a
id="version-number"
@@ -153,10 +170,19 @@
<!-- The version number would be inserted here -->
</div>
<dialog id="login-dialog">
<dialog id="login-dialog" closedby="none">
<form class="pure-form" name="login-form">
<p>Please enter password to access this website</p>
<input class="chhoto-input" type="password" id="password" />
<div>
<input class="chhoto-input" type="password" id="password" />
<button
type="button"
id="password-eye-button"
title="Toggle Password Visibility"
>
&#x1F441;
</button>
</div>
<button
class="chhoto-button pure-button pure-button-primary"
value="default"
@@ -166,5 +192,76 @@
<p id="wrong-pass" hidden>Wrong password!</p>
</form>
</dialog>
<dialog id="edit-dialog">
<form class="pure-form pure-form-stacked" name="edit-form">
<p>
Enter new long url for <span id="edit-link">placeholder</span>. <br />
Please check twice before you submit. It cannot be undone.
</p>
<fieldset>
<input class="chhoto-input" type="url" id="edited-url" />
<label for="edit-checkbox">
<input type="checkbox" id="edit-checkbox" checked="unchecked" />
Reset hit count
</label>
<button
class="chhoto-button pure-button pure-button-primary"
id="edit-cancel-button"
type="button"
>
Cancel
</button>
<button
class="chhoto-button pure-button pure-button-primary"
value="default"
>
Submit
</button>
</fieldset>
</form>
</dialog>
<dialog id="qr-code-dialog">
<!-- https://svgicons.com/icon/10667/download-solid -->
<a class="qr-button" id="qr-download" href="">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
fill-rule="evenodd"
d="M5 16.25a.75.75 0 0 1 .75.75v2c0 .138.112.25.25.25h12a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2A1.75 1.75 0 0 1 18 20.75H6A1.75 1.75 0 0 1 4.25 19v-2a.75.75 0 0 1 .75-.75"
clip-rule="evenodd"
/>
<path
fill="currentColor"
d="M10.738 3.75a.992.992 0 0 0-.988.906a36.618 36.618 0 0 0-.082 5.27c-.247.013-.493.03-.74.047l-1.49.109a.76.76 0 0 0-.585 1.167a15.555 15.555 0 0 0 4.032 4.258l.597.429a.888.888 0 0 0 1.036 0l.597-.429a15.556 15.556 0 0 0 4.032-4.258a.76.76 0 0 0-.585-1.167l-1.49-.109a42.274 42.274 0 0 0-.74-.047a36.62 36.62 0 0 0-.081-5.27a.992.992 0 0 0-.989-.906z"
/>
</svg>
</a>
<!-- https://svgicons.com/icon/13141/cross-filled -->
<button class="qr-button" id="qr-close">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g fill="currentColor" fill-rule="evenodd" clip-rule="evenodd">
<path
d="M5.47 5.47a.75.75 0 0 1 1.06 0l12 12a.75.75 0 1 1-1.06 1.06l-12-12a.75.75 0 0 1 0-1.06"
/>
<path
d="M18.53 5.47a.75.75 0 0 1 0 1.06l-12 12a.75.75 0 0 1-1.06-1.06l12-12a.75.75 0 0 1 1.06 0"
/>
</g>
</svg>
</button>
<div id="qr-code"></div>
</dialog>
</body>
</html>

View File

@@ -21,11 +21,10 @@
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: light dark;
}
* {
font-family: Montserrat;
}
body {

View File

@@ -1,11 +1,35 @@
// 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
SVG_COPY_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M9 3.25A5.75 5.75 0 0 0 3.25 9v7.107a.75.75 0 0 0 1.5 0V9A4.25 4.25 0 0 1 9 4.75h7.013a.75.75 0 0 0 0-1.5z"/><path fill="currentColor" fill-rule="evenodd" d="M18.403 6.793a44.372 44.372 0 0 0-9.806 0a2.011 2.011 0 0 0-1.774 1.76a42.581 42.581 0 0 0 0 9.894a2.01 2.01 0 0 0 1.774 1.76c3.241.362 6.565.362 9.806 0a2.01 2.01 0 0 0 1.774-1.76a42.579 42.579 0 0 0 0-9.894a2.011 2.011 0 0 0-1.774-1.76M8.764 8.284c3.13-.35 6.342-.35 9.472 0a.51.51 0 0 1 .45.444a40.95 40.95 0 0 1 0 9.544a.51.51 0 0 1-.45.444c-3.13.35-6.342.35-9.472 0a.511.511 0 0 1-.45-.444a40.95 40.95 0 0 1 0-9.544a.511.511 0 0 1 .45-.444" clip-rule="evenodd"/></svg>`;
// https://svgicons.com/icon/1207/qrcode-outlined
SVG_QR_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1024 1024"><path fill="currentColor" d="M468 128H160c-17.7 0-32 14.3-32 32v308c0 4.4 3.6 8 8 8h332c4.4 0 8-3.6 8-8V136c0-4.4-3.6-8-8-8m-56 284H192V192h220zm-138-74h56c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8m194 210H136c-4.4 0-8 3.6-8 8v308c0 17.7 14.3 32 32 32h308c4.4 0 8-3.6 8-8V556c0-4.4-3.6-8-8-8m-56 284H192V612h220zm-138-74h56c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8m590-630H556c-4.4 0-8 3.6-8 8v332c0 4.4 3.6 8 8 8h332c4.4 0 8-3.6 8-8V160c0-17.7-14.3-32-32-32m-32 284H612V192h220zm-138-74h56c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8m194 210h-48c-4.4 0-8 3.6-8 8v134h-78V556c0-4.4-3.6-8-8-8H556c-4.4 0-8 3.6-8 8v332c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V644h78v102c0 4.4 3.6 8 8 8h190c4.4 0 8-3.6 8-8V556c0-4.4-3.6-8-8-8M746 832h-48c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8m142 0h-48c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8"/></svg>`;
// https://svgicons.com/icon/10674/edit-outline
SVG_EDIT_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="M21.455 5.416a.75.75 0 0 1-.096.943l-9.193 9.192a.75.75 0 0 1-.34.195l-3.829 1a.75.75 0 0 1-.915-.915l1-3.828a.778.778 0 0 1 .161-.312L17.47 2.47a.75.75 0 0 1 1.06 0l2.829 2.828a.756.756 0 0 1 .096.118m-1.687.412L18 4.061l-8.518 8.518l-.625 2.393l2.393-.625z" clip-rule="evenodd"/><path fill="currentColor" d="M19.641 17.16a44.4 44.4 0 0 0 .261-7.04a.403.403 0 0 1 .117-.3l.984-.984a.198.198 0 0 1 .338.127a45.91 45.91 0 0 1-.21 8.372c-.236 2.022-1.86 3.607-3.873 3.832a47.77 47.77 0 0 1-10.516 0c-2.012-.225-3.637-1.81-3.873-3.832a45.922 45.922 0 0 1 0-10.67c.236-2.022 1.86-3.607 3.873-3.832a47.75 47.75 0 0 1 7.989-.213a.2.2 0 0 1 .128.34l-.993.992a.402.402 0 0 1-.297.117a46.164 46.164 0 0 0-6.66.255a2.89 2.89 0 0 0-2.55 2.516a44.421 44.421 0 0 0 0 10.32a2.89 2.89 0 0 0 2.55 2.516c3.355.375 6.827.375 10.183 0a2.89 2.89 0 0 0 2.55-2.516"/></svg>`;
// https://svgicons.com/icon/10955/trash-solid
SVG_DELETE_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M10 2.25a.75.75 0 0 0-.75.75v.75H5a.75.75 0 0 0 0 1.5h14a.75.75 0 0 0 0-1.5h-4.25V3a.75.75 0 0 0-.75-.75zM13.06 15l1.47 1.47a.75.75 0 1 1-1.06 1.06L12 16.06l-1.47 1.47a.75.75 0 1 1-1.06-1.06L10.94 15l-1.47-1.47a.75.75 0 1 1 1.06-1.06L12 13.94l1.47-1.47a.75.75 0 1 1 1.06 1.06z"/><path fill="currentColor" fill-rule="evenodd" d="M5.991 7.917a.75.75 0 0 1 .746-.667h10.526a.75.75 0 0 1 .746.667l.2 1.802c.363 3.265.363 6.56 0 9.826l-.02.177a2.853 2.853 0 0 1-2.44 2.51a27.04 27.04 0 0 1-7.498 0a2.853 2.853 0 0 1-2.44-2.51l-.02-.177a44.489 44.489 0 0 1 0-9.826zm1.417.833l-.126 1.134a42.99 42.99 0 0 0 0 9.495l.02.177a1.353 1.353 0 0 0 1.157 1.191c2.35.329 4.733.329 7.082 0a1.353 1.353 0 0 0 1.157-1.19l.02-.178c.35-3.155.35-6.34 0-9.495l-.126-1.134z" clip-rule="evenodd"/></svg>`;
// https://svgicons.com/icon/10689/eye-solid
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 = {
@@ -25,31 +49,44 @@ const prepSubdir = (link) => {
return (SUBDIR + link).replace("//", "/");
};
const hasProtocol = (url) => {
const regex = /[A-Za-z][A-Za-z0-9\+\-\.]*\:(?:\/\/)?.*\D.*/; // RFC 2396 Appendix A
return regex.test(url);
};
const getConfig = async () => {
if (!CONFIG) {
CONFIG = await fetch(prepSubdir("/api/getconfig"))
CONFIG = await fetch(prepSubdir("/api/getconfig"), { cache: "no-cache" })
.then((res) => res.json())
.catch((err) => {
console.log("Error while fetching config.");
});
if (CONFIG.site_url == null) {
SITE_URL = window.location.host.replace(/\/$/, "");
SITE_URL = window.location.host;
} else {
SITE_URL = CONFIG.site_url
.replace(/\/$/, "")
.replace(/^"/, "")
.replace(/"$/, "");
}
VERSION = CONFIG.version;
if (!hasProtocol(SITE_URL)) {
SITE_URL = window.location.protocol + "//" + SITE_URL;
}
}
VERSION = CONFIG.version;
};
const showVersion = () => {
const link = document.getElementById("version-number");
link.innerText = "v" + VERSION;
link.href =
"https://github.com/SinTan1729/chhoto-url/releases/tag/" + VERSION;
link.hidden = false;
if (VERSION) {
link.innerText = "v" + VERSION;
link.href =
"https://github.com/SinTan1729/chhoto-url/releases/tag/" + VERSION;
link.hidden = false;
} else {
link.hidden = true;
}
};
const showLogin = () => {
@@ -60,65 +97,130 @@ const showLogin = () => {
const refreshData = async () => {
try {
const res = await fetch(prepSubdir("/api/all"));
switch (res.status) {
case 200:
const data = await res.json();
await getConfig();
ADMIN = true;
displayData(data.reverse());
break;
case 401:
const loading_text = document.getElementById("loading-text");
const admin_button = document.getElementById("admin-button");
document.getElementById("table-box").hidden = true;
loading_text.hidden = false;
admin_button.innerText = "login";
const errorMsg = await res.text();
document.getElementById("url-table").innerHTML = "";
if (errorMsg.startsWith("Using public mode.")) {
admin_button.hidden = false;
loading_text.innerHTML = "Using public mode.";
const expiry = parseInt(errorMsg.split(" ").pop());
if (expiry > 0) {
loading_text.innerHTML +=
" Unless chosen a shorter expiry time, submitted links will automatically expire ";
const time = new Date();
time.setSeconds(time.getSeconds() + expiry);
loading_text.innerHTML += formatRelativeTime(time) + ".";
}
await getConfig();
showVersion();
updateInputBox();
} else {
showLogin();
const loading_text = document.getElementById("loading-text");
const admin_button = document.getElementById("admin-button");
if (!ADMIN) {
const res = await fetch(prepSubdir("/api/whoami"), { cache: "no-cache" });
if (res.status == 200) {
const role = await res.text();
switch (role) {
case "nobody":
showLogin();
break;
case "public":
await getConfig();
loading_text.innerHTML = "Using public mode.";
const expiry = parseInt(CONFIG.public_mode_expiry_delay);
if (expiry > 0) {
loading_text.innerHTML +=
" Unless chosen a shorter expiry time, submitted links will automatically expire ";
const time = new Date();
time.setSeconds(time.getSeconds() + expiry);
loading_text.innerHTML += formatRelativeTime(time) + ".";
}
admin_button.innerText = "login";
admin_button.hidden = false;
updateInputBox();
break;
case "admin":
ADMIN = true;
await getConfig();
break;
default:
throw Error("Got undefined user role.");
}
break;
default:
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
} else {
throw Error("There was an issue getting user role.");
}
}
showVersion();
if (ADMIN) {
const params = new URLSearchParams();
if (LOCAL_DATA.length == 0) {
params.append("page_size", "20");
} else {
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;
document.getElementById("url-table").innerHTML = "";
}
} catch (err) {
console.log(err);
showAlert(
`Could not copy short URL to clipboard, please do it manually: ${link_elt}`,
"light-dark(red, #ff1a1a)",
);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
}
};
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";
@@ -135,25 +237,35 @@ const displayData = (data) => {
loading_text.hidden = false;
} else {
loading_text.hidden = true;
if (!window.isSecureContext) {
const shortUrlHeader = document.getElementById("short-url-header");
shortUrlHeader.innerHTML = "Short URL<br>(right click and copy)";
}
table_box.hidden = false;
table.innerHTML = "";
data.forEach((tr) => table.appendChild(TR(tr)));
for (const [i, row] of data.entries()) {
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;
controls.appendChild(alertBox);
if (text == "&nbsp;") {
alertBox.removeAttribute("style");
} else {
alertBox.style.display = "block";
}
};
const refreshExpiryTimes = async () => {
@@ -163,7 +275,10 @@ 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;
}
}
let div = td.firstChild;
div.innerHTML = div.innerHTML.replace(div.innerText, relativeTime);
@@ -189,37 +304,30 @@ const formatRelativeTime = (timestamp) => {
}
};
const TD = (s, u, t) => {
const TD = (s, u) => {
const td = document.createElement("td");
const div = document.createElement("div");
div.innerHTML = s;
if (t != null) {
div.onclick = async (e) => {
e.preventDefault();
await copyShortUrl(t);
};
}
td.appendChild(div);
if (u !== null) td.setAttribute("label", u);
return td;
};
const TR = (row) => {
const TR = (i, row) => {
const tr = document.createElement("tr");
const longTD = TD(A_LONG(row["longlink"]), "Long URL", null);
let shortTD;
const isSafari =
/Safari/.test(navigator.userAgent) &&
/Apple Computer/.test(navigator.vendor);
// For now, we disable copying on WebKit due to a possible bug. Manual copying is enabled instead.
// Take a look at https://github.com/SinTan1729/chhoto-url/issues/36
if (window.isSecureContext && !isSafari) {
let shortlink = row["shortlink"];
shortTD = TD(A_SHORT(shortlink, "Short URL"), "Short URL", shortlink);
} else {
shortTD = TD(A_SHORT_INSECURE(row["shortlink"]), "Short URL", null);
}
const hitsTD = TD(row["hits"], null, null);
const numTD = TD(i, null);
numTD.setAttribute("name", "numColumn");
const longlink = row["longlink"];
const longTD = TD(A_LONG(longlink), "Long URL");
const shortlink = row["shortlink"];
tr.id = shortlink;
const shortTD = TD(A_SHORT(shortlink), "Short URL");
shortTD.setAttribute("name", "shortColumn");
const hitsTD = TD(row["hits"], null);
hitsTD.setAttribute("label", "Hits");
hitsTD.setAttribute("name", "hitsColumn");
@@ -236,7 +344,7 @@ const TR = (row) => {
"</span>";
}
let expiryTD = TD(expiryHTML, null, null);
let expiryTD = TD(expiryHTML, null);
if (expiryTime > 0) {
expiryTD.width = "160px";
expiryTD.setAttribute("data-time", expiryTime);
@@ -245,83 +353,173 @@ const TR = (row) => {
expiryTD.setAttribute("label", "Expiry");
expiryTD.setAttribute("name", "expiryColumn");
const btn = deleteButton(row["shortlink"]);
tr.appendChild(shortTD);
tr.appendChild(longTD);
tr.appendChild(hitsTD);
tr.appendChild(expiryTD);
tr.appendChild(btn);
const actionsTD = document.createElement("td");
actionsTD.setAttribute("name", "actions");
actionsTD.setAttribute("label", "Actions");
const btnGrp = document.createElement("div");
btnGrp.classList.add("pure-button-group");
btnGrp.role = "group";
btnGrp.appendChild(copyButton(shortlink));
btnGrp.appendChild(qrCodeButton(shortlink));
btnGrp.appendChild(editButton(shortlink, longlink));
btnGrp.appendChild(deleteButton(shortlink));
actionsTD.appendChild(btnGrp);
for (const td of [numTD, shortTD, longTD, hitsTD, expiryTD, actionsTD]) {
tr.appendChild(td);
}
return tr;
};
const copyShortUrl = async (short_link) => {
const full_link = `${SITE_URL}/${short_link}`;
const link_elt = `<a href=${full_link}>${full_link}</a>`;
const link_elt = `<a href=${full_link} target="_blank">${full_link}</a>`;
try {
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)",
);
}
};
const addProtocol = () => {
const input = document.getElementById("longUrl");
const addHTTPSToLongURL = (id) => {
const input = document.getElementById(id);
let url = input.value.trim();
if (url !== "" && !~url.indexOf("://") && !~url.indexOf("magnet:")) {
if (!!url && !hasProtocol(url)) {
url = "https://" + url;
}
input.value = url;
return input;
};
const A_LONG = (s) => `<a href='${s}'>${s}</a>`;
const A_SHORT = (s) => `<a href="#!">${s}</a>`;
const A_SHORT_INSECURE = (s, t) => `<a href="${t}/${s}">${s}</a>`;
const A_LONG = (s) => `<a href='${s}' target="_blank">${s}</a>`;
const A_SHORT = (s) => `<a href="${SITE_URL}/${s}" target="_blank">${s}</a>`;
const copyButton = (shortUrl) => {
const btn = document.createElement("button");
btn.classList.add("svg-button");
btn.innerHTML = SVG_COPY_BUTTON;
btn.title = "Copy Short URL";
btn.onclick = (e) => {
e.preventDefault();
copyShortUrl(shortUrl);
};
return btn;
};
const editButton = (shortUrl, longUrl) => {
const btn = document.createElement("button");
btn.classList.add("svg-button");
btn.innerHTML = SVG_EDIT_BUTTON;
btn.title = "Edit Short URL";
btn.onclick = () => {
document.getElementById("container").style.filter = "blur(2px)";
document.getElementById("edit-dialog").showModal();
const editUrlSpan = document.getElementById("edit-link");
const editedUrl = document.getElementById("edited-url");
if (editUrlSpan.textContent != shortUrl) {
editUrlSpan.textContent = shortUrl;
document.getElementById("edit-checkbox").checked = false;
editedUrl.value = longUrl;
}
editedUrl.focus();
};
return btn;
};
const qrCodeButton = (shortlink) => {
const btn = document.createElement("button");
btn.classList.add("svg-button");
btn.innerHTML = SVG_QR_BUTTON;
btn.title = "Show QR Code";
btn.onclick = () => {
const tmpDiv = document.createElement("div");
new QRCode(tmpDiv, {
text: `${SITE_URL}/${shortlink}`,
correctLevel: QRCode.CorrectLevel.H,
});
const oldCanvas = tmpDiv.firstChild;
const padding = "12";
const newCanvas = document.createElement("canvas");
newCanvas.height = 280;
newCanvas.width = 280;
const ctx = newCanvas.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 280, 280);
ctx.drawImage(oldCanvas, 12, 12);
const img = new Image();
img.src = "assets/favicon.svg";
img.onload = () => {
ctx.fillStyle = "white";
ctx.beginPath();
ctx.arc(140, 140, 30, 0, Math.PI * 2);
ctx.fill();
const imgWidth = 50;
const imgHeight = 50;
ctx.drawImage(img, 115, 115, 50, 50);
document.getElementById("qr-code").appendChild(newCanvas);
const qrDown = document.getElementById("qr-download");
qrDown.href = newCanvas.toDataURL();
qrDown.download = `chhoto-qr-${shortlink}.png`;
document.getElementById("container").style.filter = "blur(2px)";
document.getElementById("qr-code-dialog").showModal();
};
};
return btn;
};
const deleteButton = (shortUrl) => {
const td = document.createElement("td");
const div = document.createElement("div");
const btn = document.createElement("button");
btn.innerHTML = "&times;";
btn.classList.add("svg-button");
btn.innerHTML = SVG_DELETE_BUTTON;
btn.title = "Delete Short URL";
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",
})
.then(async (res) => {
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)",
);
});
}
};
td.setAttribute("name", "deleteBtn");
td.setAttribute("label", "Delete");
div.appendChild(btn);
td.appendChild(div);
return td;
return btn;
};
const submitForm = () => {
@@ -340,6 +538,7 @@ const submitForm = () => {
fetch(url, {
method: "POST",
cache: "no-cache",
headers: {
"Content-Type": "application/json",
},
@@ -351,14 +550,23 @@ const submitForm = () => {
})
.then(async (text) => {
if (!ok) {
showAlert(text, "light-dark(red, #ff1a1a)");
await refreshData();
showAlert(text, "light-dark(red, #a01e1e)");
} else {
await copyShortUrl(text);
longUrl.value = "";
shortUrl.value = "";
expiryDelay.value = 0;
await refreshData();
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();
}
})
.catch((err) => {
@@ -369,10 +577,64 @@ const submitForm = () => {
});
};
const submitEdit = () => {
const urlInput = document.getElementById("edited-url");
const editUrlSpan = document.getElementById("edit-link");
const longUrl = urlInput.value;
const shortUrl = editUrlSpan.textContent;
const checkBox = document.getElementById("edit-checkbox");
if (confirm("Are you sure that you want to edit " + shortUrl + "?")) {
data = {
shortlink: shortUrl,
longlink: longUrl,
reset_hits: checkBox.checked,
};
const url = prepSubdir("/api/edit");
let ok = false;
fetch(url, {
method: "PUT",
cache: "no-cache",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
.then((res) => {
ok = res.ok;
return res.text();
})
.then(async (text) => {
if (!ok) {
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;
}
displayData();
})
.catch((err) => {
console.log("Error:", err);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
});
}
};
const submitLogin = () => {
const password = document.getElementById("password");
fetch(prepSubdir("/api/login"), {
method: "POST",
cache: "no-cache",
body: password.value,
})
.then(async (res) => {
@@ -383,6 +645,7 @@ const submitLogin = () => {
password.value = "";
document.getElementById("wrong-pass").hidden = true;
ADMIN = true;
await getConfig();
await refreshData();
break;
case 401:
@@ -402,33 +665,45 @@ const submitLogin = () => {
};
const logOut = async () => {
await fetch(prepSubdir("/api/logout"), { method: "DELETE" })
.then(async (res) => {
if (res.ok) {
document.getElementById("version-number").hidden = true;
document.getElementById("admin-button").hidden = true;
showAlert("&nbsp;", "black");
ADMIN = false;
await refreshData();
} else {
showAlert(
`Logout failed. Please try again!`,
"light-dark(red, #ff1a1a)",
);
}
if (confirm("Are you sure you want to log out?")) {
await fetch(prepSubdir("/api/logout"), {
method: "DELETE",
cache: "no-cache",
})
.catch((err) => {
console.log("Error:", err);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
});
.then(async (res) => {
if (res.ok) {
document.getElementById("version-number").hidden = true;
document.getElementById("admin-button").hidden = true;
showAlert("&nbsp;", "transparent");
ADMIN = false;
VERSION = null;
LOCAL_DATA = [];
await refreshData();
} else {
showAlert(
`Logout failed. Please try again!`,
"light-dark(red, #a01e1e)",
);
}
})
.catch((err) => {
console.log("Error:", err);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
});
}
};
// This is where loading starts
refreshData()
.then(() => {
document.getElementById("longUrl").onblur = addProtocol;
document.getElementById("longUrl").onblur = () => {
addHTTPSToLongURL("longUrl");
};
document.getElementById("edited-url").onblur = () => {
addHTTPSToLongURL("edited-url");
};
const form = document.forms.namedItem("new-url-form");
form.onsubmit = (e) => {
e.preventDefault();
@@ -444,8 +719,53 @@ refreshData()
}
};
const login_form = document.forms.namedItem("login-form");
login_form.onsubmit = (e) => {
const editDialog = document.getElementById("edit-dialog");
editDialog.onclose = () => {
document.getElementById("container").style.filter = "blur(0px)";
};
document.forms.namedItem("edit-form").onsubmit = (e) => {
e.preventDefault();
submitEdit();
};
document.getElementById("edit-cancel-button").onclick = () => {
editDialog.close();
};
const passEye = document.getElementById("password-eye-button");
passEye.innerHTML = SVG_OPEN_EYE;
passEye.onclick = () => {
const passBox = document.getElementById("password");
if (passBox.type === "password") {
passBox.type = "text";
passEye.innerHTML = SVG_CLOSED_EYE;
} else {
passBox.type = "password";
passEye.innerHTML = SVG_OPEN_EYE;
}
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();
};
qrCodeDialog.onclose = () => {
document.getElementById("container").style.filter = "blur(0px)";
document.getElementById("qr-code").innerHTML = "";
};
document.forms.namedItem("login-form").onsubmit = (e) => {
e.preventDefault();
submitLogin();
};

View File

@@ -18,13 +18,18 @@ body {
color: light-dark(black, #e8e6e3);
background-color: light-dark(white, #181a1b);
}
.container {
max-width: 80em;
margin: 1em auto auto;
}
.chhoto-button {
background-color: light-dark(#0078e7, #0060b9);
}
.pure-form input.chhoto-input {
width: 65%;
border-color: light-dark(#cccccc, #3e4446);
box-shadow: light-dark(#dddddd, #2b2f31) 0px 1px 3px inset;
box-shadow: light-dark(#dddddd, #2b2f31) 0 0.1em 0.2em inset;
}
.pure-form input.chhoto-input:focus {
border-color: light-dark(#cccccc, #3e4446);
@@ -33,7 +38,7 @@ body {
#expiryDelay {
background-color: light-dark(white, #2b2a33);
border-color: light-dark(#cccccc, #3e4446);
box-shadow: light-dark(#dddddd, #2b2f31) 0px 1px 3px inset;
box-shadow: light-dark(#dddddd, #2b2f31) 0 0.1em 0.2em inset;
}
::placeholder {
@@ -43,40 +48,55 @@ body {
#logo {
color: light-dark(#333333, #c8c3bc);
border-bottom-color: light-dark(#e5e5e5, #373c3e);
font-size: 32px;
font-size: 2em;
}
.container {
max-width: 1200px;
margin: 20px auto auto;
#logo img {
height: 0.8em;
}
a {
color: light-dark(blue, #3391ff);
}
.linkButton {
background: none;
padding: 0;
border: none;
color: light-dark(blue, #3391ff);
text-decoration: underline;
text-align: left;
cursor: pointer;
}
.chhoto-table {
width: 98%;
border-collapse: separate;
border-spacing: 0px;
border-radius: 5px;
box-shadow: 0 0 0 1px light-dark(#e0e0e0, #2a2d2f);
border-spacing: 0;
border-radius: 0.3em;
box-shadow: 0 0 0 0.1em light-dark(#e0e0e0, #2a2d2f);
border-color: light-dark(black, #867d6e);
}
.chhoto-table tr td div {
max-height: 75px;
line-height: 25px;
word-wrap: break-word;
max-width: 575px;
max-height: 4.5em;
line-height: 1.5em;
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);
}
.chhoto-table caption {
color: light-dark(black, #e8e6e3);
text-align: left;
font-size: 22px;
font-size: 1.5em;
font-style: normal;
font-family: Montserrat;
}
@@ -87,6 +107,11 @@ a {
.chhoto-table th,
.chhoto-table td {
border-left: none;
max-width: 36em;
}
#short-url-header {
min-width: 6em;
}
th[name="hitsColumn"],
@@ -99,35 +124,31 @@ td[name="expiryColumn"] {
text-align: center;
}
td[name="deleteBtn"] div {
display: flex;
align-items: center;
justify-content: center;
}
th[name="deleteBtn"],
td[name="deleteBtn"] {
text-align: center;
}
td[name="deleteBtn"],
td[name="deleteBtn"] div {
th[name="actions"],
td[name="actions"] div {
align-items: center;
justify-items: center;
text-align: center;
min-width: 8em;
}
td[name="deleteBtn"] div button {
border-radius: 100%;
td[name="actions"] div button,
.pure-table caption button.svg-button {
aspect-ratio: 1;
border-style: solid;
border-style: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
display: table-cell;
vertical-align: middle;
background-color: transparent;
}
input {
width: 65%;
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;
}
@@ -140,36 +161,91 @@ div[name="links-div"] {
#password {
width: 100%;
margin-bottom: 10px;
margin-bottom: 1em;
}
#wrong-pass {
color: light-dark(red, #ff1a1a);
}
#edit-dialog,
#qr-code-dialog,
#login-dialog {
border-radius: 10px;
border-width: 2px;
border-radius: 1em;
border-width: 0.15em;
}
#login-dialog div {
position: relative;
}
#password-eye-button {
position: absolute;
right: 0.1em;
top: 1.3em;
transform: translateY(-50%);
background-color: transparent;
border-style: none;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
#edit-dialog form,
#login-dialog form {
text-align: center;
}
#edited-url {
width: 100%;
}
#edit-cancel-button {
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;
text-align: right;
}
#qr-code-dialog div {
margin: 0.5em;
}
.qr-button {
background-color: transparent;
color: black;
border-style: none;
position: absolute;
top: 0.3em;
right: 0.3em;
cursor: pointer;
}
.qr-button svg {
height: 2em;
}
#qr-download {
right: 2em;
text-decoration: none;
}
.tooltip {
position: relative;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 120px;
width: 8em;
background-color: light-dark(#eeeeee, #484a4b);
color: light-dark(black, #e8e6e3);
text-align: center;
padding: 5px 0;
border-radius: 6px;
padding: 0.3em 0;
border-radius: 0.4em;
position: absolute;
z-index: 1;
bottom: 125%;
bottom: calc(50% + 1.25em);
left: 50%;
margin-left: -60px;
margin-left: -4em;
opacity: 0;
transition: opacity 0.3s;
}
@@ -178,8 +254,8 @@ div[name="links-div"] {
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
margin-left: -0.3em;
border-width: 0.3em;
border-style: solid;
border-color: light-dark(#eeeeee, #484a4b) transparent transparent transparent;
}
@@ -191,17 +267,17 @@ div[name="links-div"] {
/* Settings for mobile devices */
@media (pointer: none), (pointer: coarse) {
.container {
max-width: 100vw;
max-width: 98vw;
}
.pure-control-group input {
.pure-form input.chhoto-input {
width: 98%;
}
.chhoto-table {
border-collapse: collapse;
}
.chhoto-table tr {
border-bottom: 2px dotted light-dark(black, #867d6e);
.chhoto-table tr:not(:last-child) {
border-bottom: 0.15em dotted light-dark(black, #867d6e);
}
.chhoto-table thead {
@@ -211,25 +287,42 @@ div[name="links-div"] {
.chhoto-table td {
display: flex;
justify-content: left;
width: 98vw;
width: 97vw;
padding: 0.1em;
}
.chhoto-table tr td:last-child {
.chhoto-table tr td[name="shortColumn"] {
padding-top: 0.5em;
}
.chhoto-table tr td[name="actions"] {
padding-bottom: 0.5em;
}
.chhoto-table tr td:first-child {
padding-top: 0.5em;
#alert-box {
display: none;
}
.chhoto-table td::before {
content: attr(label);
font-weight: bold;
width: 120px;
min-width: 120px;
min-width: 6em;
text-align: left;
align-content: center;
}
.chhoto-table td div {
align-content: center;
}
.chhoto-table th[name="numColumn"],
.chhoto-table td[name="numColumn"] {
display: none;
}
.chhoto-table caption {
padding-top: 0px;
padding-top: 0;
}
.tooltip .tooltiptext {
left: 8em;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 58 KiB