Compare commits

...

472 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
SinTan1729
07aeb38a67 build: Bumped version to 6.2.5 2025-07-10 17:33:42 -05:00
SinTan1729
42054fe3cb build: Fix typo in Makefile 2025-07-10 17:33:26 -05:00
SinTan1729
7f6f86e336 build: Updated deps 2025-07-10 17:28:49 -05:00
SinTan1729
16c2817039 fix: Tooltip looking weird 2025-07-10 15:57:24 -05:00
SinTan1729
85efb3a021 new: Added og meta tags 2025-07-10 03:30:10 -05:00
SinTan1729
ce0e625d18 docs: Prettier JSON 2025-07-10 02:51:28 -05:00
SinTan1729
98d0e8ce06 docs: Prettier JSON in docs 2025-07-10 02:48:25 -05:00
SinTan1729
f42eb60494 build: Use ghcr for dev images
Also push releases to ghcr for backup.
2025-07-09 23:18:54 -05:00
SinTan1729
c445d29fe0 build: Check for uncommitted files before tagging 2025-07-08 20:17:29 -05:00
SinTan1729
76b322f7eb build: Bumped version to 6.2.4 2025-07-08 20:12:17 -05:00
SinTan1729
3739df8e92 build: Don't push release unless we had a recent bump 2025-07-08 20:11:54 -05:00
SinTan1729
8903f6a7d1 new: Expiration information now updates every second 2025-07-08 20:01:15 -05:00
SinTan1729
4e0f48a24c chg: Some consolidation and fixes 2025-07-08 20:00:28 -05:00
SinTan1729
10e79f1c17 build: Bumped version to 6.2.3 2025-07-08 15:44:12 -05:00
SinTan1729
2dd730564f fix: get_longlink and cleanup should behave similarly
The filtering operators are now complementary, fixing a gap of a second
between when a link was expired and when it could be replaced with a new
one.
2025-07-08 15:04:39 -05:00
SinTan1729
c4b2c8aca0 test: Improve link expiry test 2025-07-08 14:53:04 -05:00
SinTan1729
92c76c9fbb build: Bumped version to 6.2.2 2025-07-08 13:12:33 -05:00
SinTan1729
715c534941 fix: Expired links blocking new ones
Previously expired but not cleaned up links would block new ones from
being added.
2025-07-08 12:56:01 -05:00
SinTan1729
78ba80e6fd fix: Public mode expiry delay enforcing
It should not be used when logged in. Fixes #67
2025-07-08 11:54:48 -05:00
SinTan1729
8dbd09b3e9 build: New Makefile entry for tag creation 2025-07-08 02:23:54 -05:00
SinTan1729
80448ad1bd build: Bumped version to 6.2.1 2025-07-08 00:42:29 -05:00
SinTan1729
bd0d42019c new: Alternating colors for table rows in desktop site 2025-07-08 00:41:36 -05:00
SinTan1729
d8e91f4815 new: Rounded corners in tables and nicer colors overall 2025-07-08 00:31:18 -05:00
SinTan1729
bd36d2fc03 fix: More idiomatic CSS
No longer need to use !important as a consequence.
2025-07-07 23:43:07 -05:00
SinTan1729
1e5da24739 chg: Use latest version 3.0.0 of PureCSS 2025-07-07 18:06:08 -05:00
SinTan1729
dbf523da02 docs: Add more info about dropping capabilities
There might still be issues, I'm dumb about these things.
2025-07-05 23:51:28 -05:00
SinTan1729
7aea0900c5 build: Bumped version to 6.2.0 2025-07-05 23:18:58 -05:00
SinTan1729
4ba1cf9e8c docs: Instructions on how to run the container with least priviledges 2025-07-05 20:44:38 -05:00
SinTan1729
6180baec6a fix: Properly prepend subdir for custom landing page 2025-07-05 17:42:56 -05:00
SinTan1729
8c1cfb8c95 docs: Updated helm-chart with new config options 2025-07-03 23:43:31 -05:00
SinTan1729
6bc1e59ca1 fix: Do not remove /admin/manage from the middle of URL
This might help if someone has a nested hosting setup
2025-07-03 23:29:22 -05:00
SinTan1729
1fd8112790 fix: Ignore empty env vars 2025-07-03 23:19:30 -05:00
SinTan1729
8a4adec560 docs: Documentation for custom landing directory 2025-07-03 23:14:58 -05:00
SinTan1729
5714ba2631 fix: Trailing slash and relative links 2025-07-03 23:07:46 -05:00
SinTan1729
29c969a52a new: Ability to serve alternative landing page, fixes #64 2025-07-03 23:07:06 -05:00
Sayantan Santra
d1491ef5f8 Merge pull request #65 from SinTan1729/allow-capital-letters
new: Allow capital letters in links
2025-07-03 20:17:33 -05:00
SinTan1729
49532e5e3e new: Added tests for capital letters 2025-07-03 20:16:32 -05:00
SinTan1729
b2bfa0398f docs: Instructions of usage of capital letters 2025-07-03 19:58:01 -05:00
SinTan1729
194d9960e0 docs: Added usage for the new /api/getconfig path 2025-07-03 19:52:31 -05:00
SinTan1729
7b27282171 new: Deprecation notice for /api/version and /api/getconfig 2025-07-03 19:48:01 -05:00
SinTan1729
04d09988c9 fix: Minimize the number of API calls 2025-07-03 19:46:29 -05:00
SinTan1729
bdf77400fd new: Support for capital letters in the frontend 2025-07-03 18:52:59 -05:00
SinTan1729
79e951013a new: Added version to getconfig 2025-07-03 18:40:18 -05:00
SinTan1729
e3707d4221 chg: Expose more config via getconfig route
This might be useful for 3rd party plugins.
All of the data exposed could be figured out easily with some fiddling
with link creation. So, this should not introduce any vulnerabilities.
2025-07-03 18:12:53 -05:00
SinTan1729
27c908c1ff new: Dark mode and nice font in 404 page 2025-07-03 15:07:05 -05:00
SinTan1729
ca4ec02f2e fix: Version in frontend 2025-07-03 00:46:59 -05:00
SinTan1729
d9d2aaeb26 chg: Open /api/getconfig in public mode 2025-07-03 00:39:44 -05:00
SinTan1729
3ec10da6ad chg: /api/version now sends back the name of the program too 2025-07-03 00:29:52 -05:00
SinTan1729
8fcbe4366a new: New route /api/getconfig
It streamlines the process of communicating config to the frontend. This
should make it easier for new config options to be added in the future,
without needing to create new API routes.
2025-07-03 00:26:23 -05:00
SinTan1729
0887629d99 chg: Just some silly name change 2025-07-02 16:22:09 -05:00
SinTan1729
64ebd47526 new: Allow capital letters in the backend
Frotend support still needs to be added
2025-07-02 16:17:30 -05:00
SinTan1729
07e0f2fae1 fix: Minor semantic changes
Mostly to make clippy happy.
2025-07-02 00:33:11 -05:00
SinTan1729
e28e3d22cf build: Bumped version to 6.1.2 2025-07-01 01:25:52 -05:00
SinTan1729
f76e383998 fix: Use proper font everywhere 2025-06-30 16:50:28 -05:00
SinTan1729
4bbe75a338 chg: Use web font instead of bundling it 2025-06-30 16:41:11 -05:00
SinTan1729
7099306ffc chg: Use svg icon in page 2025-06-29 22:39:37 -05:00
SinTan1729
ea0bcbc745 build: Bumped version to 6.1.1 2025-06-19 19:32:25 -05:00
SinTan1729
10e595ede9 fix: API key validation with Argon2 hash 2025-06-19 19:29:14 -05:00
SinTan1729
0ba4f83dc6 fix: Logging for public mode expiry delay, fixes #61
The arms of the if statement were switched.
2025-06-19 18:35:59 -05:00
SinTan1729
bbfc49e71c docs: Added notice about CLI app 2025-06-18 17:02:27 -05:00
SinTan1729
8efb7c1f36 docs: Added link to new CLI app 2025-06-18 17:00:03 -05:00
SinTan1729
da71c228d9 new: Print link to source at start 2025-06-16 00:45:59 -05:00
SinTan1729
019746b19d fix: Grammar 2025-06-15 14:22:40 -05:00
Sayantan Santra
0abbc5a129 Merge pull request #60 from paranoidPhantom/main
Mention Raycast extension
2025-06-14 11:19:19 -05:00
Andrei Hudalla
1b9049489b feat(README): mention raycast extension 2025-06-14 15:00:05 +03:00
SinTan1729
54b7e6d5d1 build: Bumped version to 6.1.0 2025-06-10 17:10:24 -05:00
SinTan1729
2908757d07 new: Comments for explaining tuple returns 2025-06-10 17:08:27 -05:00
Sayantan Santra
97103cc905 Merge pull request #59 from jnewman314/collision-retry
Optionally retry with a longer UID on collision
2025-06-09 20:17:57 -05:00
SinTan1729
2361f4895b chg: Emphasize that some errors are much worse 2025-06-09 20:15:53 -05:00
SinTan1729
840f73ffca chg: Improve uniformity of code to match style 2025-06-09 20:04:47 -05:00
Jonathan Newman
3d1ca73a01 new: Support for retrying with a longer UID on collision 2025-06-10 00:06:53 +01:00
SinTan1729
9b7a91e99f build: Bumped version to 6.0.4 2025-06-06 09:44:43 -05:00
SinTan1729
5ba5945207 build: Updated deps 2025-06-06 09:44:20 -05:00
SinTan1729
06344f6615 docs: Fix word 2025-06-03 18:45:27 -05:00
SinTan1729
db9495ea92 docs: Notes should be a section 2025-06-03 18:38:58 -05:00
SinTan1729
ea284b5ddf docs: Moved the 3rd party tools to their own section 2025-06-03 18:38:11 -05:00
SinTan1729
402df40465 chg: Small change in printing version at startup 2025-05-31 16:36:01 -04:00
SinTan1729
05fa2669b6 build: No need to be verbose during tests 2025-05-30 15:20:28 -04:00
SinTan1729
313fa67dca chg: Build in a separate step 2025-05-30 15:17:14 -04:00
SinTan1729
808ab744e6 chg: Some cleanup and logging 2025-05-30 15:14:05 -04:00
SinTan1729
50c1701646 chg: Better variable name 2025-05-30 15:10:52 -04:00
SinTan1729
bedd06ed45 chg: Use match instead of if for printing deletion info 2025-05-21 12:57:03 -05:00
SinTan1729
3374654fbb chg: Proper verb for deleted links 2025-05-19 01:06:31 -05:00
SinTan1729
7d59076e30 docs: Removed commits badge 2025-05-18 20:50:38 -05:00
SinTan1729
ff89b2bc91 fix: Cached directory locations 2025-05-17 20:40:13 -05:00
SinTan1729
200b0cad72 build: Fix caching 2025-05-17 20:28:45 -05:00
SinTan1729
01dd96f42d chg: A bit more readable code 2025-05-17 19:40:13 -05:00
SinTan1729
e6aad675a8 build: Verify after adding link in some tests 2025-05-17 19:33:39 -05:00
SinTan1729
bda6489f2e build: Removed unsupported condition 2025-05-17 01:08:04 -05:00
SinTan1729
4caee32504 build: Upgrade cache to v4 2025-05-17 01:06:05 -05:00
SinTan1729
7b9b3e1adc build: Change step name for build 2025-05-17 01:05:16 -05:00
SinTan1729
d23442f126 build: Attempt caching for test builds 2025-05-17 01:03:36 -05:00
SinTan1729
5ecd7168cc new: Dark mode for 404.html 2025-05-17 00:33:57 -05:00
SinTan1729
3a76c20170 chg: Button name 2025-05-16 17:22:34 -05:00
SinTan1729
cb12829f50 docs: Added tests badge 2025-05-16 17:19:37 -05:00
SinTan1729
7c10041186 chg: Workflow name 2025-05-16 17:15:25 -05:00
SinTan1729
c87b476d43 fix: Typo 2025-05-16 17:14:14 -05:00
SinTan1729
539393f01f fix: Let workflow be run manually 2025-05-16 17:13:21 -05:00
SinTan1729
8efb9e42ce fix: Proper runs 2025-05-16 17:09:19 -05:00
Sayantan Santra
2830876455 Create rust.yml
Add testing for pushes and PRs
2025-05-16 17:03:00 -05:00
SinTan1729
9123b940d2 build: Tidy up tests and add new test for expiry 2025-05-16 16:56:56 -05:00
SinTan1729
1629569f8e build: Bumped version to 6.0.3 2025-05-16 10:22:40 -05:00
SinTan1729
ab423337c9 build: Run tests before all builds 2025-05-16 10:21:53 -05:00
SinTan1729
d5684155fd build: Added more tests 2025-05-16 10:19:40 -05:00
SinTan1729
9a61e5f3c4 fix: Typo in SQL command for /api/expand 2025-05-16 10:18:42 -05:00
SinTan1729
535c97fd79 fix: Better reply for /api/expand 2025-05-16 10:13:02 -05:00
SinTan1729
beb290cfb0 build: Bumped version to 6.0.2 2025-05-16 02:13:25 -05:00
SinTan1729
e7d03f2190 build: More tests 2025-05-16 02:11:31 -05:00
SinTan1729
33735aa714 build: Added test to Makefile 2025-05-16 01:17:49 -05:00
SinTan1729
2e61b4c1df build: Some initial testing 2025-05-16 01:11:35 -05:00
SinTan1729
1ca63df5ec fix: Parsing of site_url 2025-05-16 01:10:51 -05:00
SinTan1729
8bcec0f28f fix: Treat empty password as password not provided 2025-05-16 00:19:28 -05:00
SinTan1729
9cacb681db fix: Cache slug configs 2025-05-15 23:43:34 -05:00
SinTan1729
721223bebc docs: Added some notes 2025-05-14 16:23:24 -05:00
SinTan1729
acb9afac16 fix: Link to discussion 2025-05-12 13:45:23 -05:00
SinTan1729
6f21f82fd2 docs: Rearranged some installation instructions 2025-05-12 13:42:17 -05:00
SinTan1729
57e4603710 docs: Added a link to FreeBSD port 2025-05-12 13:14:31 -05:00
SinTan1729
642bb0a39a build: Bumped version to 6.0.1 2025-05-11 22:51:00 -05:00
SinTan1729
17d78fe66d build: Updated deps 2025-05-11 22:50:30 -05:00
SinTan1729
0afac015aa new: Show tooltips with accurate expiry date on hover 2025-05-11 19:45:54 -05:00
SinTan1729
83955e734f docs: Updated screenshots 2025-05-09 02:26:01 -05:00
SinTan1729
2afd443dcf fix: Docker tags 2025-05-09 02:23:01 -05:00
SinTan1729
93be80313a build: Bumped version to 6.0.0 2025-05-09 02:18:22 -05:00
SinTan1729
aac45282a2 new: Decorated message during startup 2025-05-08 16:10:08 -05:00
SinTan1729
cc918f0e73 chg: Switch back to env_logger and updated deps 2025-05-08 15:18:14 -05:00
SinTan1729
a03ce4a06f fix: Show colorized logs 2025-05-08 15:13:37 -05:00
SinTan1729
e8ae933e5b chg: Precision to seconds is enough for logging 2025-05-08 14:26:48 -05:00
SinTan1729
841bc2d26b chg: Minor changes in logging 2025-05-08 14:22:49 -05:00
SinTan1729
ce291f43bd chg: Small changes in wording 2025-05-08 14:13:12 -05:00
SinTan1729
97d8c143d2 docs: Wording 2025-05-08 13:20:34 -05:00
SinTan1729
892a1ce4f9 new: Print info about read env variables in config 2025-05-08 13:18:46 -05:00
SinTan1729
b8687d2475 fix: Some log levels should be info instead of warn 2025-05-08 11:44:24 -05:00
SinTan1729
fc0e31dbdf chg: Prettier and timed logging 2025-05-08 11:32:12 -05:00
SinTan1729
4483808572 build: Use .env directly in Makefile 2025-05-07 16:23:59 -05:00
SinTan1729
9a5ccad34c chg: Refactor code to minimize syscalls 2025-05-07 10:33:35 -05:00
SinTan1729
151ffb261b fix: Enforce expiry delay in public mode when sent 0 2025-05-06 21:19:43 -05:00
SinTan1729
9be046b1e8 docs: Info about automated expiry delay in public mode 2025-05-06 21:13:47 -05:00
SinTan1729
356b04bbaa new: Support for automated delay in public mode 2025-05-06 21:13:20 -05:00
SinTan1729
9f317e8e1a fix: Login, logout texts. Also, public mode checking is slightly
different.
2025-05-06 20:20:10 -05:00
SinTan1729
8b0dbd23b3 docs: A bit more RAM is used now. 2025-05-06 18:46:20 -05:00
SinTan1729
74a5574d9f chg: Wording 2025-05-06 18:45:02 -05:00
SinTan1729
6801346e92 chg: Expiry will be added in version 6.0.0 2025-05-06 18:35:52 -05:00
SinTan1729
c6ba3c8edf docs: Ask people to backup 2025-05-06 18:35:38 -05:00
SinTan1729
ab1b085b15 docs: Renamed Installation.md to INSTALLATION.md 2025-05-06 16:26:01 -05:00
Sayantan Santra
8e415472b0 Merge pull request #55 from SinTan1729/support-auto-expiry
Support auto expiry of links
2025-05-06 16:15:46 -05:00
SinTan1729
892af51242 fix: Formatting for CLI.md 2025-05-06 16:14:14 -05:00
SinTan1729
4f771c5a7d fix: Reply with expiry time when a new link is created 2025-05-06 16:13:00 -05:00
SinTan1729
9da210872b docs: Update CLI.md with the latest changes 2025-05-06 16:01:35 -05:00
SinTan1729
8a8915ee16 docs: Factor out installation into a new file 2025-05-06 15:30:25 -05:00
SinTan1729
037dc01a04 docs: Factor out CLI usage into a different file 2025-05-06 15:24:59 -05:00
SinTan1729
c91b869f98 new: Ability to select expiry time from the UI 2025-05-06 15:17:31 -05:00
SinTan1729
8a38095df6 fix: Properly filter the expired links out 2025-05-06 14:04:11 -05:00
SinTan1729
9c2e6aef2c new: The frontend can now show expiry times 2025-05-06 14:03:36 -05:00
SinTan1729
2f76127e32 chg: Only do cleanup once every hour 2025-05-06 13:38:49 -05:00
SinTan1729
352f9b94e1 new: Consider expiry_time during queries 2025-05-06 13:37:27 -05:00
SinTan1729
6bebf8129a chg: Clean up migration code, and make deletion better 2025-05-06 13:06:20 -05:00
SinTan1729
71644fd4cf new: Get expiry to work 2025-05-06 02:05:18 -05:00
SinTan1729
fb822ec7ad new: Support for posting and getting expiry_time 2025-05-06 00:50:55 -05:00
SinTan1729
3e0d9afd92 build: Add DB_FILE in Makefile 2025-05-05 23:05:09 -05:00
SinTan1729
d4cb1c549c chg: Made changes to the incoming struct
This lets us send expiry info, while maintaining backwards
compatibility.
2025-05-05 22:12:07 -05:00
SinTan1729
d39bd8a46a fix: Errors when doing the migration 2025-05-05 19:35:01 -05:00
SinTan1729
e50dffc1e1 chg: Added a new column for expiry times 2025-05-05 18:39:07 -05:00
SinTan1729
7942dcf48d chg: Use latest tag in helm-chart 2025-05-05 00:56:59 -05:00
SinTan1729
1095d90e1f docs: Added hash_algorithm to helm-chart 2025-05-05 00:55:17 -05:00
SinTan1729
e8fe1d612d build: Bumped version to 5.8.0 2025-05-04 15:54:30 -05:00
SinTan1729
396154b10d docs: Added instructions for escaping $ 2025-05-04 15:51:53 -05:00
SinTan1729
289a3ddec3 fix: Switch to argon2 crate to fix incompatibility issues 2025-05-04 15:51:19 -05:00
SinTan1729
dd1ac6a201 chg: Wording 2025-05-04 15:19:43 -05:00
SinTan1729
10d06e3c46 fix: Missing semicolon (ffs) 2025-05-04 15:18:35 -05:00
SinTan1729
835ecea22b fix: Replaced eprintln with println whenever appropriate 2025-05-04 15:17:22 -05:00
SinTan1729
18272d2684 fix: Hashed passwords now work without API keys 2025-05-04 15:16:55 -05:00
SinTan1729
d557c9696b docs: Added info about hash in features 2025-05-04 13:48:18 -05:00
SinTan1729
5bae3b1f42 docs: Minor wording 2025-05-04 13:46:32 -05:00
SinTan1729
ab980a9371 docs: Added documentation for password hashing 2025-05-04 13:44:01 -05:00
SinTan1729
b579ee5299 new: Support hashing API Keys as well 2025-05-04 13:39:19 -05:00
SinTan1729
d1bc6fcf4b new: Added support for Argon2 hashed passwords 2025-05-04 13:22:53 -05:00
SinTan1729
bbf5811e13 build: Bumped version to 5.7.1 2025-04-23 23:57:50 -05:00
SinTan1729
efc2b03415 docs: Change wording for donations 2025-04-23 16:10:37 -05:00
SinTan1729
3aa8884676 docs: Small changes in the README 2025-04-23 16:07:32 -05:00
SinTan1729
084334cd11 fix: Better response on error when shortlink is not provided 2025-04-23 15:45:41 -05:00
SinTan1729
4eb88f3beb chg: Match using rusqlite errors instead of strings 2025-04-23 15:23:34 -05:00
SinTan1729
c23fcdc9bd chg: Properly validate error types 2025-04-21 22:20:11 -05:00
SinTan1729
21f76f2962 chg: No need to print public uri with port when custom uri is passed 2025-04-21 12:51:00 -05:00
SinTan1729
e8af830527 build: Bumped version to 5.7.0 2025-04-21 12:43:05 -05:00
SinTan1729
fc2c24d731 build: Updated deps 2025-04-21 12:40:21 -05:00
Sayantan Santra
a170954232 Merge pull request #53 from magnusja/main
Create UNIQUE INDEX on short URL
2025-04-19 20:07:01 -05:00
SinTan1729
e39578fa02 chg: Modify shortlink validation logic to utilize the INDEX
It's no longer necessary to separately validate uniqueness
since the UNIQUE INDEX does that for us already.
2025-04-19 20:04:56 -05:00
SinTan1729
88ddb4299a chg: Move the uniqueness validation to the INDEX 2025-04-19 19:31:43 -05:00
Magnus
b2bc2c450b add unique constraint and create index on short URL 2025-04-18 20:26:29 +02:00
Sayantan Santra
d198135144 Merge pull request #48 from ronnyworm/add-helm-chart
add helm chart and add instructions for its usage in README
2025-04-10 15:03:07 -05:00
SinTan1729
b838a6e027 new: Added disable_frontend to variables in helm-chart 2025-04-10 15:01:50 -05:00
Ronny Worm
0897b6b63b add other options from docker-compose 2025-04-10 21:46:39 +02:00
SinTan1729
97b56c40ae docs: Added instructions for disabling the frontend 2025-04-10 14:17:22 -05:00
SinTan1729
2c8f47c0cb new: Ability to disable frontend 2025-04-10 12:45:49 -05:00
SinTan1729
828019998e build: Bumped version to 5.6.3 2025-04-07 22:24:49 -05:00
SinTan1729
49d910fb3c build: Updated deps to mitigate a tokio security issue 2025-04-07 22:24:10 -05:00
SinTan1729
c521ad1120 docs: Added some more options and info in the compose file 2025-04-02 17:17:59 -05:00
Ronny Worm
63020b2c24 add helm chart and add intructions for its usage in README 2025-03-23 21:55:40 +01:00
Sayantan Santra
d42a738861 docs: Updated README.md 2025-03-17 18:23:27 -05:00
SinTan1729
e3eaf5aba8 docs: Updated screenshots 2025-03-17 18:22:25 -05:00
SinTan1729
3b48ce7b5e chg: Simplify how wrong password text is shown 2025-03-05 15:56:12 -06:00
SinTan1729
5363a1b056 docs: Added info about dark mode 2025-03-05 15:22:58 -06:00
SinTan1729
0d58e626a4 fix: Hide the disabled wrong password text area in dialog 2025-03-04 00:11:34 -06:00
SinTan1729
e8faf660f4 build: Bumped version to 5.6.2 2025-03-03 18:45:08 -06:00
SinTan1729
67695da86b fix: Use changed methods for rand 2025-03-03 18:44:42 -06:00
SinTan1729
d50c183c9c build: Updated deps 2025-03-03 18:37:49 -06:00
SinTan1729
90b04b1f21 fix: Link colors for dark mode 2025-03-03 18:34:57 -06:00
SinTan1729
babf3d8911 new: Automatic dark mode support 2025-03-03 18:10:53 -06:00
SinTan1729
1ae00eb3a8 chg: Some cosmetic changes to login dialog 2025-03-03 14:16:38 -06:00
SinTan1729
6f419c7b3d new: Enforce ordering of data
Closes #46
Data is returned in order of id, which should match the order it was
inserted in. In the WebUI, the entries are shown in reverse, so the
latest link is at the top.
2025-03-03 12:27:59 -06:00
SinTan1729
c557b8b262 docs: Change to github link for extension 2025-01-30 01:03:06 -06:00
SinTan1729
a63222a71a docs: Add a few words 2025-01-30 00:59:05 -06:00
SinTan1729
86cea6278f docs: Added mention of extension 2025-01-28 01:39:44 -06:00
SinTan1729
f283991740 build: Bumped version to 5.6.1 2025-01-17 23:30:43 -06:00
Sayantan Santra
1775f71347 Merge pull request #42 from SolninjaA/main
Correctly output created link
2025-01-17 23:28:30 -06:00
SinTan1729
0b1224f8e5 docs: Improve clarification of the port variables 2025-01-17 23:27:33 -06:00
Solninja A
1047763285 chg: Bind server to port env variable 2025-01-18 12:52:19 +10:00
Solninja A
fc785c3eef Re-comment the API key in compose.yaml 2025-01-14 18:16:37 +10:00
Solninja A
17d0df943b Correctly output created link 2025-01-14 17:20:41 +10:00
SolomonTechnology
7b52bd60da Rewording 2025-01-14 00:33:01 +10:00
SolomonTechnology
db8417d919 Improve documentation for the "port" and "site_url" env variables 2025-01-14 00:25:49 +10:00
SinTan1729
af1685bb70 build: Bumped version to 5.6.0 2025-01-09 00:34:22 +05:30
Sayantan Santra
a5621acfe4 Merge pull request #40 from SinTan1729/get-longlink
Get longlink
2025-01-09 00:30:58 +05:30
SinTan1729
1be89db43b docs: Add info about expand route, and put API as preferred method 2025-01-09 00:27:05 +05:30
SinTan1729
a60853fd21 fix: Only pull hits when needed 2025-01-09 00:21:05 +05:30
SinTan1729
2b9fafe440 new: Got the expand API path working
It replies with info for a single shortlink. May be useful for
applications using json interface.
2025-01-08 20:09:24 +05:30
SinTan1729
f952cb88a0 build: Bumped version to 5.5.0 2025-01-06 11:59:41 +05:30
SinTan1729
9eec252fe2 build: Updated deps 2025-01-06 11:54:49 +05:30
Sayantan Santra
f8f4dae457 Merge pull request #39 from SolninjaA/main
Improvements of the API system
2025-01-06 11:52:48 +05:30
SinTan1729
16bc211f9f fix: Confirm when secure API key is provided 2025-01-06 11:48:18 +05:30
SinTan1729
cca5bcfa1a docs: Add example command to generate API key 2025-01-06 11:47:01 +05:30
SinTan1729
cba667ded8 chg: Small cosmetic change 2025-01-06 11:40:20 +05:30
SinTan1729
1d9a8c202d build: Add API_KEY variable in Makefile 2025-01-06 11:17:10 +05:30
SinTan1729
eb4f05a87b fix: Disregard empty Site URL 2025-01-06 11:11:09 +05:30
SinTan1729
5183279cab docs: Small changes to the README 2025-01-05 16:25:08 +05:30
SinTan1729
f1c1642976 chg: Small semantic changes 2025-01-05 16:20:38 +05:30
Solninja A
eed3c2292a Cleaned up code 2025-01-03 00:28:51 +10:00
Solninja A
4fb8d0cb5c Edited the API Key header to comply with OpenAPI v3 specs 2025-01-03 00:25:55 +10:00
Solninja A
9a0cdec646 Improved API error codes 2025-01-01 19:08:35 +10:00
Solninja A
818dadb84f Made code more Rust-like 2025-01-01 17:34:09 +10:00
Solninja A
247cfb0476 Fixed compose.yaml 2024-12-31 20:32:46 +10:00
Solninja A
6347a89725 Minor code clean up 2024-12-31 20:30:55 +10:00
Solninja A
9ddf043c17 Fix typos, etc 2024-12-31 20:17:13 +10:00
Solninja A
a1f8700664 Change README.md 2024-12-31 20:15:06 +10:00
Solninja A
aab7a9b3d1 Change README.md and remove unneeded dependencies 2024-12-31 20:13:37 +10:00
Solninja A
1ef5d539d5 Improve API error handling 2024-12-31 19:54:22 +10:00
Solninja A
5c2886f651 Changes the API to use JSON data and results 2024-12-31 19:11:47 +10:00
Solninja A
2c56c68637 Improves API functionality 2024-12-31 16:19:20 +10:00
SinTan1729
756d675f06 fix: Capitalization, fixes #37 2024-12-30 18:41:48 +05:30
SinTan1729
e6eed2dd70 build: Bumped version to 5.4.6 2024-11-07 19:35:42 -06:00
SinTan1729
37a5300015 fix: Disable copying to clipboard on WebKit, fixes #36
This disables clipboard copying and lets the user
manually copy the links.
2024-11-07 19:33:34 -06:00
SinTan1729
66d94634d9 build: Bumped version to 5.4.5 2024-11-06 22:11:36 -06:00
SinTan1729
03f5529c30 build: Updated deps 2024-11-06 22:11:05 -06:00
SinTan1729
f772475d96 fix: Do not autocapitalize shorturl on mobile devices 2024-11-06 21:57:56 -06:00
SinTan1729
8b8ceca313 chg: Remove lowercasing of shorturl from the CSS, fixes #35
This makes the behavior more uniform across different banned characters.
2024-11-03 01:17:53 -05:00
SinTan1729
201d0b319f chg: Move the font to assets 2024-10-25 14:47:15 -05:00
SinTan1729
733ef6ea67 docs: Added note about Dark Reader 2024-10-06 20:38:05 -05:00
SinTan1729
cf5909c888 fix: Use a simpler password to make the shell happy 2024-10-05 00:26:38 -05:00
SinTan1729
dcb3144b22 chg: Added a better compose file 2024-10-05 00:24:16 -05:00
SinTan1729
e0c61bdb93 build: Bumped version to 5.4.4 2024-10-03 00:02:48 -05:00
SinTan1729
06f7a33d5d fix: Do not consider empty password 2024-10-02 23:52:23 -05:00
SinTan1729
514e905299 chg: Updated instructions in the compose file 2024-10-02 23:46:56 -05:00
SinTan1729
3688692c7a chg: Default db location 2024-10-02 23:46:35 -05:00
SinTan1729
a7cf0cdf30 build: Bumped version to 5.4.3 2024-09-18 11:52:24 -05:00
SinTan1729
35880f4d1e build: Updated dependencies 2024-09-18 11:51:50 -05:00
Sayantan Santra
0d0da1141b Merge pull request #27 from yilmaz08/main
Fix simple dockerfile errors
2024-08-28 11:49:51 -05:00
SinTan1729
4a8b62446c fix: Case mismatch 2024-08-28 11:37:45 -05:00
Abdürrahim YILMAZ
855145d4d7 fix: argument was not being passed
Argument was not being passed, although it is declared. So changed to a
static path in "FROM scratch" part

