Compare commits

...

169 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
SinTan1729
4a4b4be811 build: Bumped version to 6.2.9 2025-07-20 15:50:44 -05:00
SinTan1729
87477771a8 build: Fix issue with -i not supported in Ubuntu version of minify 2025-07-20 15:50:34 -05:00
Sayantan Santra
b60699b99f docs: Added link to NixOS package 2025-07-20 15:41:09 -05:00
SinTan1729
fc6734f8fb fix: Match the default look for logging 2025-07-20 01:31:07 -05:00
SinTan1729
3f7b508e4a docs: Added info about timezones 2025-07-20 01:14:45 -05:00
SinTan1729
2d51097ba4 fix: Support timezones for logging 2025-07-20 01:13:04 -05:00
SinTan1729
9bc2edf807 build: Support timezones in logging for alpine images 2025-07-20 01:02:32 -05:00
SinTan1729
f88545defc build: Remove unnecessary stuff from Makefile 2025-07-18 16:44:10 -05:00
SinTan1729
08727792a8 chg: Set font-display to swap 2025-07-18 16:26:23 -05:00
SinTan1729
1f827e68a7 docs: Slight change in wording 2025-07-18 15:53:55 -05:00
SinTan1729
8973db98df docs: Factored out related software to separate file 2025-07-18 13:07:07 -05:00
SinTan1729
c7a0358d05 docs: Added link to demo 2025-07-18 13:00:33 -05:00
SinTan1729
d505ec5571 chg: Minor changes for mobile styles
Also, grouped related styles together.
2025-07-18 12:44:01 -05:00
SinTan1729
8a931ae736 build: Slight change in gitignore 2025-07-18 11:17:34 -05:00
SinTan1729
3b1c7c656d build: Disable unused stuff 2025-07-18 11:16:27 -05:00
SinTan1729
b987abe68a chg: Use minify in place 2025-07-18 11:14:47 -05:00
SinTan1729
cf67b30c53 build: Fix typo in Makefile 2025-07-17 23:23:45 -05:00
SinTan1729
a992fc51c6 build: Bumped version to 6.2.8 2025-07-17 23:21:32 -05:00
SinTan1729
7bf9ffc6d3 build: Copy all resources regardless of minification
Also, hopefully fix changelog generation.
2025-07-17 23:21:18 -05:00
SinTan1729
f291725dfc build: Updated gitignore 2025-07-17 23:00:19 -05:00
SinTan1729
26d91e4528 build: Actually, only do minification for release builds
It doesn't make much sense for dev builds
2025-07-17 22:52:15 -05:00
SinTan1729
ee0cc96187 build: Do minification for build-dev 2025-07-17 22:31:30 -05:00
SinTan1729
ffc59f393a build: Minify resources during build process 2025-07-17 22:27:02 -05:00
SinTan1729
bf5b2507b8 chg: Use defer to load script after page load
Also, avoid use the non standard method used earlier.
Use promise -> then flow instead.
2025-07-17 22:26:42 -05:00
SinTan1729
4d7a91cb17 chg: Started using prettier to format 2025-07-17 21:48:05 -05:00
SinTan1729
64438145f2 fix: Properly catch errors in js 2025-07-17 21:18:55 -05:00
SinTan1729
944c33ef48 fix: Move remaining inline js to script.js, fixes #82 2025-07-17 17:25:07 -05:00
SinTan1729
1ad30359e4 chg: Styling for table in mobile 2025-07-17 16:57:22 -05:00
SinTan1729
07b7497b61 fix: Cliff template link to changes 2025-07-15 14:03:41 -05:00
SinTan1729
98ed94124b fix: Formatting in git cliff config 2025-07-15 11:53:45 -05:00
SinTan1729
7ed14bfd6b fix: Add remote_url macro 2025-07-15 11:47:08 -05:00
SinTan1729
b7ea0b7f5f build: Bumped version to 6.2.7 2025-07-15 11:36:15 -05:00
SinTan1729
57c28ed869 chg: More careful handling of different statuses 2025-07-15 11:20:41 -05:00
SinTan1729
84f4ba8e90 fix: Various fixes for issue #71 2025-07-14 15:42:57 -05:00
SinTan1729
967a5ed4b8 chg: Print in logs when custom_landing_dir is set 2025-07-14 14:07:49 -05:00
SinTan1729
6a64fb536d fix: Version no in public mode 2025-07-14 13:42:18 -05:00
SinTan1729
a410e43ac6 chg: Normalize path by mergeonly 2025-07-14 13:07:34 -05:00
SinTan1729
961e32091e build: Updated gitignore 2025-07-14 13:06:25 -05:00
SinTan1729
4444d7187d feat: Make shown links clickable
This was added back after testing.

Co-authored-by: TheCataliasTNT2k <44349750+TheCataliasTNT2k@users.noreply.github.com>
2025-07-14 12:59:16 -05:00
SinTan1729
f7d8e59b31 fix: Formatting 2025-07-14 12:43:50 -05:00
SinTan1729
9c035903d3 fix: Login flow and version no 2025-07-14 12:09:46 -05:00
SinTan1729
ad300e14fb fix: Cleanup remnants of open_url
This was introduced in PR #69 and removed after discussion.
But some remnants were remaining in the frontend.

