Compare commits
729 Commits
1.0
...
12862fbb5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12862fbb5a | ||
|
|
281c14034f | ||
|
|
c667c56a32 | ||
|
|
c12f37e9db | ||
|
|
390dbde520 | ||
|
|
732a532575 | ||
|
|
eaef52f98b | ||
|
|
1b8d9b9710 | ||
|
|
9a8c4f5f3d | ||
|
|
acb6cad149 | ||
|
|
771d2ebe1a | ||
|
|
a575700cac | ||
|
|
6dacbc086f | ||
|
|
37f5ea260b | ||
|
|
d6d4af366a | ||
|
|
08b8bbc057 | ||
|
|
8e8daa4d35 | ||
|
|
67f6df643f | ||
|
|
bf28169cac | ||
|
|
401bf1124a | ||
|
|
7e43ea4bef | ||
|
|
8b5bb4de81 | ||
|
|
6e0f4d623d | ||
|
|
a7f30a0be2 | ||
|
|
5b60d25e64 | ||
|
|
48a9a844a1 | ||
|
|
043c6efab6 | ||
|
|
64082b3bb5 | ||
|
|
897a7228bf | ||
|
|
77beace200 | ||
|
|
d5982234a0 | ||
|
|
08fe1ce768 | ||
|
|
35a5f394ea | ||
|
|
cd3d73c160 | ||
|
|
79dbc7aeba | ||
|
|
ef180831da | ||
|
|
dcc7d94870 | ||
|
|
a61b5ac156 | ||
|
|
833da9086c | ||
|
|
1c7227e5f1 | ||
|
|
351355ac9f | ||
|
|
20a1ac287d | ||
|
|
d3800328c1 | ||
|
|
83cac33388 | ||
|
|
5d47b58f91 | ||
|
|
d4f1414b37 | ||
|
|
77fd9a8567 | ||
|
|
d6e6360ed9 | ||
|
|
358db69d72 | ||
|
|
dd7bd0c628 | ||
|
|
eeb3420c87 | ||
|
|
a23da3d20a | ||
|
|
c695644dcd | ||
|
|
b7ce945799 | ||
|
|
3fe78d09b5 | ||
|
|
ea56e0e9aa | ||
|
|
2862975c28 | ||
|
|
0ea6a5cbf9 | ||
|
|
871c95a837 | ||
|
|
b15e6e7731 | ||
|
|
19fb92b445 | ||
|
|
dd8712019a | ||
|
|
a1ac8bb7c3 | ||
|
|
906982e514 | ||
|
|
2d1fe51bc2 | ||
|
|
0d7fa73dbb | ||
|
|
35dc14d474 | ||
|
|
139a2e3032 | ||
|
|
167f234ac9 | ||
|
|
e9cd2812c4 | ||
|
|
360d40ce30 | ||
|
|
4f606e813c | ||
|
|
3f08298257 | ||
|
|
c918fa2317 | ||
|
|
991914b1c0 | ||
|
|
0e579ec44b | ||
|
|
65b3da2979 | ||
|
|
30b9f6beb2 | ||
|
|
cef641f71f | ||
|
|
ae8ec4455f | ||
|
|
fae845e1d7 | ||
|
|
c6025519bd | ||
|
|
8aa4697025 | ||
|
|
7ef6b5fb10 | ||
|
|
0d135e6574 | ||
|
|
b89b7ed69f | ||
|
|
34076ec69e | ||
|
|
682889f246 | ||
|
|
060bee7b1d | ||
|
|
95fb879ba4 | ||
|
|
6631b1f053 | ||
|
|
0595cd4bc2 | ||
|
|
3ed1317039 | ||
|
|
72e06cbdcd | ||
|
|
3cd39b4c25 | ||
|
|
a5f8dfd57a | ||
|
|
95ae288d59 | ||
|
|
b0dc457224 | ||
|
|
7ae2497432 | ||
|
|
e7b15cb356 | ||
|
|
f5c164a470 | ||
|
|
4a4b4be811 | ||
|
|
87477771a8 | ||
|
|
b60699b99f | ||
|
|
fc6734f8fb | ||
|
|
3f7b508e4a | ||
|
|
2d51097ba4 | ||
|
|
9bc2edf807 | ||
|
|
f88545defc | ||
|
|
08727792a8 | ||
|
|
1f827e68a7 | ||
|
|
8973db98df | ||
|
|
c7a0358d05 | ||
|
|
d505ec5571 | ||
|
|
8a931ae736 | ||
|
|
3b1c7c656d | ||
|
|
b987abe68a | ||
|
|
cf67b30c53 | ||
|
|
a992fc51c6 | ||
|
|
7bf9ffc6d3 | ||
|
|
f291725dfc | ||
|
|
26d91e4528 | ||
|
|
ee0cc96187 | ||
|
|
ffc59f393a | ||
|
|
bf5b2507b8 | ||
|
|
4d7a91cb17 | ||
|
|
64438145f2 | ||
|
|
944c33ef48 | ||
|
|
1ad30359e4 | ||
|
|
07b7497b61 | ||
|
|
98ed94124b | ||
|
|
7ed14bfd6b | ||
|
|
b7ea0b7f5f | ||
|
|
57c28ed869 | ||
|
|
84f4ba8e90 | ||
|
|
967a5ed4b8 | ||
|
|
6a64fb536d | ||
|
|
a410e43ac6 | ||
|
|
961e32091e | ||
|
|
4444d7187d | ||
|
|
f7d8e59b31 | ||
|
|
9c035903d3 | ||
|
|
ad300e14fb | ||
|
|
d9495304cb | ||
|
|
0af3b9a40b | ||
|
|
fd79a6b136 | ||
|
|
ff6e1ac93a | ||
|
|
312be7ded9 | ||
|
|
609c36c32c | ||
|
|
4133c81ce4 | ||
|
|
be45135be3 | ||
|
|
e8646a9c47 | ||
|
|
5a5a66a00e | ||
|
|
a83c6bce3d | ||
|
|
986908bce2 | ||
|
|
9ad0ec026f | ||
|
|
3580e03d7d | ||
|
|
154e91fcf2 | ||
|
|
45e7af66b1 | ||
|
|
47c909c428 | ||
|
|
9fc257bb79 | ||
|
|
b022c069af | ||
|
|
877084afd3 | ||
|
|
9d9a02fa18 | ||
|
|
1b7004487a | ||
|
|
ce2fc0fedf | ||
|
|
eea773452f | ||
|
|
f59da0a927 | ||
|
|
df195fbba0 | ||
|
|
07aeb38a67 | ||
|
|
42054fe3cb | ||
|
|
7f6f86e336 | ||
|
|
16c2817039 | ||
|
|
85efb3a021 | ||
|
|
ce0e625d18 | ||
|
|
98d0e8ce06 | ||
|
|
f42eb60494 | ||
|
|
c445d29fe0 | ||
|
|
76b322f7eb | ||
|
|
3739df8e92 | ||
|
|
8903f6a7d1 | ||
|
|
4e0f48a24c | ||
|
|
10e79f1c17 | ||
|
|
2dd730564f | ||
|
|
c4b2c8aca0 | ||
|
|
92c76c9fbb | ||
|
|
715c534941 | ||
|
|
78ba80e6fd | ||
|
|
8dbd09b3e9 | ||
|
|
80448ad1bd | ||
|
|
bd0d42019c | ||
|
|
d8e91f4815 | ||
|
|
bd36d2fc03 | ||
|
|
1e5da24739 | ||
|
|
dbf523da02 | ||
|
|
7aea0900c5 | ||
|
|
4ba1cf9e8c | ||
|
|
6180baec6a | ||
|
|
8c1cfb8c95 | ||
|
|
6bc1e59ca1 | ||
|
|
1fd8112790 | ||
|
|
8a4adec560 | ||
|
|
5714ba2631 | ||
|
|
29c969a52a | ||
|
|
d1491ef5f8 | ||
|
|
49532e5e3e | ||
|
|
b2bfa0398f | ||
|
|
194d9960e0 | ||
|
|
7b27282171 | ||
|
|
04d09988c9 | ||
|
|
bdf77400fd | ||
|
|
79e951013a | ||
|
|
e3707d4221 | ||
|
|
27c908c1ff | ||
|
|
ca4ec02f2e | ||
|
|
d9d2aaeb26 | ||
|
|
3ec10da6ad | ||
|
|
8fcbe4366a | ||
|
|
0887629d99 | ||
|
|
64ebd47526 | ||
|
|
07e0f2fae1 | ||
|
|
e28e3d22cf | ||
|
|
f76e383998 | ||
|
|
4bbe75a338 | ||
|
|
7099306ffc | ||
|
|
ea0bcbc745 | ||
|
|
10e595ede9 | ||
|
|
0ba4f83dc6 | ||
|
|
bbfc49e71c | ||
|
|
8efb7c1f36 | ||
|
|
da71c228d9 | ||
|
|
019746b19d | ||
|
|
0abbc5a129 | ||
|
|
1b9049489b | ||
|
|
54b7e6d5d1 | ||
|
|
2908757d07 | ||
|
|
97103cc905 | ||
|
|
2361f4895b | ||
|
|
840f73ffca | ||
|
|
3d1ca73a01 | ||
|
|
9b7a91e99f | ||
|
|
5ba5945207 | ||
|
|
06344f6615 | ||
|
|
db9495ea92 | ||
|
|
ea284b5ddf | ||
|
|
402df40465 | ||
|
|
05fa2669b6 | ||
|
|
313fa67dca | ||
|
|
808ab744e6 | ||
|
|
50c1701646 | ||
|
|
bedd06ed45 | ||
|
|
3374654fbb | ||
|
|
7d59076e30 | ||
|
|
ff89b2bc91 | ||
|
|
200b0cad72 | ||
|
|
01dd96f42d | ||
|
|
e6aad675a8 | ||
|
|
bda6489f2e | ||
|
|
4caee32504 | ||
|
|
7b9b3e1adc | ||
|
|
d23442f126 | ||
|
|
5ecd7168cc | ||
|
|
3a76c20170 | ||
|
|
cb12829f50 | ||
|
|
7c10041186 | ||
|
|
c87b476d43 | ||
|
|
539393f01f | ||
|
|
8efb9e42ce | ||
|
|
2830876455 | ||
|
|
9123b940d2 | ||
|
|
1629569f8e | ||
|
|
ab423337c9 | ||
|
|
d5684155fd | ||
|
|
9a61e5f3c4 | ||
|
|
535c97fd79 | ||
|
|
beb290cfb0 | ||
|
|
e7d03f2190 | ||
|
|
33735aa714 | ||
|
|
2e61b4c1df | ||
|
|
1ca63df5ec | ||
|
|
8bcec0f28f | ||
|
|
9cacb681db | ||
|
|
721223bebc | ||
|
|
acb9afac16 | ||
|
|
6f21f82fd2 | ||
|
|
57e4603710 | ||
|
|
642bb0a39a | ||
|
|
17d78fe66d | ||
|
|
0afac015aa | ||
|
|
83955e734f | ||
|
|
2afd443dcf | ||
|
|
93be80313a | ||
|
|
aac45282a2 | ||
|
|
cc918f0e73 | ||
|
|
a03ce4a06f | ||
|
|
e8ae933e5b | ||
|
|
841bc2d26b | ||
|
|
ce291f43bd | ||
|
|
97d8c143d2 | ||
|
|
892a1ce4f9 | ||
|
|
b8687d2475 | ||
|
|
fc0e31dbdf | ||
|
|
4483808572 | ||
|
|
9a5ccad34c | ||
|
|
151ffb261b | ||
|
|
9be046b1e8 | ||
|
|
356b04bbaa | ||
|
|
9f317e8e1a | ||
|
|
8b0dbd23b3 | ||
|
|
74a5574d9f | ||
|
|
6801346e92 | ||
|
|
c6ba3c8edf | ||
|
|
ab1b085b15 | ||
|
|
8e415472b0 | ||
|
|
892af51242 | ||
|
|
4f771c5a7d | ||
|
|
9da210872b | ||
|
|
8a8915ee16 | ||
|
|
037dc01a04 | ||
|
|
c91b869f98 | ||
|
|
8a38095df6 | ||
|
|
9c2e6aef2c | ||
|
|
2f76127e32 | ||
|
|
352f9b94e1 | ||
|
|
6bebf8129a | ||
|
|
71644fd4cf | ||
|
|
fb822ec7ad | ||
|
|
3e0d9afd92 | ||
|
|
d4cb1c549c | ||
|
|
d39bd8a46a | ||
|
|
e50dffc1e1 | ||
|
|
7942dcf48d | ||
|
|
1095d90e1f | ||
|
|
e8fe1d612d | ||
|
|
396154b10d | ||
|
|
289a3ddec3 | ||
|
|
dd1ac6a201 | ||
|
|
10d06e3c46 | ||
|
|
835ecea22b | ||
|
|
18272d2684 | ||
|
|
d557c9696b | ||
|
|
5bae3b1f42 | ||
|
|
ab980a9371 | ||
|
|
b579ee5299 | ||
|
|
d1bc6fcf4b | ||
|
|
bbf5811e13 | ||
|
|
efc2b03415 | ||
|
|
3aa8884676 | ||
|
|
084334cd11 | ||
|
|
4eb88f3beb | ||
|
|
c23fcdc9bd | ||
|
|
21f76f2962 | ||
|
|
e8af830527 | ||
|
|
fc2c24d731 | ||
|
|
a170954232 | ||
|
|
e39578fa02 | ||
|
|
88ddb4299a | ||
|
|
b2bc2c450b | ||
|
|
d198135144 | ||
|
|
b838a6e027 | ||
|
|
0897b6b63b | ||
|
|
97b56c40ae | ||
|
|
2c8f47c0cb | ||
|
|
828019998e | ||
|
|
49d910fb3c | ||
|
|
c521ad1120 | ||
|
|
63020b2c24 | ||
|
|
d42a738861 | ||
|
|
e3eaf5aba8 | ||
|
|
3b48ce7b5e | ||
|
|
5363a1b056 | ||
|
|
0d58e626a4 | ||
|
|
e8faf660f4 | ||
|
|
67695da86b | ||
|
|
d50c183c9c | ||
|
|
90b04b1f21 | ||
|
|
babf3d8911 | ||
|
|
1ae00eb3a8 | ||
|
|
6f419c7b3d | ||
|
|
c557b8b262 | ||
|
|
a63222a71a | ||
|
|
86cea6278f | ||
|
|
f283991740 | ||
|
|
1775f71347 | ||
|
|
0b1224f8e5 | ||
|
|
1047763285 | ||
|
|
fc785c3eef | ||
|
|
17d0df943b | ||
|
|
7b52bd60da | ||
|
|
db8417d919 | ||
|
|
af1685bb70 | ||
|
|
a5621acfe4 | ||
|
|
1be89db43b | ||
|
|
a60853fd21 | ||
|
|
2b9fafe440 | ||
|
|
f952cb88a0 | ||
|
|
9eec252fe2 | ||
|
|
f8f4dae457 | ||
|
|
16bc211f9f | ||
|
|
cca5bcfa1a | ||
|
|
cba667ded8 | ||
|
|
1d9a8c202d | ||
|
|
eb4f05a87b | ||
|
|
5183279cab | ||
|
|
f1c1642976 | ||
|
|
eed3c2292a | ||
|
|
4fb8d0cb5c | ||
|
|
9a0cdec646 | ||
|
|
818dadb84f | ||
|
|
247cfb0476 | ||
|
|
6347a89725 | ||
|
|
9ddf043c17 | ||
|
|
a1f8700664 | ||
|
|
aab7a9b3d1 | ||
|
|
1ef5d539d5 | ||
|
|
5c2886f651 | ||
|
|
2c56c68637 | ||
|
|
756d675f06 | ||
|
|
e6eed2dd70 | ||
|
|
37a5300015 | ||
|
|
66d94634d9 | ||
|
|
03f5529c30 | ||
|
|
f772475d96 | ||
|
|
8b8ceca313 | ||
|
|
201d0b319f | ||
|
|
733ef6ea67 | ||
|
|
cf5909c888 | ||
|
|
dcb3144b22 | ||
|
|
e0c61bdb93 | ||
|
|
06f7a33d5d | ||
|
|
514e905299 | ||
|
|
3688692c7a | ||
|
|
a7cf0cdf30 | ||
|
|
35880f4d1e | ||
|
|
0d0da1141b | ||
|
|
4a8b62446c | ||
|
|
855145d4d7 | ||
|
|
59f679a1c2 | ||
|
|
5213a9df2e | ||
|
|
3e6f482533 | ||
|
|
56ab16aa4e | ||
|
|
066fa9c80a | ||
|
|
9fc8634704 | ||
|
|
5bbaad3001 | ||
|
|
892959d49d | ||
|
|
9948ce713c | ||
|
|
70d9d828c5 | ||
|
|
114a97a273 | ||
|
|
96495b037d | ||
|
|
69fc25a264 | ||
|
|
f19f3249cc | ||
|
|
2cf0e5d2de | ||
|
|
de9bc130d5 | ||
|
|
8ff4c3f24f | ||
|
|
eab1c9bc73 | ||
|
|
0b50a7c261 | ||
|
|
e55c6f82b4 | ||
|
|
6992d27390 | ||
|
|
39e4d2df74 | ||
|
|
41b7e37819 | ||
|
|
e67e0a88cd | ||
|
|
00ade1af40 | ||
|
|
b1632c4c87 | ||
|
|
3445d5366a | ||
|
|
5fb8587628 | ||
|
|
6cdacda510 | ||
|
|
ed2be0e883 | ||
|
|
0fce881654 | ||
|
|
168cff94a2 | ||
|
|
b1c4142296 | ||
|
|
75912e8f9d | ||
|
|
5ac822d5f9 | ||
|
|
233247e154 | ||
|
|
108abc2b30 | ||
|
|
4a158bbea6 | ||
|
|
51c6817487 | ||
|
|
0192c26fd0 | ||
|
|
8b815e1bbb | ||
|
|
3ad05f1e63 | ||
|
|
931b4a95e4 | ||
|
|
cdc3508a0c | ||
|
|
e742c0ab5e | ||
|
|
231fd3c8ca | ||
|
|
86ec787d96 | ||
|
|
30c0b8b50a | ||
|
|
62ae71f4ca | ||
|
|
e9bb9d0b65 | ||
|
|
ca14c02e70 | ||
|
|
0469f9b933 | ||
|
|
f27984a63f | ||
|
|
7ad874a1ff | ||
|
|
604e95aa9c | ||
|
|
917be6ade4 | ||
|
|
2594051a54 | ||
|
|
38b817fdf8 | ||
|
|
a9168e3459 | ||
|
|
d48b664c0a | ||
|
|
a0f0eb5280 | ||
|
|
34518affaf | ||
|
|
4f80b1b522 | ||
|
|
f38abdf1fb | ||
|
|
f2b5e1ab6d | ||
|
|
99b5298cd8 | ||
|
|
6659452c51 | ||
|
|
3441d3ae90 | ||
|
|
f6060eb649 | ||
|
|
6c7ca8d0ac | ||
|
|
599b013fc9 | ||
|
|
841f877ee8 | ||
|
|
088cd594a5 | ||
|
|
f9e642275a | ||
|
|
9a520c122e | ||
|
|
3a712f812a | ||
|
|
753b73c4a3 | ||
|
|
fbcb088260 | ||
|
|
f526e7ec5b | ||
|
|
5d8dd6fb63 | ||
|
|
5e4db14ea2 | ||
|
|
c76b39dc16 | ||
|
|
731cb41646 | ||
|
|
0cfa674029 | ||
|
|
1f18766f79 | ||
|
|
dfefff2703 | ||
|
|
6d3d220cff | ||
|
|
629e66a57c | ||
|
|
ce76f04f35 | ||
|
|
db5d1f72bd | ||
|
|
e54aa3b33b | ||
|
|
82559d38fd | ||
|
|
2baa481040 | ||
|
|
ffb4846239 | ||
|
|
5bd174d287 | ||
|
|
9221c3e371 | ||
|
|
c5cfba85f9 | ||
|
|
d6dcd2f18d | ||
|
|
d278021e1b | ||
|
|
8dbb6e9bd6 | ||
|
|
cde3fb4c89 | ||
|
|
f3d5e2cf50 | ||
|
|
fd74a941d9 | ||
|
|
2cab341e8b | ||
|
|
2a2ed7e41a | ||
|
|
5a5a1bc775 | ||
|
|
b66086be38 | ||
|
|
3be2862e9f | ||
|
|
b0603f62b4 | ||
|
|
9d5bc2d0fd | ||
|
|
94af81b802 | ||
|
|
03154fd010 | ||
|
|
4c394c8004 | ||
|
|
9d46546e44 | ||
|
|
c07bb5c25f | ||
|
|
85f150b543 | ||
|
|
d235f1aea7 | ||
|
|
13d613093b | ||
|
|
71ac19028e | ||
|
|
d8ee2ce658 | ||
|
|
5ecd29926d | ||
|
|
aa097ad982 | ||
|
|
bf0944a5fd | ||
|
|
6693941985 | ||
|
|
0bfaa49e7b | ||
|
|
a32f00c36e | ||
|
|
8b1af0a169 | ||
|
|
9fdb634d71 | ||
|
|
a5574683a6 | ||
|
|
d1ac769fc1 | ||
|
|
8ba8472940 | ||
|
|
f331370767 | ||
|
|
cb8f6f3e1d | ||
|
|
4d3e52cc95 | ||
|
|
8f0a6f0fc6 | ||
|
|
15ff8819a7 | ||
|
|
501f7f1d65 | ||
|
|
bd47f3c74b | ||
|
|
31212ab252 | ||
|
|
deff47db2c | ||
|
|
95a8263797 | ||
|
|
0227c5f783 | ||
|
|
4a8385955b | ||
|
|
1f9cf1d777 | ||
|
|
2a85189155 | ||
|
|
caa6c58fd2 | ||
|
|
583700cdbf | ||
|
|
432328b97e | ||
|
|
bcf30049a2 | ||
|
|
bfd7e111d9 | ||
|
|
0bd8fbe96c | ||
|
|
0c80a0ac21 | ||
|
|
f6255566b0 | ||
|
|
57d390a129 | ||
|
|
acb67fdcf6 | ||
|
|
9e8a9395a5 | ||
|
|
c77763c1c2 | ||
|
|
79cca4bc26 | ||
|
|
1ee7ebe847 | ||
|
|
ac82396584 | ||
|
|
bed96d6de5 | ||
|
|
7550d197b4 | ||
|
|
a4e7c6b444 | ||
|
|
7b59a9aa5c | ||
|
|
ff0a17e57b | ||
|
|
e3520be75b | ||
|
|
1c91855820 | ||
|
|
74f3fddd01 | ||
|
|
8be3e54861 | ||
|
|
f3984624d9 | ||
|
|
ca01676916 | ||
|
|
ff4801a476 | ||
|
|
2c34598faf | ||
|
|
8325f0c2d2 | ||
|
|
b657919a82 | ||
|
|
a91bcc0503 | ||
|
|
5e4f6aa1fc | ||
|
|
e14fc1f3b5 | ||
|
|
abe8238713 | ||
|
|
d22d88b985 | ||
|
|
43dab10d22 | ||
|
|
cdfcb901fa | ||
|
|
66ea86b6d3 | ||
|
|
e18a71a73c | ||
|
|
2f48b1e58c | ||
|
|
7e6e419dbc | ||
|
|
6b1d82bded | ||
|
|
3e07e9aadb | ||
|
|
a9c21a214f | ||
|
|
0add351480 | ||
|
|
3a2b1acf49 | ||
|
|
cc066c0716 | ||
|
|
ab5a032269 | ||
|
|
d831bd65e0 | ||
|
|
c3aa7438e9 | ||
|
|
4edf7349b0 | ||
|
|
0ddd75502d | ||
|
|
b779019716 | ||
|
|
66868effba | ||
|
|
96b3ae1297 | ||
|
|
34b2b116ba | ||
|
|
7d5ff40893 | ||
|
|
51e75f726e | ||
|
|
a14dbf5cd2 | ||
|
|
71d7253248 | ||
|
|
e4ff2df3f1 | ||
|
|
98d10cfd5b | ||
|
|
7f6ba5175f | ||
|
|
618fd0e53a | ||
|
|
046c6d63a4 | ||
|
|
3c699d8b02 | ||
|
|
a1f73c8a9d | ||
|
|
b9d76b6734 | ||
|
|
0e97516759 | ||
|
|
d9f7e9537d | ||
|
|
7acc336d4c | ||
|
|
e45ecd8eef | ||
|
|
6b8ae8657f | ||
|
|
d7d93d1873 | ||
|
|
c887b15a38 | ||
|
|
f6279cd313 | ||
|
|
36c2678e40 | ||
|
|
214f2af0b0 | ||
|
|
635d51a659 | ||
|
|
3aac41d3a7 | ||
|
|
cb56d31abe | ||
|
|
41e6231217 | ||
|
|
c8118a4c96 | ||
|
|
272dc38933 | ||
|
|
94aece0d1d | ||
|
|
5c8307c57a | ||
|
|
cfdc7a7644 | ||
|
|
cc15ffa463 | ||
|
|
9f68f2632f | ||
|
|
8956519a10 | ||
|
|
b803f5805d | ||
|
|
2c8076e2e1 | ||
|
|
99073bfd6c | ||
|
|
3598199957 | ||
|
|
e5f1839822 | ||
|
|
53ba9eb129 | ||
|
|
deeba64e74 | ||
|
|
e2656ff94e | ||
|
|
d196c0373f | ||
|
|
68ae439f5b | ||
|
|
e88cc1f738 | ||
|
|
a9af9fddbb | ||
|
|
c81573c3de | ||
|
|
a21933d3eb | ||
|
|
b73148706d | ||
|
|
77f835430b | ||
|
|
47c47e91dd | ||
|
|
b9dbe7f09d | ||
|
|
1dba76a08f | ||
|
|
4e10ce7003 | ||
|
|
4e18a6aeb9 | ||
|
|
3d239c189c | ||
|
|
6e2f1df108 | ||
|
|
74e5b5d1c7 | ||
|
|
fab2924250 | ||
|
|
0740bc79aa | ||
|
|
01930f6d62 | ||
|
|
81fc707d27 | ||
|
|
0ef85242ec | ||
|
|
79e01a4145 | ||
|
|
946afbf726 | ||
|
|
0367e74a7c | ||
|
|
99757dceb6 | ||
|
|
de58a15282 | ||
|
|
82410ef035 | ||
|
|
3b3babeaf4 | ||
|
|
82e6dadd53 | ||
|
|
6ad4c8b44d | ||
|
|
55cd69f1f2 | ||
|
|
6881b8b557 | ||
|
|
e06b744e3c | ||
|
|
b0e7f1ee57 | ||
|
|
0c101a55bf | ||
|
|
04421a592b | ||
|
|
25c12960f3 | ||
|
|
769db79fde | ||
|
|
58e956b2ef | ||
|
|
d26466b84c | ||
|
|
f394f30c17 | ||
|
|
d9ccd947a3 | ||
|
|
ef8f81be54 | ||
|
|
2714c4c9e0 | ||
|
|
a26e3fb98f | ||
|
|
8734ba63d9 | ||
|
|
575c73a71b | ||
|
|
71a91a253d | ||
|
|
7caec171c3 |
32
.classpath
@@ -1,32 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="src" output="bin/main" path="src/main/java">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="main"/>
|
||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="bin/main" path="src/main/resources">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="main"/>
|
||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="bin/test" path="src/test/java">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="test"/>
|
||||
<attribute name="gradle_used_by_scope" value="test"/>
|
||||
<attribute name="test" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="bin/test" path="src/test/resources">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="test"/>
|
||||
<attribute name="gradle_used_by_scope" value="test"/>
|
||||
<attribute name="test" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-11/"/>
|
||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||
<classpathentry kind="output" path="bin/default"/>
|
||||
</classpath>
|
||||
6
.gitattributes
vendored
@@ -1,6 +0,0 @@
|
||||
#
|
||||
# https://help.github.com/articles/dealing-with-line-endings/
|
||||
#
|
||||
# These are explicitly windows files and should use crlf
|
||||
*.bat text eol=crlf
|
||||
|
||||
44
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'bug'
|
||||
assignees: 'SinTan1729'
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
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]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'feature-request'
|
||||
assignees: 'SinTan1729'
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
88
.github/cliff.toml
vendored
Normal 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
@@ -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
@@ -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
|
||||
24
.gitignore
vendored
@@ -1,13 +1,15 @@
|
||||
# Ignore Gradle project-specific cache directory
|
||||
.gradle
|
||||
# Ignore build outputs
|
||||
actix/target
|
||||
resources-final
|
||||
|
||||
# Ignore Gradle build output directory
|
||||
build
|
||||
.idea/
|
||||
|
||||
local.properties
|
||||
url.iml
|
||||
.settings/
|
||||
urls.csv
|
||||
# Ignore irrelevant dotfiles
|
||||
.vscode/
|
||||
**/.directory
|
||||
.env
|
||||
urls.sqlite
|
||||
cookie*
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Testing related
|
||||
custom_dir
|
||||
testing-data
|
||||
|
||||
23
.project
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>url</name>
|
||||
<comment>Project url created by Buildship.</comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
||||
@@ -1,2 +0,0 @@
|
||||
connection.project.dir=
|
||||
eclipse.preferences.version=1
|
||||
218
CLI.md
Normal 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.
|
||||
34
Dockerfile
@@ -1,14 +1,30 @@
|
||||
FROM gradle:jdk14 AS build
|
||||
COPY --chown=gradle:gradle . /home/gradle/src
|
||||
WORKDIR /home/gradle/src
|
||||
RUN gradle build --no-daemon
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
FROM openjdk:14.0-slim
|
||||
FROM docker.io/lukemathwalker/cargo-chef:latest-rust-slim AS chef
|
||||
WORKDIR /chhoto-url
|
||||
|
||||
EXPOSE 4567
|
||||
FROM chef AS planner
|
||||
COPY ./actix/Cargo.toml ./actix/Cargo.lock ./
|
||||
COPY ./actix/src ./src
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
RUN mkdir /app
|
||||
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
|
||||
|
||||
COPY --from=build /home/gradle/src/build/libs/*.jar /app/application.jar
|
||||
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
|
||||
|
||||
ENTRYPOINT ["java", "-jar","/app/application.jar"]
|
||||
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/release /chhoto-url
|
||||
COPY ./resources /resources
|
||||
ENTRYPOINT ["/chhoto-url"]
|
||||
|
||||
19
Dockerfile.alpine
Normal 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"]
|
||||
|
||||
18
Dockerfile.scratch
Normal file
@@ -0,0 +1,18 @@
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
FROM scratch AS builder-amd64
|
||||
COPY ./actix/target/x86_64-unknown-linux-musl/release/chhoto-url /chhoto-url
|
||||
|
||||
FROM scratch AS builder-arm64
|
||||
COPY ./actix/target/aarch64-unknown-linux-musl/release/chhoto-url /chhoto-url
|
||||
|
||||
FROM scratch AS builder-arm
|
||||
COPY ./actix/target/armv7-unknown-linux-musleabihf/release/chhoto-url /chhoto-url
|
||||
|
||||
ARG TARGETARCH
|
||||
FROM builder-$TARGETARCH
|
||||
COPY ./resources /resources
|
||||
|
||||
ENTRYPOINT ["/chhoto-url"]
|
||||
|
||||
243
INSTALLATION.md
Normal 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
|
||||
```
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Przemek Dragańczuk
|
||||
Copyright (c) 2023 Sayantan Santra
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
50
Makefile
Normal file
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
include .env
|
||||
|
||||
.PHONY: clean test setup build podman-build podman-stop podman-test build-release tag audit
|
||||
|
||||
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
|
||||
|
||||
podman-build: build
|
||||
podman build --tag chhoto-url --build-arg TARGETARCH=amd64 -f Dockerfile.alpine .
|
||||
|
||||
podman-stop:
|
||||
podman ps -q --filter "name=chhoto-url" | xargs -r podman stop
|
||||
podman ps -aq --filter "name=chhoto-url" | xargs -r podman rm
|
||||
|
||||
test: audit
|
||||
cargo test --release --locked --manifest-path=actix/Cargo.toml --target x86_64-unknown-linux-musl
|
||||
|
||||
audit:
|
||||
cargo audit --file actix/Cargo.lock
|
||||
|
||||
podman-test: test podman-build podman-stop
|
||||
podman run -t -p ${port}:${port} --name chhoto-url --env-file ./.env -v "${db_dir}:/data" -d chhoto-url
|
||||
podman logs chhoto-url -f
|
||||
|
||||
conf_tag := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)"$$/\1/p')
|
||||
last_tag := $(shell git tag -l | tail -1)
|
||||
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: podman-stop
|
||||
cargo clean --manifest-path=actix/Cargo.toml
|
||||
|
||||
196
README.md
@@ -1,102 +1,124 @@
|
||||
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
|
||||
[](https://github.com/SinTan1729/chhoto-url/actions/workflows/rust-tests.yml)
|
||||
[](https://hub.docker.com/r/sintan1729/chhoto-url)
|
||||
[](https://github.com/SinTan1729)
|
||||
[](https://github.com/SinTan1729/chhoto-url/releases/latest)
|
||||
[](https://hub.docker.com/r/sintan1729/chhoto-url/tags)
|
||||
[](https://spdx.org/licenses/MIT.html)
|
||||
|
||||
#  <span style="font-size:42px">Chhoto URL</span>
|
||||
|
||||
# What is it?
|
||||
A simple selfhosted URL shortener with no name because naming is hard
|
||||
|
||||
A simple selfhosted URL shortener with no unnecessary features. Simplicity
|
||||
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
|
||||
and fix every bug you report. I will also try to keep it updated in terms of
|
||||
security vulnerabilities.
|
||||
|
||||
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?
|
||||
I've looked at a couple popular URL shorteners, however they either have
|
||||
unnecessary features, or they didn't have all the features I wanted.
|
||||
|
||||
Most URL shorteners are either bloated with unnecessary features, or are a pain to set up.
|
||||
Even fewer are written with simplicity and lightness in mind. When I saw the `simply-shorten`
|
||||
project (linked below), I really liked the idea but thought that it missed some features. Also,
|
||||
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 (ছোট, [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 fixed length, randomly generated string
|
||||
|
||||
- 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 (Missing in a surprising number of alternatives)
|
||||
- Opening the fixed length URL in your browser will instantly redirect you
|
||||
to the correct long URL (you'd think that's a standard feature, but
|
||||
apparently it's not)
|
||||
- Provides a simple API for adding new short links
|
||||
- Links are stored in an SQLite database
|
||||
- Available as a Docker container
|
||||
- Backend written in Java using [Spark Java](http://sparkjava.com/), frontend
|
||||
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 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.
|
||||
- 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, 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
|
||||
|
||||
for styling.
|
||||
- Uses very basic authentication using a provided password. It's not encrypted in transport.
|
||||
I recommend using a reverse proxy such as [caddy](https://caddyserver.com/) to
|
||||
encrypt the connection by SSL.
|
||||
|
||||
# Bloat that will not be implemented
|
||||
- Logging, tracking or spying of any kind. The only logs that still exist are
|
||||
errors printed to stderr and the default SLF4J warning.
|
||||
- User management. If you need a shortener for your whole organisation, 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 or via email.
|
||||
[github@draganczuk.tk](mailto:github@draganczuk.tk)
|
||||
|
||||
I _might_ add one of those "fork me on github" thingies in the corner, though I
|
||||
doubt I will
|
||||
- **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.
|
||||
|
||||
# Screenshot
|
||||

|
||||
# Screenshots
|
||||
|
||||
# Usage
|
||||
Clone this repository
|
||||
```
|
||||
git clone https://github.com/draganczukp/simply-shorten
|
||||
```
|
||||
## Building from source
|
||||
Gradle 6.x.x and JDK 11 are required. Other versions are not tested
|
||||
### 1. Build the `.jar` file
|
||||
```
|
||||
gradle build --no-daemon
|
||||
```
|
||||
The `--no-daemon` option means that gradle should exit as soon as the build is
|
||||
finished. Without it, gradle would still be running in the background
|
||||
in order to speed up future builds.
|
||||
<p align="middle">
|
||||
<img src="screenshot-desktop.webp" height="250" alt="desktop screenshot" />
|
||||
<img src="screenshot-mobile.webp" height="250" alt="mobile screenshot" />
|
||||
</p>
|
||||
|
||||
### 2. Set environment variables
|
||||
```bash
|
||||
# Required for authentication
|
||||
export username=<api username>
|
||||
export password=<api password>
|
||||
# Sets where the database exists. Can be local or remote (optional)
|
||||
export db_url=<url> # Default: './urls.sqlite'
|
||||
```
|
||||
# Installation and configuration
|
||||
|
||||
### 3. Run it
|
||||
```
|
||||
java -jar build/libs/url.jar
|
||||
```
|
||||
You can optionally set the port the server listens on by appending `--port=[port]`
|
||||
[See here.](./INSTALLATION.md)
|
||||
|
||||
### 4. Navigate to `http://localhost:4567` in your browser, add links as you wish.
|
||||
# Instructions for CLI usage
|
||||
|
||||
## Running with docker
|
||||
### `docker run` method
|
||||
0. (Only if you really want to) Build the image
|
||||
```
|
||||
docker build . -t simply-shorten:latest
|
||||
```
|
||||
1. Run the image
|
||||
```
|
||||
docker run -p 4567:4567
|
||||
-d url:latest
|
||||
-e username="username"
|
||||
-e password="password"
|
||||
-d simply-shorten:latest
|
||||
```
|
||||
1.a Make the database file available to host (optional)
|
||||
```
|
||||
touch ./urls.sqlite
|
||||
docker run -p 4567:4567 \
|
||||
-e username="username" \
|
||||
-e password="password" \
|
||||
-v ./urls.sqlite:/urls.sqlite \
|
||||
-e db_url=/urls.sqlite \
|
||||
-d simply-shorten:latest
|
||||
```
|
||||
## `docker-compose`
|
||||
There is a sample `docker-compose.yml` 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 --build
|
||||
```
|
||||
[See here.](./CLI.md)
|
||||
|
||||
# Related software
|
||||
|
||||
[See here.](./TOOLS.md)
|
||||
|
||||
# Notes
|
||||
|
||||
- It started as a fork of [`simply-shorten`](https://gitlab.com/draganczukp/simply-shorten).
|
||||
- The list of adjectives and names used for random short url generation is a modified
|
||||
version of [this list used by docker](https://github.com/moby/moby/blob/master/pkg/namesgenerator/names-generator.go).
|
||||
- It is highly recommended that you [enable WAL mode](./INSTALLATION.md/#use_wal_mode-).
|
||||
- Although it's unlikely, it's possible that your database is mangled after some update. For mission critical use cases,
|
||||
it's recommended to keep regular versioned backups of the database, and sticking to a minor release tag e.g. 5.8.
|
||||
- If you intend to have more than a few thousand 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
@@ -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)
|
||||
2286
actix/Cargo.lock
generated
Normal file
49
actix/Cargo.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
[package]
|
||||
name = "chhoto-url"
|
||||
version = "6.5.2"
|
||||
edition = "2021"
|
||||
authors = ["Sayantan Santra <sayantan[dot]santra689[at]gmail[dot]com"]
|
||||
license = "mit"
|
||||
description = "A simple selfhosted URL shortener with no unnecessary features."
|
||||
homepage = "https://github.com/SinTan1729/chhoto-url"
|
||||
documentation = "https://github.com/SinTan1729/chhoto-url"
|
||||
repository = "https://github.com/SinTan1729/chhoto-url"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"docker",
|
||||
"rust",
|
||||
"self-hosted",
|
||||
"url-shortener",
|
||||
"webapp",
|
||||
"shortener",
|
||||
"link-shortener",
|
||||
"actix-web",
|
||||
]
|
||||
categories = ["web-programming"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.5.1"
|
||||
actix-files = "0.6.5"
|
||||
rusqlite = { version = "0.37.0", features = [ "bundled" ] }
|
||||
regex = "1.10.3"
|
||||
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"
|
||||
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"
|
||||
151
actix/src/auth.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use actix_session::Session;
|
||||
use actix_web::HttpRequest;
|
||||
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
|
||||
use log::{debug, warn};
|
||||
use passwords::PasswordGenerator;
|
||||
use std::{rc::Rc, time::SystemTime};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::services::JSONResponse;
|
||||
|
||||
// If the api_key environment variable exists
|
||||
pub fn is_api_ok(http: HttpRequest, config: &Config) -> JSONResponse {
|
||||
// If the api_key environment variable exists
|
||||
if config.api_key.is_some() {
|
||||
// If the header exists
|
||||
if let Some(header) = get_api_header(&http) {
|
||||
// If the header is correct
|
||||
if is_key_valid(header, config) {
|
||||
JSONResponse {
|
||||
success: true,
|
||||
error: false,
|
||||
reason: "Correct API key".to_string(),
|
||||
}
|
||||
} else {
|
||||
JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "Incorrect API key".to_string(),
|
||||
}
|
||||
}
|
||||
// The header may not exist when the user logs in through the web interface, so allow a request with no header.
|
||||
// Further authentication checks will be conducted in services.rs
|
||||
} else {
|
||||
// Due to the implementation of this result in services.rs, this JSON object will not be outputted.
|
||||
JSONResponse {
|
||||
success: false,
|
||||
error: false,
|
||||
reason: "No valid authentication was found".to_string(),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If the API key isn't set, but an API Key header is provided
|
||||
if get_api_header(&http).is_some() {
|
||||
JSONResponse {
|
||||
success: false,
|
||||
error: true,
|
||||
reason: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".to_string(),
|
||||
}
|
||||
} else {
|
||||
JSONResponse {
|
||||
success: false,
|
||||
error: false,
|
||||
reason: "".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate API key
|
||||
pub fn 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 config.password.is_none() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Ok(token) = session.get::<String>("chhoto-url-auth") {
|
||||
is_token_valid(token.as_deref())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Check a token cryptographically
|
||||
fn is_token_valid(token: Option<&str>) -> bool {
|
||||
if let Some(token_body) = token {
|
||||
let token_parts: Rc<[&str]> = token_body.split(';').collect();
|
||||
if token_parts.len() < 2 {
|
||||
false
|
||||
} else {
|
||||
let token_text = token_parts[0];
|
||||
let token_time = token_parts[1].parse::<u64>().unwrap_or(0);
|
||||
let time_now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("Time went backwards!")
|
||||
.as_secs();
|
||||
token_text == "chhoto-url-auth" && time_now < token_time + 1209600 // There are 1209600 seconds in 14 days
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new cryptographic token
|
||||
pub fn gen_token() -> String {
|
||||
let token_text = String::from("chhoto-url-auth");
|
||||
let time = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("Time went backwards!")
|
||||
.as_secs();
|
||||
format!("{token_text};{time}")
|
||||
}
|
||||
219
actix/src/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
340
actix/src/database.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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)]
|
||||
pub struct DBRow {
|
||||
shortlink: String,
|
||||
longlink: String,
|
||||
hits: i64,
|
||||
expiry_time: i64,
|
||||
}
|
||||
|
||||
// Find a single URL for /api/expand
|
||||
pub fn find_url(shortlink: &str, db: &Connection) -> Result<(String, i64, i64), ChhotoError> {
|
||||
// Long link, hits, expiry time
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let query = "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, 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,
|
||||
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 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 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 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: &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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
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
|
||||
}
|
||||
159
actix/src/main.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use actix_files::Files;
|
||||
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
|
||||
use actix_web::{
|
||||
cookie::Key,
|
||||
middleware,
|
||||
web::{self, Redirect},
|
||||
App, HttpServer,
|
||||
};
|
||||
use log::info;
|
||||
use rusqlite::Connection;
|
||||
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,
|
||||
config: config::Config,
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> Result<()> {
|
||||
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();
|
||||
|
||||
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 || {
|
||||
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)
|
||||
.cookie_secure(false)
|
||||
.build(),
|
||||
)
|
||||
// Maintain a single instance of database throughout
|
||||
.app_data(web::Data::new(AppState {
|
||||
db: database::open_db(&conf.db_location, conf.use_wal_mode, conf.ensure_acid),
|
||||
config: conf_clone.clone(),
|
||||
}))
|
||||
.wrap(if let Some(header) = &conf.cache_control_header {
|
||||
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))
|
||||
})
|
||||
// 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
@@ -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
@@ -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"));
|
||||
}
|
||||
237
actix/src/utils.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use log::error;
|
||||
use nanoid::nanoid;
|
||||
use rand::seq::IndexedRandom;
|
||||
use regex::Regex;
|
||||
use rusqlite::Connection;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
database,
|
||||
services::{
|
||||
ChhotoError::{self, ClientError, ServerError},
|
||||
GetReqParams,
|
||||
},
|
||||
};
|
||||
|
||||
// Struct for reading link pairs sent during API call for new link
|
||||
#[derive(Deserialize)]
|
||||
struct NewURLRequest {
|
||||
#[serde(default)]
|
||||
shortlink: String,
|
||||
longlink: String,
|
||||
#[serde(default)]
|
||||
expiry_delay: i64,
|
||||
}
|
||||
|
||||
// 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 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, 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: &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 {
|
||||
return Err(ClientError {
|
||||
reason: "Invalid request!".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
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: &str,
|
||||
db: &Connection,
|
||||
allow_capital_letters: bool,
|
||||
) -> Result<(), ChhotoError> {
|
||||
if is_link_valid(shortlink, allow_capital_letters) {
|
||||
database::delete_link(shortlink, db)
|
||||
} else {
|
||||
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: &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",
|
||||
"cranky", "crazy", "dazzling", "determined", "distracted", "dreamy", "eager", "ecstatic", "elastic", "elated", "elegant", "eloquent", "epic",
|
||||
"exciting", "fervent", "festive", "flamboyant", "focused", "friendly", "frosty", "funny", "gallant", "gifted", "goofy", "gracious",
|
||||
"great", "happy", "hardcore", "heuristic", "hopeful", "hungry", "infallible", "inspiring", "intelligent", "interesting", "jolly",
|
||||
"jovial", "keen", "kind", "laughing", "loving", "lucid", "magical", "modest", "musing", "mystifying", "naughty", "nervous", "nice",
|
||||
"nifty", "nostalgic", "objective", "optimistic", "peaceful", "pedantic", "pensive", "practical", "priceless", "quirky", "quizzical",
|
||||
"recursing", "relaxed", "reverent", "romantic", "sad", "serene", "sharp", "silly", "sleepy", "stoic", "strange", "stupefied", "suspicious",
|
||||
"sweet", "tender", "thirsty", "trusting", "unruffled", "upbeat", "vibrant", "vigilant", "vigorous", "wizardly", "wonderful", "xenodochial",
|
||||
"youthful", "zealous", "zen"];
|
||||
#[rustfmt::skip]
|
||||
static NAMES: [&str; 241] = ["agnesi", "albattani", "allen", "almeida", "antonelli", "archimedes", "ardinghelli", "aryabhata", "austin",
|
||||
"babbage", "banach", "banzai", "bardeen", "bartik", "bassi", "beaver", "bell", "benz", "bhabha", "bhaskara", "black", "blackburn", "blackwell",
|
||||
"bohr", "booth", "borg", "bose", "bouman", "boyd", "brahmagupta", "brattain", "brown", "buck", "burnell", "cannon", "carson", "cartwright",
|
||||
"carver", "cauchy", "cerf", "chandrasekhar", "chaplygin", "chatelet", "chatterjee", "chaum", "chebyshev", "clarke", "cohen", "colden", "cori",
|
||||
"cray", "curie", "curran", "darwin", "davinci", "dewdney", "dhawan", "diffie", "dijkstra", "dirac", "driscoll", "dubinsky", "easley", "edison",
|
||||
"einstein", "elbakyan", "elgamal", "elion", "ellis", "engelbart", "euclid", "euler", "faraday", "feistel", "fermat", "fermi", "feynman", "franklin",
|
||||
"gagarin", "galileo", "galois", "ganguly", "gates", "gauss", "germain", "goldberg", "goldstine", "goldwasser", "golick", "goodall", "gould", "greider",
|
||||
"grothendieck", "haibt", "hamilton", "hardy", "haslett", "hawking", "heisenberg", "hellman", "hermann", "herschel", "hertz", "heyrovsky", "hodgkin",
|
||||
"hofstadter", "hoover", "hopper", "hugle", "hypatia", "ishizaka", "jackson", "jang", "jemison", "jennings", "jepsen", "johnson", "joliot", "jones",
|
||||
"kalam", "kapitsa", "kare", "keldysh", "keller", "kepler", "khayyam", "khorana", "kilby", "kirch", "knuth", "kowalevski", "lalande", "lamarr",
|
||||
"lamport", "leakey", "leavitt", "lederberg", "lehmann", "lewin", "lichterman", "liskov", "lovelace", "lumiere", "mahavira", "margulis", "matsumoto",
|
||||
"maxwell", "mayer", "mccarthy", "mcclintock", "mclaren", "mclean", "mcnulty", "meitner", "mendel", "mendeleev", "meninsky", "merkle", "mestorf",
|
||||
"mirzakhani", "montalcini", "moore", "morse", "moser", "murdock", "napier", "nash", "neumann", "newton", "nightingale", "nobel", "noether", "northcutt",
|
||||
"noyce", "panini", "pare", "pascal", "pasteur", "payne", "perlman", "pike", "poincare", "poitras", "proskuriakova", "ptolemy", "raman", "ramanujan",
|
||||
"rhodes", "ride", "riemann", "ritchie", "robinson", "roentgen", "rosalind", "rubin", "saha", "sammet", "sanderson", "satoshi", "shamir", "shannon",
|
||||
"shaw", "shirley", "shockley", "shtern", "sinoussi", "snyder", "solomon", "spence", "stonebraker", "sutherland", "swanson", "swartz", "swirles",
|
||||
"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"];
|
||||
|
||||
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" {
|
||||
if allow_capital_letters {
|
||||
nanoid!(len, &CHARS_CAPITAL)
|
||||
} else {
|
||||
nanoid!(len, &CHARS_SMALL)
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"{0}-{1}",
|
||||
ADJECTIVES
|
||||
.choose(&mut rand::rng())
|
||||
.expect("Error choosing random adjective."),
|
||||
NAMES
|
||||
.choose(&mut rand::rng())
|
||||
.expect("Error choosing random name.")
|
||||
)
|
||||
}
|
||||
}
|
||||
33
build.gradle
@@ -1,33 +0,0 @@
|
||||
|
||||
plugins {
|
||||
// Apply the java plugin to add support for Java
|
||||
id 'java'
|
||||
|
||||
// Apply the application plugin to add support for building a CLI application.
|
||||
id 'application'
|
||||
}
|
||||
|
||||
repositories {
|
||||
jcenter()
|
||||
}
|
||||
|
||||
jar {
|
||||
manifest {
|
||||
attributes "Main-Class": "tk.draganczuk.url.App"
|
||||
}
|
||||
|
||||
from {
|
||||
configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compile "com.sparkjava:spark-core:2.8.0"
|
||||
compile 'com.qmetric:spark-authentication:1.4'
|
||||
compile group: 'org.xerial', name: 'sqlite-jdbc', version: '3.30.1'
|
||||
|
||||
}
|
||||
|
||||
application {
|
||||
mainClassName = 'tk.draganczuk.url.App'
|
||||
}
|
||||
53
chhoto-url.container
Normal file
@@ -0,0 +1,53 @@
|
||||
# SPDX-FileCopyrightText: 2025 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# chhoto-url.container
|
||||
#
|
||||
# To be used with rootless quadlets. Put inside your $XDG_CONFIG_HOME/containers/systemd/
|
||||
# Take a look at README for the explanation of the configs.
|
||||
# The commented out configs are optional.
|
||||
[Unit]
|
||||
Description=Caddy
|
||||
#AssertPathIsDirectory=%h/podman/chhoto-url/data
|
||||
|
||||
[Container]
|
||||
ContainerName=chhoto-url
|
||||
Image=sintan1729/chhoto-url:latest
|
||||
PodmanArgs=--tty
|
||||
PublishPort=4567:4567
|
||||
DropCapability=ALL
|
||||
|
||||
# Environment variables
|
||||
Environment=db_url=/db/urls.sqlite
|
||||
Environment=use_wal_mode = True
|
||||
#Environment=ensure_acid = True
|
||||
#Environment=site_url=https://www.example.com
|
||||
#Environment=hash_algorithm=Argon2
|
||||
Environment=password=TopSecretPass
|
||||
Environment=port=4567
|
||||
#Environment=api_key=SECURE_API_KEY
|
||||
Environment=redirect_method=TEMPORARY
|
||||
Environment=slug_style=Pair
|
||||
#Environment=slug_length=8
|
||||
#Environment=try_longer_slug=False
|
||||
#Environment=allow_capital_letters=False
|
||||
#Environment=public_mode=Disable
|
||||
#Environment=public_mode_expiry_delay=3600
|
||||
#Environment=disable_frontend=False
|
||||
#Environment=custom_landing_directory=/custom/dir/location
|
||||
#Environment=cache_control_header=no-cache, private
|
||||
|
||||
# Volume
|
||||
Volume=db:/db
|
||||
|
||||
# Health check
|
||||
# Only enable this if using the alpine images.
|
||||
# HealthCmd=CMD-SHELL wget --no-verbose --tries=1 --spider http://chhoto-url:4567/api/whoami || exit 1
|
||||
# HealthInterval=60s
|
||||
# HealthRetries=3
|
||||
# HealthStartPeriod=10s
|
||||
# HealthTimeout=10s
|
||||
# HealthOnFailure=kill
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
110
compose.yaml
Normal file
@@ -0,0 +1,110 @@
|
||||
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
services:
|
||||
chhoto-url:
|
||||
image: sintan1729/chhoto-url:latest
|
||||
# You may want to check out the alpine images for extra features. The images can also be
|
||||
# pulled from ghcr.io
|
||||
restart: unless-stopped
|
||||
container_name: chhoto-url
|
||||
tty: true
|
||||
# You may enable the next two options if you want. Make sure that you run the container as the proper
|
||||
# user. In most cases, user: 1000:1000 should work. You might also need to mount a directory with your
|
||||
# db and not just the file itself. Make sure to adjust db_url accordingly.
|
||||
# It does add extra security, but I don't know enough about docker to help in case it breaks something.
|
||||
# read_only: true
|
||||
# cap_drop:
|
||||
# - ALL
|
||||
ports:
|
||||
# If you changed the "port" environment variable, adjust accordingly
|
||||
# The number AFTER the colon should match the "port" variable and the number
|
||||
# before the colon is the port where you would access the container from outside.
|
||||
- 4567:4567
|
||||
environment:
|
||||
# Change if you want to mount the database somewhere else.
|
||||
# In this case, you can get rid of the db volume below
|
||||
# and instead do a mount manually by specifying the location.
|
||||
- db_url=/db/urls.sqlite
|
||||
# Uncomment the next line to enable WAL mode. It's highly recommended.
|
||||
# Make sure that you mount a directory instead of a bare file
|
||||
# since that might have a (low, but non-zero) possibility of
|
||||
# corrupting your db since we use WAL journaling mode
|
||||
# (In fact, I'd suggest that you do that so that you can keep
|
||||
# a copy of your database.)
|
||||
# - use_wal_mode = True
|
||||
# If you'd like to disable ACID compliance, uncomment the next line.
|
||||
# Note that there are risks. Look at the README for more.
|
||||
# - ensure_acid = False
|
||||
|
||||
# Change this if your server URL is not "http://localhost"
|
||||
# This must not be surrounded by quotes. For example:
|
||||
# site_url="https://www.example.com" incorrect
|
||||
# site_url=https://www.example.com correct
|
||||
# This is important to ensure Chhoto URL outputs the shortened link with the correct URL.
|
||||
# - site_url=https://www.example.com
|
||||
|
||||
# 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:
|
||||
@@ -1,20 +0,0 @@
|
||||
version: "3"
|
||||
services:
|
||||
url:
|
||||
image: draganczukp/simply-shorten
|
||||
container_name: url
|
||||
environment:
|
||||
# Change if you want to mount the database somewhere else
|
||||
# - db_url=/urls.sqlite
|
||||
- username=admin
|
||||
- password=$3CuReP4S$W0rD
|
||||
volumes:
|
||||
- db:/urls.sqlite
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
BIN
favicon.svgz
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
5
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,5 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.4.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
183
gradlew
vendored
@@ -1,183 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
100
gradlew.bat
vendored
@@ -1,100 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
24
helm-chart/Chart.yaml
Normal 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"
|
||||
23
helm-chart/templates/ingress.yml
Normal 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
|
||||
18
helm-chart/templates/issuer.yml
Normal 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
|
||||
13
helm-chart/templates/pv.yml
Normal 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 }}
|
||||
10
helm-chart/templates/secret.yml
Normal 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 }}
|
||||
81
helm-chart/templates/sts.yml
Normal 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
|
||||
14
helm-chart/templates/svc.yml
Normal 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
@@ -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
|
||||
BIN
resources/assets/favicon-196.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
resources/assets/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
resources/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
1
resources/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!--Created with Inkscape (http://www.inkscape.org/)--><svg width="512" height="512" viewBox="0 0 135.46667 135.46667" version="1.1" id="svg21" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g id="layer1"><path style="display:inline;fill:#febf00;stroke-width:0.264583" d="M61.84 126.59c-.46-.458-.722-1.073-.822-1.918-.081-.684-.699-12.614-1.372-26.512-.674-13.897-1.302-25.47-1.397-25.718-.149-.387-1.565.156-10.173 3.903-6.704 2.918-10.308 4.353-10.934 4.353-.68 0-1.145-.21-1.708-.773-.676-.677-.774-.966-.774-2.3 0-1.488 4.89-64.763 5.175-66.952.09-.695.395-1.354.827-1.786l.685-.685h31.517l.742.711c.571.547.743.959.748 1.786.004.591-.966 6.167-2.155 12.391-1.19 6.224-2.115 11.363-2.057 11.421.058.058 6.356-1.497 13.996-3.454 12.526-3.21 13.987-3.533 14.873-3.278 1.234.355 1.796 1.142 1.791 2.509-.006 1.48-34.3 95.45-35.162 96.345-.884.918-2.862.896-3.8-.043z" id="path336"/><path style="fill:#1333a3;stroke-width:0.264583" d="M25.93 93.21c-4.168-.728-8.702-2.46-11.907-4.546-4.221-2.749-8.808-7.968-10.86-12.359C.998 71.678-.052 65.466.504 60.592c.854-7.473 3.513-13.026 8.749-18.268 4.023-4.028 8.259-6.44 14.03-7.987 2.237-.6 2.337-.603 20.505-.603 18.167 0 18.267.003 20.505.603 5.77 1.548 10.006 3.96 14.03 7.987 4.026 4.031 6.194 7.639 7.668 12.762 1.085 3.771 1.481 7.824 1.107 11.342-.413 3.884-.737 4.76-1.99 5.372-2.894 1.413-7.95 1.886-11.937 1.115-2.984-.577-3.488-1.854-1.949-4.934.772-1.544.844-1.906.845-4.21 0-2.265-.089-2.73-.881-4.6-1.205-2.842-3.21-4.975-6.008-6.393 0 0-2.82-1.166-7.137-.986l-12.798-.066-11.412-.22c-5.53.294-8.432 1.18-8.432 1.18-4.845.677-7.612 7.415-7.02 11.571.642 4.518 3.09 8.008 7.02 10.007 0 0 1.334.93 5.26 1.13l7.925.012c12.544.02 11.551-.028 12.185 2.17 1.652 5.733 2.962 8.133 6.04 11.069 2.394 2.282 2.736 3.15 1.663 4.224-.518.518-.857.528-16.206.493-8.622-.02-15.912.01-16.338-.152zm47.889.005c-4.316-.788-8.701-2.464-11.907-4.551-4.22-2.749-8.807-7.968-10.86-12.359-2.129-4.553-3.203-10.851-2.684-15.733.455-4.28.66-4.719 2.564-5.49 3.397-1.378 6.656-1.697 10.89-1.067 3.414.508 4.031 1.783 2.421 5.004-.771 1.544-.844 1.906-.844 4.21-.001 2.265.088 2.73.88 4.6 1.206 2.842 3.21 4.975 6.008 6.393 0 0 1.9 1.397 6.966 1.126l12.97-.074 10.999.114c4.964-.068 8.845-1.073 8.845-1.073 4.783-1.08 7.611-7.416 7.02-11.572-.643-4.518-3.086-8-7.02-10.005 0 0-2.377-1.35-6.864-1.437l-6.321.293c-12.531.58-11.555.028-12.182-2.17-1.636-5.73-2.927-8.096-6.033-11.058-2.413-2.301-2.749-3.16-1.662-4.246.535-.535.719-.212 16.867-.448l7.83-.114c4.475-.193 10.878.882 10.878.882 5.858.959 9.647 3.893 13.633 7.884 4.05 4.055 6.225 7.687 7.667 12.807 1.406 4.988 1.609 9.247.67 14.058-1.223 6.274-3.665 10.81-8.337 15.487-3.985 3.99-7.666 6.52-13.382 8.142 0 0-1.739.334-2.503.445-.737.106-17.988.024-17.988.024-9.823.042-18.157-.005-18.521-.072z" id="path1143"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
267
resources/index.html
Normal file
@@ -0,0 +1,267 @@
|
||||
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
|
||||
<!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" />
|
||||
|
||||
<title>Chhoto URL</title>
|
||||
|
||||
<meta
|
||||
name="description"
|
||||
content="A simple selfhosted URL shortener with no unnecessary features."
|
||||
/>
|
||||
<meta property="og:title" content="Chhoto URL" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A simple selfhosted URL shortener with no unnecessary features."
|
||||
/>
|
||||
|
||||
<meta
|
||||
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.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"> </div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<p id="loading-text">Loading links table...</p>
|
||||
<table class="chhoto-table pure-table" id="table-box" hidden>
|
||||
<caption>
|
||||
<span>Active links</span>
|
||||
<span id="pageControls" hidden="true">
|
||||
<button
|
||||
id="prevPageBtn"
|
||||
class="svg-button"
|
||||
title="Previous Page"
|
||||
></button>
|
||||
<button
|
||||
id="nextPageBtn"
|
||||
class="svg-button"
|
||||
title="Next Page"
|
||||
></button>
|
||||
</span>
|
||||
</caption>
|
||||
<br />
|
||||
<thead>
|
||||
<tr>
|
||||
<th name="numColumn">#</th>
|
||||
<th id="short-url-header">Short URL</th>
|
||||
<th>Long URL</th>
|
||||
<th name="hitsColumn">Hits</th>
|
||||
<th name="expiryColumn">Expiry</th>
|
||||
<th name="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="url-table">
|
||||
<!-- The links would be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div name="links-div">
|
||||
<button class="linkButton" id="admin-button" hidden>login</button>
|
||||
|
||||
<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" 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"
|
||||
>
|
||||
👁
|
||||
</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>
|
||||
|
||||
<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>
|
||||
55
resources/static/404.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error 404</title>
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
|
||||
/>
|
||||
<link rel="stylesheet" type="text/css" target="_blank" href="styles.css" />
|
||||
</head>
|
||||
|
||||
<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;
|
||||
}
|
||||
: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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
778
resources/static/script.js
Normal file
@@ -0,0 +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) => {
|
||||
if (!SUBDIR) {
|
||||
const thisPage = new URL(window.location.href);
|
||||
SUBDIR = thisPage.pathname.replace(/\/admin\/manage\/$/, "/");
|
||||
}
|
||||
return (SUBDIR + link).replace("//", "/");
|
||||
};
|
||||
|
||||
const hasProtocol = (url) => {
|
||||
const regex = /[A-Za-z][A-Za-z0-9\+\-\.]*\:(?:\/\/)?.*\D.*/; // RFC 2396 Appendix A
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
const getConfig = async () => {
|
||||
if (!CONFIG) {
|
||||
CONFIG = await fetch(prepSubdir("/api/getconfig"), { 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 () => {
|
||||
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 {
|
||||
document.getElementById("table-box").hidden = true;
|
||||
loading_text.hidden = false;
|
||||
document.getElementById("url-table").innerHTML = "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (!alert("Something went wrong! Click Ok to refresh page.")) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pullData = async (params) => {
|
||||
const res = await fetch(prepSubdir(`/api/all?${params}`), {
|
||||
cache: "no-cache",
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
return data;
|
||||
} else {
|
||||
throw Error("There was an error getting data.");
|
||||
}
|
||||
};
|
||||
|
||||
const gotoPrevPage = () => {
|
||||
if (PROCESSING_PAGE_TRANSITION) {
|
||||
return;
|
||||
}
|
||||
PROCESSING_PAGE_TRANSITION = true;
|
||||
if (CUR_PAGE > 0) {
|
||||
CUR_PAGE -= 1;
|
||||
}
|
||||
displayData();
|
||||
managePageControls();
|
||||
};
|
||||
|
||||
const gotoNextPage = () => {
|
||||
if (PROCESSING_PAGE_TRANSITION) {
|
||||
return;
|
||||
}
|
||||
PROCESSING_PAGE_TRANSITION = true;
|
||||
CUR_PAGE += 1;
|
||||
if (LOCAL_DATA.length <= (CUR_PAGE + 1) * 10) {
|
||||
refreshData();
|
||||
} else {
|
||||
displayData();
|
||||
managePageControls();
|
||||
}
|
||||
};
|
||||
|
||||
const updateInputBox = () => {
|
||||
if (CONFIG.allow_capital_letters) {
|
||||
const input_box = document.getElementById("shortUrl");
|
||||
input_box.pattern = "[A-Za-z0-9\\\-_]+";
|
||||
input_box.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 managePageControls = () => {
|
||||
const on_first_page = CUR_PAGE == 0;
|
||||
const on_last_page = LOCAL_DATA.length <= (CUR_PAGE + 1) * 10;
|
||||
|
||||
document.getElementById("prevPageBtn").disabled = on_first_page;
|
||||
document.getElementById("nextPageBtn").disabled = on_last_page;
|
||||
document.getElementById("pageControls").hidden =
|
||||
on_first_page && on_last_page;
|
||||
PROCESSING_PAGE_TRANSITION = false;
|
||||
};
|
||||
|
||||
const showAlert = (text, col) => {
|
||||
const alertBox = document.getElementById("alert-box");
|
||||
alertBox.style.background = col;
|
||||
alertBox.innerHTML = text;
|
||||
if (text == " ") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const TD = (s, u) => {
|
||||
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(" ", "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 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");
|
||||
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: "PUT",
|
||||
cache: "no-cache",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((res) => {
|
||||
ok = res.ok;
|
||||
return res.text();
|
||||
})
|
||||
.then(async (text) => {
|
||||
if (!ok) {
|
||||
showAlert(text, "light-dark(red, #a01e1e)");
|
||||
} else {
|
||||
document.getElementById("edit-dialog").close();
|
||||
editUrlSpan.textContent = shortUrl;
|
||||
const editedIndex = LOCAL_DATA.findIndex(
|
||||
(item) => item["shortlink"] == shortUrl,
|
||||
);
|
||||
LOCAL_DATA[editedIndex]["longlink"] = longUrl;
|
||||
if (checkBox.checked) {
|
||||
LOCAL_DATA[editedIndex]["hits"] = 0;
|
||||
}
|
||||
checkBox.checked = false;
|
||||
}
|
||||
displayData();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error:", err);
|
||||
if (!alert("Something went wrong! Click Ok to refresh page.")) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const submitLogin = () => {
|
||||
const password = document.getElementById("password");
|
||||
fetch(prepSubdir("/api/login"), {
|
||||
method: "POST",
|
||||
cache: "no-cache",
|
||||
body: password.value,
|
||||
})
|
||||
.then(async (res) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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(" ", "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();
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
328
resources/static/styles.css
Normal file
@@ -0,0 +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, "Open Sans", Helvetica, Arial, sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Montserrat;
|
||||
}
|
||||
body {
|
||||
color: light-dark(black, #e8e6e3);
|
||||
background-color: light-dark(white, #181a1b);
|
||||
}
|
||||
.container {
|
||||
max-width: 80em;
|
||||
margin: 1em auto auto;
|
||||
}
|
||||
|
||||
.chhoto-button {
|
||||
background-color: light-dark(#0078e7, #0060b9);
|
||||
}
|
||||
.pure-form input.chhoto-input {
|
||||
width: 65%;
|
||||
border-color: light-dark(#cccccc, #3e4446);
|
||||
box-shadow: light-dark(#dddddd, #2b2f31) 0 0.1em 0.2em inset;
|
||||
}
|
||||
.pure-form input.chhoto-input:focus {
|
||||
border-color: light-dark(#cccccc, #3e4446);
|
||||
}
|
||||
|
||||
#expiryDelay {
|
||||
background-color: light-dark(white, #2b2a33);
|
||||
border-color: light-dark(#cccccc, #3e4446);
|
||||
box-shadow: light-dark(#dddddd, #2b2f31) 0 0.1em 0.2em inset;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: light-dark(#757575, #636061);
|
||||
}
|
||||
|
||||
#logo {
|
||||
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: 1em;
|
||||
}
|
||||
#wrong-pass {
|
||||
color: light-dark(red, #ff1a1a);
|
||||
}
|
||||
|
||||
#edit-dialog,
|
||||
#qr-code-dialog,
|
||||
#login-dialog {
|
||||
border-radius: 1em;
|
||||
border-width: 0.15em;
|
||||
}
|
||||
#login-dialog div {
|
||||
position: relative;
|
||||
}
|
||||
#password-eye-button {
|
||||
position: absolute;
|
||||
right: 0.1em;
|
||||
top: 1.3em;
|
||||
transform: translateY(-50%);
|
||||
background-color: transparent;
|
||||
border-style: none;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
#edit-dialog form,
|
||||
#login-dialog form {
|
||||
text-align: center;
|
||||
}
|
||||
#edited-url {
|
||||
width: 100%;
|
||||
}
|
||||
#edit-cancel-button {
|
||||
background-color: light-dark(#dd1a1a, #901010);
|
||||
}
|
||||
|
||||
#alert-box {
|
||||
padding: 0.5em;
|
||||
border-radius: 0.3em;
|
||||
margin-top: 0.2em;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
#qr-code-dialog {
|
||||
background-color: white;
|
||||
border-color: grey;
|
||||
text-align: right;
|
||||
}
|
||||
#qr-code-dialog div {
|
||||
margin: 0.5em;
|
||||
}
|
||||
.qr-button {
|
||||
background-color: transparent;
|
||||
color: black;
|
||||
border-style: none;
|
||||
position: absolute;
|
||||
top: 0.3em;
|
||||
right: 0.3em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qr-button svg {
|
||||
height: 2em;
|
||||
}
|
||||
#qr-download {
|
||||
right: 2em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
.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: 98vw;
|
||||
}
|
||||
.pure-form input.chhoto-input {
|
||||
width: 98%;
|
||||
}
|
||||
|
||||
.chhoto-table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.chhoto-table tr:not(:last-child) {
|
||||
border-bottom: 0.15em dotted light-dark(black, #867d6e);
|
||||
}
|
||||
|
||||
.chhoto-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chhoto-table td {
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
width: 97vw;
|
||||
padding: 0.1em;
|
||||
}
|
||||
|
||||
.chhoto-table tr td[name="shortColumn"] {
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
.chhoto-table tr td[name="actions"] {
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
#alert-box {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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
|
After Width: | Height: | Size: 23 KiB |
BIN
screenshot-mobile.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
screenshot.png
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,10 +0,0 @@
|
||||
/*
|
||||
* This file was generated by the Gradle 'init' task.
|
||||
*
|
||||
* The settings file is used to specify which projects to include in your build.
|
||||
*
|
||||
* Detailed information about configuring a multi-project build in Gradle can be found
|
||||
* in the user manual at https://docs.gradle.org/6.1.1/userguide/multi_project_builds.html
|
||||
*/
|
||||
|
||||
rootProject.name = 'url'
|
||||
@@ -1,43 +0,0 @@
|
||||
package tk.draganczuk.url;
|
||||
|
||||
import static spark.Spark.*;
|
||||
|
||||
public class App {
|
||||
|
||||
public static void main(String[] args) {
|
||||
// Useful for developing the frontend
|
||||
// http://sparkjava.com/documentation#examples-and-faq -> How do I enable automatic refresh of static files?
|
||||
if (System.getenv("dev") != null) {
|
||||
String projectDir = System.getProperty("user.dir");
|
||||
String staticDir = "/src/main/resources/public";
|
||||
staticFiles.externalLocation(projectDir + staticDir);
|
||||
} else {
|
||||
staticFiles.location("/public");
|
||||
}
|
||||
|
||||
port(Integer.parseInt(System.getenv().getOrDefault("port", "4567")));
|
||||
|
||||
// Add GZIP compression
|
||||
after(Filters::addGZIP);
|
||||
|
||||
// No need to auth in dev
|
||||
if (System.getenv("dev") == null) {
|
||||
// Authenticate
|
||||
before("/api/*", Filters.createAuthFilter());
|
||||
}
|
||||
|
||||
get("/", (req, res) -> {
|
||||
res.redirect("/index.html");
|
||||
return "Redirect";
|
||||
});
|
||||
|
||||
|
||||
path("/api", () -> {
|
||||
get("/all", Routes::getAll);
|
||||
post("/new", Routes::addUrl);
|
||||
delete("/:shortUrl", Routes::delete);
|
||||
});
|
||||
|
||||
get("/:shortUrl", Routes::goToLongUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package tk.draganczuk.url;
|
||||
|
||||
import com.qmetric.spark.authentication.AuthenticationDetails;
|
||||
import com.qmetric.spark.authentication.BasicAuthenticationFilter;
|
||||
import spark.Filter;
|
||||
import spark.Request;
|
||||
import spark.Response;
|
||||
|
||||
public class Filters {
|
||||
public static void addGZIP(Request request, Response response) {
|
||||
response.header("Content-Encoding", "gzip");
|
||||
}
|
||||
|
||||
public static Filter createAuthFilter() {
|
||||
String username = System.getenv("username");
|
||||
String password = System.getenv("password");
|
||||
|
||||
return new BasicAuthenticationFilter(new AuthenticationDetails(username, password));
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package tk.draganczuk.url;
|
||||
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import spark.Request;
|
||||
import spark.Response;
|
||||
|
||||
public class Routes {
|
||||
|
||||
private static UrlRepository urlRepository;
|
||||
|
||||
static {
|
||||
urlRepository = new UrlRepository();
|
||||
}
|
||||
|
||||
public static String getAll(Request req, Response res) {
|
||||
return String.join("\n", urlRepository.getAll());
|
||||
}
|
||||
|
||||
public static String addUrl(Request req, Response res) {
|
||||
String longUrl = req.queryParams("long");
|
||||
String shortUrl = req.queryParams("short");
|
||||
|
||||
if (shortUrl == null || shortUrl.isBlank()) {
|
||||
shortUrl = Utils.randomString();
|
||||
}
|
||||
|
||||
if (Utils.validate(shortUrl)) {
|
||||
return urlRepository.addUrl(longUrl, shortUrl);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST_400);
|
||||
return "shortUrl not valid ([a-z0-9]+)";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static String goToLongUrl(Request req, Response res) {
|
||||
String shortUrl = req.params("shortUrl");
|
||||
var longUrlOpt = urlRepository
|
||||
.findForShortUrl(shortUrl);
|
||||
|
||||
if (longUrlOpt.isEmpty()) {
|
||||
res.status(404);
|
||||
return "";
|
||||
}
|
||||
|
||||
res.redirect(longUrlOpt.get());
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
public static String delete(Request req, Response res) {
|
||||
String shortUrl = req.params("shortUrl");
|
||||
|
||||
urlRepository.deleteEntry(shortUrl);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package tk.draganczuk.url;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
public class UrlRepository {
|
||||
private static final String INSERT_ROW_SQL = "INSERT INTO urls (long_url, short_url) VALUES (?, ?)";
|
||||
private static final String CREATE_TABLE_SQL = "CREATE TABLE IF NOT EXISTS urls\n" +
|
||||
"(\n" +
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" +
|
||||
" long_url TEXT NOT NULL,\n" +
|
||||
" short_url TEXT NOT NULL\n" +
|
||||
");";
|
||||
private static final String SELECT_FOR_SHORT_SQL = "SELECT long_url FROM urls WHERE short_url = ?";
|
||||
private static final String DELETE_ROW_SQL = "DELETE FROM urls WHERE short_url = ?";
|
||||
|
||||
private final String databaseUrl;
|
||||
|
||||
|
||||
public UrlRepository() {
|
||||
String path = System.getenv().getOrDefault("db_url", "/urls.sqlite");
|
||||
|
||||
databaseUrl = "jdbc:sqlite:" + path;
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(databaseUrl)) {
|
||||
if (conn != null) {
|
||||
DatabaseMetaData meta = conn.getMetaData();
|
||||
|
||||
conn.createStatement()
|
||||
.execute(CREATE_TABLE_SQL);
|
||||
|
||||
System.out.println("Database initialised");
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
System.out.println(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getAll() {
|
||||
try (final var con = DriverManager.getConnection(databaseUrl)) {
|
||||
var statement = con.createStatement();
|
||||
|
||||
statement.execute("SELECT * FROM urls");
|
||||
ResultSet rs = statement.getResultSet();
|
||||
|
||||
List<String> result = new ArrayList<>();
|
||||
|
||||
while (rs.next()) {
|
||||
result.add(String.format("%s,%s", rs.getString("short_url"), rs.getString("long_url")));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
public String addUrl(String longURL, String shortUrl) {
|
||||
try (final var con = DriverManager.getConnection(databaseUrl)) {
|
||||
final var stmt = con.prepareStatement(INSERT_ROW_SQL);
|
||||
stmt.setString(1, longURL);
|
||||
stmt.setString(2, shortUrl);
|
||||
if (stmt.execute()) {
|
||||
return String.format("%s,%s", shortUrl, longURL);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public Optional<String> findForShortUrl(String shortUrl) {
|
||||
try (final var con = DriverManager.getConnection(databaseUrl)) {
|
||||
final var stmt = con.prepareStatement(SELECT_FOR_SHORT_SQL);
|
||||
stmt.setString(1, shortUrl);
|
||||
if (stmt.execute()) {
|
||||
ResultSet rs = stmt.getResultSet();
|
||||
if (rs.next()) {
|
||||
return Optional.of(rs.getString("long_url"));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public void deleteEntry(String shortUrl) {
|
||||
try (final var con = DriverManager.getConnection(databaseUrl)) {
|
||||
final var stmt = con.prepareStatement(DELETE_ROW_SQL);
|
||||
stmt.setString(1, shortUrl);
|
||||
stmt.execute();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package tk.draganczuk.url;
|
||||
|
||||
import java.util.Random;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class Utils {
|
||||
private static final Random random = new Random(System.currentTimeMillis());
|
||||
|
||||
private static final String SHORT_URL_PATTERN = "[a-z0-9_-]+";
|
||||
private static final Pattern PATTERN = Pattern.compile(SHORT_URL_PATTERN);
|
||||
|
||||
public static String randomString() {
|
||||
int leftLimit = 48; // numeral '0'
|
||||
int rightLimit = 122; // letter 'z'
|
||||
int targetStringLength = 10;
|
||||
|
||||
return random.ints(leftLimit, rightLimit + 1)
|
||||
.filter(i -> (i <= 57 || i >= 97))
|
||||
.limit(targetStringLength)
|
||||
.collect(StringBuilder::new,
|
||||
StringBuilder::appendCodePoint,
|
||||
StringBuilder::append)
|
||||
.toString();
|
||||
}
|
||||
|
||||
public static boolean validate(String shortUrl) {
|
||||
return PATTERN.matcher(shortUrl)
|
||||
.matches();
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<!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">
|
||||
<title>Simply Shorten</title>
|
||||
|
||||
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.1/build/pure-min.css"
|
||||
integrity="sha384-oAOxQR6DkCoMliIh8yFnu25d7Eq/PHS21PClpwjOTeU2jRSq11vu66rf90/cZr47" crossorigin="anonymous">
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 950px;
|
||||
margin: 20px auto auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<form class="pure-form pure-form-aligned" name="new-url-form">
|
||||
<fieldset>
|
||||
<legend>Add new URL</legend>
|
||||
<div class="pure-control-group">
|
||||
<label for="longUrl">Long URL</label>
|
||||
<input type="url" name="longUrl" id="longUrl" placeholder="Long URL" required/>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<label for="shortUrl">Short URL (Optional). Only letters, number dashes and underscores
|
||||
permitted</label>
|
||||
<input type="text" name="shortUrl" id="shortUrl" placeholder="Short URL (optional)"
|
||||
pattern="[a-z0-9_-]+"/>
|
||||
</div>
|
||||
<div class="pure-controls">
|
||||
<button class="pure-button pure-button-primary">Submit</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
</form>
|
||||
|
||||
<table class="pure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Long URL</td>
|
||||
<td>Short url</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="url-table">
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,85 +0,0 @@
|
||||
const refreshData = async () => {
|
||||
let data = await fetch("/api/all").then(res => res.text());
|
||||
data = data
|
||||
.split("\n")
|
||||
.filter(line => line !== "")
|
||||
.map(line => line.split(","))
|
||||
.map(arr => ({
|
||||
long: arr[1],
|
||||
short: arr[0]
|
||||
}));
|
||||
|
||||
displayData(data);
|
||||
};
|
||||
|
||||
const displayData = (data) => {
|
||||
const table = document.querySelector("#url-table");
|
||||
table.innerHTML = ''; // Clear
|
||||
data.map(TR)
|
||||
.forEach(tr => table.appendChild(tr));
|
||||
};
|
||||
|
||||
const TR = (row) => {
|
||||
const tr = document.createElement("tr");
|
||||
const longTD = TD(A(row.long));
|
||||
const shortTD = TD(A_INT(row.short));
|
||||
const btn = deleteButton(row.short);
|
||||
|
||||
tr.appendChild(longTD);
|
||||
tr.appendChild(shortTD);
|
||||
tr.appendChild(btn);
|
||||
|
||||
return tr;
|
||||
};
|
||||
|
||||
const A = (s) => `<a href='${s}'>${s}</a>`;
|
||||
const A_INT = (s) => `<a href='/${s}'>${window.location.host}/${s}</a>`;
|
||||
|
||||
const deleteButton = (shortUrl) => {
|
||||
const btn = document.createElement("button");
|
||||
|
||||
btn.innerHTML = "×";
|
||||
|
||||
btn.onclick = e => {
|
||||
e.preventDefault();
|
||||
fetch(`/api/${shortUrl}`, {
|
||||
method: "DELETE"
|
||||
}).then(_ => refreshData());
|
||||
};
|
||||
|
||||
return btn;
|
||||
};
|
||||
|
||||
const TD = (s) => {
|
||||
const td = document.createElement("td");
|
||||
td.innerHTML = s;
|
||||
return td;
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
const form = document.forms.namedItem("new-url-form");
|
||||
const longUrl = form.elements["longUrl"];
|
||||
const shortUrl = form.elements["shortUrl"];
|
||||
|
||||
const url = `/api/new?long=${longUrl.value}&short=${shortUrl.value}`;
|
||||
|
||||
fetch(url, {
|
||||
method: "POST"
|
||||
})
|
||||
.then(_ => {
|
||||
longUrl.value = "";
|
||||
shortUrl.value = "";
|
||||
|
||||
refreshData();
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
(async () => {
|
||||
await refreshData();
|
||||
const form = document.forms.namedItem("new-url-form");
|
||||
form.onsubmit = e => {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}
|
||||
})();
|
||||