Error message before the fix:

Step 17/19 : COPY --from=builder
/chhoto-url/target/$target/release/chhoto-url /chhoto-url
COPY failed: stat chhoto-url/target//release/chhoto-url: file does not
exist
2024-08-28 03:12:33 +03:00
Abdürrahim YILMAZ
59f679a1c2 fix: copy destination path
Error message before the fix:

Step 4/19 : COPY ./actix/Cargo.toml ./actix/Cargo.lock .

When using COPY with more than one source file, the destination must be
a directory and end with a /
2024-08-28 03:04:37 +03:00
SinTan1729
5213a9df2e docs: Added a docker image size badge 2024-06-21 10:01:19 -05:00
SinTan1729
3e6f482533 docs: Updated the screenshots 2024-06-21 09:46:47 -05:00
SinTan1729
56ab16aa4e build: Bumped version to 5.4.2 2024-06-20 20:59:06 -05:00
SinTan1729
066fa9c80a build: Updated deps 2024-06-20 20:48:06 -05:00
SinTan1729
9fc8634704 fix: Typo during reading slug_length, fixes #24 2024-06-20 20:46:26 -05:00
SinTan1729
5bbaad3001 build: Bumped version to 5.4.1 2024-06-07 15:34:28 -05:00
SinTan1729
892959d49d build: Updated deps 2024-06-07 15:33:58 -05:00
SinTan1729
9948ce713c chg: Some small changes to the html pages
Should be nicer on mobile devices
2024-06-01 00:48:40 -05:00
Sayantan Santra
70d9d828c5 Update bug_report.md 2024-06-01 00:36:14 -05:00
SinTan1729
114a97a273 build: Bumped version to 5.4.0 2024-05-31 20:31:29 -05:00
SinTan1729
96495b037d new: Made the Cache-Control headers fully configurable 2024-05-31 20:30:09 -05:00
SinTan1729
69fc25a264 build: Add port settings to the docker-test recipe 2024-05-31 11:29:41 -05:00
SinTan1729
f19f3249cc docs: Changed a word in the README 2024-05-31 01:51:11 -05:00
SinTan1729
2cf0e5d2de new: Ability to disable Cache-Control headers in testing recipe 2024-05-31 01:45:19 -05:00
SinTan1729
de9bc130d5 docs: Add info about configuring Cache-Control header 2024-05-31 01:42:51 -05:00
SinTan1729
8ff4c3f24f chg: Make the Cache-Control header configurable 2024-05-31 01:41:55 -05:00
SinTan1729
eab1c9bc73 new: Added cache-control header, fixes #22 2024-05-31 01:24:33 -05:00
SinTan1729
0b50a7c261 chg: Move all the services into their own file 2024-05-30 14:45:18 -05:00
SinTan1729
e55c6f82b4 fix: Make the link clickable when securecontext is missing 2024-05-29 19:14:31 -05:00
SinTan1729
6992d27390 build: Bumped version to 5.3.1 2024-05-29 16:12:31 -05:00
SinTan1729
39e4d2df74 chg: Show the whole link when clipboard is not accessible 2024-05-29 16:03:03 -05:00
SinTan1729
41b7e37819 build: Bumped version to 5.3.0 2024-05-29 10:45:42 -05:00
SinTan1729
e67e0a88cd build: Updated deps 2024-05-29 10:45:15 -05:00
SinTan1729
00ade1af40 fix: Do not allow empty values for db_url or site_url 2024-05-29 10:40:51 -05:00
SinTan1729
b1632c4c87 fix: Fixed some UI visibility related stuff 2024-05-29 10:40:51 -05:00
SinTan1729
3445d5366a fix: Properly handle visibility of elements 2024-05-29 10:40:51 -05:00
Sayantan Santra
5fb8587628 Merge pull request #21 from SinTan1729/public-mode
Enable support for public mode, closes #20
2024-05-29 00:02:38 -05:00
SinTan1729
6cdacda510 build: Make testing more configurable using env variables 2024-05-28 18:20:20 -05:00
SinTan1729
ed2be0e883 docs: Some documentation about public-mode 2024-05-28 18:20:09 -05:00
SinTan1729
0fce881654 new: Got public mode working, testing needed 2024-05-28 18:16:58 -05:00
SinTan1729
168cff94a2 build: Bumped version to 5.2.6 2024-05-06 16:49:47 -05:00
SinTan1729
b1c4142296 chg: Reply when shortlink cannot be added 2024-05-06 16:49:01 -05:00
SinTan1729
75912e8f9d build: Updated deps 2024-05-06 16:41:31 -05:00
SinTan1729
5ac822d5f9 fix: Middleware order 2024-04-11 13:01:33 -05:00
41 changed files with 5602 additions and 1320 deletions