Co-authored-by: TheCataliasTNT2k <44349750+TheCataliasTNT2k@users.noreply.github.com>
2025-07-14 11:51:38 -05:00
TheCataliasTNT2k
d9495304cb Remove inline js (#70)
* refactored js

* removed inline js

* fix: Add slash when accessing /admin/manage

---------

Co-authored-by: SinTan1729 <sayantan.santra689@gmail.com>
2025-07-14 09:43:17 -05:00
Sayantan Santra
0af3b9a40b Merge pull request #69 from TheCataliasTNT2k/main
Remove ambiguous characters from links, and stylistic changes
2025-07-14 09:34:54 -05:00
TheCataliasTNT2k
fd79a6b136 fixed docker build 2025-07-14 14:23:15 +02:00
TheCataliasTNT2k
ff6e1ac93a Fix styles 2025-07-14 14:23:05 +02:00
TheCataliasTNT2k
312be7ded9 Show generated link 2025-07-14 14:23:00 +02:00
TheCataliasTNT2k
609c36c32c Exclude ambiguous characters, when using UID mode 2025-07-14 14:22:31 +02:00
SinTan1729
4133c81ce4 docs: Update stats to match long term trends 2025-07-13 18:10:17 -05:00
SinTan1729
be45135be3 docs: Image size badge should link to docker tags 2025-07-13 18:06:42 -05:00
SinTan1729
e8646a9c47 build: Fix caching for cross-rs 2025-07-13 15:31:35 -05:00
SinTan1729
5a5a66a00e docs: Added some more info about the published images 2025-07-13 01:12:26 -05:00
SinTan1729
a83c6bce3d fix: Changelog generation in CI 2025-07-12 17:32:33 -05:00
SinTan1729
986908bce2 build: Bumped version to 6.2.6 2025-07-12 17:18:01 -05:00
SinTan1729
9ad0ec026f fix: Permissions in CI build 2025-07-12 17:17:32 -05:00
SinTan1729
3580e03d7d docs: Added info about GHCR 2025-07-12 17:17:32 -05:00
SinTan1729
154e91fcf2 docs: Added info about alpine flavor 2025-07-12 17:17:32 -05:00
SinTan1729
45e7af66b1 fix: Some typos in the release action 2025-07-12 17:17:32 -05:00
SinTan1729
47c909c428 build: Do the alpine builds first 2025-07-12 16:52:29 -05:00
SinTan1729
9fc257bb79 build: Fix typos and permissions 2025-07-12 16:47:36 -05:00
SinTan1729
b022c069af build: Added comments 2025-07-12 16:37:58 -05:00
SinTan1729
877084afd3 build: Don't push dev images to Docker Hub 2025-07-12 16:30:24 -05:00
SinTan1729
9d9a02fa18 build: Do both scratch and alpine builds 2025-07-12 16:18:55 -05:00
SinTan1729
1b7004487a docs: Added a few words 2025-07-11 21:13:34 -05:00
SinTan1729
ce2fc0fedf docs: Added statement about GH action 2025-07-11 21:12:27 -05:00
SinTan1729
eea773452f build: Fix CI, should work properly now 2025-07-11 21:08:38 -05:00
SinTan1729
f59da0a927 build: Added GH release workflow 2025-07-11 17:37:01 -05:00
SinTan1729
df195fbba0 docs: It's a FreeBSD port, not OpenBSD 2025-07-10 20:59:54 -05:00
33 changed files with 3344 additions and 1697 deletions

88
.github/cliff.toml vendored Normal file
View File

@@ -0,0 +1,88 @@
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[changelog]
# A Tera template to be rendered as the changelog's header.
# See https://keats.github.io/tera/docs/#introduction
header = ""
body = """
{%- macro remote_url() -%}
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
{%- endmacro -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{%- for commit in commits %}
- {{ commit.message | split(pat="\n") | first | upper_first | trim }}\
{% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%}
{% if commit.remote.pr_number %} in \
[#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \
{%- endif -%}
{% endfor %}
{% endfor %}
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
## New Contributors
{%- endif -%}
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution
{%- if contributor.pr_number %} in \
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
{%- endif %}
{%- endfor %}\n\n
"""
# A Tera template to be rendered as the changelog's footer.
# See https://keats.github.io/tera/docs/#introduction
footer = """
{%- macro remote_url() -%}
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
{%- endmacro -%}
{% for release in releases -%}
{% if release.version -%}
{% if release.previous.version -%}
**Full Changelog:** \
{{ self::remote_url() }}/compare/{{ release.previous.version }}...{{ release.version }}
[Link to Docker Hub](https://hub.docker.com/r/sintan1729/{{ remote.github.repo }})
{% endif -%}
{% else -%}
**Full Changelog:** {{ self::remote_url() }}/compare/{{ release.previous.version }}...HEAD
[Link to Docker Hub](https://hub.docker.com/r/sintan1729/{{ remote.github.repo }})
{% endif -%}
{% endfor %}
"""
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
[git]
# Parse commits according to the conventional commits specification.
# See https://www.conventionalcommits.org
conventional_commits = true
# Exclude commits that do not match the conventional commits specification.
filter_unconventional = false
# An array of regex based parsers to modify commit messages prior to further processing.
commit_preprocessors = [
# Remove issue numbers.
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" },
]
# An array of regex based parsers for extracting data from the commit message.
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = "^[a|A]dd", group = "<0>New" },
{ message = "^[n|N]ew", group = "<0>New" },
{ message = "^[f|F]eat", group = "<0>New" },
{ message = "^[f|F]ix", group = "<1>Fixes" },
{ message = "^[c|C]hange", group = "<2>Changes" },
{ message = "^[c|C]hg", group = "<2>Changes" },
{ message = "^[r|R]emove", group = "<3>Removed" },
{ message = "^[r|R]mv", group = "<3>Removed" },
]
# Exclude commits that are not matched by any commit parser.
filter_commits = true
# Order releases topologically instead of chronologically.
topo_order = false
# Order of commits in each group/release within the changelog.
# Allowed values: newest, oldest
sort_commits = "newest"

216
.github/workflows/docker-release.yml vendored Normal file
View File

@@ -0,0 +1,216 @@
name: Test and Docker Release
on:
push:
tags:
- "*"
branches: ["main"]
paths: ["actix/**", "resources/**"]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
IMAGE_NAME: chhoto-url
jobs:
build:
name: Build and Test - ${{ matrix.platform.os-name }}
strategy:
matrix:
platform:
- os-name: Linux-x86_64
target: x86_64-unknown-linux-musl
- os-name: Linux-arm64
target: aarch64-unknown-linux-musl
- os-name: Linux-armv7
target: armv7-unknown-linux-musleabihf
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Build the binaries and upload
- name: Build binary and test
uses: houseabsolute/actions-rust-cross@v1
with:
command: both
target: ${{ matrix.platform.target }}
args: "--locked --release --manifest-path=actix/Cargo.toml"
rust-cache-parameters: '{"workspaces":"actix -> target"}'
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform.target }}
path: ./actix/target/${{ matrix.platform.target }}/release/${{ env.IMAGE_NAME }}
retention-days: 1
if-no-files-found: error
merge:
name: Docker Release
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
packages: write
attestations: write
needs: build
steps:
# Prep the files
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@v4
- name: Move stuff around and fix permissions
run: |
for f in *-unknown-linux-musl*
do
mkdir -p actix/target/$f/release
mv $f/chhoto-url actix/target/$f/release/
chmod +x actix/target/$f/release/chhoto-url
done
- 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/
- name: Display current directory structure
run: ls -R
- name: Log in to the Docker Hub
uses: docker/login-action@v3
if: github.ref_type == 'tag'
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata
- name: Extract metadata (tags, labels) for Docker - alpine
id: meta-alpine
uses: docker/metadata-action@v5
if: github.ref_type == 'tag'
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with:
images: |
sintan1729/${{ env.IMAGE_NAME }}
ghcr.io/sintan1729/${{ env.IMAGE_NAME }}
flavor: |
suffix=-alpine,onlatest=true
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}}
- name: Extract metadata (tags, labels) for Docker - scratch
id: meta-scratch
uses: docker/metadata-action@v5
if: github.ref_type == 'tag'
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with:
images: |
sintan1729/${{ env.IMAGE_NAME }}
ghcr.io/sintan1729/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}}
- name: Extract metadata (tags, labels) for Docker - dev
id: meta-dev
uses: docker/metadata-action@v5
if: github.ref_type != 'tag'
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with:
images: |
ghcr.io/sintan1729/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=dev
# Build and push docker images
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image - alpine
id: push-alpine
uses: docker/build-push-action@v6
if: github.ref_type == 'tag'
with:
context: .
push: true
file: Dockerfile.alpine
tags: ${{ steps.meta-alpine.outputs.tags }}
labels: ${{ steps.meta-alpine.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
annotations: ${{ steps.meta-alpine.outputs.annotations }}
- name: Build and push Docker image - scratch
id: push-scratch
uses: docker/build-push-action@v6
if: github.ref_type == 'tag'
with:
context: .
push: true
file: Dockerfile.scratch
tags: ${{ steps.meta-scratch.outputs.tags }}
labels: ${{ steps.meta-scratch.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
annotations: ${{ steps.meta-scratch.outputs.annotations }}
- name: Build and push Docker image - dev
id: push-dev
uses: docker/build-push-action@v6
if: github.ref_type != 'tag'
with:
context: .
push: true
file: Dockerfile.alpine
tags: ${{ steps.meta-dev.outputs.tags }}
labels: ${{ steps.meta-dev.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Attestation
- name: Generate artifact attestation - alpine
uses: actions/attest-build-provenance@v2
if: github.ref_type == 'tag'
with:
subject-name: sintan1729/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push-alpine.outputs.digest }}
- name: Generate artifact attestation - scratch
uses: actions/attest-build-provenance@v2
if: github.ref_type == 'tag'
with:
subject-name: sintan1729/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push-scratch.outputs.digest }}
- name: Generate artifact attestation - dev
uses: actions/attest-build-provenance@v2
if: github.ref_type != 'tag'
with:
subject-name: sintan1729/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push-dev.outputs.digest }}
# Create a release with changelog
- name: Generate a changelog
uses: orhun/git-cliff-action@v4
if: github.ref_type == 'tag'
id: git-cliff
with:
config: .github/cliff.toml
args: --latest --strip header
env:
GITHUB_REPO: ${{ github.repository }}
OUTPUT: CHANGELOG.md
- name: Show the changelog
if: github.ref_type == 'tag'
run: cat CHANGELOG.md
- name: Release
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
body_path: ${{ steps.git-cliff.outputs.changelog }}
make_latest: true

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