View File

@@ -23,6 +23,12 @@ A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Which version of Chhoto-URL are you experiencing the problem on?**
e.g. v5.x.x
**Can you reproduce the issue in the latest version?**
Yes/No
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]

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

10
.gitignore vendored
View File

@@ -1,11 +1,15 @@
# Ignore build outputs
actix/target
# Ignore SQLite file
urls.sqlite
resources-final
# Ignore irrelevant dotfiles
.vscode/
**/.directory
.env
cookie*
.idea/
.DS_Store
# Testing related
custom_dir
testing-data

218
CLI.md Normal file
View File

@@ -0,0 +1,218 @@
## 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.
You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and
get the siteurl using `curl http://localhost:4567/api/siteurl`. These routes are accessible without any authentication.
### API key validation
**This is required for programs that rely on a JSON response from Chhoto URL**
In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie
validation (see section above). If the API key is insecure, a warning will be outputted along with a generated API key which may be used.
**All responses for requests using API key are JSON encoded.**
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
```
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,
"error": false,
"shorturl": "<shortlink>",
"expiry_time": <expiry_time>
}
```
or
```json
{
"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
```
(This would work without authentication in public mode.)
The server will reply in the following format.
```json
{
"version": "<version>",
"site_url": "<site_url>",
"allow_capital_letters": true/false,
"public_mode": true/false,
"public_mode_expiry_delay": "<delay>",
"slug_style": "<style>",
"slug_length": "<len>",
"try_longer_slug": true/false
}
```
```json
{
"success": false,
"error": true,
"reason": "<reason>"
}
```
#### `/api/whoami`
To get the current user role:
```bash
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,
"error": false,
"longurl": "<longurl>",
"hits": "<hits>",
"expiry_time": <expiry_time>
}
```
or
```json
{
"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.
This if not recommended in actual use however, as it will allow anyone to create new links and delete
old ones. This might not seem like a bad idea, until you have hundreds of links
pointing to illegal content. Since there are no logs, it's impossible to prove
that those links aren't created by you.

View File

@@ -1,15 +1,15 @@
# 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
COPY ./actix/Cargo.toml ./actix/Cargo.lock .
FROM chef AS planner
COPY ./actix/Cargo.toml ./actix/Cargo.lock ./
COPY ./actix/src ./src
RUN cargo chef prepare --recipe-path recipe.json
FROM chef as builder
FROM chef AS builder
ARG target=x86_64-unknown-linux-musl
RUN apt-get update && apt-get install -y musl-tools
RUN rustup target add $target
@@ -18,12 +18,13 @@ COPY --from=planner /chhoto-url/recipe.json recipe.json
# Build dependencies - this is the caching Docker layer
RUN cargo chef cook --release --target=$target --recipe-path recipe.json
COPY ./actix/Cargo.toml ./actix/Cargo.lock .
COPY ./actix/Cargo.toml ./actix/Cargo.lock ./
COPY ./actix/src ./src
# Build application
RUN cargo build --release --target=$target --locked --bin chhoto-url
RUN cp /chhoto-url/target/$target/release/chhoto-url /chhoto-url/release
FROM scratch
COPY --from=builder /chhoto-url/target/$target/release/chhoto-url /chhoto-url
COPY --from=builder /chhoto-url/release /chhoto-url
COPY ./resources /resources
ENTRYPOINT ["/chhoto-url"]

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,13 +1,13 @@
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
# SPDX-License-Identifier: MIT
FROM scratch as builder-amd64
FROM scratch AS builder-amd64
COPY ./actix/target/x86_64-unknown-linux-musl/release/chhoto-url /chhoto-url
FROM scratch as builder-arm64
FROM scratch AS builder-arm64
COPY ./actix/target/aarch64-unknown-linux-musl/release/chhoto-url /chhoto-url
FROM scratch as builder-arm
FROM scratch AS builder-arm
COPY ./actix/target/armv7-unknown-linux-musleabihf/release/chhoto-url /chhoto-url
ARG TARGETARCH

243
INSTALLATION.md Normal file
View File

@@ -0,0 +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. 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`, 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 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 ./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`.
If no API key is provided, the website will still work, but it'll be a significantly worse experience if you try
to use Chhoto URL from the CLI.
### `use_wal_mode` \#
If set to `True`, enables [`WAL` journal mode](https://sqlite.org/wal.html). Any other value is ignored.
It's highly recommended that you enable it, but make sure that you mount either a whole directory, or a named
volume, and have the database inside it. DO NOT mount a single file, as there will be a small chance of partial
data loss in that case.
If this is enabled, there'll be a significant boost in performance under high load, since write will no longer block reads.
Also, automated backups of the database will be enabled. Otherwise, `DELETE` journal mode is used by default, along with
[`EXTRA` synchronous](https://sqlite.org/pragma.html#pragma_synchronous) pragma. In `WAL` mode, `FULL` synchronous pragma is
used instead.
In both cases, we have full ACID compliance, but it does cost a bit of performance. If you expect to see high throughput (in the
order of hundreds of read/writes per second), take a look at the `ensure_acid` configuration option.
### `ensure_acid`
By default, the database is
[ACID (i.e. Atomic, Consistent, Isolated, and Durable)](https://www.slingacademy.com/article/acid-properties-in-sqlite-why-they-matter).
If you'd like to let go of durability for an increase in throughput, set this to `False`. Any other value will be ignored.
This is done by setting the [synchronous pragma](https://sqlite.org/pragma.html#pragma_synchronous) to `FULL` in `WAL`
[journal mode](https://sqlite.org/pragma.html#pragma_journal_mode), and to `EXTRA` in `DELETE` journal mode.
_Note: There might be partial data loss only in case of system failure or power loss. Durability is maintained across application
crashes. If you do have data loss, you should only lose the data stored after the last sync with the database file. So, under normal
loads, you shouldn't lose any data anyway. But this is a real thing that can technically happen._
### `redirect_method` \#
Sets which redirection is used when a shortlink is resolved.
Can be set to `TEMPORARY` or `PERMANENT`, which will enable Temporary 307 or Permanent 308 redirects. Any other value
will be ignored, and a default of `PERMANENT` will be used.
### `slug_style`
Sets the style of slug used when auto-generating shortlinks.
Can be set to either `Pair` or `UID`. Any other value will be ignored, and a default value of `Pair` will be used.
In pair mode, adjective-name pairs are used for auto-generated links e.g. `gifted-ramanujan`. In UID mode, a randomly
generated slug is used.
### `slug_length`
If UID slugs are enabled, the length of the slug can be set using this. A minimum of 4 is supported, and it defaults to 16.
If you intend to have more than a few thousand shortlinks, it's strongly recommended that you use the UID `slug_style` with
a `slug_length` of 16 or more.
### `try_longer_slug`
If you do choose to use a short UID despite anticipating collisions, it's recommended that you set this to `True`.
In the event of a collision, this variable will result in a single retry attempt using a UID four digits longer than
`slug_length`. It has no effect for adjective-name slugs.
_Note: If not set, one retry will be attempted, just like adjective-name slugs. But it would use the same slug length._
### `listen_address`
The address Chhoto URL will bind to. Defaults to `0.0.0.0`.
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._
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.
### `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`.
### `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
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
cd helm-chart
helm upgrade --install chhoto-url . -n chhoto-url --create-namespace -f my-values.yaml
```

View File

@@ -1,48 +1,50 @@
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
# SPDX-License-Identifier: MIT
# .env file has the variables $DOCKER_USERNAME and $PASSWORD defined
include .env
setup:
cargo install cross
rustup target add x86_64-unknown-linux-musl
docker buildx create --use --platform=linux/arm64,linux/amd64 --name multi-platform-builder
docker buildx inspect --bootstrap
.PHONY: clean test setup build podman-build podman-stop podman-test build-release tag audit
build-dev:
setup:
rustup target add x86_64-unknown-linux-musl
podman buildx inspect --bootstrap
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
docker-test: docker-local docker-stop
docker run -p 4567:4567 --name chhoto-url -e password="${PASSWORD}" -d chhoto-url
docker logs chhoto-url -f
test: audit
cargo test --release --locked --manifest-path=actix/Cargo.toml --target x86_64-unknown-linux-musl
docker-dev: build-dev
docker build --push --tag ${DOCKER_USERNAME}/chhoto-url:dev --build-arg TARGETARCH=amd64 -f Dockerfile.multiarch .
audit:
cargo audit --file actix/Cargo.lock
build-release:
cross build --release --locked --manifest-path=actix/Cargo.toml --target aarch64-unknown-linux-musl
cross build --release --locked --manifest-path=actix/Cargo.toml --target armv7-unknown-linux-musleabihf
cross build --release --locked --manifest-path=actix/Cargo.toml --target x86_64-unknown-linux-musl
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
V_PATCH := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)"$$/\1/p')
V_MINOR := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)\..+"$$/\1/p')
V_MAJOR := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)\..+\..+"$$/\1/p')
docker-release: build-release
docker buildx build --push --tag ${DOCKER_USERNAME}/chhoto-url:${V_MAJOR} --tag ${DOCKER_USERNAME}/chhoto-url:${V_MINOR} \
--tag ${DOCKER_USERNAME}/chhoto-url:${V_PATCH} --tag ${DOCKER_USERNAME}/chhoto-url:latest \
--platform linux/amd64,linux/arm64,linux/arm/v7 -f Dockerfile.multiarch .
conf_tag := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)"$$/\1/p')
last_tag := $(shell git tag -l | tail -1)
bumped := $(shell git log -1 --pretty=%B | grep "build: Bumped version to " | wc -l)
uncommitted := $(shell git status --porcelain=v1 2>/dev/null | wc -l)
tag:
ifeq (${bumped}, 1)
ifneq (${uncommitted}, 0)
false;
endif
ifneq (${conf_tag}, ${last_tag})
git tag ${conf_tag} -m "Version ${conf_tag}"
endif
else
false;
endif
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
.PHONY: build-dev docker-local docker-stop build-release

183
README.md
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)
[![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)
![commit-since-latest-release-badge](https://img.shields.io/github/commits-since/SinTan1729/chhoto-url/latest?sort=semver&label=commits%20since%20latest%20release)
[![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 <5 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,153 +25,100 @@ 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 details. Also,
Even fewer are written with simplicity and lightness in mind. When I saw the `simply-shorten`
project (linked below), I really liked the idea but thought that it missed some features. Also,
I didn't like the fact that a simple app like this had a ~200 MB docker image (mostly due to the
included java runtime). So, I decided to rewrite it in Rust and add some features to it that I
thought were essential (e.g. hit counting).
## What does the name mean?
Chhoto (ছোট, [IPA](https://en.wikipedia.org/wiki/Help:IPA/Bengali): /tʃʰoʈo/) is the Bangla word
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
one. (It's surprisingly missing in a surprising number of alternatives.)
one. (It's missing in a surprising number of alternatives.)
- Opening the shortened URL in your browser will instantly redirect you
to the correct long URL. (So no stupid redirecting pages.)
to the correct long URL. (So no stupid redirection pages.)
- Super lightweight and snappy. (The docker image is only ~6MB and RAM uasge
stays under 5MB under normal use.)
- Counts number of hits for each short link in a privacy respecting way
i.e. only the hit is recorded, and nothing else.
- Has a mobile friendly UI.
- 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
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.
- Available as a Docker container.
- Backend written in Rust using [Actix](https://actix.rs/), frontend
- 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 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 something like [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 the basic logging (only warnings) provided by the
[`env_logger`](https://crates.io/crates/env_logger) crate.
- 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 support me (for
whatever reason), you can message me through GitHub issues.
# Screenshot
![Screenshot](screenshot.png)
- **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.
# Usage
## 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
```
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.
# Screenshots
## 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.
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
```
<p align="middle">
<img src="screenshot-desktop.webp" height="250" alt="desktop screenshot" />
<img src="screenshot-mobile.webp" height="250" alt="mobile screenshot" />
</p>
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.
# Installation and configuration
## 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.
[See here.](./INSTALLATION.md)
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.
# Instructions for CLI usage
To add a link, do
```bash
curl -X POST -d '{"shortlink":"<shortlink>", "longlink":"<longlink>"}' http://localhost:4567/api/new
```
Send an empty `<shortlink>` if you want it to be auto-generated. The server will reply with the generated shortlink.
[See here.](./CLI.md)
To get a list of all the currently available links as `json`, do
```bash
curl http://localhost:4567/api/all
```
# Related software
To delete a link, do
```bash
curl -X DELETE http://localhost:4567/api/del/<shortlink>
```
The server will send a confirmation.
[See here.](./TOOLS.md)
You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and
get the siteurl using `curl http://localhost:4567/api/siteurl`.
# Notes
## Disable authentication
If you do not define a password environment variable when starting the docker image, authentication
will be disabled.
This if not recommended in actual use however, as it will allow anyone to create new links and delete
old ones. This might not seem like a bad idea, until you have hundreds of links
pointing to illegal content. Since there are no logs, it's impossible to prove
that those links aren't created by you.
## Notes
- It started as a fork of [this project](https://gitlab.com/draganczukp/simply-shorten).
- It started as a fork of [`simply-shorten`](https://gitlab.com/draganczukp/simply-shorten).
- The list of adjectives and names used for random short url generation is a modified
version of [this list used by docker](https://github.com/moby/moby/blob/master/pkg/namesgenerator/names-generator.go).
- It is highly recommended that you [enable WAL mode](./INSTALLATION.md/#use_wal_mode-).
- Although it's unlikely, it's possible that your database is mangled after some update. For mission critical use cases,
it's recommended to keep regular versioned backups of the database, and sticking to a minor release tag e.g. 5.8.
- If you intend to have more than a few thousand short links, it's strongly recommended that you use the UID `slug_style`
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)

1446
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 = "5.2.5"
version = "6.5.2"
edition = "2021"
authors = ["Sayantan Santra <sayantan[dot]santra689[at]gmail[dot]com"]
license = "mit"
@@ -29,11 +29,21 @@ categories = ["web-programming"]
[dependencies]
actix-web = "4.5.1"
actix-files = "0.6.5"
rusqlite = { version = "0.31.0", features = ["bundled"] }
rusqlite = { version = "0.37.0", features = [ "bundled" ] }
regex = "1.10.3"
rand = "0.8.5"
actix-session = { version = "0.9.0", features = ["cookie-session"] }
env_logger = "0.11.1"
rand = "0.9.0"
passwords = "3.1.16"
actix-session = { version = "0.11.0", features = [ "cookie-session" ] }
nanoid = "0.4.0"
serde = { version = "1.0.197", features = [ "derive", "rc" ] }
serde_json = "1.0.115"
serde = { version = "1.0.197", features = [ "derive" ] }
argon2 = "0.5.3"
chrono = "0.4.41"
tokio = "1.44.2"
log = "0.4.27"
env_logger = "0.11.8"
[dev-dependencies]
actix-http = "3.11.0"
actix-service = "2.0.3"
regex = "1.10.3"

View File

@@ -2,26 +2,128 @@
// SPDX-License-Identifier: MIT
use actix_session::Session;
use std::{env, time::SystemTime};
use actix_web::HttpRequest;
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
use log::{debug, warn};
use passwords::PasswordGenerator;
use std::{rc::Rc, time::SystemTime};
// Validate a given password
pub fn validate(session: Session) -> bool {
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 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() {
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
};
if !authorized {
warn!("Incorrect API key was provided when connecting to Chhoto URL.");
false
} else {
debug!("Server accessed with API key.");
true
}
} else {
warn!("API was accessed with API key validation but no API key was specified. Set the 'api_key' environment variable.");
false
}
}
// Generate an API key if the user doesn't specify a secure key
// Called in main.rs
pub fn gen_key() -> String {
let key = PasswordGenerator {
length: 128,
numbers: true,
lowercase_letters: true,
uppercase_letters: true,
symbols: false,
spaces: false,
exclude_similar_characters: false,
strict: true,
};
key.generate_one().unwrap()
}
// Check if the API key header exists
pub fn get_api_header(req: &HttpRequest) -> Option<&str> {
req.headers().get("X-API-Key")?.to_str().ok()
}
// Validate a session
pub fn is_session_valid(session: Session, config: &Config) -> bool {
// If there's no password provided, just return true
if env::var("password").is_err() {
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 {

219
actix/src/config.rs Normal file
View File

@@ -0,0 +1,219 @@
// SPDX-FileCopyrightText: 2025 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
use log::{info, warn};
use passwords::{analyzer::analyze, scorer::score};
use std::env::var;
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>,
pub disable_frontend: bool,
pub site_url: Option<String>,
pub public_mode: bool,
pub public_mode_expiry_delay: i64,
pub use_temp_redirect: bool,
pub password: Option<String>,
pub hash_algorithm: Option<String>,
pub api_key: Option<String>,
pub slug_style: String,
pub slug_length: usize,
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()
.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"))
.parse::<u16>()
.expect("Supplied port is not an integer");
info!("Listening port is set to {port}.");
let cache_control_header = var("cache_control_header")
.ok()
.inspect(|h| info!("Using \"{h}\" as Cache-Control header."))
.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 {
info!("Frontend is disabled.")
};
// If an API key is set, check the security
let api_key = var("api_key").ok();
if let Some(key) = &api_key {
// Determine whether the inputted API key is sufficiently secure
if score(&analyze(key)) < 90.0 {
warn!("API key is insecure! Please change it. Current key is: {}. Generated secure key which you may use: {}", key, auth::gen_key());
} else {
info!("Secure API key was provided.");
}
}
let public_mode = var("public_mode") == Ok(String::from("Enable"));
let public_mode_expiry_delay = var("public_mode_expiry_delay")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or_default();
if public_mode {
if public_mode_expiry_delay > 0 {
info!("Enabling public mode with an enforced expiry delay of {public_mode_expiry_delay} seconds.");
} else {
info!("Enabling public mode with no enforced expiry delay.");
}
}
let use_temp_redirect = var("redirect_method") == Ok(String::from("TEMPORARY"));
if use_temp_redirect {
info!("Using Temporary redirection.");
} else {
info!("Using Permanent redirection (default).")
}
let password = var("password").ok().filter(|s| !s.trim().is_empty());
if password.is_none() {
warn!("No password was provided. The API will be accessible to the public.")
};
let hash_algorithm = var("hash_algorithm")
.ok()
.filter(|h| h == "Argon2")
.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()
.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();
let first = chars.next();
let last = chars.next_back();
let url = chars.as_str();
// If the site_url is encapsulated by quotes (i.e. invalid)
if first == Option::from('"') || first == Option::from('\'') && first == last {
// Set the site_url without the quotes
warn!("The site_url environment variable is encapsulated by quotes. Automatically adjusting to: {url}");
Some(url.to_string())
} else {
info!("Configured Site URL is: {provided_url}");
Some(provided_url)
}
} else {
// Site URL is not configured
warn!(
"The site_url environment variable is not configured. Using http://localhost by default."
);
let protocol = if port == 443 { "https" } else { "http" };
let port_text = if [80, 443].contains(&port) {
String::new()
} else {
format!(":{port}")
};
// No issues
info!("Public URL is: {protocol}://localhost{port_text}.");
None
};
let slug_style = var("slug_style")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or(String::from("Pair"));
let slug_length = var("slug_length")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|&s| s >= 4)
.unwrap_or(8);
let try_longer_slug = var("try_longer_slug").is_ok_and(|s| s.trim() == "True");
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.");
}
} 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())
.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,
disable_frontend,
site_url,
public_mode,
public_mode_expiry_delay,
use_temp_redirect,
password,
hash_algorithm,
api_key,
slug_style,
slug_length,
try_longer_slug,
allow_capital_letters,
custom_landing_directory,
use_wal_mode,
ensure_acid,
}
}

View File

@@ -1,8 +1,12 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
use rusqlite::Connection;
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)]
@@ -10,86 +14,327 @@ pub struct DBRow {
shortlink: String,
longlink: String,
hits: i64,
expiry_time: i64,
}
// Find a single URL
pub fn find_url(shortlink: &str, db: &Connection) -> Option<String> {
let mut statement = db
.prepare_cached("SELECT long_url FROM urls WHERE short_url = ?1")
.expect("Error preparing SQL statement for find_url.");
// 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 = "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);
};
statement
.query_row([shortlink], |row| row.get("long_url"))
.ok()
.query_row((shortlink, now), |row| {
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(),
})
}
// Get all URLs in DB
pub fn getall(db: &Connection) -> Vec<DBRow> {
let mut statement = db
.prepare_cached("SELECT * FROM urls")
.expect("Error preparing SQL statement for getall.");
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 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([])
.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."),
};
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
pub fn add_link(shortlink: String, longlink: String, db: &Connection) -> bool {
db.execute(
"INSERT INTO urls (long_url, short_url, hits) VALUES (?1, ?2, ?3)",
(longlink, shortlink, 0),
)
.is_ok()
}
// 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
pub fn add_link(
shortlink: &str,
longlink: &str,
expiry_delay: i64,
db: &Connection,
) -> Result<i64, ChhotoError> {
let now = chrono::Utc::now().timestamp();
let expiry_time = if expiry_delay == 0 {
0
} else {
false
now + expiry_delay
};
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)
}
}
}
// Open the DB, and create schema if missing
pub fn open_db(path: String) -> Connection {
// 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, use_wal_mode: bool) {
let now = chrono::Utc::now().timestamp();
info!("Starting database cleanup.");
let mut statement = db
.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.");
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.");
}
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 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: &str, use_wal_mode: bool, ensure_acid: bool) -> Connection {
// Set current user_version. Should be incremented on change of schema.
let user_version = 1;
let db = Connection::open(path).expect("Unable to open database!");
// 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'",
[],
|row| row.get(0),
)
.expect("Error querying existence of table.");
// Create table if it doesn't exist
db.execute(
"CREATE TABLE IF NOT EXISTS urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
long_url TEXT NOT NULL,
short_url TEXT NOT NULL,
hits INTEGER NOT NULL
)",
hits INTEGER NOT NULL,
expiry_time INTEGER NOT NULL DEFAULT 0
)",
// expiry_time is added later during migration 1
[],
)
.expect("Unable to initialize empty database.");
// Create index on short_url for faster lookups
db.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_short_url ON urls (short_url)",
[],
)
.expect("Unable to create index on short_url.");
let current_user_version: u32 = if table_exists == 0 {
// It would mean that the table is newly created i.e. has the desired schema
user_version
} else {
db.query_row_and_then("SELECT user_version FROM pragma_user_version", [], |row| {
row.get(0)
})
.unwrap_or_default()
};
// Migration 1: Add expiry_time, introduced in 6.0.0
if current_user_version < 1 {
db.execute(
"ALTER TABLE urls ADD COLUMN expiry_time INTEGER NOT NULL DEFAULT 0",
[],
)
.expect("Unable to apply migration 1.");
}
// Create index on expiry_time for faster lookups
db.execute(
"CREATE INDEX IF NOT EXISTS idx_expiry_time ON urls (expiry_time)",
[],
)
.expect("Unable to create index on expiry_time.");
// Set the user version
db.pragma_update(None, "user_version", 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

@@ -1,154 +1,115 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
use actix_files::{Files, NamedFile};
use actix_session::{storage::CookieSessionStore, Session, SessionMiddleware};
use actix_files::Files;
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
use actix_web::{
cookie::Key,
delete, get,
http::StatusCode,
middleware, post,
middleware,
web::{self, Redirect},
App, Either, HttpResponse, HttpServer, Responder,
App, HttpServer,
};
use log::info;
use rusqlite::Connection;
use std::{env, io::Result};
use std::{fs, io::Result};
use tokio::{spawn, time};
// Import modules
mod auth;
mod config;
mod database;
mod services;
mod utils;
// Tests
#[cfg(test)]
mod tests;
// This struct represents state
struct AppState {
db: Connection,
}
// Store the version number
const VERSION: &str = env!("CARGO_PKG_VERSION");
// Define the routes
// Add new links
#[post("/api/new")]
async fn add_link(req: String, data: web::Data<AppState>, session: Session) -> HttpResponse {
if auth::validate(session) {
let out = utils::add_link(req, &data.db);
if out.0 {
HttpResponse::Created().body(out.1)
} else {
HttpResponse::Conflict().body(out.1)
}
} else {
HttpResponse::Unauthorized().body("Not logged in!")
}
}
// Return all active links
#[get("/api/all")]
async fn getall(data: web::Data<AppState>, session: Session) -> HttpResponse {
if auth::validate(session) {
HttpResponse::Ok().body(utils::getall(&data.db))
} else {
HttpResponse::Unauthorized().body("Not logged in!")
}
}
// Get the site URL
#[get("/api/siteurl")]
async fn siteurl(session: Session) -> HttpResponse {
if auth::validate(session) {
let site_url = env::var("site_url").unwrap_or(String::from("unset"));
HttpResponse::Ok().body(site_url)
} else {
HttpResponse::Unauthorized().body("Not logged in!")
}
}
// Get the version number
#[get("/api/version")]
async fn version() -> HttpResponse {
HttpResponse::Ok().body(VERSION)
}
// 404 error page
async fn error404() -> impl Responder {
NamedFile::open_async("./resources/static/404.html")
.await
.customize()
.with_status(StatusCode::NOT_FOUND)
}
// Handle a given shortlink
#[get("/{shortlink}")]
async fn link_handler(shortlink: web::Path<String>, data: web::Data<AppState>) -> impl Responder {
let shortlink_str = shortlink.to_string();
if let Some(longlink) = utils::get_longurl(shortlink_str, &data.db) {
let redirect_method = env::var("redirect_method").unwrap_or(String::from("PERMANENT"));
database::add_hit(shortlink.as_str(), &data.db);
if redirect_method == "TEMPORARY" {
Either::Left(Redirect::to(longlink))
} else {
// Defaults to permanent redirection
Either::Left(Redirect::to(longlink).permanent())
}
} else {
Either::Right(
NamedFile::open_async("./resources/static/404.html")
.await
.customize()
.with_status(StatusCode::NOT_FOUND),
)
}
}
// Handle login
#[post("/api/login")]
async fn login(req: String, session: Session) -> HttpResponse {
if let Ok(password) = env::var("password") {
if password != req {
eprintln!("Failed login attempt!");
return HttpResponse::Unauthorized().body("Wrong password!");
}
}
// Return Ok if no password was set on the server side
session
.insert("chhoto-url-auth", auth::gen_token())
.expect("Error inserting auth token.");
HttpResponse::Ok().body("Correct password!")
}
// Delete a given shortlink
#[delete("/api/del/{shortlink}")]
async fn delete_link(
shortlink: web::Path<String>,
data: web::Data<AppState>,
session: Session,
) -> HttpResponse {
if auth::validate(session) {
if utils::delete_link(shortlink.to_string(), &data.db) {
HttpResponse::Ok().body(format!("Deleted {shortlink}"))
} else {
HttpResponse::NotFound().body("Not found!")
}
} else {
HttpResponse::Unauthorized().body("Not logged in!")
}
config: config::Config,
}
#[actix_web::main]
async fn main() -> Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("warn"));
env_logger::builder()
.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
let secret_key = Key::generate();
let db_location = env::var("db_url").unwrap_or(String::from("/urls.sqlite"));
let port = env::var("port")
.unwrap_or(String::from("4567"))
.parse::<u16>()
.expect("Supplied port is not an integer");
eprintln!("----------------------------------------------------------------------");
info!("Starting Chhoto URL Server v{}", env!("CARGO_PKG_VERSION"));
info!("Source: https://github.com/SinTan1729/chhoto-url");
eprintln!("----------------------------------------------------------------------");
// Read config from env vars
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 listening to {} on port {}.",
conf.listen_address, conf.port
);
// Do periodic cleanup
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, conf.use_wal_mode, conf.ensure_acid);
let mut interval = time::interval(time::Duration::from_secs(3600));
loop {
interval.tick().await;
database::cleanup(&db, conf.use_wal_mode);
}
});
let conf_clone = conf.clone();
// Actually start the server
HttpServer::new(move || {
App::new()
let mut app = App::new()
.wrap(middleware::Logger::default())
.wrap(middleware::Compress::default())
.wrap(middleware::NormalizePath::new(
middleware::TrailingSlash::MergeOnly,
))
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
.cookie_same_site(actix_web::cookie::SameSite::Strict)
@@ -157,21 +118,42 @@ async fn main() -> Result<()> {
)
// Maintain a single instance of database throughout
.app_data(web::Data::new(AppState {
db: database::open_db(env::var("db_url").unwrap_or(db_location.clone())),
db: database::open_db(&conf.db_location, conf.use_wal_mode, conf.ensure_acid),
config: conf_clone.clone(),
}))
.wrap(middleware::Logger::default())
.wrap(middleware::Compress::default())
.service(link_handler)
.service(getall)
.service(siteurl)
.service(version)
.service(add_link)
.service(delete_link)
.service(login)
.service(Files::new("/", "./resources/").index_file("index.html"))
.default_service(web::get().to(error404))
.wrap(if let Some(header) = &conf.cache_control_header {
middleware::DefaultHeaders::new().add(("Cache-Control", header.to_owned()))
} else {
middleware::DefaultHeaders::new()
})
.service(services::link_handler)
.service(services::edit_link)
.service(services::getall)
.service(services::siteurl)
.service(services::version)
.service(services::getconfig)
.service(services::add_link)
.service(services::delete_link)
.service(services::login)
.service(services::logout)
.service(services::expand)
.service(services::whoami);
if !conf.disable_frontend {
if let Some(dir) = &conf.custom_landing_directory {
app = app
.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"));
}
}
app.default_service(actix_web::web::get().to(services::error404))
})
.bind(("0.0.0.0", port))?
// Hardcode the port the server listens to. Allows for more intuitive Docker Compose port management
.bind((conf.listen_address, conf.port))?
.run()
.await
}

481
actix/src/services.rs Normal file
View File