8
.gitignore vendored
View File

@@ -1,8 +1,6 @@
# Ignore build outputs
actix/target
# Ignore SQLite file
*.sqlite
resources-final
# Ignore irrelevant dotfiles
.vscode/
@@ -11,3 +9,7 @@ actix/target
cookie*
.idea/
.DS_Store
# Testing related
custom_dir
testing-data

119
CLI.md
View File

@@ -1,8 +1,10 @@
## Official CLI App
There's an official CLI app for Linux. It's maintained by me, even though I cannot promise to provide proper support. Take a look at it
[here](https://github.com/SinTan1729/chhoto-url-cli). The instructions below describe how to use all the features using `curl`.
## Instructions for CLI usage
The application can be used from the terminal using something like `curl`. In all the examples
below, replace `http://localhost:4567` with where your instance of `chhoto-url` is accessible.
@@ -10,6 +12,7 @@ You can get the version of `chhoto-url` the server is running using `curl http:/
get the siteurl using `curl http://localhost:4567/api/siteurl`. These routes are accessible without any authentication.
### API key validation
**This is required for programs that rely on a JSON response from Chhoto URL**
In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie
@@ -19,14 +22,28 @@ 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.
An empty or missing `<shortlink>` will result in it being auto-generated.
Expiry delay is in seconds. It is capped to a maximum of 5 years. A missing `<expiry_delay>` or a value of 0 will disable expiry.
The server will reply in the following format.
```json
{
"success": true,
@@ -35,21 +52,29 @@ The server will reply in the following format.
"expiry_time": <expiry_time>
}
```
or
```json
{
"success": false,
"error": true,
"reason": "<reason>"
"success": false,
"error": true,
"reason": "<reason>"
}
```
#### `/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.)
The server will reply in the following format.
```json
{
"version": "<version>",
@@ -65,17 +90,59 @@ The server will reply in the following format.
```json
{
"success": false,
"error": true,
"reason": "<reason>"
"success": false,
"error": true,
"reason": "<reason>"
}
```
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.
```json
{
"success": true,
@@ -85,39 +152,63 @@ The server will reply in the following format.
"expiry_time": <expiry_time>
}
```
or
```json
{
"success": false,
"error": true,
"reason": "<reason>"
"success": false,
"error": true,
"reason": "<reason>"
}
```
(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
curl -X DELETE -H "X-API-Key: <YOUR_API_KEY>" http://localhost:4567/api/del/<shortlink>
```
Where `<shortlink>` is name of the shortened link you would like to delete. For example, if the shortened link is
`http://localhost:4567/example`, `<shortlink>` would be `example`.
The server will output when the instance is accessed over API, when an incorrect API key is received, etc.
### Cookie validation
If you have set up a password, first do the following to get an authentication cookie and store it in a file.
```bash
curl -X POST -d "<your-password>" -c cookie.txt http://localhost:4567/api/login
```
You should receive "Correct password!" if the provided password was correct. For any subsequent
request, please add `-b cookie.txt` to provide authentication. Unless specified, all API methods should work with cookies.
## Disable authentication
If you do not define a password environment variable when starting the docker image, authentication
will be disabled.

View File

@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
# SPDX-License-Identifier: MIT
FROM lukemathwalker/cargo-chef:latest-rust-slim AS chef
FROM docker.io/lukemathwalker/cargo-chef:latest-rust-slim AS chef
WORKDIR /chhoto-url
FROM chef AS planner

19
Dockerfile.alpine Normal file
View File

@@ -0,0 +1,19 @@
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
# SPDX-License-Identifier: MIT
FROM --platform=$TARGETARCH alpine AS builder-amd64
COPY ./actix/target/x86_64-unknown-linux-musl/release/chhoto-url /chhoto-url
FROM --platform=$TARGETARCH alpine AS builder-arm64
COPY ./actix/target/aarch64-unknown-linux-musl/release/chhoto-url /chhoto-url
FROM --platform=$TARGETARCH alpine AS builder-arm
COPY ./actix/target/armv7-unknown-linux-musleabihf/release/chhoto-url /chhoto-url
ARG TARGETARCH
FROM builder-$TARGETARCH
RUN apk add --no-cache tzdata
COPY ./resources /resources
ENTRYPOINT ["/chhoto-url"]

View File

@@ -1,124 +1,243 @@
# Installation and Configuration
## Using `docker compose` (Recommended method)
There is a sample `compose.yaml` file in this repository. It contains
everything needed for a basic install. You can use it as a base, modifying
it as needed. Run it with
everything needed for a basic install. The OCI image itself is built with
a GitHub action (starting from version 6.2.6), and you can [check the workflow for yourself](./.github/workflows/docker-release.yml)
and confirm that it's indeed built from source and nothing silly is going on.
The container images come in two flavors. The default one is made from scratch, and is as light as possible.
The tags with `-alpine` suffix are built on top of alpine, so are a little bit larger. But they have
the basic UNIX tools for debugging, so might be worth using in case you want to play around with the image.
The `dev` tags are always built on top of alpine. All of these images are available both on the Docker Hub (recommended)
and GHCR, except the `dev` builds which are only available on GHCR. All of these images are available for `linux/amd64`,
`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](./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
### `docker run` method
0. (Only if you really want to) Build the image for the default `x86_64-unknown-linux-musl` target:
```
docker build . -t chhoto-url
```
For building on `arm64` or `arm/v7`, use the following:
```
docker build . -t chhoto-url --build-arg target=<desired-target>
```
Make sure that the desired target is a `musl` one, since the docker image is built from `scratch`.
For cross-compilation, take a look at the `Makefile`. It builds and pushes for `linux/amd64`, `linux/aarch64`
and `linux/arm/v7` architectures. For any other architectures, open a discussion, and I'll try to help you out.
For cross-compilation, take a look at the `Makefile`. It has instructions to build the architectures
mentioned above., For any other architectures, open a discussion, and I'll try to help you out.
1. Run the image
```
docker run -p 4567:4567
-e password="password"
-d chhoto-url:latest
```
1.a Make the database file available to host (optional)
```
touch ./urls.sqlite
docker run -p 4567:4567 \
-e password="password" \
-v ./urls.sqlite:/urls.sqlite \
-e db_url=/urls.sqlite \
-d chhoto-url:latest
```
1.b Further, set the URL of your website (optional)
```
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
```
1.c Further, set an API key to activate JSON result mode (optional)
```
touch ./urls.sqlite
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" \
-v ./data:/data \
-e db_url=/data/urls.sqlite \
-d chhoto-url:latest
```
_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)_
## Configuration options
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-).
### `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
echo -n <password> | argon2 <salt> -id -t 3 -m 16 -l 32 -e
```
You may also use online tools for this step.
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
### `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. 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`
to send your desired headers. It must be a comma separated list of valid
### `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
served through a proxy.
## Deploying in your Kubernetes cluster with Helm
The helm values are very sparse to keep it simple. If you need more values to be variable, feel free to adjust.
The PVC allocates 100Mi and the PV is using a host path volume.
The helm chart assumes you have [cert manager](https://github.com/jetstack/cert-manager) deployed to have TLS
certificates managed easily in your cluster. Feel free to remove the issuer and adjust the ingress if you're on
The helm chart assumes you have [cert manager](https://github.com/jetstack/cert-manager) deployed to have TLS
certificates managed easily in your cluster. Feel free to remove the issuer and adjust the ingress if you're on
AWS with EKS for example. To install cert-manager, I recommend using the
["kubectl apply" way](https://cert-manager.io/docs/installation/kubectl/) to install cert-manager.
To get started, `cp helm-chart/values.yaml helm-chart/my-values.yaml` and adjust `password`, `fqdn`
and `letsencryptmail` in your new `my-values.yaml`, then just run
``` bash
```bash
cd helm-chart
helm upgrade --install chhoto-url . -n chhoto-url --create-namespace -f my-values.yaml
```

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 --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.multiarch .
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.multiarch .
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,19 +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
docker buildx build --push --tag ${docker_username}/chhoto-url:${v_major} --tag ${docker_username}/chhoto-url:${v_minor} \
--tag ${docker_username}/chhoto-url:${v_patch} --tag ${docker_username}/chhoto-url:latest \
--platform linux/amd64,linux/arm64,linux/arm/v7 -f Dockerfile.multiarch .
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.multiarch .
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,18 +1,20 @@
<!-- 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)
![docker-image-size-badge](https://img.shields.io/docker/image-size/sintan1729/chhoto-url)
[![docker-image-size-badge](https://img.shields.io/docker/image-size/sintan1729/chhoto-url)](https://hub.docker.com/r/sintan1729/chhoto-url/tags)
[![license-badge](https://img.shields.io/github/license/SinTan1729/chhoto-url)](https://spdx.org/licenses/MIT.html)
# ![Logo](resources/assets/favicon-32.png) <span style="font-size:42px">Chhoto URL</span>
# What is it?
A simple selfhosted URL shortener with no unnecessary features. Simplicity
and speed are the main foci of this project. The docker image is ~6 MB (compressed),
and it uses <10 MB of RAM under regular use.
and speed are the main foci of this project. The scratch docker image is <6 MB (compressed),
the alpine one is <10 MB (compressed), and it uses <15 MB of RAM under regular use.
Don't worry if you see no activity for a long time. I consider this project
to be complete, not dead. I'm unlikely to add any new features, but I will try
@@ -23,6 +25,7 @@ If you feel like a feature is missing, please let me know by creating an issue
using the "feature request" template.
## But why another URL shortener?
Most URL shorteners are either bloated with unnecessary features, or are a pain to set up.
Even fewer are written with simplicity and lightness in mind. When I saw the `simply-shorten`
project (linked below), I really liked the idea but thought that it missed some features. Also,
@@ -31,10 +34,22 @@ included java runtime). So, I decided to rewrite it in Rust and add some feature
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
Link: [https://chhoto-url-demo.sayantansantra.com](https://chhoto-url-demo.sayantansantra.com)
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 me know, and I'll add it to the README.
# Features
- Shortens URLs of any length to a randomly generated link.
- Automatic expiry of links after a chosen time.
- (Optional) Allows you to specify the shortened URL instead of the generated
@@ -45,72 +60,65 @@ for small. URL means, well... URL. So the name simply means Small URL.
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.
- Has a public mode, where anyone can add links without authentication. Deleting
or listing available links will need admin access using the password. It's also
possible to completely disable the frontend. It's also possible to force an expiry
- Has a public mode, where anyone can add links without authentication. Deleting
or listing available links will need admin access using the password. It's also
possible to completely disable the frontend. It's also possible to force an expiry
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.
I recommend using a reverse proxy such as [caddy](https://caddyserver.com/) to
I recommend using a reverse proxy such as [caddy](https://caddyserver.com/) to
encrypt the connection by SSL.
# Bloat that will not be implemented
- **Tracking or spying of any kind.** The only logs that still exist are
errors printed to stderr and some basic logging of configs.
- **User management.** If you need a shortener for your whole organization, either
run separate containers for everyone or use something else.
- **Cookies, newsletters**, "we value your privacy" popups or any of the multiple
other ways modern web shows how anti-user it is. We all hate those, and they're
not needed here.
- **Paywalls** or messages begging for donations. If you want to buy me a coffee,
you can message me through GitHub discussions or mail me.
# Screenshots
# Bloat that will not be implemented
- **Tracking or spying of any kind.** The only logs that still exist are
errors printed to stderr and some basic logging of configs.
- **User management.** If you need a shortener for your whole organization, either
run separate containers for everyone or use something else.
- **Cookies, newsletters**, "we value your privacy" popups or any of the multiple
other ways modern web shows how anti-user it is. We all hate those, and they're
not needed here.
- **Paywalls** or messages begging for donations. If you want to buy me a coffee,
you can message me through GitHub discussions or mail me.
# Screenshots
<p align="middle">
<img src="screenshot-desktop.webp" height="250" alt="desktop screenshot" />
<img src="screenshot-mobile.webp" height="250" alt="mobile screenshot" />
</p>
# Installation and Configuration
# Installation and configuration
[See here.](./INSTALLATION.md)
# Instructions for CLI usage
[See here.](./CLI.md)
# Official CLI Application
It's maintained by me, even though I cannot promise to provide proper support. Take a look at it
[here](https://github.com/SinTan1729/chhoto-url-cli).
# Related software
# 3rd Party Tools
The following tools are 3rd party, and are not supported officially. If you have any problems with them, please file an issue
in the respective repos.
## Browser extension
There's an (unofficial) browser extension maintained by @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.
[You can get it from the Raycast extension store.](https://www.raycast.com/andrei_hudalla/chhoto)
## OpenBSD package
There's an (unofficial) FreeBSD package maintained by @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).
[See here.](./TOOLS.md)
# Notes
- 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.

31
TOOLS.md Normal file
View File

@@ -0,0 +1,31 @@
# Software related to Chhoto URL
# Official CLI application
It's maintained by me, even though I cannot promise to provide proper support. Take a look at it
[here](https://github.com/SinTan1729/chhoto-url-cli).
# 3rd Party Tools
The following tools are 3rd party, and are not supported officially. If you have any problems with them, please file an issue
in the respective repos.
## Browser extension
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](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](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](https://github.com/Defelo) for Chhoto URL.
[You can take a look at it here.](https://search.nixos.org/packages?query=chhoto-url)

786
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.5"
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();
@@ -122,13 +138,14 @@ pub fn read() -> Config {
format!(":{port}")
};
// No issues
info!("Public URI is: {protocol}://localhost{port_text}.");
info!("Public URL is: {protocol}://localhost{port_text}.");
None
};
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,20 +158,45 @@ 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()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
.filter(|s| !s.is_empty())
.inspect(|s| {
info!("Custom landing directory is set to {s}.");
info!("The dashboard will be available at /admin/manage/");
});
Config {
listen_address,
port,
db_location,
cache_control_header,
@@ -171,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

@@ -3,10 +3,15 @@
use actix_files::Files;
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
use actix_web::{cookie::Key, middleware, web, App, HttpServer};
use actix_web::{
cookie::Key,
middleware,
web::{self, Redirect},
App, HttpServer,
};
use log::info;
use rusqlite::Connection;
pub(crate) use std::io::Result;
use std::{fs, io::Result};
use tokio::{spawn, time};
// Import modules
@@ -29,8 +34,30 @@ struct AppState {
#[actix_web::main]
async fn main() -> Result<()> {
env_logger::builder()
.parse_filters("warn,chhoto_url=info,actix_session::middleware=error")
.format_timestamp_secs()
.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};
use std::io::Write;
let subtle = Style::new().fg_color(Some(AnsiColor::BrightBlack.into()));
let level_style = buf.default_level_style(record.level());
writeln!(
buf,
"{subtle}[{subtle:#}{} {level_style}{:<5}{level_style:#}{}{subtle}]{subtle:#} {}",
Local::now().format("%Y-%m-%d %H:%M:%S%Z"),
record.level(),
record.module_path().unwrap_or_default(),
record.args()
)
})
.init();
// Generate session key in runtime so that restart invalidates older logins
@@ -45,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);
}
});
@@ -65,7 +107,9 @@ async fn main() -> Result<()> {
let mut app = App::new()
.wrap(middleware::Logger::default())
.wrap(middleware::Compress::default())
.wrap(middleware::NormalizePath::trim())
.wrap(middleware::NormalizePath::new(
middleware::TrailingSlash::MergeOnly,
))
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
.cookie_same_site(actix_web::cookie::SameSite::Strict)
@@ -74,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 {
@@ -83,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)
@@ -91,13 +136,15 @@ 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 {
app = app
.service(Files::new("/admin/manage", "./resources/").index_file("index.html"));
app = app.service(Files::new("/", dir).index_file("index.html"));
.service(Redirect::new("/admin/manage", "/admin/manage/"))
.service(Files::new("/admin/manage/", "./resources/").index_file("index.html"))
.service(Files::new("/", dir).index_file("index.html"));
} else {
app = app.service(Files::new("/", "./resources/").index_file("index.html"));
}
@@ -106,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",
@@ -232,11 +208,13 @@ fn gen_link(style: &String, len: usize, allow_capital_letters: bool) -> String {
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r',
's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
];
static CHARS_CAPITAL: [char; 62] = [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R',
'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1',
'2', '3', '4', '5', '6', '7', '8', '9',
// uppercase and lowercase characters; exclude ambiguous characters
static CHARS_CAPITAL: [char; 58] = [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T',
'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'm',
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '1', '2', '3', '4', '5',
'6', '7', '8', '9',
];
if style == "UID" {

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

@@ -2,93 +2,109 @@
# SPDX-License-Identifier: MIT
services:
chhoto-url:
image: sintan1729/chhoto-url:latest
restart: unless-stopped
container_name: chhoto-url
tty: true
# You may enable the next two options if you want. Make sure that you run the container as the proper
# user. In most cases, user: 1000:1000 should work. You might also need to mount a directory with your
# db and not just the file itself. Make sure to adjust db_url accordingly.
# It does add extra security, but I don't know enough about docker to help in case it breaks something.
# read_only: true
# cap_drop:
# - ALL
ports:
# If you changed the "port" environment variable, adjust accordingly
# The number AFTER the colon should match the "port" variable and the number
# before the colon is the port where you would access the container from outside.
- 4567:4567
environment:
# Change if you want to mount the database somewhere else.
# 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.
# (In fact, I'd suggest that you do that so that you can keep
# a copy of your database.)
- db_url=/db/urls.sqlite
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
# You may enable the next two options if you want. Make sure that you run the container as the proper
# user. In most cases, user: 1000:1000 should work. You might also need to mount a directory with your
# db and not just the file itself. Make sure to adjust db_url accordingly.
# It does add extra security, but I don't know enough about docker to help in case it breaks something.
# read_only: true
# cap_drop:
# - ALL
ports:
# If you changed the "port" environment variable, adjust accordingly
# The number AFTER the colon should match the "port" variable and the number
# before the colon is the port where you would access the container from outside.
- 4567:4567
environment:
# Change if you want to mount the database somewhere else.
# In this case, you can get rid of the db volume below
# and instead do a mount manually by specifying the location.
- 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.)
# - 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:
# site_url="https://www.example.com" incorrect
# site_url=https://www.example.com correct
# This is important to ensure Chhoto URL outputs the shortened link with the correct URL.
# - site_url=https://www.example.com
# Change this if your server URL is not "http://localhost"
# This must not be surrounded by quotes. For example:
# site_url="https://www.example.com" incorrect
# site_url=https://www.example.com correct
# This is important to ensure Chhoto URL outputs the shortened link with the correct URL.
# - site_url=https://www.example.com
# If you want to provided hashed password and API Key, uncomment the next line. Read the README
# for instructions for the hashing. Make sure to escape $ by $$.
# - hash_algorithm=Argon2
# If you want to provided hashed password and API Key, uncomment the next line. Read the README
# for instructions for the hashing. Make sure to escape $ by $$.
# - hash_algorithm=Argon2
# Change this if you are running Chhoto URL on a port which is not 4567.
# This is important to ensure Chhoto URL outputs the shortened link with the correct port.
# - port=4567
- password=TopSecretPass
# Change this if you are running Chhoto URL on a port which is not 4567.
# This is important to ensure Chhoto URL outputs the shortened link with the correct port.
# - port=4567
# This needs to be set in order to use programs that use the JSON interface of Chhoto URL.
# You will get a warning if this is insecure, and a generated value will be output
# You may use that value if you can't think of a secure key
# - api_key=SECURE_API_KEY
- password=TopSecretPass
# Pass the redirect method, if needed. TEMPORARY and PERMANENT
# are accepted values, defaults to PERMANENT.
# - redirect_method=TEMPORARY
# By default, the auto-generated pairs are adjective-name pairs.
# If you want UIDs, please change slug_style to UID.
# Supported values for slug_style are Pair and UID.
# The length is 8 by default, and a minimum of 4 is allowed.
# - slug_style=Pair
# - slug_length=8
# To retry (once) with a longer UID upon collision, change the following to True.
# - try_longer_slug=False
# If you want to use capital letters in the shortlink, change the following to
# True. This will also allow capital letters in UID slugs, if it is enabled.
# - allow_capital_letters=False
# In case you want to provide public access to adding links (and not
# delete, or listing), change the following option to Enable.
# - public_mode=Disable
# Additionally, it's possible to force an expiry delay in public mode.
# The user can still choose a shorter expiry delay. The input must be in seconds.
# It defaults to 0 i.e. no expiry.
# - public_mode_expiry_delay=3600
# In case you want to completely disable the frontend, change the following
# to True.
# - disable_frontend=False
# 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 the following to the path of the directory. Remember to first
# mount the directory inside the container. The admin page will then be located at /admin/manage.
# - custom_landing_directory=/custom/dir/location
# By default, the server sends no Cache-Control headers. You can supply a
# comma separated list of valid header as per RFC 7234 §5.2 to send those
# headers instead.
# - cache_control_header=no-cache, private
volumes:
- db:/db
# This needs to be set in order to use programs that use the JSON interface of Chhoto URL.
# You will get a warning if this is insecure, and a generated value will be output
# You may use that value if you can't think of a secure key
# - api_key=SECURE_API_KEY
# Pass the redirect method, if needed. TEMPORARY and PERMANENT
# are accepted values, defaults to PERMANENT.
# - redirect_method=TEMPORARY
# By default, the auto-generated pairs are adjective-name pairs.
# If you want UIDs, please change slug_style to UID.
# Supported values for slug_style are Pair and UID.
# The length is 8 by default, and a minimum of 4 is allowed.
# - slug_style=Pair
# - slug_length=8
# To retry (once) with a longer UID upon collision, change the following to True.
# - try_longer_slug=False
# If you want to use capital letters in the shortlink, change the following to
# True. This will also allow capital letters in UID slugs, if it is enabled.
# - allow_capital_letters=False
# In case you want to provide public access to adding links (and not
# delete, or listing), change the following option to Enable.
# - public_mode=Disable
# Additionally, it's possible to force an expiry delay in public mode.
# The user can still choose a shorter expiry delay. The input must be in seconds.
# It defaults to 0 i.e. no expiry.
# - public_mode_expiry_delay=3600
# In case you want to completely disable the frontend, change the following
# to True.
# - disable_frontend=False
# 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 the following to the path of the directory. Remember to first
# mount the directory inside the container. The admin page will then be located at /admin/manage.
# - custom_landing_directory=/custom/dir/location
# By default, the server sends no Cache-Control headers. You can supply a
# comma separated list of valid header as per RFC 7234 §5.2 to send those
# headers instead.
# - cache_control_header=no-cache, private
# 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:
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

@@ -1,112 +1,267 @@
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
<!-- SPDX-License-Identifier: MIT -->
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8" />
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
<meta
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
/>
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<script type="text/javascript">
if (!document.head.baseURI.match(/\/$/)) {
window.location.replace(window.location.href + "/");
}
</script>
<title>Chhoto URL</title>
<meta name="description" content="A simple selfhosted URL shortener with no unnecessary features." />
<meta
name="description"
content="A simple selfhosted URL shortener with no unnecessary features."
/>
<meta property="og:title" content="Chhoto URL" />
<meta property="og:description" content="A simple selfhosted URL shortener with no unnecessary features." />
<meta
property="og:description"
content="A simple selfhosted URL shortener with no unnecessary features."
/>
<meta name="keywords" content="url shortener, link shortener, self hosted, open source" />
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" sizes="any" />
<meta
name="keywords"
content="url shortener, link shortener, self hosted, open source"
/>
<link
rel="icon"
type="image/x-icon"
href="assets/favicon.ico"
sizes="any"
/>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<link rel="icon" type="image/png" href="assets/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="assets/favicon-196.png" sizes="196x196" />
<link
rel="icon"
type="image/png"
href="assets/favicon-32.png"
sizes="32x32"
/>
<link
rel="icon"
type="image/png"
href="assets/favicon-196.png"
sizes="196x196"
/>
<script src="static/script.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css"
integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" target="_blank" href="static/styles.css" />
</head>
<body>
<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"
href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css"
integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
type="text/css"
target="_blank"
href="static/styles.css"
/>
</head>
<body>
<div class="container" id="container">
<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</legend>
<div class="pure-control-group">
<label for="longUrl">Long URL</label>
<input class="chhoto-input" type="url" name="longUrl" id="longUrl" placeholder="Please enter a valid URL"
onblur="addProtocol(this)" required />
</div>
<div class="pure-control-group">
<label for="shortUrl">Short URL (optional)</label>
<input class="chhoto-input" type="text" name="shortUrl" id="shortUrl" placeholder="Only a-z, 0-9, - and _ are allowed"
pattern="[a-z0-9\-_]+" title="Only a-z, 0-9, - and _ are allowed" autocapitalize="off"/>
</div>
<div class="pure-control-group">
<label for="expiryDelay">Expiry</label>
<select class="chhoto-select" name="expiryDelay" id="expiryDelay">
<option value=0>Never</option>
<option value=600>10 Minutes</option>
<option value=1800>30 Minutes</option>
<option value=3600>1 Hour</option>
<option value=43200>12 Hours</option>
<option value=86400>1 Day</option>
<option value=604800>1 Week</option>
<option value=2592000>1 Month</option>
<option value=7776000>3 Months</option>
<option value=15552000>6 Months</option>
<option value=31536000>1 Year</option>
</select>
</div>
<div class="pure-controls" id="controls">
<button class="chhoto-button pure-button pure-button-primary">Shorten!</button>
<p id="alert-box">&nbsp;</p>
</div>
</fieldset>
</form>
<form class="pure-form pure-form-aligned" name="new-url-form">
<fieldset>
<legend id="logo">
<img src="assets/favicon.svg" alt="chhoto-url-logo" /> Chhoto URL
</legend>
<div class="pure-control-group">
<label for="longUrl">Long URL</label>
<input
class="chhoto-input"
type="url"
name="longUrl"
id="longUrl"
placeholder="Please enter a valid URL"
required
/>
</div>
<div class="pure-control-group">
<label for="shortUrl">Short URL (optional)</label>
<input
class="chhoto-input"
type="text"
name="shortUrl"
id="shortUrl"
placeholder="Only a-z, 0-9, - and _ are allowed"
pattern="[a-z0-9\-_]+"
title="Only a-z, 0-9, - and _ are allowed"
autocapitalize="off"
/>
</div>
<div class="pure-control-group">
<label for="expiryDelay">Expiry</label>
<select class="chhoto-select" name="expiryDelay" id="expiryDelay">
<option value="0">Never</option>
<option value="600">10 Minutes</option>
<option value="1800">30 Minutes</option>
<option value="3600">1 Hour</option>
<option value="43200">12 Hours</option>
<option value="86400">1 Day</option>
<option value="604800">1 Week</option>
<option value="2592000">1 Month</option>
<option value="7776000">3 Months</option>
<option value="15552000">6 Months</option>
<option value="31536000">1 Year</option>
</select>
</div>
<div class="pure-controls" id="controls">
<button class="chhoto-button pure-button pure-button-primary">
Shorten!
</button>
<div id="alert-box">&nbsp;</div>
</div>
</fieldset>
</form>
<p id="loading-text">Loading links table...</p>
<table class="chhoto-table pure-table" id="table-box" hidden>
<caption>Active links</caption>
<br />
<thead>
<tr>
<th id="short-url-header">Short URL (click to copy)</th>
<th>Long URL</th>
<th name="hitsColumn">Hits</th>
<th name="expiryColumn">Expiry</th>
<th name="deleteBtn">&times;</th>
</tr>
</thead>
<tbody id="url-table">
<!-- The links would be inserted here -->
</tbody>
</table>
<p id="loading-text">Loading links table...</p>
<table class="chhoto-table pure-table" id="table-box" hidden>
<caption>
<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 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="actions">Actions</th>
</tr>
</thead>
<tbody id="url-table">
<!-- The links would be inserted here -->
</tbody>
</table>
</div>
<div name="links-div">
<a id="admin-button" href="javascript:getLogin()" hidden>login</a>
&nbsp;
<a id="version-number" href="https://github.com/SinTan1729/chhoto-url" target="_blank" rel="noopener noreferrer"
hidden>Source Code</a>
<!-- The version number would be inserted here -->
<button class="linkButton" id="admin-button" hidden>login</button>
&nbsp;
<a
id="version-number"
href="https://github.com/SinTan1729/chhoto-url"
target="_blank"
rel="noopener noreferrer"
hidden
>Source Code</a
>
<!-- The version number would be inserted here -->
</div>
<dialog id="login-dialog">
<form class="pure-form" name="login-form">
<p>Please enter password to access this website</p>
<input class="chhoto-input" type="password" id="password" />
<button class="chhoto-button pure-button pure-button-primary" value="default">Log in</button>
<p id="wrong-pass" hidden>Wrong password!</p>
</form>
<dialog id="login-dialog" closedby="none">
<form class="pure-form" name="login-form">
<p>Please enter password to access this website</p>
<div>
<input class="chhoto-input" type="password" id="password" />
<button
type="button"
id="password-eye-button"
title="Toggle Password Visibility"
>
&#x1F441;
</button>
</div>
<button
class="chhoto-button pure-button pure-button-primary"
value="default"
>
Log in
</button>
<p id="wrong-pass" hidden>Wrong password!</p>
</form>
</dialog>
</body>
<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

@@ -1,57 +1,55 @@
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
<!-- SPDX-License-Identifier: MIT -->
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<head>
<title>Error 404</title>
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
<meta
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
/>
<link rel="stylesheet" type="text/css" target="_blank" href="styles.css" />
</head>
</head>
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap');
<style>
@import url("https://fonts.googleapis.com/css2?family=Montserrat&display=swap");
@font-face {
font-family: Montserrat, "Open Sans", Helvetica, Arial, sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
}
:root {
color-scheme: light dark;
font-family: Montserrat, "Open Sans", Helvetica, Arial, sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-display: swap;
}
* {
font-family: Montserrat;
:root {
color-scheme: light dark;
font-family: Montserrat;
}
body {
color: light-dark(black, #e8e6e3);
background-color: light-dark(white, #181a1b);
text-align: center;
color: light-dark(black, #e8e6e3);
background-color: light-dark(white, #181a1b);
text-align: center;
}
#quote {
text-indent: 4em;
text-indent: 4em;
}
/* Settings for mobile devices */
@media (pointer:none),
(pointer:coarse) {
body {
text-align: left;
}
@media (pointer: none), (pointer: coarse) {
body {
text-align: left;
}
}
</style>
</style>
<body>
<body>
<h1>Error 404!</h1>
<div style="display: inline-block; text-align:left;">
<p>You step in the stream,</p>
<p>But the water has moved on.</p>
<p>The page is not here.</p>
<p id="quote"> — Cass Whittington</p>
<div style="display: inline-block; text-align: left">
<p>You step in the stream,</p>
<p>But the water has moved on.</p>
<p>The page is not here.</p>
<p id="quote">— Cass Whittington</p>
</div>
</body>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,245 +1,328 @@
/* SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> */
/* SPDX-License-Identifier: MIT */
@import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Montserrat&display=swap");
@font-face {
font-family: Montserrat, "Open Sans", Helvetica, Arial, sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
}
:root {
color-scheme: light dark;
font-family: Montserrat;
font-family: Montserrat, "Open Sans", Helvetica, Arial, sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: light dark;
font-family: Montserrat;
}
body {
color: light-dark(black, #e8e6e3);
background-color: light-dark(white, #181a1b);
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);
background-color: light-dark(#0078e7, #0060b9);
}
.pure-form input.chhoto-input {
border-color: light-dark(#cccccc, #3e4446);
box-shadow: light-dark(#dddddd, #2b2f31) 0px 1px 3px inset;
width: 65%;
border-color: light-dark(#cccccc, #3e4446);
box-shadow: light-dark(#dddddd, #2b2f31) 0 0.1em 0.2em inset;
}
.pure-form input.chhoto-input:focus {
border-color: light-dark(#cccccc, #3e4446);
border-color: light-dark(#cccccc, #3e4446);
}
#expiryDelay {
background-color: light-dark(white, #2b2a33);
border-color: light-dark(#cccccc, #3e4446);
box-shadow: light-dark(#dddddd, #2b2f31) 0px 1px 3px inset;
background-color: light-dark(white, #2b2a33);
border-color: light-dark(#cccccc, #3e4446);
box-shadow: light-dark(#dddddd, #2b2f31) 0 0.1em 0.2em inset;
}
::placeholder {
color: light-dark(#757575, #636061);
color: light-dark(#757575, #636061);
}
#logo {
color: light-dark(#333333, #c8c3bc);
border-bottom-color: light-dark(#e5e5e5 ,#373c3e);
color: light-dark(#333333, #c8c3bc);
border-bottom-color: light-dark(#e5e5e5, #373c3e);
font-size: 2em;
}
.container {
max-width: 1200px;
margin: 20px auto auto;
#logo img {
height: 0.8em;
}
a {
color: light-dark(blue, #3391ff);
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-color: light-dark(black, #867d6e);
width: 98%;
border-collapse: separate;
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;
overflow: auto;
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);
background-color: light-dark(#f2f2f2, #080a0b);
}
.chhoto-table caption {
color: light-dark(black, #e8e6e3);
text-align: left;
font-size: 22px;
font-style: normal;
font-family: Montserrat;
color: light-dark(black, #e8e6e3);
text-align: left;
font-size: 1.5em;
font-style: normal;
font-family: Montserrat;
}
.chhoto-table thead {
color: light-dark(black, #e8e6e3);
background-color: light-dark(#e0e0e0, #2a2d2f);
color: light-dark(black, #e8e6e3);
background-color: light-dark(#e0e0e0, #2a2d2f);
}
.chhoto-table th,
.chhoto-table td {
border-left: none;
max-width: 36em;
}
.chhoto-table th, .chhoto-table td {
border-left: none;
#short-url-header {
min-width: 6em;
}
th[name="hitsColumn"], td[name = "hitsColumn"] {
text-align: right;
th[name="hitsColumn"],
td[name="hitsColumn"] {
text-align: right;
}
th[name="expiryColumn"], td[name = "expiryColumn"] {
text-align: center;
th[name="expiryColumn"],
td[name="expiryColumn"] {
text-align: center;
}
td[name="deleteBtn"] div {
display: flex;
align-items: center;
justify-content: center;
th[name="actions"],
td[name="actions"] div {
align-items: center;
justify-items: center;
text-align: center;
min-width: 8em;
}
td[name="actions"] div button,
.pure-table caption button.svg-button {
aspect-ratio: 1;
border-style: none;
cursor: pointer;
display: table-cell;
vertical-align: middle;
background-color: transparent;
}
td[name="actions"] div button.svg-button svg,
.pure-table caption button.svg-button svg {
height: 1.3em;
}
th[name="deleteBtn"], td[name = "deleteBtn"] {
text-align: center;
}
td[name="deleteBtn"] div button {
border-radius: 100%;
aspect-ratio: 1;
border-style: solid;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
background-color: transparent;
}
input {
width: 65%;
.pure-table caption span {
margin-right: 1em;
}
form input[name="shortUrl"]::placeholder {
text-transform: none;
text-transform: none;
}
div[name="links-div"] {
position: absolute;
right: 0.5%;
top: 0.5%;
}
#logo {
font-size: 32px;
position: absolute;
right: 0.5%;
top: 0.5%;
}
#password {
width: 100%;
margin-bottom: 10px;
width: 100%;
margin-bottom: 1em;
}
#login-dialog {
border-radius: 10px;
border-width: 2px;
}
#login-dialog form {
text-align: center;
}
#wrong-pass {
color: light-dark(red, #ff1a1a);
color: light-dark(red, #ff1a1a);
}
#edit-dialog,
#qr-code-dialog,
#login-dialog {
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;
position: relative;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 120px;
background-color: light-dark(#eeeeee, #484a4b);
color: light-dark(black, #e8e6e3);
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
margin-left: -60px;
opacity: 0;
transition: opacity 0.3s;
visibility: hidden;
width: 8em;
background-color: light-dark(#eeeeee, #484a4b);
color: light-dark(black, #e8e6e3);
text-align: center;
padding: 0.3em 0;
border-radius: 0.4em;
position: absolute;
z-index: 1;
bottom: calc(50% + 1.25em);
left: 50%;
margin-left: -4em;
opacity: 0;
transition: opacity 0.3s;
}
.tooltip .tooltiptext::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: light-dark(#eeeeee, #484a4b) transparent transparent transparent;
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -0.3em;
border-width: 0.3em;
border-style: solid;
border-color: light-dark(#eeeeee, #484a4b) transparent transparent transparent;
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
visibility: visible;
opacity: 1;
}
/* Settings for mobile devices */
@media (pointer:none),
(pointer:coarse) {
.container {
max-width: 100vw;
}
@media (pointer: none), (pointer: coarse) {
.container {
max-width: 98vw;
}
.pure-form input.chhoto-input {
width: 98%;
}
.pure-control-group input {
width: 98%;
}
.chhoto-table {
border-collapse: collapse;
}
.chhoto-table tr:not(:last-child) {
border-bottom: 0.15em dotted light-dark(black, #867d6e);
}
.chhoto-table {
border-collapse: collapse;
}
.chhoto-table thead {
display: none;
}
.chhoto-table tr {
border-bottom: 1px solid light-dark(black, #867d6e);
}
.chhoto-table td {
display: flex;
justify-content: left;
width: 97vw;
padding: 0.1em;
}
.chhoto-table tr:nth-child(even) {
background-color: light-dark(white, #181a1b);
}
.chhoto-table tr td[name="shortColumn"] {
padding-top: 0.5em;
}
.chhoto-table tr td[name="actions"] {
padding-bottom: 0.5em;
}
.chhoto-table thead {
display: none;
}
#alert-box {
display: none;
}
.chhoto-table td {
display: flex;
justify-content: left;
width: 98vw;
padding: .5em .1em;
}
.chhoto-table td::before {
content: attr(label);
font-weight: bold;
min-width: 6em;
text-align: left;
align-content: center;
}
.chhoto-table td div {
align-content: center;
}
.chhoto-table td::before {
content: attr(label);
font-weight: bold;
width: 120px;
min-width: 120px;
text-align: left;
}
.chhoto-table th[name="numColumn"],
.chhoto-table td[name="numColumn"] {
display: none;
}
.chhoto-table caption {
padding-top: 0px;
}
.chhoto-table caption {
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