@@ -0,0 +1,481 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
use actix_files::NamedFile;
use actix_session::Session;
use actix_web::{
delete, get,
http::StatusCode,
post, put,
web::{self, Redirect},
Either, HttpRequest, HttpResponse, Responder,
};
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::env;
use crate::AppState;
use crate::{auth, database};
use crate::{auth::is_session_valid, utils};
use ChhotoError::{ClientError, ServerError};
// Store the version number
const VERSION: &str = env!("CARGO_PKG_VERSION");
// Error types
pub enum ChhotoError {
ServerError,
ClientError { reason: String },
}
// Define JSON struct for returning success/error data
#[derive(Serialize)]
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,
site_url: Option<String>,
allow_capital_letters: bool,
public_mode: bool,
public_mode_expiry_delay: i64,
slug_style: String,
slug_length: usize,
try_longer_slug: bool,
}
// Needed to return the short URL to make it easier for programs leveraging the API
#[derive(Serialize)]
struct CreatedURL {
success: bool,
error: bool,
shorturl: String,
expiry_time: i64,
}
// Struct for returning information about a shortlink in expand
#[derive(Serialize)]
struct LinkInfo {
success: bool,
error: bool,
longurl: String,
hits: i64,
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
#[post("/api/new")]
pub async fn add_link(
req: String,
data: web::Data<AppState>,
session: Session,
http: HttpRequest,
) -> HttpResponse {
let config = &data.config;
// Call is_api_ok() function, pass HttpRequest
let result = auth::is_api_ok(http, config);
// If success, add new link
if result.success {
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 {
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}")
};
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 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)
} else {
return HttpResponse::Unauthorized().body("Not logged in!");
};
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),
}
}
}
// Return all active links
#[get("/api/all")]
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 = auth::is_api_ok(http, config);
// If success, return all links
if result.success {
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::is_session_valid(session, config) {
HttpResponse::Ok().body(utils::getall(&data.db, params.into_inner()))
} else {
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 = auth::is_api_ok(http, &data.config);
if result.success {
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)
}
}
// Get the site URL
// This is deprecated, and might be removed in the future.
// Use /api/getconfig instead
#[get("/api/siteurl")]
pub async fn siteurl(data: web::Data<AppState>) -> HttpResponse {
if let Some(url) = &data.config.site_url {
HttpResponse::Ok().body(url.clone())
} else {
HttpResponse::Ok().body("unset")
}
}
// Get the version number
// This is deprecated, and might be removed in the future.
// Use /api/getconfig instead
#[get("/api/version")]
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(
data: web::Data<AppState>,
session: Session,
http: HttpRequest,
) -> HttpResponse {
let config = &data.config;
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,
public_mode: config.public_mode,
public_mode_expiry_delay: config.public_mode_expiry_delay,
site_url: config.site_url.clone(),
slug_style: config.slug_style.clone(),
slug_length: config.slug_length,
try_longer_slug: config.try_longer_slug,
};
HttpResponse::Ok().json(backend_config)
} else {
HttpResponse::Unauthorized().json(result)
}
}
// 404 error page
pub async fn error404() -> impl Responder {
NamedFile::open_async("./resources/static/404.html")
.await
.customize()
.with_status(StatusCode::NOT_FOUND)
}
// Handle a given shortlink
#[get("/{shortlink}")]
pub async fn link_handler(
shortlink: web::Path<String>,
data: web::Data<AppState>,
) -> impl Responder {
let shortlink_str = shortlink.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 {
// Defaults to permanent redirection
Either::Left(Redirect::to(longlink).permanent())
}
} else {
Either::Right(
NamedFile::open_async("./resources/static/404.html")
.await
.customize()
.with_status(StatusCode::NOT_FOUND),
)
}
}
// Handle login
#[post("/api/login")]
pub async fn login(req: String, session: Session, data: web::Data<AppState>) -> HttpResponse {
let config = &data.config;
// 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() {
debug!("Using Argon2 hash for password validation.");
let hash = PasswordHash::new(password).expect("The provided password hash is invalid.");
Some(
Argon2::default()
.verify_password(req.as_bytes(), &hash)
.is_ok(),
)
} else {
// If hashing is not enabled, use the plaintext password for matching
Some(password == &req)
}
} else {
None
};
if config.api_key.is_some() {
if let Some(valid_pass) = authorized {
if !valid_pass {
warn!("Failed login attempt!");
let response = JSONResponse {
success: false,
error: true,
reason: "Wrong password!".to_string(),
};
return HttpResponse::Unauthorized().json(response);
}
}
// Return Ok if no password was set on the server side
session
.insert("chhoto-url-auth", auth::gen_token())
.expect("Error inserting auth token.");
let response = JSONResponse {
success: true,
error: false,
reason: "Correct password!".to_string(),
};
info!("Successful login.");
HttpResponse::Ok().json(response)
} else {
// Keep this function backwards compatible
if let Some(valid_pass) = authorized {
if !valid_pass {
warn!("Failed login attempt!");
return HttpResponse::Unauthorized().body("Wrong password!");
}
}
// Return Ok if no password was set on the server side
session
.insert("chhoto-url-auth", auth::gen_token())
.expect("Error inserting auth token.");
info!("Successful login.");
HttpResponse::Ok().body("Correct password!")
}
}
// Handle logout
// There's no reason to be calling this route with an API key
#[delete("/api/logout")]
pub async fn logout(session: Session) -> HttpResponse {
if session.remove("chhoto-url-auth").is_some() {
info!("Successful logout.");
HttpResponse::Ok().body("Logged out!")
} else {
HttpResponse::Unauthorized().body("You don't seem to be logged in.")
}
}
// Delete a given shortlink
#[delete("/api/del/{shortlink}")]
pub async fn delete_link(
shortlink: web::Path<String>,
data: web::Data<AppState>,
session: Session,
http: HttpRequest,
) -> HttpResponse {
let config = &data.config;
// Call is_api_ok() function, pass HttpRequest
let result = auth::is_api_ok(http, config);
// If success, delete shortlink
if result.success {
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 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!")
}
} else {
HttpResponse::Unauthorized().body("Not logged in!")
}
}

516
actix/src/tests.rs Normal file
View File

@@ -0,0 +1,516 @@
use actix_http::{Request, StatusCode};
use actix_service::Service;
use actix_web::{body::to_bytes, dev::ServiceResponse, test, web::Bytes, App, Error};
use regex::Regex;
use serde::Deserialize;
use std::{fmt::Display, fs, rc::Rc, thread::sleep, time::Duration};
use super::*;
trait BodyTest {
fn as_str(&self) -> &str;
}
impl BodyTest for Bytes {
fn as_str(&self) -> &str {
std::str::from_utf8(self).unwrap()
}
}
#[derive(Deserialize)]
struct URLData {
shortlink: String,
longlink: String,
hits: i64,
expiry_time: i64,
}
#[derive(Deserialize)]
struct CreatedURL {
#[serde(default)]
reason: String,
#[serde(default)]
shorturl: String,
#[serde(default)]
longurl: String,
#[serde(default)]
hits: i64,
}
#[derive(Deserialize)]
struct BackendConfig {
version: String,
slug_length: usize,
}
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,
disable_frontend: true,
site_url: Some(String::from("https://mydomain.com")),
public_mode: false,
public_mode_expiry_delay: 0,
use_temp_redirect: false,
password: Some(String::from("testpass")),
hash_algorithm: None,
api_key: Some(String::from("Z8FNjh2J2v3yfb0xPDIVA58Pj4D0e2jSERVdoqM5pJCbU2w5tmg3PNioD6GUhaQwHHaDLBNZj0EQE8MS4TLKcUyusa05")),
slug_style: "Pair".to_string(),
slug_length: 8,
try_longer_slug: false,
allow_capital_letters: false,
custom_landing_directory: None,
use_wal_mode: true,
ensure_acid: false,
};
conf
}
async fn create_app(
conf: &config::Config,
test: &str,
) -> impl Service<Request, Response = ServiceResponse, Error = Error> {
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
let app = test::init_service(
App::new()
.app_data(web::Data::new(AppState {
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)
.service(services::version)
.service(services::getconfig)
.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;
app
}
async fn add_link<T: Service<Request, Response = ServiceResponse, Error = Error>, S: Display>(
app: T,
api_key: &str,
shortlink: S,
expiry_delay: i64,
) -> (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}}}"))
.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 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
//
#[test]
async fn basic_site_config() {
let test = "basic";
let conf = default_config(test);
let app = create_app(&conf, test).await;
let req = test::TestRequest::get().uri("/api/siteurl").to_request();
let resp = test::call_service(&app, req).await;
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();
assert_eq!(
body.as_str(),
format!("Chhoto URL v{}", env!("CARGO_PKG_VERSION"))
);
let req = test::TestRequest::get()
.uri("/api/getconfig")
.insert_header(("X-API-Key", conf.api_key.unwrap()))
.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 conf: BackendConfig = serde_json::from_str(body.as_str()).unwrap();
assert_eq!(conf.version, env!("CARGO_PKG_VERSION"));
assert_eq!(conf.slug_length, 8);
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn adding_link_with_shortlink() {
let test = "adding";
let conf = default_config(test);
let app = create_app(&conf, test).await;
let api_key = conf.api_key.unwrap();
for shortlink in ["test1", "test2", "test3"] {
let (status, reply) = add_link(&app, &api_key, shortlink, 10).await;
assert!(status.is_success());
assert_eq!(reply.shorturl, format!("https://mydomain.com/{shortlink}"));
}
let (status, reply) = add_link(&app, &api_key, "test1", 10).await;
assert!(status.is_client_error());
assert_eq!(reply.reason, "Short URL is already in use!");
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn adding_link_with_shortlink_capital_letters() {
let test = "adding-capital";
let mut conf = default_config(test);
conf.allow_capital_letters = true;
let app = create_app(&conf, test).await;
let api_key = conf.api_key.unwrap();
for shortlink in ["Test1", "Test2", "Test3"] {
let (status, reply) = add_link(&app, &api_key, shortlink, 10).await;
assert!(status.is_success());
assert_eq!(reply.shorturl, format!("https://mydomain.com/{shortlink}"));
}
let (status, reply) = add_link(&app, &api_key, "Test1", 10).await;
assert!(status.is_client_error());
assert_eq!(reply.reason, "Short URL is already in use!");
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn link_resolution() {
let test = "link-resolution";
let conf = default_config(test);
let app = create_app(&conf, test).await;
let (status, _) = add_link(&app, &conf.api_key.unwrap(), "test1", 10).await;
assert!(status.is_success());
let req = test::TestRequest::get().uri("/test1").to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_redirection());
assert_eq!(
resp.headers().get("location").unwrap(),
"https://example-test1.com"
);
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn link_deletion() {
let test = "link-deletion";
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, "test2", 10).await;
assert!(status.is_success());
let req = test::TestRequest::delete()
.uri("/api/del/test2")
.insert_header(("X-API-Key", conf.api_key.unwrap()))
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn data_fetching_all() {
let test = "data-fetching-all";
let conf = default_config(test);
let app = create_app(&conf, test).await;
let api_key = conf.api_key.clone().unwrap();
let _ = add_link(&app, &api_key, "test1", 10).await;
let _ = add_link(&app, &api_key, "test3", 10).await;
let req = test::TestRequest::get().uri("/test1").to_request();
let _ = test::call_service(&app, req).await;
let req = test::TestRequest::get()
.uri("/api/all")
.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(), 2);
assert_eq!(reply_chunks[0].shortlink, "test1");
assert_eq!(reply_chunks[1].shortlink, "test3");
assert_eq!(reply_chunks[0].longlink, "https://example-test1.com");
assert_eq!(reply_chunks[1].longlink, "https://example-test3.com");
assert_eq!(reply_chunks[0].hits, 1);
assert_eq!(reply_chunks[1].hits, 0);
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"));
}
#[test]
async fn adding_link_with_generated_shortlink_with_pair_slug() {
let test = "shortlink-with-pair-slug";
let conf = default_config(test);
let app = create_app(&conf, test).await;
let (status, reply) = add_link(&app, &conf.api_key.unwrap(), "", 10).await;
assert!(status.is_success());
let re = Regex::new(r"^https://mydomain.com/[a-z]+-[a-z]+$").unwrap();
assert!(re.is_match(reply.shorturl.as_str()));
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn adding_link_with_generated_shortlink_with_uid_slug() {
let test = "autogen-with-uid-slug";
let mut conf = default_config(test);
conf.slug_style = "UID".to_string();
conf.slug_length = 12;
let app = create_app(&conf, test).await;
let (status, reply) = add_link(&app, &conf.api_key.unwrap(), "", 10).await;
assert!(status.is_success());
let re = Regex::new(r"^https://mydomain.com/[a-z0-9]{12}$").unwrap();
assert!(re.is_match(reply.shorturl.as_str()));
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn adding_link_with_generated_shortlink_with_uid_slug_capital_letters() {
let test = "autogen-with-uid-slug-capital";
let mut conf = default_config(test);
conf.slug_style = "UID".to_string();
conf.slug_length = 12;
conf.allow_capital_letters = true;
let app = create_app(&conf, test).await;
let (status, reply) = add_link(&app, &conf.api_key.unwrap(), "", 10).await;
assert!(status.is_success());
let re = Regex::new(r"^https://mydomain.com/[A-Za-z0-9]{12}$").unwrap();
assert!(re.is_match(reply.shorturl.as_str()));
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn adding_link_with_retry_on_collision() {
let test = "retry_on_collision";
let mut conf = default_config(test);
conf.slug_style = "UID".to_string();
conf.slug_length = 1;
conf.try_longer_slug = true;
let app = create_app(&conf, test).await;
let api_key = &conf.api_key.unwrap();
// Add every possible single-character shorturl
{
#[rustfmt::skip]
static CHARS: [char; 36] = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
for c in CHARS.iter() {
let (status, _) = add_link(&app, api_key, c, 10).await;
assert!(status.is_success());
}
}
// Generated shorturls should now be 5 characters
{
let (status, reply) = add_link(&app, api_key, "", 10).await;
assert!(status.is_success());
assert_eq!(
reply.shorturl.chars().count(),
"https://mydomain.com/".len() + 5
);
}
// But a colliding provided shorturl should fail
{
let (status, _) = add_link(&app, api_key, "a", 10).await;
assert!(status.is_client_error());
}
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn expand_link() {
let test = "expand-link";
let conf = default_config(test);
let app = create_app(&conf, test).await;
let api_key = conf.api_key.unwrap();
let _ = add_link(&app, &api_key, "test4", 10).await;
let req = test::TestRequest::post()
.uri("/api/expand")
.insert_header(("X-API-Key", api_key))
.set_payload("test4")
.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: CreatedURL = serde_json::from_str(body.as_str()).unwrap();
assert_eq!(reply.longurl, "https://example-test4.com");
let _ = fs::remove_file(format!("/tmp/chhoto-url-test-{test}.sqlite"));
}
#[test]
async fn link_expiry() {
let test = "link-expiry";
let conf = default_config(test);
let app = create_app(&conf, test).await;
let api_key = conf.api_key.unwrap();
let (status, _) = add_link(&app, &api_key, "test1", 1).await;
assert!(status.is_success());
let one_second = Duration::from_secs(1);
sleep(one_second);
let req = test::TestRequest::get().uri("/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,89 +1,178 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
use log::error;
use nanoid::nanoid;
use rand::seq::SliceRandom;
use rand::seq::IndexedRandom;
use regex::Regex;
use rusqlite::Connection;
use serde::Deserialize;
use std::env;
use crate::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,
#[serde(default)]
expiry_delay: i64,
}
// Request the DB for searching an URL
pub fn get_longurl(shortlink: String, db: &Connection) -> Option<String> {
if validate_link(&shortlink) {
database::find_url(shortlink.as_str(), db)
} else {
None
}
// Struct for reading link pairs sent during API call for editing link
#[derive(Deserialize)]
struct EditURLRequest {
shortlink: String,
longlink: String,
reset_hits: bool,
}
// Only have a-z, 0-9, - and _ as valid characters in a shortlink
fn validate_link(link: &str) -> bool {
let re = Regex::new("^[a-z0-9-_]+$").expect("Regex generation failed.");
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 {
Regex::new("^[a-z0-9-_]+$").expect("Regex generation failed.")
};
re.is_match(link)
}
// Request the DB for all URLs
pub fn getall(db: &Connection) -> String {
let links = database::getall(db);
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, db: &Connection) -> (bool, String) {
let mut chunks: URLPair;
if let Ok(json) = serde_json::from_str(&req) {
pub fn add_link(
req: &str,
db: &Connection,
config: &Config,
using_public_mode: bool,
) -> Result<(String, i64), ChhotoError> {
// Ok : shortlink, expiry_time
let mut chunks: NewURLRequest;
if let Ok(json) = serde_json::from_str(req) {
chunks = json;
} else {
// shorturl should always be supplied, even if empty
return (false, String::from("Invalid request!"));
return Err(ClientError {
reason: "Invalid request!".to_string(),
});
}
let style = env::var("slug_style").unwrap_or(String::from("Pair"));
let mut len = env::var("slug_style")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(8);
if len < 4 {
len = 4;
}
if chunks.shortlink.is_empty() {
chunks.shortlink = gen_link(style, len);
}
if validate_link(chunks.shortlink.as_str())
&& get_longurl(chunks.shortlink.clone(), db).is_none()
{
(
database::add_link(chunks.shortlink.clone(), chunks.longlink, db),
chunks.shortlink,
)
let style = &config.slug_style;
let len = config.slug_length;
let allow_capital_letters = config.allow_capital_letters;
let shortlink_provided = if chunks.shortlink.is_empty() {
chunks.shortlink = gen_link(style, len, allow_capital_letters);
false
} else {
(false, String::from("shortUrl not valid or already in use"))
true
};
// In public mode, set automatic expiry delay
if using_public_mode && config.public_mode_expiry_delay > 0 {
if chunks.expiry_delay == 0 {
chunks.expiry_delay = config.public_mode_expiry_delay;
} else {
chunks.expiry_delay = chunks.expiry_delay.min(config.public_mode_expiry_delay);
}
}
// Allow max delay of 5 years
chunks.expiry_delay = chunks.expiry_delay.min(157784760);
chunks.expiry_delay = chunks.expiry_delay.max(0);
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) => Ok((chunks.shortlink, expiry_time)),
Err(ClientError { reason }) => {
if shortlink_provided {
Err(ClientError { reason })
} else {
// 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 {
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) -> bool {
if validate_link(shortlink.as_str()) {
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) -> 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",
@@ -115,20 +204,33 @@ fn gen_link(style: String, len: usize) -> String {
"taussig", "tesla", "tharp", "thompson", "torvalds", "tu", "turing", "varahamihira", "vaughan", "vaughn", "villani", "visvesvaraya", "volhard",
"wescoff", "weierstrass", "wilbur", "wiles", "williams", "williamson", "wilson", "wing", "wozniak", "wright", "wu", "yalow", "yonath", "zhukovsky"];
#[rustfmt::skip]
static CHARS: [char; 36] = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
static CHARS_SMALL: [char; 36] = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r',
's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
];
// 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" {
nanoid!(len, &CHARS)
if allow_capital_letters {
nanoid!(len, &CHARS_CAPITAL)
} else {
nanoid!(len, &CHARS_SMALL)
}
} else {
format!(
"{0}-{1}",
ADJECTIVES
.choose(&mut rand::thread_rng())
.choose(&mut rand::rng())
.expect("Error choosing random adjective."),
NAMES
.choose(&mut rand::thread_rng())
.choose(&mut rand::rng())
.expect("Error choosing random name.")
)
}

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,44 +2,109 @@
# SPDX-License-Identifier: MIT
services:
chhoto-url:
image: sintan1729/chhoto-url:latest
restart: unless-stopped
container_name: chhoto-url
ports:
- 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=/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 it in case you want to set the website name
# displayed in front of the shorturls, defaults to
# the hostname you're accessing it from
# - 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
- password=$3CuReP4S$W0rD
# 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
volumes:
- db:/urls.sqlite
networks:
- proxy
# 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
# 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:
networks:
proxy:
external: true
db:

24
helm-chart/Chart.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: v2
name: chhoto-url
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.2.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "6"

View File

@@ -0,0 +1,23 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: chhoto-url
annotations:
cert-manager.io/issuer: "letsencrypt"
acme.cert-manager.io/http01-edit-in-place: "true"
spec:
tls:
- hosts:
- {{ .Values.fqdn }}
secretName: my-tls
rules:
- host: {{ .Values.fqdn }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: chhoto-url
port:
number: 80

View File

@@ -0,0 +1,18 @@
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: letsencrypt
spec:
acme:
# The ACME server URL
server: https://acme-v02.api.letsencrypt.org/directory
# Email address used for ACME registration
email: {{ .Values.letsencryptmail }}
# Name of a secret used to store the ACME account private key
privateKeySecretRef:
name: letsencrypt
# Enable the HTTP-01 challenge provider
solvers:
- http01:
ingress:
ingressClassName: nginx

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: chhoto-pv
labels:
app: chhoto-url
spec:
capacity:
storage: 100Mi
accessModes:
- ReadWriteOnce
hostPath:
path: {{ .Values.persistence.hostPath.path }}

View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: Secret
metadata:
name: secret
type: Opaque
data:
password: {{ .Values.password }}
{{- if .Values.api_key }}
api_key: {{ .Values.api_key }}
{{- end }}

View File

@@ -0,0 +1,81 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: chhoto-url
spec:
replicas: 1
selector:
matchLabels:
app: chhoto-url
template:
metadata:
labels:
app: chhoto-url
spec:
containers:
- name: chhoto-url
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: 4567
env:
- name: password
valueFrom:
secretKeyRef:
name: secret
key: password
{{- if .Values.api_key }}
- name: api_key
valueFrom:
secretKeyRef:
name: secret
key: api_key
{{- end }}
{{- if .Values.hash_algorithm }}
- name: hash_algorithm
value: {{ .Values.hash_algorithm }}
{{- end }}
- name: db_url
value: /db/urls.sqlite
- name: site_url
value: "{{ .Values.protocol }}://{{ .Values.fqdn }}"
- name: redirect_method
value: {{ .Values.redirect_method }}
- name: slug_style
value: {{ .Values.slug_style }}
- name: slug_length
value: "{{ .Values.slug_length }}"
- name: try_longer_slug
value: "{{ .Values.try_longer_slug }}"
- name: public_mode
value: {{ .Values.public_mode }}
- name: public_mode_expiry_delay
value: {{ .Values.public_mode_expiry_delay }}
- name: disable_frontend
value: {{ .Values.disable_frontend }}
- name: allow_capital_letters
value: {{ .Values.allow_capital_letters }}
{{- if .Values.custom_landing_directory }}
- name: custom_landing_directory
value: {{ .Values.custom_landing_directory }}
{{- end }}
{{- if .Values.cache_control_header }}
- 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
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 100Mi

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: chhoto-url
labels:
app: chhoto-url
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 4567
protocol: TCP
selector:
app: chhoto-url

35
helm-chart/values.yaml Normal file
View File

@@ -0,0 +1,35 @@
# Default values for chhoto-url.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
image:
repository: sintan1729/chhoto-url
pullPolicy: IfNotPresent
tag: "latest"
# hash_algorithm: Argon2
# please use a better password in your values and base64 encode it
password: cGFzc3dvcmQ=
# if used, needs to be base64 encoded as well
# api_key: U0VDVVJFX0FQSV9LRVk=
persistence:
hostPath:
path: /mnt/data/chhoto-data
redirect_method: PERMANENT
slug_style: Pair
slug_length: 8
try_longer_slug: False
public_mode: Disable
public_mode_expiry_delay: 0
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
letsencryptmail: your.mail@address.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,85 +1,267 @@
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
<!-- SPDX-License-Identifier: MIT -->
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<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 http-equiv="X-UA-Compatible" content="ie=edge">
<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 http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Chhoto URL</title>
<meta name="description" content="A simple selfhosted URL shortener with no unnecessary features.">
<meta name="keywords" content="url shortener, link shortener, self hosted, open source">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" sizes="any">
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="icon" type="image/png" href="assets/favicon-32.png" sizes="32x32">
<link rel="icon" type="image/png" href="assets/favicon-196.png" sizes="196x196">
<script src="static/script.js"></script>
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.1/build/pure-min.css"
integrity="sha384-oAOxQR6DkCoMliIh8yFnu25d7Eq/PHS21PClpwjOTeU2jRSq11vu66rf90/cZr47" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" target="_blank" href="static/styles.css">
</head>
<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."
/>
<body>
<meta
name="keywords"
content="url shortener, link shortener, self hosted, open source"
/>
<link
rel="icon"
type="image/x-icon"
href="assets/favicon.ico"
sizes="any"
/>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<link
rel="icon"
type="image/png"
href="assets/favicon-32.png"
sizes="32x32"
/>
<link
rel="icon"
type="image/png"
href="assets/favicon-196.png"
sizes="196x196"
/>
<script
src="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-32.png" width="26px" alt="logo"> Chhoto URL</legend>
<div class="pure-control-group">
<label for="longUrl">Long URL</label>
<input type="url" name="longUrl" id="longUrl" placeholder="Please enter a valid URL"
onblur="addProtocol(this)" required />
</div>
<div class=" pure-control-group">
<label for="shortUrl">Short URL (optional)</label>
<input type="text" name="shortUrl" id="shortUrl" placeholder="Only a-z, 0-9, - and _ are allowed"
pattern="[a-z0-9\-_]+" title="Only a-z, 0-9, - and _ are allowed"/>
</div>
<div class="pure-controls">
<button class="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 name="loading-text">Loading links table...</p>
<table class="pure-table">
<caption>Active links</caption>
<br />
<thead>
<tr>
<td id="short-url-header">Short URL (click to copy)</td>
<td>Long URL</td>
<td name="hitsColumn">Hits</td>
<td name="deleteBtn">&times;</td>
</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="github-link">
<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 name="links-div">
<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 type="password" id="password" />
<button class="pure-button pure-button-primary" value="default">Log in</button>
<p id="wrong-pass">&nbsp;</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,29 +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=">
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<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>
<style>
#quote {
text-indent: 4em;
<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;
font-display: swap;
}
</style>
:root {
color-scheme: light dark;
font-family: Montserrat;
}
body {
color: light-dark(black, #e8e6e3);
background-color: light-dark(white, #181a1b);
text-align: center;
}
#quote {
text-indent: 4em;
}
/* Settings for mobile devices */
@media (pointer: none), (pointer: coarse) {
body {
text-align: left;
}
}
</style>
<body style="text-align: center;">
<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>

Binary file not shown.

View File

@@ -1,238 +1,778 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
// Application state
let VERSION = null;
let SITE_URL = "-";
let CONFIG = null;
let SUBDIR = null;
let ADMIN = false;
let LOCAL_DATA = [];
let CUR_PAGE = 0;
// Flags
let PROCESSING_PAGE_TRANSITION = true;
// Buttons
// https://svgicons.com/icon/10648/copy-outline
SVG_COPY_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M9 3.25A5.75 5.75 0 0 0 3.25 9v7.107a.75.75 0 0 0 1.5 0V9A4.25 4.25 0 0 1 9 4.75h7.013a.75.75 0 0 0 0-1.5z"/><path fill="currentColor" fill-rule="evenodd" d="M18.403 6.793a44.372 44.372 0 0 0-9.806 0a2.011 2.011 0 0 0-1.774 1.76a42.581 42.581 0 0 0 0 9.894a2.01 2.01 0 0 0 1.774 1.76c3.241.362 6.565.362 9.806 0a2.01 2.01 0 0 0 1.774-1.76a42.579 42.579 0 0 0 0-9.894a2.011 2.011 0 0 0-1.774-1.76M8.764 8.284c3.13-.35 6.342-.35 9.472 0a.51.51 0 0 1 .45.444a40.95 40.95 0 0 1 0 9.544a.51.51 0 0 1-.45.444c-3.13.35-6.342.35-9.472 0a.511.511 0 0 1-.45-.444a40.95 40.95 0 0 1 0-9.544a.511.511 0 0 1 .45-.444" clip-rule="evenodd"/></svg>`;
// https://svgicons.com/icon/1207/qrcode-outlined
SVG_QR_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1024 1024"><path fill="currentColor" d="M468 128H160c-17.7 0-32 14.3-32 32v308c0 4.4 3.6 8 8 8h332c4.4 0 8-3.6 8-8V136c0-4.4-3.6-8-8-8m-56 284H192V192h220zm-138-74h56c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8m194 210H136c-4.4 0-8 3.6-8 8v308c0 17.7 14.3 32 32 32h308c4.4 0 8-3.6 8-8V556c0-4.4-3.6-8-8-8m-56 284H192V612h220zm-138-74h56c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8m590-630H556c-4.4 0-8 3.6-8 8v332c0 4.4 3.6 8 8 8h332c4.4 0 8-3.6 8-8V160c0-17.7-14.3-32-32-32m-32 284H612V192h220zm-138-74h56c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8m194 210h-48c-4.4 0-8 3.6-8 8v134h-78V556c0-4.4-3.6-8-8-8H556c-4.4 0-8 3.6-8 8v332c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V644h78v102c0 4.4 3.6 8 8 8h190c4.4 0 8-3.6 8-8V556c0-4.4-3.6-8-8-8M746 832h-48c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8m142 0h-48c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8"/></svg>`;
// https://svgicons.com/icon/10674/edit-outline
SVG_EDIT_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M21.455 5.416a.75.75 0 0 1-.096.943l-9.193 9.192a.75.75 0 0 1-.34.195l-3.829 1a.75.75 0 0 1-.915-.915l1-3.828a.778.778 0 0 1 .161-.312L17.47 2.47a.75.75 0 0 1 1.06 0l2.829 2.828a.756.756 0 0 1 .096.118m-1.687.412L18 4.061l-8.518 8.518l-.625 2.393l2.393-.625z" clip-rule="evenodd"/><path fill="currentColor" d="M19.641 17.16a44.4 44.4 0 0 0 .261-7.04a.403.403 0 0 1 .117-.3l.984-.984a.198.198 0 0 1 .338.127a45.91 45.91 0 0 1-.21 8.372c-.236 2.022-1.86 3.607-3.873 3.832a47.77 47.77 0 0 1-10.516 0c-2.012-.225-3.637-1.81-3.873-3.832a45.922 45.922 0 0 1 0-10.67c.236-2.022 1.86-3.607 3.873-3.832a47.75 47.75 0 0 1 7.989-.213a.2.2 0 0 1 .128.34l-.993.992a.402.402 0 0 1-.297.117a46.164 46.164 0 0 0-6.66.255a2.89 2.89 0 0 0-2.55 2.516a44.421 44.421 0 0 0 0 10.32a2.89 2.89 0 0 0 2.55 2.516c3.355.375 6.827.375 10.183 0a2.89 2.89 0 0 0 2.55-2.516"/></svg>`;
// https://svgicons.com/icon/10955/trash-solid
SVG_DELETE_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M10 2.25a.75.75 0 0 0-.75.75v.75H5a.75.75 0 0 0 0 1.5h14a.75.75 0 0 0 0-1.5h-4.25V3a.75.75 0 0 0-.75-.75zM13.06 15l1.47 1.47a.75.75 0 1 1-1.06 1.06L12 16.06l-1.47 1.47a.75.75 0 1 1-1.06-1.06L10.94 15l-1.47-1.47a.75.75 0 1 1 1.06-1.06L12 13.94l1.47-1.47a.75.75 0 1 1 1.06 1.06z"/><path fill="currentColor" fill-rule="evenodd" d="M5.991 7.917a.75.75 0 0 1 .746-.667h10.526a.75.75 0 0 1 .746.667l.2 1.802c.363 3.265.363 6.56 0 9.826l-.02.177a2.853 2.853 0 0 1-2.44 2.51a27.04 27.04 0 0 1-7.498 0a2.853 2.853 0 0 1-2.44-2.51l-.02-.177a44.489 44.489 0 0 1 0-9.826zm1.417.833l-.126 1.134a42.99 42.99 0 0 0 0 9.495l.02.177a1.353 1.353 0 0 0 1.157 1.191c2.35.329 4.733.329 7.082 0a1.353 1.353 0 0 0 1.157-1.19l.02-.178c.35-3.155.35-6.34 0-9.495l-.126-1.134z" clip-rule="evenodd"/></svg>`;
// https://svgicons.com/icon/10689/eye-solid
SVG_OPEN_EYE = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 9.75a2.25 2.25 0 1 0 0 4.5a2.25 2.25 0 0 0 0-4.5"/><path fill="currentColor" fill-rule="evenodd" d="M12 5.5c-2.618 0-4.972 1.051-6.668 2.353c-.85.652-1.547 1.376-2.036 2.08c-.48.692-.796 1.418-.796 2.067c0 .649.317 1.375.796 2.066c.49.705 1.186 1.429 2.036 2.08C7.028 17.45 9.382 18.5 12 18.5c2.618 0 4.972-1.051 6.668-2.353c.85-.652 1.547-1.376 2.035-2.08c.48-.692.797-1.418.797-2.067c0-.649-.317-1.375-.797-2.066c-.488-.705-1.185-1.429-2.035-2.08C16.972 6.55 14.618 5.5 12 5.5M8.25 12a3.75 3.75 0 1 1 7.5 0a3.75 3.75 0 0 1-7.5 0" clip-rule="evenodd"/></svg>`;
// https://svgicons.com/icon/10687/eye-closed-solid
SVG_CLOSED_EYE = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M20.53 4.53a.75.75 0 0 0-1.06-1.06l-16 16a.75.75 0 1 0 1.06 1.06l3.035-3.035C8.883 18.103 10.392 18.5 12 18.5c2.618 0 4.972-1.051 6.668-2.353c.85-.652 1.547-1.376 2.035-2.08c.48-.692.797-1.418.797-2.067c0-.649-.317-1.375-.797-2.066c-.488-.705-1.185-1.429-2.035-2.08c-.27-.208-.558-.41-.86-.601zm-5.4 5.402l-1.1 1.098a2.25 2.25 0 0 1-3 3l-1.1 1.1a3.75 3.75 0 0 0 5.197-5.197" clip-rule="evenodd"/><path fill="currentColor" d="M12.67 8.31a.26.26 0 0 0 .23-.07l1.95-1.95a.243.243 0 0 0-.104-.407A10.214 10.214 0 0 0 12 5.5c-2.618 0-4.972 1.051-6.668 2.353c-.85.652-1.547 1.376-2.036 2.08c-.48.692-.796 1.418-.796 2.067c0 .649.317 1.375.796 2.066a9.287 9.287 0 0 0 1.672 1.79a.246.246 0 0 0 .332-.017l2.94-2.94a.26.26 0 0 0 .07-.23a3.75 3.75 0 0 1 4.36-4.36"/></svg>`;
// https://svgicons.com/icon/10926/skip-prev-outline
SVG_PREV_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M6.75 7a.75.75 0 0 0-1.5 0v10a.75.75 0 0 0 1.5 0z"/><path fill="currentColor" fill-rule="evenodd" d="M9.393 13.253a1.584 1.584 0 0 1 0-2.505a25.76 25.76 0 0 1 7.143-3.902l.466-.165c1.023-.364 2.1.329 2.238 1.381c.34 2.59.34 5.286 0 7.876c-.138 1.052-1.215 1.745-2.238 1.381l-.466-.165a25.758 25.758 0 0 1-7.143-3.902m.918-1.32a.084.084 0 0 0 0 .133a24.257 24.257 0 0 0 6.727 3.674l.466.166c.1.035.232-.033.249-.163c.322-2.46.322-5.025 0-7.486a.194.194 0 0 0-.25-.163l-.465.166c-2.423.86-4.694 2.1-6.727 3.674" clip-rule="evenodd"/></svg>`;
// https://svgicons.com/icon/10924/skip-next-outline
SVG_NEXT_BUTTON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M14.607 10.748c.82.634.82 1.87 0 2.505a25.758 25.758 0 0 1-7.143 3.9l-.466.166c-1.023.364-2.1-.329-2.238-1.381c-.34-2.59-.34-5.286 0-7.876c.138-1.052 1.215-1.745 2.238-1.381l.466.165a25.76 25.76 0 0 1 7.143 3.902m-.918 1.318a.084.084 0 0 0 0-.132A24.257 24.257 0 0 0 6.962 8.26l-.466-.166a.194.194 0 0 0-.249.163a29.063 29.063 0 0 0 0 7.486c.017.13.15.198.25.163l.465-.166c2.423-.86 4.694-2.1 6.727-3.674M18 6.25a.75.75 0 0 1 .75.75v10a.75.75 0 0 1-1.5 0V7a.75.75 0 0 1 .75-.75" clip-rule="evenodd"/></svg>`;
// in miliseconds
const UNITS = {
year: 31536000000,
month: 2592000000,
day: 86400000,
hour: 3600000,
minute: 60000,
second: 1000,
};
const prepSubdir = (link) => {
let thisPage = new URL(window.location.href);
let subdir = thisPage.pathname;
let out = (subdir + link).replace('//', '/');
console.log(out);
return (subdir + link).replace('//', '/');
}
if (!SUBDIR) {
const thisPage = new URL(window.location.href);
SUBDIR = thisPage.pathname.replace(/\/admin\/manage\/$/, "/");
}
return (SUBDIR + link).replace("//", "/");
};
const getSiteUrl = async () => {
let url = await fetch(prepSubdir("/api/siteurl"))
.then(res => res.text());
if (url == "unset") {
return window.location.host.replace(/\/$/, '');
}
else {
return url.replace(/\/$/, '').replace(/^"/, '').replace(/"$/, '');
}
}
const hasProtocol = (url) => {
const regex = /[A-Za-z][A-Za-z0-9\+\-\.]*\:(?:\/\/)?.*\D.*/; // RFC 2396 Appendix A
return regex.test(url);
};
const getVersion = async () => {
let ver = await fetch(prepSubdir("/api/version"))
.then(res => res.text());
return ver;
}
const getConfig = async () => {
if (!CONFIG) {
CONFIG = await fetch(prepSubdir("/api/getconfig"), { cache: "no-cache" })
.then((res) => res.json())
.catch((err) => {
console.log("Error while fetching config.");
});
if (CONFIG.site_url == null) {
SITE_URL = window.location.host;
} else {
SITE_URL = CONFIG.site_url
.replace(/\/$/, "")
.replace(/^"/, "")
.replace(/"$/, "");
}
if (!hasProtocol(SITE_URL)) {
SITE_URL = window.location.protocol + "//" + SITE_URL;
}
}
VERSION = CONFIG.version;
};
const showVersion = () => {
const link = document.getElementById("version-number");
if (VERSION) {
link.innerText = "v" + VERSION;
link.href =
"https://github.com/SinTan1729/chhoto-url/releases/tag/" + VERSION;
link.hidden = false;
} else {
link.hidden = true;
}
};
const showLogin = () => {
document.getElementById("container").style.filter = "blur(2px)";
document.getElementById("login-dialog").showModal();
document.getElementById("password").focus();
};
const refreshData = async () => {
let res = await fetch(prepSubdir("/api/all"));
if (!res.ok) {
let errorMsg = await res.text();
console.log(errorMsg);
document.getElementById("container").style.filter = "blur(2px)";
document.getElementById("login-dialog").showModal();
document.getElementById("password").focus();
try {
const loading_text = document.getElementById("loading-text");
const admin_button = document.getElementById("admin-button");
if (!ADMIN) {
const res = await fetch(prepSubdir("/api/whoami"), { cache: "no-cache" });
if (res.status == 200) {
const role = await res.text();
switch (role) {
case "nobody":
showLogin();
break;
case "public":
await getConfig();
loading_text.innerHTML = "Using public mode.";
const expiry = parseInt(CONFIG.public_mode_expiry_delay);
if (expiry > 0) {
loading_text.innerHTML +=
" Unless chosen a shorter expiry time, submitted links will automatically expire ";
const time = new Date();
time.setSeconds(time.getSeconds() + expiry);
loading_text.innerHTML += formatRelativeTime(time) + ".";
}
admin_button.innerText = "login";
admin_button.hidden = false;
updateInputBox();
break;
case "admin":
ADMIN = true;
await getConfig();
break;
default:
throw Error("Got undefined user role.");
}
} else {
throw Error("There was an issue getting user role.");
}
}
showVersion();
if (ADMIN) {
const params = new URLSearchParams();
if (LOCAL_DATA.length == 0) {
params.append("page_size", "20");
} else {
if (LOCAL_DATA.length <= CUR_PAGE * 10) {
console.log("Reached the end of URLs.");
return;
}
displayData();
params.append("page_size", "10");
params.append("page_after", LOCAL_DATA.at(-1)["shortlink"]);
}
const data = await pullData(params);
await getConfig();
ADMIN = true;
LOCAL_DATA.push(...data.reverse());
if (CUR_PAGE == 0) {
displayData();
}
managePageControls();
} else {
let data = await res.json();
displayData(data);
document.getElementById("table-box").hidden = true;
loading_text.hidden = false;
document.getElementById("url-table").innerHTML = "";
}
}
const displayData = async (data) => {
let version = await getVersion();
link = document.getElementById("version-number")
link.innerText = "v" + version;
link.href = "https://github.com/SinTan1729/chhoto-url/releases/tag/" + version;
link.hidden = false;
let site = await getSiteUrl();
table_box = document.querySelector(".pure-table");
loading_text = document.getElementsByName("loading-text")[0];
if (data.length == 0) {
table_box.style.visibility = "hidden";
loading_text.style.display = "block";
loading_text.innerHTML = "No active links.";
} catch (err) {
console.log(err);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
else {
loading_text.style.display = "none";
const table = document.querySelector("#url-table");
if (!window.isSecureContext) {
const shortUrlHeader = document.getElementById("short-url-header");
shortUrlHeader.innerHTML = "Short URL<br>(right click and copy)";
}
table_box.style.visibility = "visible";
table.innerHTML = ''; // Clear
data.forEach(tr => table.appendChild(TR(tr, site)));
}
};
const pullData = async (params) => {
const res = await fetch(prepSubdir(`/api/all?${params}`), {
cache: "no-cache",
});
if (res.status == 200) {
const data = await res.json();
return data;
} else {
throw Error("There was an error getting data.");
}
};
const gotoPrevPage = () => {
if (PROCESSING_PAGE_TRANSITION) {
return;
}
PROCESSING_PAGE_TRANSITION = true;
if (CUR_PAGE > 0) {
CUR_PAGE -= 1;
}
displayData();
managePageControls();
};
const gotoNextPage = () => {
if (PROCESSING_PAGE_TRANSITION) {
return;
}
PROCESSING_PAGE_TRANSITION = true;
CUR_PAGE += 1;
if (LOCAL_DATA.length <= (CUR_PAGE + 1) * 10) {
refreshData();
} else {
displayData();
managePageControls();
}
};
const updateInputBox = () => {
if (CONFIG.allow_capital_letters) {
const input_box = document.getElementById("shortUrl");
input_box.pattern = "[A-Za-z0-9\\\-_]+";
input_box.title = "Only A-Z, a-z, 0-9, - and _ are allowed";
input_box.placeholder = "Only A-Z, a-z, 0-9, - and _ are allowed";
}
};
const displayData = () => {
if (CUR_PAGE < 0) {
console.log("Trying to access negative numbered page.");
return;
}
const data = LOCAL_DATA.slice(CUR_PAGE * 10, CUR_PAGE * 10 + 10);
showVersion();
const admin_button = document.getElementById("admin-button");
admin_button.innerText = "logout";
admin_button.hidden = false;
updateInputBox();
const table_box = document.getElementById("table-box");
const loading_text = document.getElementById("loading-text");
const table = document.getElementById("url-table");
if (data.length === 0) {
table_box.hidden = true;
loading_text.innerHTML = "No active links.";
loading_text.hidden = false;
} else {
loading_text.hidden = true;
table_box.hidden = false;
table.innerHTML = "";
for (const [i, row] of data.entries()) {
table.appendChild(TR(CUR_PAGE * 10 + i + 1, row));
}
}
setTimeout(refreshExpiryTimes, 1000);
}
};
const showAlert = async (text, col) => {
document.getElementById("alert-box")?.remove();
const controls = document.querySelector(".pure-controls");
const alertBox = document.createElement("p");
alertBox.id = "alert-box";
alertBox.style.color = col;
alertBox.innerHTML = text;
controls.appendChild(alertBox);
}
const managePageControls = () => {
const on_first_page = CUR_PAGE == 0;
const on_last_page = LOCAL_DATA.length <= (CUR_PAGE + 1) * 10;
const TR = (row, site) => {
const tr = document.createElement("tr");
const longTD = TD(A_LONG(row["longlink"]), "Long URL");
var shortTD = null;
if (window.isSecureContext) {
shortTD = TD(A_SHORT(row["shortlink"], site), "Short URL");
document.getElementById("prevPageBtn").disabled = on_first_page;
document.getElementById("nextPageBtn").disabled = on_last_page;
document.getElementById("pageControls").hidden =
on_first_page && on_last_page;
PROCESSING_PAGE_TRANSITION = false;
};
const showAlert = (text, col) => {
const alertBox = document.getElementById("alert-box");
alertBox.style.background = col;
alertBox.innerHTML = text;
if (text == "&nbsp;") {
alertBox.removeAttribute("style");
} else {
alertBox.style.display = "block";
}
};
const refreshExpiryTimes = async () => {
const tds = document.getElementsByClassName("tooltip");
for (let i = 0; i < tds.length; i++) {
let td = tds[i];
let expiryTimeParsed = new Date(td.getAttribute("data-time") * 1000);
let relativeTime = formatRelativeTime(expiryTimeParsed);
if (relativeTime == "expired") {
td.style.color = "light-dark(red, #a01e1e)";
for (const btn of td.parentElement.lastChild.querySelectorAll("button")) {
btn.disabled = true;
}
}
else {
shortTD = TD(A_SHORT_INSECURE(row["shortlink"], site), "Short URL");
let div = td.firstChild;
div.innerHTML = div.innerHTML.replace(div.innerText, relativeTime);
}
if (tds.length > 0) {
setTimeout(refreshExpiryTimes, 1000);
}
};
const formatRelativeTime = (timestamp) => {
const now = new Date();
const diff = timestamp - now;
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (diff <= 0) {
return "expired";
}
// "Math.abs" accounts for both "past" & "future" scenarios
for (const u in UNITS) {
if (Math.abs(diff) > UNITS[u] || u === "second") {
return rtf.format(Math.round(diff / UNITS[u]), u);
}
let hitsTD = TD(row["hits"]);
hitsTD.setAttribute("label", "Hits");
hitsTD.setAttribute("name", "hitsColumn");
const btn = deleteButton(row["shortlink"]);
tr.appendChild(shortTD);
tr.appendChild(longTD);
tr.appendChild(hitsTD);
tr.appendChild(btn);
return tr;
}
const copyShortUrl = async (link) => {
const site = await getSiteUrl();
try {
navigator.clipboard.writeText(`${site}/${link}`);
showAlert(`Short URL ${link} was copied to clipboard!`, "green");
} catch (e) {
console.log(e);
showAlert("Could not copy short URL to clipboard, please do it manually.", "red");
}
}
const addProtocol = (input) => {
var url = input.value.trim();
if (url != "" && !~url.indexOf("://") && !~url.indexOf("magnet:")) {
url = "https://" + url;
}
input.value = url;
return input;
}
const A_LONG = (s) => `<a href='${s}'>${s}</a>`;
const A_SHORT = (s, t) => `<a href="javascript:copyShortUrl('${s}');">${s}</a>`;
const A_SHORT_INSECURE = (s, t) => `<a href="${t}/${s}">${s}</a>`;
const deleteButton = (shortUrl) => {
const td = document.createElement("td");
const div = document.createElement("div");
const btn = document.createElement("button");
btn.innerHTML = "&times;";
btn.onclick = e => {
e.preventDefault();
if (confirm("Do you want to delete the entry " + shortUrl + "?")) {
document.getElementById("alert-box")?.remove();
showAlert("&nbsp;", "black");
fetch(prepSubdir(`/api/del/${shortUrl}`), {
method: "DELETE"
}).then(res => {
if (res.ok) {
console.log("Deleted " + shortUrl);
} else {
console.log("Unable to delete " + shortUrl);
}
refreshData();
});
}
};
td.setAttribute("name", "deleteBtn");
td.setAttribute("label", "Delete");
div.appendChild(btn);
td.appendChild(div);
return td;
}
}
};
const TD = (s, u) => {
const td = document.createElement("td");
const div = document.createElement("div");
div.innerHTML = s;
td.appendChild(div);
td.setAttribute("label", u);
return td;
}
const td = document.createElement("td");
const div = document.createElement("div");
div.innerHTML = s;
td.appendChild(div);
if (u !== null) td.setAttribute("label", u);
return td;
};
const TR = (i, row) => {
const tr = document.createElement("tr");
const numTD = TD(i, null);
numTD.setAttribute("name", "numColumn");
const longlink = row["longlink"];
const longTD = TD(A_LONG(longlink), "Long URL");
const shortlink = row["shortlink"];
tr.id = shortlink;
const shortTD = TD(A_SHORT(shortlink), "Short URL");
shortTD.setAttribute("name", "shortColumn");
const hitsTD = TD(row["hits"], null);
hitsTD.setAttribute("label", "Hits");
hitsTD.setAttribute("name", "hitsColumn");
const expiryTime = row["expiry_time"];
let expiryHTML = "-";
if (expiryTime > 0) {
expiryTimeParsed = new Date(expiryTime * 1000);
const relativeExpiryTime = formatRelativeTime(expiryTimeParsed);
const accurateExpiryTime = expiryTimeParsed.toLocaleString();
expiryHTML =
relativeExpiryTime +
'<span class="tooltiptext">' +
accurateExpiryTime +
"</span>";
}
let expiryTD = TD(expiryHTML, null);
if (expiryTime > 0) {
expiryTD.width = "160px";
expiryTD.setAttribute("data-time", expiryTime);
expiryTD.classList.add("tooltip");
}
expiryTD.setAttribute("label", "Expiry");
expiryTD.setAttribute("name", "expiryColumn");
const actionsTD = document.createElement("td");
actionsTD.setAttribute("name", "actions");
actionsTD.setAttribute("label", "Actions");
const btnGrp = document.createElement("div");
btnGrp.classList.add("pure-button-group");
btnGrp.role = "group";
btnGrp.appendChild(copyButton(shortlink));
btnGrp.appendChild(qrCodeButton(shortlink));
btnGrp.appendChild(editButton(shortlink, longlink));
btnGrp.appendChild(deleteButton(shortlink));
actionsTD.appendChild(btnGrp);
for (const td of [numTD, shortTD, longTD, hitsTD, expiryTD, actionsTD]) {
tr.appendChild(td);
}
return tr;
};
const copyShortUrl = async (short_link) => {
const full_link = `${SITE_URL}/${short_link}`;
const link_elt = `<a href=${full_link} target="_blank">${full_link}</a>`;
try {
await navigator.clipboard.writeText(full_link);
showAlert(
`Short URL ${link_elt} was copied to clipboard!`,
"light-dark(green, #1e501e)",
);
} catch (err) {
console.log(err);
showAlert(
`Could not copy short URL to clipboard, please do it manually: ${link_elt}`,
"light-dark(red, #a01e1e)",
);
}
};
const addHTTPSToLongURL = (id) => {
const input = document.getElementById(id);
let url = input.value.trim();
if (!!url && !hasProtocol(url)) {
url = "https://" + url;
}
input.value = url;
};
const A_LONG = (s) => `<a href='${s}' target="_blank">${s}</a>`;
const A_SHORT = (s) => `<a href="${SITE_URL}/${s}" target="_blank">${s}</a>`;
const copyButton = (shortUrl) => {
const btn = document.createElement("button");
btn.classList.add("svg-button");
btn.innerHTML = SVG_COPY_BUTTON;
btn.title = "Copy Short URL";
btn.onclick = (e) => {
e.preventDefault();
copyShortUrl(shortUrl);
};
return btn;
};
const editButton = (shortUrl, longUrl) => {
const btn = document.createElement("button");
btn.classList.add("svg-button");
btn.innerHTML = SVG_EDIT_BUTTON;
btn.title = "Edit Short URL";
btn.onclick = () => {
document.getElementById("container").style.filter = "blur(2px)";
document.getElementById("edit-dialog").showModal();
const editUrlSpan = document.getElementById("edit-link");
const editedUrl = document.getElementById("edited-url");
if (editUrlSpan.textContent != shortUrl) {
editUrlSpan.textContent = shortUrl;
document.getElementById("edit-checkbox").checked = false;
editedUrl.value = longUrl;
}
editedUrl.focus();
};
return btn;
};
const qrCodeButton = (shortlink) => {
const btn = document.createElement("button");
btn.classList.add("svg-button");
btn.innerHTML = SVG_QR_BUTTON;
btn.title = "Show QR Code";
btn.onclick = () => {
const tmpDiv = document.createElement("div");
new QRCode(tmpDiv, {
text: `${SITE_URL}/${shortlink}`,
correctLevel: QRCode.CorrectLevel.H,
});
const oldCanvas = tmpDiv.firstChild;
const padding = "12";
const newCanvas = document.createElement("canvas");
newCanvas.height = 280;
newCanvas.width = 280;
const ctx = newCanvas.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 280, 280);
ctx.drawImage(oldCanvas, 12, 12);
const img = new Image();
img.src = "assets/favicon.svg";
img.onload = () => {
ctx.fillStyle = "white";
ctx.beginPath();
ctx.arc(140, 140, 30, 0, Math.PI * 2);
ctx.fill();
const imgWidth = 50;
const imgHeight = 50;
ctx.drawImage(img, 115, 115, 50, 50);
document.getElementById("qr-code").appendChild(newCanvas);
const qrDown = document.getElementById("qr-download");
qrDown.href = newCanvas.toDataURL();
qrDown.download = `chhoto-qr-${shortlink}.png`;
document.getElementById("container").style.filter = "blur(2px)";
document.getElementById("qr-code-dialog").showModal();
};
};
return btn;
};
const deleteButton = (shortUrl) => {
const btn = document.createElement("button");
btn.classList.add("svg-button");
btn.innerHTML = SVG_DELETE_BUTTON;
btn.title = "Delete Short URL";
btn.onclick = (e) => {
e.preventDefault();
if (confirm("Do you want to delete the entry " + shortUrl + "?")) {
showAlert("&nbsp;", "transparent");
fetch(prepSubdir(`/api/del/${shortUrl}`), {
method: "DELETE",
cache: "no-cache",
})
.then(async (res) => {
if (!res.ok) {
throw new Error("Could not delete.");
}
LOCAL_DATA = LOCAL_DATA.filter(
(item) => item["shortlink"] != shortUrl,
);
if (LOCAL_DATA.length <= CUR_PAGE * 10 && CUR_PAGE > 0) {
CUR_PAGE -= 1;
}
PROCESSING_PAGE_TRANSITION = true;
displayData();
managePageControls();
})
.catch((err) => {
console.log("Error:", err);
showAlert(
"Unable to delete " + shortUrl + ". Please try again!",
"light-dark(red, #a01e1e)",
);
});
}
};
return btn;
};
const submitForm = () => {
const form = document.forms.namedItem("new-url-form");
const data = {
"longlink": form.elements["longUrl"].value,
"shortlink": form.elements["shortUrl"].value,
};
const form = document.forms.namedItem("new-url-form");
const longUrl = form.elements["longUrl"];
const shortUrl = form.elements["shortUrl"];
const expiryDelay = form.elements["expiryDelay"];
const data = {
longlink: longUrl.value,
shortlink: shortUrl.value,
expiry_delay: parseInt(expiryDelay.value),
};
const url = prepSubdir("/api/new");
const url = prepSubdir("/api/new");
let ok = false;
fetch(url, {
method: "POST",
cache: "no-cache",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
.then((res) => {
ok = res.ok;
return res.text();
})
.then(async (text) => {
if (!ok) {
showAlert(text, "light-dark(red, #a01e1e)");
} else {
await copyShortUrl(text);
longUrl.value = "";
shortUrl.value = "";
expiryDelay.value = 0;
const params = new URLSearchParams();
params.append("page_size", 1);
const newEntry = await pullData(params);
LOCAL_DATA.unshift(newEntry[0]);
if (LOCAL_DATA.length == (CUR_PAGE + 1) * 10 + 1) {
LOCAL_DATA.pop();
}
CUR_PAGE = 0;
PROCESSING_PAGE_TRANSITION = true;
displayData();
managePageControls();
}
})
.catch((err) => {
console.log("Error:", err);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
});
};
const submitEdit = () => {
const urlInput = document.getElementById("edited-url");
const editUrlSpan = document.getElementById("edit-link");
const longUrl = urlInput.value;
const shortUrl = editUrlSpan.textContent;
const checkBox = document.getElementById("edit-checkbox");
if (confirm("Are you sure that you want to edit " + shortUrl + "?")) {
data = {
shortlink: shortUrl,
longlink: longUrl,
reset_hits: checkBox.checked,
};
const url = prepSubdir("/api/edit");
let ok = false;
fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
method: "PUT",
cache: "no-cache",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
.then(res => {
ok = res.ok;
return res.text();
})
.then(text => {
if (!ok) {
showAlert(text, "red");
}
else {
copyShortUrl(text);
longUrl.value = "";
shortUrl.value = "";
refreshData();
}
})
}
.then((res) => {
ok = res.ok;
return res.text();
})
.then(async (text) => {
if (!ok) {
showAlert(text, "light-dark(red, #a01e1e)");
} else {
document.getElementById("edit-dialog").close();
editUrlSpan.textContent = shortUrl;
const editedIndex = LOCAL_DATA.findIndex(
(item) => item["shortlink"] == shortUrl,
);
LOCAL_DATA[editedIndex]["longlink"] = longUrl;
if (checkBox.checked) {
LOCAL_DATA[editedIndex]["hits"] = 0;
}
checkBox.checked = false;
}
displayData();
})
.catch((err) => {
console.log("Error:", err);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
});
}
};
const submitLogin = () => {
const password = document.getElementById("password");
fetch(prepSubdir("/api/login"), {
method: "POST",
body: password.value
}).then(res => {
if (res.ok) {
document.getElementById("container").style.filter = "blur(0px)"
document.getElementById("login-dialog").remove();
refreshData();
} else {
const wrongPassBox = document.getElementById("wrong-pass");
wrongPassBox.innerHTML = "Wrong password!";
wrongPassBox.style.color = "red";
password.focus();
}
const password = document.getElementById("password");
fetch(prepSubdir("/api/login"), {
method: "POST",
cache: "no-cache",
body: password.value,
})
.then(async (res) => {
switch (res.status) {
case 200:
document.getElementById("container").style.filter = "blur(0px)";
document.getElementById("login-dialog").close();
password.value = "";
document.getElementById("wrong-pass").hidden = true;
ADMIN = true;
await getConfig();
await refreshData();
break;
case 401:
document.getElementById("wrong-pass").hidden = false;
password.focus();
break;
default:
throw new Error("Got status " + res.status);
}
})
}
.catch((err) => {
console.log("Error:", err);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
});
};
(async () => {
await refreshData();
const logOut = async () => {
if (confirm("Are you sure you want to log out?")) {
await fetch(prepSubdir("/api/logout"), {
method: "DELETE",
cache: "no-cache",
})
.then(async (res) => {
if (res.ok) {
document.getElementById("version-number").hidden = true;
document.getElementById("admin-button").hidden = true;
showAlert("&nbsp;", "transparent");
ADMIN = false;
VERSION = null;
LOCAL_DATA = [];
await refreshData();
} else {
showAlert(
`Logout failed. Please try again!`,
"light-dark(red, #a01e1e)",
);
}
})
.catch((err) => {
console.log("Error:", err);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
});
}
};
// This is where loading starts
refreshData()
.then(() => {
document.getElementById("longUrl").onblur = () => {
addHTTPSToLongURL("longUrl");
};
document.getElementById("edited-url").onblur = () => {
addHTTPSToLongURL("edited-url");
};
const form = document.forms.namedItem("new-url-form");
form.onsubmit = e => {
e.preventDefault();
submitForm();
}
form.onsubmit = (e) => {
e.preventDefault();
submitForm();
};
const login_form = document.forms.namedItem("login-form");
login_form.onsubmit = e => {
e.preventDefault();
submitLogin();
document.getElementById("admin-button").onclick = (e) => {
e.preventDefault();
if (ADMIN) {
logOut();
} else {
showLogin();
}
};
const editDialog = document.getElementById("edit-dialog");
editDialog.onclose = () => {
document.getElementById("container").style.filter = "blur(0px)";
};
document.forms.namedItem("edit-form").onsubmit = (e) => {
e.preventDefault();
submitEdit();
};
document.getElementById("edit-cancel-button").onclick = () => {
editDialog.close();
};
const passEye = document.getElementById("password-eye-button");
passEye.innerHTML = SVG_OPEN_EYE;
passEye.onclick = () => {
const passBox = document.getElementById("password");
if (passBox.type === "password") {
passBox.type = "text";
passEye.innerHTML = SVG_CLOSED_EYE;
} else {
passBox.type = "password";
passEye.innerHTML = SVG_OPEN_EYE;
}
document.getElementById("password").focus();
};
const prevPageBtn = document.getElementById("prevPageBtn");
prevPageBtn.innerHTML = SVG_PREV_BUTTON;
prevPageBtn.onclick = () => {
gotoPrevPage();
};
const nextPageBtn = document.getElementById("nextPageBtn");
nextPageBtn.innerHTML = SVG_NEXT_BUTTON;
nextPageBtn.onclick = () => {
gotoNextPage();
};
const qrCodeDialog = document.getElementById("qr-code-dialog");
document.getElementById("qr-close").onclick = () => {
qrCodeDialog.close();
};
qrCodeDialog.onclose = () => {
document.getElementById("container").style.filter = "blur(0px)";
document.getElementById("qr-code").innerHTML = "";
};
document.forms.namedItem("login-form").onsubmit = (e) => {
e.preventDefault();
submitLogin();
};
})
.catch((err) => {
console.log("Something went wrong:", err);
if (!alert("Something went wrong! Click Ok to refresh page.")) {
window.location.reload();
}
})()
});

View File

@@ -1,135 +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");
@font-face {
font-family: Montserrat;
src: url('Montserrat-VF.woff2');
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);
}
.container {
max-width: 1200px;
margin: 20px auto auto;
max-width: 80em;
margin: 1em auto auto;
}
table tr td div {
max-height: 75px;
line-height: 25px;
word-wrap: break-word;
max-width: 575px;
overflow: auto;
.chhoto-button {
background-color: light-dark(#0078e7, #0060b9);
}
.pure-form input.chhoto-input {
width: 65%;
border-color: light-dark(#cccccc, #3e4446);
box-shadow: light-dark(#dddddd, #2b2f31) 0 0.1em 0.2em inset;
}
.pure-form input.chhoto-input:focus {
border-color: light-dark(#cccccc, #3e4446);
}
.pure-table td {
border-left: none;
#expiryDelay {
background-color: light-dark(white, #2b2a33);
border-color: light-dark(#cccccc, #3e4446);
box-shadow: light-dark(#dddddd, #2b2f31) 0 0.1em 0.2em inset;
}
td[name="hitsColumn"] {
text-align: right;
}
td[name="deleteBtn"] div {
display: flex;
align-items: center;
justify-content: center;
}
td[name="deleteBtn"] {
text-align: center;
}
td[name="deleteBtn"] div button {
border-radius: 100%;
aspect-ratio: 1;
border-style: solid;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
background-color: transparent;
}
input {
width: 65%;
}
form input[name="shortUrl"] {
text-transform: lowercase;
}
form input[name="shortUrl"]::placeholder {
text-transform: none;
}
div[name="github-link"] {
position: absolute;
right: 0.5%;
top: 0.5%;
}
.pure-table {
width: 98%;
visibility: hidden;
}
.pure-table caption {
font-size: 22px;
text-align: left;
font-style: normal;
::placeholder {
color: light-dark(#757575, #636061);
}
#logo {
font-size: 32px;
color: light-dark(#333333, #c8c3bc);
border-bottom-color: light-dark(#e5e5e5, #373c3e);
font-size: 2em;
}
#logo img {
height: 0.8em;
}
a {
color: light-dark(blue, #3391ff);
}
.linkButton {
background: none;
padding: 0;
border: none;
color: light-dark(blue, #3391ff);
text-decoration: underline;
text-align: left;
cursor: pointer;
}
.chhoto-table {
width: 98%;
border-collapse: separate;
border-spacing: 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: 4.5em;
line-height: 1.5em;
word-break: break-word;
overflow: auto;
}
.chhoto-table tr td[label="Long URL"] div {
word-break: break-all;
}
.chhoto-table tr td[name="numColumn"] div {
word-break: normal;
}
.chhoto-table tr td[name="hitsColumn"] div {
word-break: normal;
}
.chhoto-table tr:nth-child(even) {
background-color: light-dark(#f2f2f2, #080a0b);
}
.chhoto-table caption {
color: light-dark(black, #e8e6e3);
text-align: left;
font-size: 1.5em;
font-style: normal;
font-family: Montserrat;
}
.chhoto-table thead {
color: light-dark(black, #e8e6e3);
background-color: light-dark(#e0e0e0, #2a2d2f);
}
.chhoto-table th,
.chhoto-table td {
border-left: none;
max-width: 36em;
}
#short-url-header {
min-width: 6em;
}
th[name="hitsColumn"],
td[name="hitsColumn"] {
text-align: right;
}
th[name="expiryColumn"],
td[name="expiryColumn"] {
text-align: 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;
}
.pure-table caption span {
margin-right: 1em;
}
form input[name="shortUrl"]::placeholder {
text-transform: none;
}
div[name="links-div"] {
position: absolute;
right: 0.5%;
top: 0.5%;
}
#password {
width: 100%;
margin-bottom: 10px;
width: 100%;
margin-bottom: 1em;
}
#wrong-pass {
color: light-dark(red, #ff1a1a);
}
dialog form {
text-align: center;
#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;
}
.tooltip .tooltiptext {
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: -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;
}
/* 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);
}
table tr {
border-bottom: 1px solid #999;
}
.chhoto-table thead {
display: none;
}
table thead {
display: none;
}
.chhoto-table td {
display: flex;
justify-content: left;
width: 97vw;
padding: 0.1em;
}
table td {
display: flex;
justify-content: left !important;
width: 98vw;
padding: .5em .1em !important;
}
.chhoto-table tr td[name="shortColumn"] {
padding-top: 0.5em;
}
.chhoto-table tr td[name="actions"] {
padding-bottom: 0.5em;
}
table td::before {
content: attr(label);
font-weight: bold;
width: 120px;
min-width: 120px;
text-align: left;
}
#alert-box {
display: none;
}
.pure-table caption {
padding-top: 0px;
}
.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 th[name="numColumn"],
.chhoto-table td[name="numColumn"] {
display: none;
}
.chhoto-table caption {
padding-top: 0;
}
.tooltip .tooltiptext {
left: 8em;
}
}

BIN
screenshot-desktop.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
screenshot-mobile.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB