Compare commits
633 Commits
v0.4.2
...
v1.0.0-rc.
Author | SHA1 | Date | |
---|---|---|---|
bd8462b2b3 | |||
0857e4ea68 | |||
9ac81dd138 | |||
3e48bc8048 | |||
33cd0544a1 | |||
f394b17537 | |||
755fdacf60 | |||
9e7db8193a | |||
4336e89ba2 | |||
5539c2802b | |||
c8262bed6c | |||
03c2ab56af | |||
0124f71cea | |||
6b06f37626 | |||
34ffb1a679 | |||
0c36918cb8 | |||
1a0dc70670 | |||
aa0b27ebc8 | |||
44f56460ab | |||
f3f79c4193 | |||
4dc9ed92cd | |||
2c97755868 | |||
552d728049 | |||
8fb0fe7fb5 | |||
efb631e906 | |||
a7b3252096 | |||
b15b070487 | |||
ff00a69025 | |||
4dcf352896 | |||
cb286d827d | |||
6a5defe9d6 | |||
d97cbbb183 | |||
200242aa67 | |||
ba62afc034 | |||
52421b1598 | |||
b34cd23d83 | |||
a7d45c5347 | |||
4b15bd734e | |||
522ecd2518 | |||
8f01a01a46 | |||
ebc5272023 | |||
c06029f040 | |||
2fbe1e5664 | |||
ae62cac34d | |||
f306c82a4f | |||
9b060cb7e7 | |||
c5a38d14ad | |||
3937abb17b | |||
f276c979fa | |||
a49f87c55f | |||
b74e4f90c1 | |||
8511c09c63 | |||
b359b8f1cc | |||
220cabfb5b | |||
baa3378ff7 | |||
b64ae1399a | |||
0ac2554545 | |||
6c54732cd1 | |||
85f5f03be9 | |||
784d91ab75 | |||
9d6d93ab39 | |||
f11a295ac2 | |||
ff035d25ba | |||
643a6051b2 | |||
1cf274389c | |||
4098ac824e | |||
93bb880e8e | |||
2ddb47f4df | |||
0aab1a0e5d | |||
f89100d721 | |||
3c0b3369b8 | |||
d3dcab3445 | |||
acb33080f3 | |||
080929faed | |||
966e1d9ce3 | |||
1ee13c6859 | |||
c196cbc7d5 | |||
4b7c494163 | |||
86b4f4aa9f | |||
e3c2dc8441 | |||
85adb885fe | |||
a44e3e23d9 | |||
340025002b | |||
fc20673706 | |||
3df3405ad5 | |||
c4b26cac38 | |||
a7c49a9ac0 | |||
5d703f563a | |||
c571720d42 | |||
a3943f5b2d | |||
80548aaf2c | |||
da14b9b7e5 | |||
7e35d3a319 | |||
dba5067d51 | |||
20e3212c2e | |||
d04d0d0e37 | |||
01f1b961e1 | |||
06b8f32a94 | |||
8eac931592 | |||
8d6ad68d47 | |||
c356bc03e5 | |||
da94907913 | |||
f5edcff24b | |||
c98e717f5b | |||
a5bc443db9 | |||
faa6fcf31c | |||
0be4d8c906 | |||
63ebd6f8ea | |||
972b3a3106 | |||
f057cd0078 | |||
9876fb27a4 | |||
e63c8dde09 | |||
d4e575774c | |||
db09ac2c5c | |||
ea7ea0ac24 | |||
80304070e7 | |||
7c31fd444c | |||
00e2a6fd96 | |||
768af5b096 | |||
61dd989df4 | |||
647726fc2d | |||
6db8611a58 | |||
e12c83137d | |||
89d1812c07 | |||
e53ced8996 | |||
f9e5978a08 | |||
0297e9aa2d | |||
c4e72f35c3 | |||
7179cde44a | |||
ba286a2e9c | |||
c2541f0b43 | |||
b6322fb532 | |||
32b321dcfd | |||
cc0884b07e | |||
133c701194 | |||
04f5c7ff1a | |||
81aa4d3d4e | |||
a013edcfe3 | |||
90bc42ea71 | |||
a7057f9e9f | |||
87e251d6b0 | |||
b6271938b3 | |||
a113d82e9b | |||
d513e89438 | |||
075f51f745 | |||
ecf77e0774 | |||
61d01a53eb | |||
8f8cd81c14 | |||
5dd045e080 | |||
2ad51a3d42 | |||
51ed88d5aa | |||
1de9973af3 | |||
324d6899c0 | |||
ad54dd14c8 | |||
8010f54747 | |||
87deeca110 | |||
6920313b77 | |||
b1051418c6 | |||
5f3ce82b37 | |||
5313b5a3e1 | |||
59b56d9374 | |||
f9c1401437 | |||
2502218fa1 | |||
89dca1b1ef | |||
cf640123b8 | |||
e9856115a8 | |||
abe715acce | |||
0a92ba03a8 | |||
5dca1373fc | |||
9b6ba2a5bb | |||
6ac5ff20cd | |||
6366379c34 | |||
399047ef30 | |||
9e887f604d | |||
eac6cb1df3 | |||
d6bb852720 | |||
c3f32eb7f4 | |||
c78f8c6e42 | |||
2f35d7cb2d | |||
4b5294f806 | |||
d6f9dd8145 | |||
9db11c110e | |||
1dd6b85128 | |||
277f262fb1 | |||
d9e52579eb | |||
078c18c61c | |||
425aaab954 | |||
b90234c1de | |||
444e77bd8a | |||
7d696bf6d1 | |||
dae12720cd | |||
acb252ac4f | |||
8216768b7e | |||
8037f054af | |||
bc222bfa8d | |||
2264e0cf9f | |||
24544be0e6 | |||
1fea5102cf | |||
ba77def895 | |||
8cd414f8e9 | |||
50f3134bfa | |||
817e7ff87a | |||
bb97cccb31 | |||
558be11808 | |||
2f18894e1a | |||
270b61c08f | |||
410599f21a | |||
73a16941b2 | |||
9df080d379 | |||
36008483eb | |||
28301ebb48 | |||
a8723c2d94 | |||
c1885823a1 | |||
cbfdc03bb9 | |||
8c05ca4338 | |||
d96253798d | |||
a695d8ed36 | |||
c3baef9ddc | |||
1cc9736aa0 | |||
cf7e3813fe | |||
0482491352 | |||
dfc42476c4 | |||
5cfd6a28bf | |||
35100dd533 | |||
797164bd39 | |||
461cf2f45e | |||
291c7a88d9 | |||
a0692aae66 | |||
dc821e7221 | |||
348be0b76e | |||
0cf3a937da | |||
d8795db70f | |||
a03b9678c7 | |||
b91273605e | |||
7c2858e1ae | |||
70bdbcfb21 | |||
a5957aa6db | |||
b12f316991 | |||
618fbb19c5 | |||
075633ea10 | |||
ba22186908 | |||
c10e1968d1 | |||
d0d11f2c98 | |||
8dde790e51 | |||
233e10b264 | |||
d2ae38ff6b | |||
3441ff4821 | |||
2e25347de0 | |||
4a05cf99f2 | |||
e0e4a1af8f | |||
769b474bdc | |||
d51d180a29 | |||
15ca4fe7ac | |||
e82bac9385 | |||
df0c24354a | |||
567636ff2b | |||
ebc0feb259 | |||
9d2c6bcc37 | |||
d522eae296 | |||
2ba0694597 | |||
c6b516a054 | |||
d482183b4f | |||
2558331d8f | |||
482fb21fe9 | |||
a4408bff0e | |||
970bd9afb0 | |||
55a0c7f800 | |||
9cdc815312 | |||
856cad8697 | |||
6746b9dc58 | |||
4444c72042 | |||
26aa00e20b | |||
0602b2475a | |||
9e8eff134b | |||
e55c48865a | |||
f98a61ba94 | |||
0fc3497a5b | |||
af9bcade33 | |||
2fd1224255 | |||
55974b1e6c | |||
c727cc2476 | |||
9b4e58f8c7 | |||
3b0678ee7a | |||
f4a9e7bed7 | |||
2a59f0339a | |||
57caafec59 | |||
38dfbb9012 | |||
f0121e2a01 | |||
0e2940b58b | |||
344fcab972 | |||
8b2a8df5b0 | |||
9f52377a3a | |||
b582ccd0c9 | |||
ca199c3486 | |||
2791dc98ac | |||
ed02309f3b | |||
db485ba0a9 | |||
a291635edd | |||
807481eb7f | |||
d395221ea1 | |||
a79b1e80d8 | |||
ad65c66be4 | |||
3ef8b785c3 | |||
cd45d5a5b5 | |||
4861136fdd | |||
2746607368 | |||
8ee002074d | |||
76031a9126 | |||
efb7910f3a | |||
90e7e48f66 | |||
2efdec60fe | |||
714e01ee4e | |||
bb45bb3f36 | |||
101aa6a10f | |||
399364af01 | |||
660f7fd955 | |||
43be41b8a5 | |||
92eaa3c613 | |||
17fd86726d | |||
393574d57d | |||
10c94b0128 | |||
8aebafd531 | |||
70602b8c6c | |||
832b3945d9 | |||
7eeef74f4c | |||
c708585a0b | |||
6393531ac5 | |||
549dad7261 | |||
b883905f8a | |||
91e0fae1d9 | |||
9a2618f42b | |||
6bb99ed3cc | |||
6e11c28d3e | |||
f6f564913a | |||
40deb997ea | |||
59118109bd | |||
971eb4e8f7 | |||
1c70d9484e | |||
6a8c07f93a | |||
0ef6a6038a | |||
dd521103c9 | |||
b04ea04062 | |||
1774e525b3 | |||
d502e3ce74 | |||
80e52829fa | |||
c61aa8020a | |||
d2d63836d4 | |||
e0ad25b2c6 | |||
cc669f1be0 | |||
5bf86601e6 | |||
b7484363dc | |||
5264dc9d8a | |||
8649e562dc | |||
905b962e0b | |||
07c863b251 | |||
69f2c7ad89 | |||
e2c7b8c7b9 | |||
c6821a7090 | |||
b1125f3727 | |||
c7af8d6afa | |||
1025d8a2ed | |||
3f3d7a4c58 | |||
80f0af8723 | |||
730cff1148 | |||
0e3481b593 | |||
abacc9af8b | |||
d837cbd0ff | |||
3f7abce427 | |||
b6bcc3cda6 | |||
07d9436e1e | |||
5c1c238453 | |||
02fb415260 | |||
d866268a7a | |||
98d73e81c0 | |||
47821879fa | |||
7c16b1e00f | |||
29043f63b6 | |||
87d626cd1d | |||
201cf83afe | |||
35de611fd1 | |||
5c02bb98bf | |||
c1f915ae31 | |||
faae146a86 | |||
4a6c6b4b2a | |||
fafacc92eb | |||
b5f5ae2483 | |||
cdfb015638 | |||
435fe04ab3 | |||
4e73882bf1 | |||
f2d9b29baa | |||
eaf9113c92 | |||
194571e132 | |||
f34b33c1a2 | |||
2552a7645e | |||
5dab623793 | |||
95f1570796 | |||
85848ee317 | |||
385499e642 | |||
59ee192bf8 | |||
2771602e5c | |||
38cd5fabee | |||
4bc2a0ff42 | |||
d46e83b735 | |||
a37fce2849 | |||
764d776524 | |||
7c9798b6b1 | |||
70b0645f2e | |||
88606e0a0c | |||
91708da5fc | |||
867d150a6d | |||
9259a85e69 | |||
546d87ca0b | |||
b73f7070e4 | |||
bec2c15ac9 | |||
d4c7de3916 | |||
47346182f0 | |||
aa1351f815 | |||
997b057a21 | |||
fb7fc2443f | |||
43cda4e2fb | |||
dbd3888fe1 | |||
6eb3ff412d | |||
4c66edc170 | |||
a7d48e8059 | |||
41cb597f03 | |||
a9071d629a | |||
9173c8f19a | |||
6350b19478 | |||
8b13c94b22 | |||
add523f8a5 | |||
5c3df55b72 | |||
5f69ab67df | |||
e7d2bd0be6 | |||
9ac6188707 | |||
3d109dc1b4 | |||
c45a48966d | |||
263812f98f | |||
b7999a4db2 | |||
38e5398cb9 | |||
a4e91541cf | |||
5e227da0c4 | |||
59e1281960 | |||
01e49e23b5 | |||
2f30162add | |||
3be52e7ab8 | |||
c85442d39f | |||
61b167ef66 | |||
c449669793 | |||
0c2283a831 | |||
50d9873ec1 | |||
0b5f54b5b2 | |||
ec581076ef | |||
c71575faed | |||
35785a1a28 | |||
832eb7cbf1 | |||
65504cf537 | |||
6fa1c30fb7 | |||
d18872aa5f | |||
5f94f3f893 | |||
b03c94f75d | |||
e9905cbc39 | |||
fdee03cc99 | |||
cb98be1891 | |||
80edd1b9a9 | |||
91ad30ae27 | |||
168ad39076 | |||
0a62579814 | |||
3da0e4720e | |||
dad0d91d01 | |||
92635fe395 | |||
fbc089569d | |||
2296eb96ef | |||
30d9dd04bb | |||
e89358cb0a | |||
bbe2bdffe3 | |||
af9655eeaf | |||
916423cc89 | |||
dddb643bed | |||
0f7a771e85 | |||
8d8b892d2a | |||
8a4e07120f | |||
8de658709c | |||
cb3e3bfaef | |||
4a25fbb2f6 | |||
83970d5d55 | |||
626b0df21c | |||
8f608dc522 | |||
8f982c5695 | |||
94baa04bb1 | |||
1505e9fa56 | |||
cab701f11b | |||
a3743d7ac6 | |||
7715905204 | |||
f770149066 | |||
f3f2218e91 | |||
b3e766926d | |||
6ed9ecffde | |||
c8d8c4e40c | |||
4f94927b5c | |||
f5f8616f2e | |||
033c007654 | |||
0fb5377226 | |||
f0afa13b8d | |||
53df3a9c1c | |||
8faaf8ced1 | |||
67c3bbf1ee | |||
68745ba9e0 | |||
015336b8c3 | |||
82ac6ab985 | |||
898ca70ad1 | |||
5b2a8394d7 | |||
16e17bffb3 | |||
015040cc1d | |||
c8869e67c7 | |||
a9ae7d2e96 | |||
db9034ccf9 | |||
4d1705dca5 | |||
3225e7c47b | |||
328397612c | |||
c846cde5b4 | |||
5c2cb99866 | |||
742c7da2eb | |||
88b247410f | |||
01417943fb | |||
09f7c33135 | |||
fe3b78f844 | |||
0fd54426e6 | |||
690e14e4ed | |||
7795b17fd1 | |||
c7dd4dc3eb | |||
6ee6a5166e | |||
8c753e9557 | |||
6126701025 | |||
8ef7d5f0d0 | |||
fa8d2f6639 | |||
8cd976791e | |||
010271c668 | |||
383d4f27f0 | |||
cb9786ef7c | |||
e936bb6f15 | |||
60c440ae10 | |||
fc8808ce04 | |||
e88327f2a3 | |||
159dfc9446 | |||
f78b072bb8 | |||
24fe368974 | |||
46fa546a7d | |||
96f6fa4257 | |||
8436d86661 | |||
a1d1e0f0f2 | |||
0907ad2681 | |||
e1b8bc607b | |||
528ecf72a3 | |||
f0ffe2e419 | |||
0df3164654 | |||
b97fb13929 | |||
3488cd04c0 | |||
07e0bb2d4c | |||
a58ebd27ca | |||
d0a25e3ab2 | |||
92fba82927 | |||
790a8a2e17 | |||
4e3d727b58 | |||
41eea8b571 | |||
8f17abdbf0 | |||
58cb5c7e2e | |||
271c133913 | |||
763205a89b | |||
e82e61d54d | |||
0af4903657 | |||
7f020eade9 | |||
ebe54d1131 | |||
9e8de4644a | |||
a372d07c4b | |||
dd5cce63c5 | |||
3c4155e6a1 | |||
6cb493b4a1 | |||
75d152922e | |||
908f95772d | |||
8992d48b3e | |||
aa247ccef2 | |||
0ba373373d | |||
e843594a02 | |||
032d9c1220 | |||
e5e50b6874 | |||
a7858075d8 | |||
cff6c54b52 | |||
5e6190b181 | |||
b50e809125 | |||
7348f47ef8 | |||
126e4a62f8 | |||
78282dab4d | |||
4a50248fbc | |||
4f0a8cdc0a | |||
a49a708fc5 | |||
bb99341aba | |||
0ce934413a | |||
65e366fdf1 | |||
2fcd496fd2 | |||
7cde25bdb5 | |||
35c396a88f | |||
a970d85e14 | |||
4733e4796d | |||
7c4ccbef3f | |||
b8f31cfd25 | |||
98cb5a2292 | |||
96c1901dce | |||
b807417885 | |||
6495c2081d | |||
0f92ccb22d | |||
bdf7f327d2 | |||
efc3815edf | |||
f5817c575c | |||
40814a801a | |||
e0f805f679 | |||
c4fcfbd6aa | |||
86d17188e1 | |||
88f8c00088 | |||
8612715371 | |||
e91050c803 | |||
ec2ec74e31 | |||
bfb640f201 | |||
34f8a97309 | |||
1c58702716 | |||
bd31c19a15 | |||
7e0ada6161 | |||
b5d6036fcf | |||
0fcee9baf2 | |||
f6fefdb8e6 | |||
0ec06423e5 | |||
8f028e4054 | |||
ae3b632f53 | |||
bafb17015c |
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
*/*/node_modules
|
26
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
- package-ecosystem: npm
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
directory: "/frontend/web"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
- package-ecosystem: npm
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
directory: "/frontend/extension"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
17
.github/workflows/backend-tests.yml
vendored
@ -12,10 +12,10 @@ jobs:
|
|||||||
go-static-checks:
|
go-static-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: 1.19
|
go-version: 1.23
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Verify go.mod is tidy
|
- name: Verify go.mod is tidy
|
||||||
@ -23,18 +23,19 @@ jobs:
|
|||||||
go mod tidy
|
go mod tidy
|
||||||
git diff --exit-code
|
git diff --exit-code
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v6
|
||||||
with:
|
with:
|
||||||
args: -v
|
version: v1.61.0
|
||||||
|
args: --verbose --timeout=3m
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|
||||||
go-tests:
|
go-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: 1.19
|
go-version: 1.23
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Run all tests
|
- name: Run all tests
|
||||||
|
@ -1,44 +1,83 @@
|
|||||||
name: build-and-push-release-image
|
name: build-and-push-stable-image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
# Run on pushing branches like `release/1.0.0`
|
# Match stable and rc versions, such as 'v1.0.0' or 'v0.23.0-rc.0'
|
||||||
- "release/*.*.*"
|
- "v*.*.*"
|
||||||
|
- "v*.*.*-rc.*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-release-image:
|
build-and-push-stable-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Extract build args
|
- name: Extract build args
|
||||||
# Extract version from branch name
|
# Extract version number and check if it's an rc version
|
||||||
# Example: branch name `release/1.0.0` sets up env.VERSION=1.0.0
|
|
||||||
run: |
|
run: |
|
||||||
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
if [[ "${GITHUB_REF_NAME}" =~ -rc ]]; then
|
||||||
|
echo "PRE_RELEASE=true" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "PRE_RELEASE=false" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: yourselfhosted
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
install: true
|
install: true
|
||||||
|
version: v0.9.1
|
||||||
|
|
||||||
|
# Metadata for stable versions
|
||||||
|
- name: Docker meta for stable
|
||||||
|
id: meta-stable
|
||||||
|
if: env.PRE_RELEASE == 'false'
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
yourselfhosted/slash
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=${{ env.VERSION }}
|
||||||
|
type=raw,value=stable
|
||||||
|
flavor: |
|
||||||
|
latest=true
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.version=${{ env.VERSION }}
|
||||||
|
|
||||||
|
# Metadata for rc versions
|
||||||
|
- name: Docker meta for rc
|
||||||
|
id: meta-rc
|
||||||
|
if: env.PRE_RELEASE == 'true'
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
yourselfhosted/slash
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ env.VERSION }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.version=${{ env.VERSION }}
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: yourselfhosted/slash:latest, yourselfhosted/slash:${{ env.VERSION }}
|
tags: ${{ steps.meta-stable.outputs.tags || steps.meta-rc.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-stable.outputs.labels || steps.meta-rc.outputs.labels }}
|
||||||
|
10
.github/workflows/build-and-push-test-image.yml
vendored
@ -8,27 +8,27 @@ jobs:
|
|||||||
build-and-push-test-image:
|
build-and-push-test-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: yourselfhosted
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
install: true
|
install: true
|
||||||
version: v0.9.1
|
version: v0.9.1
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
33
.github/workflows/build-artifacts.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
name: Build artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
goreleaser:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: 1.23
|
||||||
|
check-latest: true
|
||||||
|
cache: true
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
# either 'goreleaser' (default) or 'goreleaser-pro'
|
||||||
|
distribution: goreleaser
|
||||||
|
# 'latest', 'nightly', or a semver
|
||||||
|
version: latest
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
31
.github/workflows/extension-test.yml
vendored
@ -8,42 +8,41 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "extension/**"
|
- "frontend/extension/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
eslint-checks:
|
eslint-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v4.0.0
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/setup-node@v4
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "extension/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: extension
|
working-directory: frontend/extension
|
||||||
- name: Run eslint check
|
- name: Run eslint check
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
working-directory: extension
|
working-directory: frontend/extension
|
||||||
|
|
||||||
extension-build:
|
extension-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v4.0.0
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "extension/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: extension
|
working-directory: frontend/extension
|
||||||
- name: Run extension build
|
- name: Run extension build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: extension
|
working-directory: frontend/extension
|
||||||
|
34
.github/workflows/frontend-test.yml
vendored
@ -8,42 +8,44 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "web/**"
|
- "frontend/web/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
eslint-checks:
|
eslint-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v4.0.0
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/setup-node@v4
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: web
|
working-directory: frontend/web
|
||||||
- name: Run eslint check
|
- name: Run eslint check
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
working-directory: web
|
working-directory: frontend/web
|
||||||
|
- name: Run type check
|
||||||
|
run: pnpm type-check
|
||||||
|
working-directory: frontend/web
|
||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v4.0.0
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: web
|
working-directory: frontend/web
|
||||||
- name: Run frontend build
|
- name: Run frontend build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: web
|
working-directory: frontend/web
|
||||||
|
2
.github/workflows/proto-linter.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup buf
|
- name: Setup buf
|
||||||
|
9
.gitignore
vendored
@ -4,10 +4,13 @@
|
|||||||
# temp folder
|
# temp folder
|
||||||
tmp
|
tmp
|
||||||
|
|
||||||
# Frontend asset
|
|
||||||
web/dist
|
|
||||||
|
|
||||||
# build folder
|
# build folder
|
||||||
build
|
build
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
dist/
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
|
- errcheck
|
||||||
- goimports
|
- goimports
|
||||||
- revive
|
- revive
|
||||||
- govet
|
- govet
|
||||||
@ -10,17 +11,26 @@ linters:
|
|||||||
- rowserrcheck
|
- rowserrcheck
|
||||||
- nilerr
|
- nilerr
|
||||||
- godot
|
- godot
|
||||||
|
- forbidigo
|
||||||
|
- mirror
|
||||||
|
- bodyclose
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
|
include:
|
||||||
|
# https://golangci-lint.run/usage/configuration/#command-line-options
|
||||||
exclude:
|
exclude:
|
||||||
- Rollback
|
- Rollback
|
||||||
- fmt.Printf
|
- fmt.Printf
|
||||||
- fmt.Print
|
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
|
goimports:
|
||||||
|
# Put imports beginning with prefix after 3rd-party packages.
|
||||||
|
local-prefixes: github.com/yourselfhosted/slash
|
||||||
revive:
|
revive:
|
||||||
|
# Default to run all linters so that new rules in the future could automatically be added to the static check.
|
||||||
enable-all-rules: true
|
enable-all-rules: true
|
||||||
rules:
|
rules:
|
||||||
|
# The following rules are too strict and make coding harder. We do not enable them for now.
|
||||||
- name: file-header
|
- name: file-header
|
||||||
disabled: true
|
disabled: true
|
||||||
- name: line-length-limit
|
- name: line-length-limit
|
||||||
@ -51,14 +61,30 @@ linters-settings:
|
|||||||
disabled: true
|
disabled: true
|
||||||
- name: early-return
|
- name: early-return
|
||||||
disabled: true
|
disabled: true
|
||||||
|
- name: use-any
|
||||||
|
disabled: true
|
||||||
|
- name: var-naming
|
||||||
|
disabled: true
|
||||||
|
- name: unchecked-type-assertion
|
||||||
|
disabled: true
|
||||||
|
- name: max-control-nesting
|
||||||
|
disabled: true
|
||||||
|
- name: exported
|
||||||
|
arguments:
|
||||||
|
- "disableStutteringCheck"
|
||||||
gocritic:
|
gocritic:
|
||||||
disabled-checks:
|
disabled-checks:
|
||||||
- ifElseChain
|
- ifElseChain
|
||||||
govet:
|
govet:
|
||||||
settings:
|
settings:
|
||||||
printf:
|
printf: # The name of the analyzer, run `go tool vet help` to see the list of all analyzers
|
||||||
funcs:
|
funcs: # Run `go tool vet help printf` to see the full configuration of `printf`.
|
||||||
- common.Errorf
|
- common.Errorf
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- fieldalignment
|
||||||
|
- shadow
|
||||||
forbidigo:
|
forbidigo:
|
||||||
forbid:
|
forbid:
|
||||||
- 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
|
- 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
|
||||||
|
- 'ioutil\.ReadDir(# Please use os\.ReadDir)?'
|
||||||
|
38
.goreleaser.yaml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
# You may remove this if you don't use go modules.
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
main: ./bin/slash
|
||||||
|
binary: slash
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- format: tar.gz
|
||||||
|
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||||
|
name_template: >-
|
||||||
|
{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- "^docs:"
|
||||||
|
- "^test:"
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
disable: true
|
||||||
|
|
||||||
|
release:
|
||||||
|
draft: true
|
||||||
|
replace_existing_draft: true
|
||||||
|
make_latest: true
|
||||||
|
mode: replace
|
||||||
|
skip_upload: false
|
4
.vscode/settings.json
vendored
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"go.lintOnSave": "workspace",
|
|
||||||
"go.lintTool": "golangci-lint"
|
|
||||||
}
|
|
16
Dockerfile
@ -1,26 +1,26 @@
|
|||||||
# Build frontend dist.
|
# Build frontend dist.
|
||||||
FROM node:18.12.1-alpine3.16 AS frontend
|
FROM node:18-alpine AS frontend
|
||||||
WORKDIR /frontend-build
|
WORKDIR /frontend-build
|
||||||
|
|
||||||
COPY ./web/package.json ./web/pnpm-lock.yaml ./
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /frontend-build/frontend/web
|
||||||
|
|
||||||
RUN corepack enable && pnpm i --frozen-lockfile
|
RUN corepack enable && pnpm i --frozen-lockfile
|
||||||
|
|
||||||
COPY ./web/ .
|
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Build backend exec file.
|
# Build backend exec file.
|
||||||
FROM golang:1.19.3-alpine3.16 AS backend
|
FROM golang:1.23-alpine AS backend
|
||||||
WORKDIR /backend-build
|
WORKDIR /backend-build
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=frontend /frontend-build/dist ./server/dist
|
COPY --from=frontend /frontend-build/frontend/web/dist /backend-build/server/route/frontend/dist
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -o slash ./cmd/slash/main.go
|
RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
|
||||||
|
|
||||||
# Make workspace with above generated files.
|
# Make workspace with above generated files.
|
||||||
FROM alpine:3.16 AS monolithic
|
FROM alpine:latest AS monolithic
|
||||||
WORKDIR /usr/local/slash
|
WORKDIR /usr/local/slash
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata
|
||||||
|
145
LICENSE
@ -1,5 +1,5 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
@ -7,17 +7,15 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
|||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@ -72,7 +60,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
|||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
@ -631,40 +629,33 @@ to attach them to the start of each source file to most effectively
|
|||||||
state the exclusion of warranty; and each file should have at least
|
state the exclusion of warranty; and each file should have at least
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<Slash>
|
||||||
|
Copyright (C) <2023> <Steven>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
interface could display a "Source" link that leads users to an archive
|
||||||
This is free software, and you are welcome to redistribute it
|
of the code. There are many ways you could offer source, and different
|
||||||
under certain conditions; type `show c' for details.
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
34
README.md
@ -1,23 +1,33 @@
|
|||||||
# Slash
|
# Slash
|
||||||
|
|
||||||
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
**Slash** is an open source, self-hosted platform designed to help you organize, manage, and share your most important links. Easily create customizable, human-readable shortcuts to streamline your link management. Use tags to categorize your links and share them easily with your team or publicly.
|
||||||
|
|
||||||
**Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them using custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
|
🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/)
|
||||||
|
|
||||||
|
Getting started with Slash's [Shortcuts](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/shortcuts.md) and [Collections](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/collections.md).
|
||||||
|
|
||||||
|
[👉 Join our Discord 💬](https://discord.gg/QZqUuUAhDV)
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a>
|
||||||
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
||||||
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg" /></a>
|
|
||||||
<a href="https://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github" /></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
In today's workplace, essential information is often scattered across the cloud in the form of links. We understand the frustration of endlessly searching through emails, messages, and websites just to find the right link. Links are notorious for being unwieldy, complex, and easily lost in the shuffle. Remembering and sharing them can be a challenge.
|
||||||
|
|
||||||
|
That's why we developed Slash, a solution that transforms these links into easily accessible, discoverable, and shareable shortcuts(e.g., `s/shortcut`). Say goodbye to link chaos and welcome the organizational ease of Slash into your daily online workflow.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Create customizable `/s/` short links for any URL.
|
- Create customizable `s/` short links for any URL.
|
||||||
- Share short links privately or with teammates.
|
- Share short links public or only with your teammates.
|
||||||
- View analytics on link traffic and sources.
|
- View analytics on link traffic and sources.
|
||||||
- Easy access to your shortcuts with browser extension.
|
- Easy access to your shortcuts with browser extension.
|
||||||
|
- Share your shortcuts with Collection to anyone, on any browser.
|
||||||
- Open source self-hosted solution.
|
- Open source self-hosted solution.
|
||||||
|
|
||||||
## Deploy with Docker in seconds
|
## Deploy with Docker in seconds
|
||||||
@ -26,16 +36,20 @@
|
|||||||
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md).
|
Learn more in [Self-hosting Slash with Docker](https://github.com/yourselfhosted/slash/blob/main/docs/install.md).
|
||||||
|
|
||||||
## Browser Extension
|
## Browser Extension
|
||||||
|
|
||||||
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
|
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
Learn more in [The Browser Extension of Slash](https://github.com/yourselfhosted/slash/blob/main/docs/install-browser-extension.md).
|
||||||
|
|
||||||
### Chromium based browsers
|
### Chromium based browsers
|
||||||
|
|
||||||
For Chromium based browsers(Chrome, Edge, Arc, ...), you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
|
For Chromium based browsers(Chrome, Edge, Arc, ...), you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
|
||||||
|
|
||||||
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md).
|
### Firefox
|
||||||
|
|
||||||
|
For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/your-slash/).
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
type ActivityShorcutCreatePayload struct {
|
|
||||||
ShortcutID int32 `json:"shortcutId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActivityShorcutViewPayload struct {
|
|
||||||
ShortcutID int32 `json:"shortcutId"`
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Referer string `json:"referer"`
|
|
||||||
UserAgent string `json:"userAgent"`
|
|
||||||
}
|
|
@ -1,128 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/mssola/useragent"
|
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReferenceInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeviceInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrowserInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AnalysisData struct {
|
|
||||||
ReferenceData []ReferenceInfo `json:"referenceData"`
|
|
||||||
DeviceData []DeviceInfo `json:"deviceData"`
|
|
||||||
BrowserData []BrowserInfo `json:"browserData"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
|
||||||
g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
|
||||||
Type: store.ActivityShortcutView,
|
|
||||||
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcutID)},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
referenceMap := make(map[string]int)
|
|
||||||
deviceMap := make(map[string]int)
|
|
||||||
browserMap := make(map[string]int)
|
|
||||||
for _, activity := range activities {
|
|
||||||
payload := &ActivityShorcutViewPayload{}
|
|
||||||
if err := json.Unmarshal([]byte(activity.Payload), payload); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal payload, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := referenceMap[payload.Referer]; !ok {
|
|
||||||
referenceMap[payload.Referer] = 0
|
|
||||||
}
|
|
||||||
referenceMap[payload.Referer]++
|
|
||||||
|
|
||||||
ua := useragent.New(payload.UserAgent)
|
|
||||||
deviceName := ua.OSInfo().Name
|
|
||||||
browserName, _ := ua.Browser()
|
|
||||||
|
|
||||||
if _, ok := deviceMap[deviceName]; !ok {
|
|
||||||
deviceMap[deviceName] = 0
|
|
||||||
}
|
|
||||||
deviceMap[deviceName]++
|
|
||||||
|
|
||||||
if _, ok := browserMap[browserName]; !ok {
|
|
||||||
browserMap[browserName] = 0
|
|
||||||
}
|
|
||||||
browserMap[browserName]++
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, &AnalysisData{
|
|
||||||
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
|
||||||
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
|
||||||
BrowserData: mapToBrowserInfoSlice(browserMap),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
|
|
||||||
referenceInfoSlice := make([]ReferenceInfo, 0)
|
|
||||||
for key, value := range m {
|
|
||||||
referenceInfoSlice = append(referenceInfoSlice, ReferenceInfo{
|
|
||||||
Name: key,
|
|
||||||
Count: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) bool {
|
|
||||||
return i.Count > j.Count
|
|
||||||
})
|
|
||||||
return referenceInfoSlice
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
|
|
||||||
deviceInfoSlice := make([]DeviceInfo, 0)
|
|
||||||
for key, value := range m {
|
|
||||||
deviceInfoSlice = append(deviceInfoSlice, DeviceInfo{
|
|
||||||
Name: key,
|
|
||||||
Count: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) bool {
|
|
||||||
return i.Count > j.Count
|
|
||||||
})
|
|
||||||
return deviceInfoSlice
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
|
|
||||||
browserInfoSlice := make([]BrowserInfo, 0)
|
|
||||||
for key, value := range m {
|
|
||||||
browserInfoSlice = append(browserInfoSlice, BrowserInfo{
|
|
||||||
Name: key,
|
|
||||||
Count: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) bool {
|
|
||||||
return i.Count > j.Count
|
|
||||||
})
|
|
||||||
return browserInfoSlice
|
|
||||||
}
|
|
171
api/v1/auth.go
@ -1,171 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SignInRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SignUpRequest struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|
||||||
g.POST("/auth/signin", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
signin := &SignInRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signin request, err: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
Email: &signin.Email,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user by email %s", signin.Email)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("user not found with email %s", signin.Email))
|
|
||||||
} else if user.RowStatus == store.Archived {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("user has been archived with email %s", signin.Email))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare the stored hashed password, with the hashed version of the password that was received.
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), secret)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.POST("/auth/signup", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
disallowSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
|
||||||
Key: store.WorkspaceDisallowSignUp,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if disallowSignUpSetting != nil && disallowSignUpSetting.Value == "true" {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "sign up has been disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
signup := &SignUpRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signup request, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
create := &store.User{
|
|
||||||
Email: signup.Email,
|
|
||||||
Nickname: signup.Nickname,
|
|
||||||
PasswordHash: string(passwordHash),
|
|
||||||
}
|
|
||||||
existingUsers, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find existing users, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
// The first user to sign up is an admin by default.
|
|
||||||
if len(existingUsers) == 0 {
|
|
||||||
create.Role = store.RoleAdmin
|
|
||||||
} else {
|
|
||||||
create.Role = store.RoleUser
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.CreateUser(ctx, create)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), secret)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.POST("/auth/logout", func(c echo.Context) error {
|
|
||||||
RemoveTokensAndCookies(c)
|
|
||||||
c.Response().WriteHeader(http.StatusOK)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
|
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "failed to get user access tokens")
|
|
||||||
}
|
|
||||||
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
Description: "Account sign in",
|
|
||||||
}
|
|
||||||
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
|
||||||
UserId: user.ID,
|
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
|
||||||
Value: &storepb.UserSetting_AccessTokensUserSetting{
|
|
||||||
AccessTokensUserSetting: &storepb.AccessTokensUserSetting{
|
|
||||||
AccessTokens: userAccessTokens,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveTokensAndCookies removes the jwt token from the cookies.
|
|
||||||
func RemoveTokensAndCookies(c echo.Context) {
|
|
||||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setTokenCookie sets the token to the cookie.
|
|
||||||
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
|
||||||
cookie := new(http.Cookie)
|
|
||||||
cookie.Name = name
|
|
||||||
cookie.Value = token
|
|
||||||
cookie.Expires = expiration
|
|
||||||
cookie.Path = "/"
|
|
||||||
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
|
||||||
cookie.HttpOnly = true
|
|
||||||
cookie.SameSite = http.SameSiteStrictMode
|
|
||||||
c.SetCookie(cookie)
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
// RowStatus is the status for a row.
|
|
||||||
type RowStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Normal is the status for a normal row.
|
|
||||||
Normal RowStatus = "NORMAL"
|
|
||||||
// Archived is the status for an archived row.
|
|
||||||
Archived RowStatus = "ARCHIVED"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s RowStatus) String() string {
|
|
||||||
return string(s)
|
|
||||||
}
|
|
137
api/v1/jwt.go
@ -1,137 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
|
||||||
"github.com/boojack/slash/internal/util"
|
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// The key name used to store user id in the context
|
|
||||||
// user id is extracted from the jwt token subject field.
|
|
||||||
UserIDContextKey = "user-id"
|
|
||||||
)
|
|
||||||
|
|
||||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
|
||||||
authHeader := c.Request().Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
authHeaderParts := strings.Fields(authHeader)
|
|
||||||
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
|
||||||
return "", errors.New("Authorization header format must be Bearer {token}")
|
|
||||||
}
|
|
||||||
|
|
||||||
return authHeaderParts[1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAccessToken(c echo.Context) string {
|
|
||||||
accessToken := ""
|
|
||||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
|
||||||
if cookie != nil {
|
|
||||||
accessToken = cookie.Value
|
|
||||||
}
|
|
||||||
if accessToken == "" {
|
|
||||||
accessToken, _ = extractTokenFromHeader(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
return accessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
|
||||||
for _, v := range audience {
|
|
||||||
if v == token {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWTMiddleware validates the access token.
|
|
||||||
func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
|
||||||
return func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
path := c.Request().URL.Path
|
|
||||||
method := c.Request().Method
|
|
||||||
|
|
||||||
// Pass auth and profile endpoints.
|
|
||||||
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
token := findAccessToken(c)
|
|
||||||
if token == "" {
|
|
||||||
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
|
||||||
if util.HasPrefixes(path, "/s/") && method == http.MethodGet {
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
|
||||||
}
|
|
||||||
|
|
||||||
claims := &auth.ClaimsMessage{}
|
|
||||||
_, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
|
||||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
|
||||||
}
|
|
||||||
if kid, ok := t.Header["kid"].(string); ok {
|
|
||||||
if kid == "v1" {
|
|
||||||
return []byte(secret), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
|
||||||
}
|
|
||||||
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
|
|
||||||
}
|
|
||||||
|
|
||||||
// We either have a valid access token or we will attempt to generate new access token.
|
|
||||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.").WithInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
|
||||||
}
|
|
||||||
if !validateAccessToken(token, accessTokens) {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Even if there is no error, we still need to make sure the user still exists.
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stores userID into context.
|
|
||||||
c.Set(UserIDContextKey, userID)
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
|
||||||
for _, userAccessToken := range userAccessTokens {
|
|
||||||
if accessTokenString == userAccessToken.AccessToken {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
@ -1,115 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
|
||||||
g.GET("/*", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
if len(c.ParamValues()) == 0 {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid shortcut name")
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutName := c.ParamValues()[0]
|
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
|
||||||
Name: &shortcutName,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if shortcut == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with name: %s", shortcutName))
|
|
||||||
}
|
|
||||||
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
|
||||||
}
|
|
||||||
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.createShortcutViewActivity(c, shortcut); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirectToShortcut(c, shortcut)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func redirectToShortcut(c echo.Context, shortcut *storepb.Shortcut) error {
|
|
||||||
isValidURL := isValidURLString(shortcut.Link)
|
|
||||||
if shortcut.OgMetadata == nil || (shortcut.OgMetadata.Title == "" && shortcut.OgMetadata.Description == "" && shortcut.OgMetadata.Image == "") {
|
|
||||||
if isValidURL {
|
|
||||||
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
|
||||||
}
|
|
||||||
return c.String(http.StatusOK, shortcut.Link)
|
|
||||||
}
|
|
||||||
|
|
||||||
htmlTemplate := `<html><head>%s</head><body>%s</body></html>`
|
|
||||||
metadataList := []string{
|
|
||||||
fmt.Sprintf(`<title>%s</title>`, shortcut.OgMetadata.Title),
|
|
||||||
fmt.Sprintf(`<meta name="description" content="%s" />`, shortcut.OgMetadata.Description),
|
|
||||||
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OgMetadata.Title),
|
|
||||||
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OgMetadata.Description),
|
|
||||||
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OgMetadata.Image),
|
|
||||||
`<meta property="og:type" content="website" />`,
|
|
||||||
// Twitter related metadata.
|
|
||||||
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OgMetadata.Title),
|
|
||||||
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OgMetadata.Description),
|
|
||||||
fmt.Sprintf(`<meta name="twitter:image" content="%s" />`, shortcut.OgMetadata.Image),
|
|
||||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
|
||||||
}
|
|
||||||
if isValidURL {
|
|
||||||
metadataList = append(metadataList, fmt.Sprintf(`<meta property="og:url" content="%s" />`, shortcut.Link))
|
|
||||||
}
|
|
||||||
body := ""
|
|
||||||
if isValidURL {
|
|
||||||
body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link)
|
|
||||||
} else {
|
|
||||||
body = html.EscapeString(shortcut.Link)
|
|
||||||
}
|
|
||||||
htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
|
|
||||||
return c.HTML(http.StatusOK, htmlString)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *storepb.Shortcut) error {
|
|
||||||
payload := &ActivityShorcutViewPayload{
|
|
||||||
ShortcutID: shortcut.Id,
|
|
||||||
IP: c.RealIP(),
|
|
||||||
Referer: c.Request().Referer(),
|
|
||||||
UserAgent: c.Request().UserAgent(),
|
|
||||||
}
|
|
||||||
payloadStr, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
|
||||||
}
|
|
||||||
activity := &store.Activity{
|
|
||||||
CreatorID: BotID,
|
|
||||||
Type: store.ActivityShortcutView,
|
|
||||||
Level: store.ActivityInfo,
|
|
||||||
Payload: string(payloadStr),
|
|
||||||
}
|
|
||||||
_, err = s.Store.CreateActivity(c.Request().Context(), activity)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to create activity")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidURLString(s string) bool {
|
|
||||||
_, err := url.ParseRequestURI(s)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestIsValidURLString(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
link string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
link: "https://google.com",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
link: "http://google.com",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
link: "google.com",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
link: "mailto:email@example.com",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
if isValidURLString(test.link) != test.expected {
|
|
||||||
t.Errorf("isValidURLString(%s) = %v, expected %v", test.link, !test.expected, test.expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,384 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/internal/util"
|
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Visibility is the type of a shortcut visibility.
|
|
||||||
type Visibility string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// VisibilityPublic is the PUBLIC visibility.
|
|
||||||
VisibilityPublic Visibility = "PUBLIC"
|
|
||||||
// VisibilityWorkspace is the WORKSPACE visibility.
|
|
||||||
VisibilityWorkspace Visibility = "WORKSPACE"
|
|
||||||
// VisibilityPrivate is the PRIVATE visibility.
|
|
||||||
VisibilityPrivate Visibility = "PRIVATE"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (v Visibility) String() string {
|
|
||||||
return string(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenGraphMetadata struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Shortcut struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
|
|
||||||
// Standard fields
|
|
||||||
CreatorID int32 `json:"creatorId"`
|
|
||||||
Creator *User `json:"creator"`
|
|
||||||
CreatedTs int64 `json:"createdTs"`
|
|
||||||
UpdatedTs int64 `json:"updatedTs"`
|
|
||||||
RowStatus RowStatus `json:"rowStatus"`
|
|
||||||
|
|
||||||
// Domain specific fields
|
|
||||||
Name string `json:"name"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Visibility Visibility `json:"visibility"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
View int `json:"view"`
|
|
||||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateShortcutRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Visibility Visibility `json:"visibility"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PatchShortcutRequest struct {
|
|
||||||
RowStatus *RowStatus `json:"rowStatus"`
|
|
||||||
Name *string `json:"name"`
|
|
||||||
Link *string `json:"link"`
|
|
||||||
Title *string `json:"title"`
|
|
||||||
Description *string `json:"description"`
|
|
||||||
Visibility *Visibility `json:"visibility"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|
||||||
g.POST("/shortcut", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
create := &CreateShortcutRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut := &storepb.Shortcut{
|
|
||||||
CreatorId: userID,
|
|
||||||
Name: strings.ToLower(create.Name),
|
|
||||||
Link: create.Link,
|
|
||||||
Title: create.Title,
|
|
||||||
Description: create.Description,
|
|
||||||
Visibility: convertVisibilityToStorepb(create.Visibility),
|
|
||||||
Tags: create.Tags,
|
|
||||||
OgMetadata: &storepb.OpenGraphMetadata{},
|
|
||||||
}
|
|
||||||
if create.OpenGraphMetadata != nil {
|
|
||||||
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
|
||||||
Title: create.OpenGraphMetadata.Title,
|
|
||||||
Description: create.OpenGraphMetadata.Description,
|
|
||||||
Image: create.OpenGraphMetadata.Image,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, shortcutMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
|
||||||
ID: &shortcutID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if shortcut == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
|
||||||
}
|
|
||||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := &PatchShortcutRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(patch); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode patch shortcut request, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if patch.Name != nil {
|
|
||||||
name := strings.ToLower(*patch.Name)
|
|
||||||
patch.Name = &name
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutUpdate := &store.UpdateShortcut{
|
|
||||||
ID: shortcutID,
|
|
||||||
Name: patch.Name,
|
|
||||||
Link: patch.Link,
|
|
||||||
Title: patch.Title,
|
|
||||||
Description: patch.Description,
|
|
||||||
}
|
|
||||||
if patch.RowStatus != nil {
|
|
||||||
shortcutUpdate.RowStatus = (*store.RowStatus)(patch.RowStatus)
|
|
||||||
}
|
|
||||||
if patch.Visibility != nil {
|
|
||||||
shortcutUpdate.Visibility = (*store.Visibility)(patch.Visibility)
|
|
||||||
}
|
|
||||||
if patch.Tags != nil {
|
|
||||||
tag := strings.Join(patch.Tags, " ")
|
|
||||||
shortcutUpdate.Tag = &tag
|
|
||||||
}
|
|
||||||
if patch.OpenGraphMetadata != nil {
|
|
||||||
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
|
|
||||||
Title: patch.OpenGraphMetadata.Title,
|
|
||||||
Description: patch.OpenGraphMetadata.Description,
|
|
||||||
Image: patch.OpenGraphMetadata.Image,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, shortcutMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/shortcut", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
|
|
||||||
find := &store.FindShortcut{}
|
|
||||||
if tag := c.QueryParam("tag"); tag != "" {
|
|
||||||
find.Tag = &tag
|
|
||||||
}
|
|
||||||
|
|
||||||
list := []*storepb.Shortcut{}
|
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
|
||||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut list, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
list = append(list, visibleShortcutList...)
|
|
||||||
|
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
|
|
||||||
find.CreatorID = &userID
|
|
||||||
privateShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch private shortcut list, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
list = append(list, privateShortcutList...)
|
|
||||||
|
|
||||||
shortcutMessageList := []*Shortcut{}
|
|
||||||
for _, shortcut := range list {
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
shortcutMessageList = append(shortcutMessageList, shortcutMessage)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, shortcutMessageList)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/shortcut/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
|
||||||
ID: &shortcutID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if shortcut == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, shortcutMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
|
||||||
ID: &shortcutID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if shortcut == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
|
||||||
}
|
|
||||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
|
||||||
if shortcut == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &shortcut.CreatorID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "Failed to get creator")
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("Creator not found")
|
|
||||||
}
|
|
||||||
shortcut.Creator = convertUserFromStore(user)
|
|
||||||
|
|
||||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
|
||||||
Type: store.ActivityShortcutView,
|
|
||||||
Level: store.ActivityInfo,
|
|
||||||
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcut.ID)},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "Failed to list activities")
|
|
||||||
}
|
|
||||||
shortcut.View = len(activityList)
|
|
||||||
|
|
||||||
return shortcut, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
|
|
||||||
return &Shortcut{
|
|
||||||
ID: shortcut.Id,
|
|
||||||
CreatedTs: shortcut.CreatedTs,
|
|
||||||
UpdatedTs: shortcut.UpdatedTs,
|
|
||||||
CreatorID: shortcut.CreatorId,
|
|
||||||
RowStatus: RowStatus(shortcut.RowStatus.String()),
|
|
||||||
Name: shortcut.Name,
|
|
||||||
Link: shortcut.Link,
|
|
||||||
Title: shortcut.Title,
|
|
||||||
Description: shortcut.Description,
|
|
||||||
Visibility: Visibility(shortcut.Visibility.String()),
|
|
||||||
Tags: shortcut.Tags,
|
|
||||||
OpenGraphMetadata: &OpenGraphMetadata{
|
|
||||||
Title: shortcut.OgMetadata.Title,
|
|
||||||
Description: shortcut.OgMetadata.Description,
|
|
||||||
Image: shortcut.OgMetadata.Image,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
|
|
||||||
switch visibility {
|
|
||||||
case VisibilityPublic:
|
|
||||||
return storepb.Visibility_PUBLIC
|
|
||||||
case VisibilityWorkspace:
|
|
||||||
return storepb.Visibility_WORKSPACE
|
|
||||||
case VisibilityPrivate:
|
|
||||||
return storepb.Visibility_PRIVATE
|
|
||||||
default:
|
|
||||||
return storepb.Visibility_PUBLIC
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
|
||||||
payload := &ActivityShorcutCreatePayload{
|
|
||||||
ShortcutID: shortcut.Id,
|
|
||||||
}
|
|
||||||
payloadStr, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
|
||||||
}
|
|
||||||
activity := &store.Activity{
|
|
||||||
CreatorID: shortcut.CreatorId,
|
|
||||||
Type: store.ActivityShortcutCreate,
|
|
||||||
Level: store.ActivityInfo,
|
|
||||||
Payload: string(payloadStr),
|
|
||||||
}
|
|
||||||
_, err = s.Store.CreateActivity(ctx, activity)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to create activity")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"go.deanishe.net/favicon"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
|
|
||||||
// GET /url/favicon?url=...
|
|
||||||
g.GET("/url/favicon", func(c echo.Context) error {
|
|
||||||
url := c.QueryParam("url")
|
|
||||||
icons, err := favicon.Find(url)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to find favicon, err: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
availableIcons := []*favicon.Icon{}
|
|
||||||
for _, icon := range icons {
|
|
||||||
if icon.Width == icon.Height {
|
|
||||||
availableIcons = append(availableIcons, icon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(availableIcons) == 0 {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "no favicon found")
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, availableIcons[0].URL)
|
|
||||||
})
|
|
||||||
}
|
|
320
api/v1/user.go
@ -1,320 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/mail"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/internal/util"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// BotID is the id of bot.
|
|
||||||
BotID = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
// Role is the type of a role.
|
|
||||||
type Role string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// RoleAdmin is the ADMIN role.
|
|
||||||
RoleAdmin Role = "ADMIN"
|
|
||||||
// RoleUser is the USER role.
|
|
||||||
RoleUser Role = "USER"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (r Role) String() string {
|
|
||||||
switch r {
|
|
||||||
case RoleAdmin:
|
|
||||||
return "ADMIN"
|
|
||||||
case RoleUser:
|
|
||||||
return "USER"
|
|
||||||
}
|
|
||||||
return "USER"
|
|
||||||
}
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
|
|
||||||
// Standard fields
|
|
||||||
CreatedTs int64 `json:"createdTs"`
|
|
||||||
UpdatedTs int64 `json:"updatedTs"`
|
|
||||||
RowStatus RowStatus `json:"rowStatus"`
|
|
||||||
|
|
||||||
// Domain specific fields
|
|
||||||
Email string `json:"email"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Role Role `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Role Role `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (create CreateUserRequest) Validate() error {
|
|
||||||
if create.Email != "" && !validateEmail(create.Email) {
|
|
||||||
return fmt.Errorf("invalid email format")
|
|
||||||
}
|
|
||||||
if create.Nickname != "" && len(create.Nickname) < 3 {
|
|
||||||
return fmt.Errorf("nickname is too short, minimum length is 3")
|
|
||||||
}
|
|
||||||
if len(create.Password) < 3 {
|
|
||||||
return fmt.Errorf("password is too short, minimum length is 3")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type PatchUserRequest struct {
|
|
||||||
RowStatus *RowStatus `json:"rowStatus"`
|
|
||||||
Email *string `json:"email"`
|
|
||||||
Nickname *string `json:"nickname"`
|
|
||||||
Password *string `json:"password"`
|
|
||||||
Role *Role `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|
||||||
g.POST("/user", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
|
||||||
}
|
|
||||||
if currentUser == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
|
||||||
}
|
|
||||||
if currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
|
|
||||||
}
|
|
||||||
|
|
||||||
userCreate := &CreateUserRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
|
||||||
}
|
|
||||||
if err := userCreate.Validate(); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.CreateUser(ctx, &store.User{
|
|
||||||
Role: store.Role(userCreate.Role),
|
|
||||||
Email: userCreate.Email,
|
|
||||||
Nickname: userCreate.Nickname,
|
|
||||||
PasswordHash: string(passwordHash),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userMessage := convertUserFromStore(user)
|
|
||||||
return c.JSON(http.StatusOK, userMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/user", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to list users, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userList := []*User{}
|
|
||||||
for _, user := range list {
|
|
||||||
userList = append(userList, convertUserFromStore(user))
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, userList)
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /api/user/me is used to check if the user is logged in.
|
|
||||||
g.GET("/user/me", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/user/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.PATCH("/user/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
currentUserID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: ¤tUserID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err)
|
|
||||||
}
|
|
||||||
if currentUser == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userPatch := &PatchUserRequest{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUser := &store.UpdateUser{
|
|
||||||
ID: userID,
|
|
||||||
}
|
|
||||||
if userPatch.Email != nil {
|
|
||||||
if !validateEmail(*userPatch.Email) {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
|
||||||
}
|
|
||||||
updateUser.Email = userPatch.Email
|
|
||||||
}
|
|
||||||
if userPatch.Nickname != nil {
|
|
||||||
updateUser.Nickname = userPatch.Nickname
|
|
||||||
}
|
|
||||||
if userPatch.Password != nil && *userPatch.Password != "" {
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to hash password, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordHashStr := string(passwordHash)
|
|
||||||
updateUser.PasswordHash = &passwordHashStr
|
|
||||||
}
|
|
||||||
if userPatch.RowStatus != nil {
|
|
||||||
rowStatus := store.RowStatus(*userPatch.RowStatus)
|
|
||||||
updateUser.RowStatus = &rowStatus
|
|
||||||
}
|
|
||||||
if userPatch.Role != nil {
|
|
||||||
adminRole := store.RoleAdmin
|
|
||||||
adminUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
|
|
||||||
Role: &adminRole,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list admin users, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if len(adminUsers) == 1 && adminUsers[0].ID == userID && *userPatch.Role != RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "cannot remove admin role from the last admin user")
|
|
||||||
}
|
|
||||||
role := store.Role(*userPatch.Role)
|
|
||||||
updateUser.Role = &role
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.UpdateUser(ctx, updateUser)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to update user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.DELETE("/user/:id", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
currentUserID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: ¤tUserID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find current session user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if currentUser == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if currentUser.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
|
||||||
}
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user.Role == store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
|
||||||
ID: userID,
|
|
||||||
}); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateEmail validates the email.
|
|
||||||
func validateEmail(email string) bool {
|
|
||||||
if _, err := mail.ParseAddress(email); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// convertUserFromStore converts a store user to a user.
|
|
||||||
func convertUserFromStore(user *store.User) *User {
|
|
||||||
return &User{
|
|
||||||
ID: user.ID,
|
|
||||||
CreatedTs: user.CreatedTs,
|
|
||||||
UpdatedTs: user.UpdatedTs,
|
|
||||||
RowStatus: RowStatus(user.RowStatus),
|
|
||||||
Email: user.Email,
|
|
||||||
Nickname: user.Nickname,
|
|
||||||
Role: Role(user.Role),
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,66 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserSettingKey string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// UserSettingLocaleKey is the key type for user locale.
|
|
||||||
UserSettingLocaleKey UserSettingKey = "locale"
|
|
||||||
)
|
|
||||||
|
|
||||||
// String returns the string format of UserSettingKey type.
|
|
||||||
func (k UserSettingKey) String() string {
|
|
||||||
return string(k)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
UserSettingLocaleValue = []string{"en", "zh"}
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserSetting struct {
|
|
||||||
UserID int
|
|
||||||
Key UserSettingKey `json:"key"`
|
|
||||||
// Value is a JSON string with basic value.
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserSettingUpsert struct {
|
|
||||||
UserID int
|
|
||||||
Key UserSettingKey `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (upsert UserSettingUpsert) Validate() error {
|
|
||||||
if upsert.Key == UserSettingLocaleKey {
|
|
||||||
localeValue := "en"
|
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to unmarshal user setting locale value")
|
|
||||||
}
|
|
||||||
|
|
||||||
invalid := true
|
|
||||||
for _, value := range UserSettingLocaleValue {
|
|
||||||
if localeValue == value {
|
|
||||||
invalid = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if invalid {
|
|
||||||
return fmt.Errorf("invalid user setting locale value")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("invalid user setting key")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserSettingFind struct {
|
|
||||||
UserID int
|
|
||||||
|
|
||||||
Key *UserSettingKey `json:"key"`
|
|
||||||
}
|
|
38
api/v1/v1.go
@ -1,38 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/boojack/slash/server/profile"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type APIV1Service struct {
|
|
||||||
Profile *profile.Profile
|
|
||||||
Store *store.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAPIV1Service(profile *profile.Profile, store *store.Store) *APIV1Service {
|
|
||||||
return &APIV1Service{
|
|
||||||
Profile: profile,
|
|
||||||
Store: store,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
|
||||||
apiV1Group := apiGroup.Group("/api/v1")
|
|
||||||
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
||||||
return JWTMiddleware(s, next, secret)
|
|
||||||
})
|
|
||||||
s.registerURLUtilRoutes(apiV1Group)
|
|
||||||
s.registerWorkspaceRoutes(apiV1Group)
|
|
||||||
s.registerAuthRoutes(apiV1Group, secret)
|
|
||||||
s.registerUserRoutes(apiV1Group)
|
|
||||||
s.registerShortcutRoutes(apiV1Group)
|
|
||||||
s.registerAnalyticsRoutes(apiV1Group)
|
|
||||||
|
|
||||||
redirectorGroup := apiGroup.Group("/s")
|
|
||||||
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
||||||
return JWTMiddleware(s, next, secret)
|
|
||||||
})
|
|
||||||
s.registerRedirectorRoutes(redirectorGroup)
|
|
||||||
}
|
|
@ -1,133 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/server/profile"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WorkspaceSetting struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceSettingUpsert struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (upsert WorkspaceSettingUpsert) Validate() error {
|
|
||||||
if upsert.Key == store.WorkspaceDisallowSignUp.String() {
|
|
||||||
value := false
|
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to unmarshal workspace setting disallow signup value")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("invalid workspace setting key")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceProfile struct {
|
|
||||||
Profile *profile.Profile `json:"profile"`
|
|
||||||
DisallowSignUp bool `json:"disallowSignUp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
|
|
||||||
g.GET("/workspace/profile", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
workspaceProfile := WorkspaceProfile{
|
|
||||||
Profile: s.Profile,
|
|
||||||
DisallowSignUp: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
disallowSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
|
||||||
Key: store.WorkspaceDisallowSignUp,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if disallowSignUpSetting != nil {
|
|
||||||
workspaceProfile.DisallowSignUp = disallowSignUpSetting.Value == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, workspaceProfile)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.POST("/workspace/setting", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil || user.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
upsert := &WorkspaceSettingUpsert{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(upsert); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if err := upsert.Validate(); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request body, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceSetting, err := s.Store.UpsertWorkspaceSetting(ctx, &store.WorkspaceSetting{
|
|
||||||
Key: store.WorkspaceSettingKey(upsert.Key),
|
|
||||||
Value: upsert.Value,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert workspace setting, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, convertWorkspaceSettingFromStore(workspaceSetting))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/workspace/setting", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil || user.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := s.Store.ListWorkspaceSettings(ctx, &store.FindWorkspaceSetting{})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list workspace settings, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceSettingList := []*WorkspaceSetting{}
|
|
||||||
for _, workspaceSetting := range list {
|
|
||||||
workspaceSettingList = append(workspaceSettingList, convertWorkspaceSettingFromStore(workspaceSetting))
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, workspaceSettingList)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertWorkspaceSettingFromStore(workspaceSetting *store.WorkspaceSetting) *WorkspaceSetting {
|
|
||||||
return &WorkspaceSetting{
|
|
||||||
Key: workspaceSetting.Key.String(),
|
|
||||||
Value: workspaceSetting.Value,
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
var allowedMethodsWhenUnauthorized = map[string]bool{}
|
|
||||||
|
|
||||||
// isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized.
|
|
||||||
func isUnauthorizeAllowedMethod(methodName string) bool {
|
|
||||||
if strings.HasPrefix(methodName, "/grpc.reflection") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return allowedMethodsWhenUnauthorized[methodName]
|
|
||||||
}
|
|
||||||
|
|
||||||
var allowedMethodsOnlyForAdmin = map[string]bool{
|
|
||||||
"/slash.api.v2.UserService/CreateUser": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
|
|
||||||
func isOnlyForAdminAllowedMethod(methodName string) bool {
|
|
||||||
return allowedMethodsOnlyForAdmin[methodName]
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
|
|
||||||
switch rowStatus {
|
|
||||||
case store.Normal:
|
|
||||||
return apiv2pb.RowStatus_NORMAL
|
|
||||||
case store.Archived:
|
|
||||||
return apiv2pb.RowStatus_ARCHIVED
|
|
||||||
default:
|
|
||||||
return apiv2pb.RowStatus_ROW_STATUS_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,153 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ShortcutService struct {
|
|
||||||
apiv2pb.UnimplementedShortcutServiceServer
|
|
||||||
|
|
||||||
Secret string
|
|
||||||
Store *store.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewShortcutService creates a new Shortcut service.
|
|
||||||
func NewShortcutService(secret string, store *store.Store) *ShortcutService {
|
|
||||||
return &ShortcutService{
|
|
||||||
Secret: secret,
|
|
||||||
Store: store,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
|
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
|
||||||
find := &store.FindShortcut{}
|
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
|
||||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to fetch visible shortcut list, err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
|
|
||||||
find.CreatorID = &userID
|
|
||||||
shortcutList, err := s.Store.ListShortcuts(ctx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to fetch private shortcut list, err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutList = append(shortcutList, visibleShortcutList...)
|
|
||||||
shortcuts := []*apiv2pb.Shortcut{}
|
|
||||||
for _, shortcut := range shortcutList {
|
|
||||||
shortcuts = append(shortcuts, convertShortcutFromStorepb(shortcut))
|
|
||||||
}
|
|
||||||
|
|
||||||
response := &apiv2pb.ListShortcutsResponse{
|
|
||||||
Shortcuts: shortcuts,
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ShortcutService) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) {
|
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
|
||||||
Name: &request.Name,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
|
|
||||||
}
|
|
||||||
if shortcut == nil {
|
|
||||||
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
|
||||||
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
|
||||||
}
|
|
||||||
shortcutMessage := convertShortcutFromStorepb(shortcut)
|
|
||||||
response := &apiv2pb.GetShortcutResponse{
|
|
||||||
Shortcut: shortcutMessage,
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ShortcutService) CreateShortcut(ctx context.Context, request *apiv2pb.CreateShortcutRequest) (*apiv2pb.CreateShortcutResponse, error) {
|
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
|
||||||
shortcut := &storepb.Shortcut{
|
|
||||||
CreatorId: userID,
|
|
||||||
Name: request.Shortcut.Name,
|
|
||||||
Link: request.Shortcut.Link,
|
|
||||||
Title: request.Shortcut.Title,
|
|
||||||
Tags: request.Shortcut.Tags,
|
|
||||||
Description: request.Shortcut.Description,
|
|
||||||
Visibility: storepb.Visibility(request.Shortcut.Visibility),
|
|
||||||
OgMetadata: &storepb.OpenGraphMetadata{},
|
|
||||||
}
|
|
||||||
if request.Shortcut.OgMetadata != nil {
|
|
||||||
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
|
||||||
Title: request.Shortcut.OgMetadata.Title,
|
|
||||||
Description: request.Shortcut.OgMetadata.Description,
|
|
||||||
Image: request.Shortcut.OgMetadata.Image,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to create shortcut, err: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to create activity, err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
response := &apiv2pb.CreateShortcutResponse{
|
|
||||||
Shortcut: convertShortcutFromStorepb(shortcut),
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ShortcutService) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
|
||||||
payload := &storepb.ActivityShorcutCreatePayload{
|
|
||||||
ShortcutId: shortcut.Id,
|
|
||||||
}
|
|
||||||
payloadStr, err := protojson.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
|
||||||
}
|
|
||||||
activity := &store.Activity{
|
|
||||||
CreatorID: shortcut.CreatorId,
|
|
||||||
Type: store.ActivityShortcutCreate,
|
|
||||||
Level: store.ActivityInfo,
|
|
||||||
Payload: string(payloadStr),
|
|
||||||
}
|
|
||||||
_, err = s.Store.CreateActivity(ctx, activity)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to create activity")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *apiv2pb.Shortcut {
|
|
||||||
return &apiv2pb.Shortcut{
|
|
||||||
Id: shortcut.Id,
|
|
||||||
CreatorId: shortcut.CreatorId,
|
|
||||||
CreatedTs: shortcut.CreatedTs,
|
|
||||||
UpdatedTs: shortcut.UpdatedTs,
|
|
||||||
RowStatus: apiv2pb.RowStatus(shortcut.RowStatus),
|
|
||||||
Name: shortcut.Name,
|
|
||||||
Link: shortcut.Link,
|
|
||||||
Title: shortcut.Title,
|
|
||||||
Tags: shortcut.Tags,
|
|
||||||
Description: shortcut.Description,
|
|
||||||
Visibility: apiv2pb.Visibility(shortcut.Visibility),
|
|
||||||
OgMetadata: &apiv2pb.OpenGraphMetadata{
|
|
||||||
Title: shortcut.OgMetadata.Title,
|
|
||||||
Description: shortcut.OgMetadata.Description,
|
|
||||||
Image: shortcut.OgMetadata.Image,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,257 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserService struct {
|
|
||||||
apiv2pb.UnimplementedUserServiceServer
|
|
||||||
|
|
||||||
Secret string
|
|
||||||
Store *store.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserService creates a new UserService.
|
|
||||||
func NewUserService(secret string, store *store.Store) *UserService {
|
|
||||||
return &UserService{
|
|
||||||
Secret: secret,
|
|
||||||
Store: store,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &request.Id,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to find user: %v", err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
userMessage := convertUserFromStore(user)
|
|
||||||
response := &apiv2pb.GetUserResponse{
|
|
||||||
User: userMessage,
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) {
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to hash password: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.CreateUser(ctx, &store.User{
|
|
||||||
Email: request.User.Email,
|
|
||||||
Nickname: request.User.Nickname,
|
|
||||||
Role: store.RoleUser,
|
|
||||||
PasswordHash: string(passwordHash),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
|
|
||||||
}
|
|
||||||
response := &apiv2pb.CreateUserResponse{
|
|
||||||
User: convertUserFromStore(user),
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
|
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
|
||||||
if userID != request.Id {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessTokens := []*apiv2pb.UserAccessToken{}
|
|
||||||
for _, userAccessToken := range userAccessTokens {
|
|
||||||
claims := &auth.ClaimsMessage{}
|
|
||||||
_, err := jwt.ParseWithClaims(userAccessToken.AccessToken, claims, func(t *jwt.Token) (any, error) {
|
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
|
||||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
|
||||||
}
|
|
||||||
if kid, ok := t.Header["kid"].(string); ok {
|
|
||||||
if kid == "v1" {
|
|
||||||
return []byte(s.Secret), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// If the access token is invalid or expired, just ignore it.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
userAccessToken := &apiv2pb.UserAccessToken{
|
|
||||||
AccessToken: userAccessToken.AccessToken,
|
|
||||||
Description: userAccessToken.Description,
|
|
||||||
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
|
||||||
}
|
|
||||||
if claims.ExpiresAt != nil {
|
|
||||||
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
|
|
||||||
}
|
|
||||||
accessTokens = append(accessTokens, userAccessToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by issued time in descending order.
|
|
||||||
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) bool {
|
|
||||||
return i.IssuedAt.Seconds > j.IssuedAt.Seconds
|
|
||||||
})
|
|
||||||
response := &apiv2pb.ListUserAccessTokensResponse{
|
|
||||||
AccessTokens: accessTokens,
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
|
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
|
||||||
if userID != request.Id {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, request.UserAccessToken.ExpiresAt.AsTime(), s.Secret)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
claims := &auth.ClaimsMessage{}
|
|
||||||
_, err = jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
|
||||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
|
||||||
}
|
|
||||||
if kid, ok := t.Header["kid"].(string); ok {
|
|
||||||
if kid == "v1" {
|
|
||||||
return []byte(s.Secret), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to parse access token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert the access token to user setting store.
|
|
||||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, request.UserAccessToken.Description); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userAccessToken := &apiv2pb.UserAccessToken{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
Description: request.UserAccessToken.Description,
|
|
||||||
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
|
||||||
}
|
|
||||||
if claims.ExpiresAt != nil {
|
|
||||||
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
|
|
||||||
}
|
|
||||||
response := &apiv2pb.CreateUserAccessTokenResponse{
|
|
||||||
AccessToken: userAccessToken,
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
|
|
||||||
userID := ctx.Value(UserIDContextKey).(int32)
|
|
||||||
if userID != request.Id {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
|
||||||
}
|
|
||||||
updatedUserAccessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
|
|
||||||
for _, userAccessToken := range userAccessTokens {
|
|
||||||
if userAccessToken.AccessToken == request.AccessToken {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
updatedUserAccessTokens = append(updatedUserAccessTokens, userAccessToken)
|
|
||||||
}
|
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
|
||||||
UserId: userID,
|
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
|
||||||
Value: &storepb.UserSetting_AccessTokensUserSetting{
|
|
||||||
AccessTokensUserSetting: &storepb.AccessTokensUserSetting{
|
|
||||||
AccessTokens: updatedUserAccessTokens,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error {
|
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "failed to get user access tokens")
|
|
||||||
}
|
|
||||||
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
Description: description,
|
|
||||||
}
|
|
||||||
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
|
||||||
UserId: user.ID,
|
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
|
||||||
Value: &storepb.UserSetting_AccessTokensUserSetting{
|
|
||||||
AccessTokensUserSetting: &storepb.AccessTokensUserSetting{
|
|
||||||
AccessTokens: userAccessTokens,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return errors.Wrap(err, "failed to upsert user setting")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertUserFromStore(user *store.User) *apiv2pb.User {
|
|
||||||
return &apiv2pb.User{
|
|
||||||
Id: int32(user.ID),
|
|
||||||
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
|
||||||
CreatedTs: user.CreatedTs,
|
|
||||||
UpdatedTs: user.UpdatedTs,
|
|
||||||
Role: convertUserRoleFromStore(user.Role),
|
|
||||||
Email: user.Email,
|
|
||||||
Nickname: user.Nickname,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertUserRoleFromStore(role store.Role) apiv2pb.Role {
|
|
||||||
switch role {
|
|
||||||
case store.RoleAdmin:
|
|
||||||
return apiv2pb.Role_ADMIN
|
|
||||||
case store.RoleUser:
|
|
||||||
return apiv2pb.Role_USER
|
|
||||||
default:
|
|
||||||
return apiv2pb.Role_ROLE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
71
api/v2/v2.go
@ -1,71 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
|
||||||
"github.com/boojack/slash/server/profile"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
)
|
|
||||||
|
|
||||||
type APIV2Service struct {
|
|
||||||
Secret string
|
|
||||||
Profile *profile.Profile
|
|
||||||
Store *store.Store
|
|
||||||
|
|
||||||
grpcServer *grpc.Server
|
|
||||||
grpcServerPort int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, grpcServerPort int) *APIV2Service {
|
|
||||||
authProvider := NewGRPCAuthInterceptor(store, secret)
|
|
||||||
grpcServer := grpc.NewServer(
|
|
||||||
grpc.ChainUnaryInterceptor(
|
|
||||||
authProvider.AuthenticationInterceptor,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
apiv2pb.RegisterUserServiceServer(grpcServer, NewUserService(secret, store))
|
|
||||||
apiv2pb.RegisterShortcutServiceServer(grpcServer, NewShortcutService(secret, store))
|
|
||||||
|
|
||||||
return &APIV2Service{
|
|
||||||
Secret: secret,
|
|
||||||
Profile: profile,
|
|
||||||
Store: store,
|
|
||||||
grpcServer: grpcServer,
|
|
||||||
grpcServerPort: grpcServerPort,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
|
||||||
return s.grpcServer
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterGateway registers the gRPC-Gateway with the given Echo instance.
|
|
||||||
func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error {
|
|
||||||
// Create a client connection to the gRPC Server we just started.
|
|
||||||
// This is where the gRPC-Gateway proxies the requests.
|
|
||||||
conn, err := grpc.DialContext(
|
|
||||||
ctx,
|
|
||||||
fmt.Sprintf(":%d", s.grpcServerPort),
|
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
gwMux := grpcRuntime.NewServeMux()
|
|
||||||
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := apiv2pb.RegisterShortcutServiceHandler(context.Background(), gwMux, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
143
bin/slash/main.go
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
|
"github.com/yourselfhosted/slash/server"
|
||||||
|
"github.com/yourselfhosted/slash/server/common"
|
||||||
|
"github.com/yourselfhosted/slash/server/profile"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
|
"github.com/yourselfhosted/slash/store/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
greetingBanner = `Welcome to Slash!`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
rootCmd = &cobra.Command{
|
||||||
|
Use: "slash",
|
||||||
|
Short: `An open source, self-hosted platform for sharing and managing your most frequently used links.`,
|
||||||
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
|
serverProfile := &profile.Profile{
|
||||||
|
Mode: viper.GetString("mode"),
|
||||||
|
Port: viper.GetInt("port"),
|
||||||
|
Data: viper.GetString("data"),
|
||||||
|
DSN: viper.GetString("dsn"),
|
||||||
|
Driver: viper.GetString("driver"),
|
||||||
|
Version: common.GetCurrentVersion(viper.GetString("mode")),
|
||||||
|
}
|
||||||
|
if err := serverProfile.Validate(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
dbDriver, err := db.NewDBDriver(serverProfile)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
slog.Error("failed to create db driver", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storeInstance := store.New(dbDriver, serverProfile)
|
||||||
|
if err := storeInstance.Migrate(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
slog.Error("failed to migrate db", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := server.NewServer(ctx, serverProfile, storeInstance)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
slog.Error("failed to create server", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := make(chan os.Signal, 1)
|
||||||
|
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
||||||
|
// The default signal sent by the `kill` command is SIGTERM,
|
||||||
|
// which is taken as the graceful shutdown signal for many systems, eg., Kubernetes, Gunicorn.
|
||||||
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
sig := <-c
|
||||||
|
slog.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||||
|
s.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
printGreetings(serverProfile)
|
||||||
|
|
||||||
|
if err := s.Start(ctx); err != nil {
|
||||||
|
if err != http.ErrServerClosed {
|
||||||
|
slog.Error("failed to start server", "error", err)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for CTRL-C.
|
||||||
|
<-ctx.Done()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
viper.SetDefault("mode", "demo")
|
||||||
|
viper.SetDefault("driver", "sqlite")
|
||||||
|
viper.SetDefault("port", 8082)
|
||||||
|
|
||||||
|
rootCmd.PersistentFlags().String("mode", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
||||||
|
rootCmd.PersistentFlags().String("addr", "", "address of server")
|
||||||
|
rootCmd.PersistentFlags().Int("port", 8082, "port of server")
|
||||||
|
rootCmd.PersistentFlags().String("data", "", "data directory")
|
||||||
|
rootCmd.PersistentFlags().String("driver", "sqlite", "database driver")
|
||||||
|
rootCmd.PersistentFlags().String("dsn", "", "database source name(aka. DSN)")
|
||||||
|
|
||||||
|
if err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := viper.BindPFlag("driver", rootCmd.PersistentFlags().Lookup("driver")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
viper.SetEnvPrefix("slash")
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printGreetings(serverProfile *profile.Profile) {
|
||||||
|
println("---")
|
||||||
|
println("Server profile")
|
||||||
|
println("dsn:", serverProfile.DSN)
|
||||||
|
println("port:", serverProfile.Port)
|
||||||
|
println("mode:", serverProfile.Mode)
|
||||||
|
println("version:", serverProfile.Version)
|
||||||
|
println("---")
|
||||||
|
println(greetingBanner)
|
||||||
|
fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port)
|
||||||
|
println("---")
|
||||||
|
println("See more in:")
|
||||||
|
fmt.Printf("👉GitHub: %s\n", "https://github.com/yourselfhosted/slash")
|
||||||
|
println("---")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
@ -1,130 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/server"
|
|
||||||
_profile "github.com/boojack/slash/server/profile"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/boojack/slash/store/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
greetingBanner = `Welcome to Slash!`
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
profile *_profile.Profile
|
|
||||||
mode string
|
|
||||||
port int
|
|
||||||
data string
|
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
|
||||||
Use: "slash",
|
|
||||||
Short: `An open source, self-hosted bookmarks and link sharing platform.`,
|
|
||||||
Run: func(_cmd *cobra.Command, _args []string) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
db := db.NewDB(profile)
|
|
||||||
if err := db.Open(ctx); err != nil {
|
|
||||||
cancel()
|
|
||||||
fmt.Printf("failed to open db, error: %+v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
storeInstance := store.New(db.DBInstance, profile)
|
|
||||||
s, err := server.NewServer(ctx, profile, storeInstance)
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
fmt.Printf("failed to create server, error: %+v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c := make(chan os.Signal, 1)
|
|
||||||
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
|
||||||
// The default signal sent by the `kill` command is SIGTERM,
|
|
||||||
// which is taken as the graceful shutdown signal for many systems, eg., Kubernetes, Gunicorn.
|
|
||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
||||||
go func() {
|
|
||||||
sig := <-c
|
|
||||||
fmt.Printf("%s received.\n", sig.String())
|
|
||||||
s.Shutdown(ctx)
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
println(greetingBanner)
|
|
||||||
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
|
||||||
if err := s.Start(ctx); err != nil {
|
|
||||||
if err != http.ErrServerClosed {
|
|
||||||
fmt.Printf("failed to start server, error: %+v\n", err)
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for CTRL-C.
|
|
||||||
<-ctx.Done()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func Execute() error {
|
|
||||||
return rootCmd.Execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cobra.OnInitialize(initConfig)
|
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
|
||||||
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8082, "port of server")
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
|
||||||
|
|
||||||
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
viper.SetDefault("mode", "demo")
|
|
||||||
viper.SetDefault("port", 8082)
|
|
||||||
viper.SetEnvPrefix("slash")
|
|
||||||
}
|
|
||||||
|
|
||||||
func initConfig() {
|
|
||||||
viper.AutomaticEnv()
|
|
||||||
var err error
|
|
||||||
profile, err = _profile.GetProfile()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("failed to get profile, error: %+v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
println("---")
|
|
||||||
println("Server profile")
|
|
||||||
println("dsn:", profile.DSN)
|
|
||||||
println("port:", profile.Port)
|
|
||||||
println("mode:", profile.Mode)
|
|
||||||
println("version:", profile.Version)
|
|
||||||
println("---")
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
err := Execute()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
13
docker-compose.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
slash:
|
||||||
|
image: yourselfhosted/slash:latest
|
||||||
|
container_name: slash
|
||||||
|
ports:
|
||||||
|
- 5231:5231
|
||||||
|
volumes:
|
||||||
|
- slash:/var/opt/slash
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
slash:
|
BIN
docs/assets/browser-extension-example.png
Normal file
After Width: | Height: | Size: 134 KiB |
BIN
docs/assets/demo.png
Normal file
After Width: | Height: | Size: 613 KiB |
Before Width: | Height: | Size: 155 KiB |
Before Width: | Height: | Size: 154 KiB |
Before Width: | Height: | Size: 77 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 69 KiB |
BIN
docs/assets/getting-started/github-sso.png
Normal file
After Width: | Height: | Size: 180 KiB |
BIN
docs/assets/getting-started/sso-setting.png
Normal file
After Width: | Height: | Size: 144 KiB |
BIN
docs/assets/wechat.png
Normal file
After Width: | Height: | Size: 125 KiB |
43
docs/getting-started/collections.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Slash Collections
|
||||||
|
|
||||||
|
**Slash Collections** introduces a feature to help you better organize and manage related Shortcuts.
|
||||||
|
|
||||||
|
## What is a Collection?
|
||||||
|
|
||||||
|
A Collection is like a virtual folder where you can group and organize your related Shortcuts. It acts as a container that holds Shortcuts together for a specific purpose or theme. Let's break down the key attributes:
|
||||||
|
|
||||||
|
- **Name:** Your chosen label for the Collection. This becomes a crucial part of the URL, enabling direct and quick access to the Collection. For example, if your Collection is named "work-projects", the direct access link would be `c/work-projects`. This user-defined name significantly enhances the accessibility and recognition of your Collections.
|
||||||
|
- **Title:** A brief title summarizing the Collection's content.
|
||||||
|
- **Description:** A short description explaining what the Collection is about.
|
||||||
|
- **Shortcuts:** The Shortcuts included in the Collection.
|
||||||
|
- **Visibility:** Settings to control who can access the Collection.
|
||||||
|
|
||||||
|
## What Problems Does It Solve?
|
||||||
|
|
||||||
|
Slash Collections tackle the challenge of efficiently managing and organizing related Shortcuts. By grouping Shortcuts into Collections, you can create a more structured and accessible workflow. This makes it easier to find, access, and share information based on specific themes or projects.
|
||||||
|
|
||||||
|
## How to Use Collections
|
||||||
|
|
||||||
|
### Creating a Collection
|
||||||
|
|
||||||
|
1. **Define the Collection:** Give your Collection a meaningful name and a descriptive title.
|
||||||
|
2. **Add Details:** Provide a brief description of the content within the Collection.
|
||||||
|
3. **Add Shortcuts:** Include relevant Shortcuts by selecting them from your existing list.
|
||||||
|
4. **Set Visibility:** Choose who should have access to the Collection.
|
||||||
|
5. **Save:** Once saved, your Collection is ready to use.
|
||||||
|
|
||||||
|
### Accessing Collections
|
||||||
|
|
||||||
|
Access a Collection directly by using the assigned name. For example, if your Collection is named "work-projects", the direct access link would be `{YOUR_DOMAIN}/c/work-projects`.
|
||||||
|
|
||||||
|
### Updating and Managing Collections
|
||||||
|
|
||||||
|
Modify Collection details, such as name, title, or included Shortcuts, to keep your organization streamlined and relevant.
|
||||||
|
|
||||||
|
### Sharing Collections
|
||||||
|
|
||||||
|
Share Collections by providing the assigned name to collaborators for easy access to grouped Shortcuts.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Slash Collections offer a user-friendly and organized way to group, manage, and share related Shortcuts. By utilizing the defined Collection attributes, users can seamlessly categorize and access information, promoting collaboration and improving overall productivity.
|
47
docs/getting-started/shortcuts.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Slash Shortcuts
|
||||||
|
|
||||||
|
**Slash Shortcuts** is a handy tool designed to make handling and sharing links in your digital workspace a breeze.
|
||||||
|
|
||||||
|
## What is a Shortcut?
|
||||||
|
|
||||||
|
A Shortcut is a simplified version of a link with essential details, making it easy to remember, organize, and share. Let's break down the key elements:
|
||||||
|
|
||||||
|
- **Name:** Your chosen label for the Shortcut. This becomes a crucial part of the URL, enabling direct and quick access to the Shortcut. For example, if your Shortcut is named "meet-john", the direct access link would be `s/meet-john`. This user-defined name significantly enhances the accessibility and recognition of your Shortcuts.
|
||||||
|
- **Link:** The original web link you want to streamline.
|
||||||
|
- **Title:** A quick overview of what's behind the link.
|
||||||
|
- **Tags:** Custom labels for easy sorting.
|
||||||
|
- **Description:** A short summary of the content.
|
||||||
|
- **Visibility:** Controls who can access the Shortcut.
|
||||||
|
|
||||||
|
## How to Use Shortcuts
|
||||||
|
|
||||||
|
### Creating a Shortcut
|
||||||
|
|
||||||
|
1. **Define the Link:** Paste the original link you want to simplify.
|
||||||
|
2. **Add Details:** Give it a name, tags, and a brief description for better organization.
|
||||||
|
3. **Set Visibility:** Choose who should be able to access the Shortcut.
|
||||||
|
4. **Save:** Once saved, your Shortcut is ready to go.
|
||||||
|
|
||||||
|
### Accessing Shortcuts
|
||||||
|
|
||||||
|
#### Direct Access
|
||||||
|
|
||||||
|
Effortlessly access your Shortcut's content directly by using the assigned name as part of the Slash Shortcuts format.
|
||||||
|
|
||||||
|
For example, if your Shortcut is named "meet-john", the direct access link would be `{YOUR_DOMAIN}/s/meet-john`. Simply enter this user-friendly shortcut into your browser to reach the associated content with ease.
|
||||||
|
|
||||||
|
#### Browser Extension Access
|
||||||
|
|
||||||
|
Install the Slash Shortcuts browser extension for even quicker access. Once installed, simply type `s/meet-john` into your browser's address bar, and the extension will seamlessly redirect you to the corresponding page.
|
||||||
|
|
||||||
|
### Updating and Managing Shortcuts
|
||||||
|
|
||||||
|
Adjust attributes like name and tags to update a Shortcut. Keep your Shortcuts organized based on categories and visibility settings.
|
||||||
|
|
||||||
|
### Sharing Shortcuts
|
||||||
|
|
||||||
|
Share Shortcuts by providing the assigned name to collaborators for easy access.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Shortcuts provide a simple way to manage, organize, and share links within your digital workspace. By using the defined Shortcut attributes, users can easily create, access, and share information, promoting collaboration and boosting productivity.
|
37
docs/getting-started/sso.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Single Sign-On(SSO)
|
||||||
|
|
||||||
|
> **Note**: This feature is only available in the **Enterprise** plan.
|
||||||
|
|
||||||
|
**Single Sign-On (SSO)** is an authentication method that enables users to securely authenticate with multiple applications and websites by using just one set of credentials.
|
||||||
|
|
||||||
|
Slash supports SSO integration with **OAuth 2.0** standard.
|
||||||
|
|
||||||
|
## Create a new SSO provider
|
||||||
|
|
||||||
|
As an Admin user, you can create a new SSO provider in Setting > Workspace settings > SSO.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
For example, to integrate with GitHub, you might need to fill in the following fields:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Identity provider information
|
||||||
|
|
||||||
|
The information is the base concept of OAuth 2.0 and comes from your provider.
|
||||||
|
|
||||||
|
- **Client ID** is a public identifier of the custom provider;
|
||||||
|
- **Client Secret** is the OAuth2 client secret from identity provider;
|
||||||
|
- **Authorization endpoint** is the custom provider's OAuth2 login page address;
|
||||||
|
- **Token endpoint** is the API address for obtaining access token;
|
||||||
|
- **User endpoint** URL is the API address for obtaining user information by access token;
|
||||||
|
- **Scopes** is the scope parameter carried when accessing the OAuth2 URL, which is filled in according to the custom provider;
|
||||||
|
|
||||||
|
### User information mapping
|
||||||
|
|
||||||
|
For different providers, the structures returned by their user information API are usually not the same. In order to know how to map the user information from an provider into user fields, you need to fill the user information mapping form.
|
||||||
|
|
||||||
|
Slash will use the mapping to import the user profile fields when creating new accounts. The most important user field mapping is the identifier which is used to identify the Slash account associated with the OAuth 2.0 login.
|
||||||
|
|
||||||
|
- **Identifier** is the field name of primary email in 3rd-party user info;
|
||||||
|
- **Display name** is the field name of display name in 3rd-party user info (optional);
|
44
docs/getting-started/subscription.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Subscription
|
||||||
|
|
||||||
|
Slash is an open source, self-hosted platform for sharing and managing your most frequently used links. Easily create customizable, human-readable shortcuts to streamline your link management. Our source code is available and accessible on GitHub so anyone can get it, inspect it and review it.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
|
||||||
|
### Free
|
||||||
|
|
||||||
|
The Free plan is designed for personal use not for commercial use. It allows you to create up to 100 shortcuts and invite up to 5 members.
|
||||||
|
|
||||||
|
### Pro
|
||||||
|
|
||||||
|
The Pro plan is designed for teams and businesses. It allows you to create unlimited shortcuts and invite unlimited members. It also includes priority support. The Pro plan is $4 per month.
|
||||||
|
|
||||||
|
### Team
|
||||||
|
|
||||||
|
The Team plan is designed for teams that need more than the Pro plan. It allows you to use Single Sign-On(SSO) and other advanced features. If you need a team plan, please contact us at `yourselfhosted@gmail.com`.
|
||||||
|
|
||||||
|
## Using a License Key
|
||||||
|
|
||||||
|
After purchasing a Pro or Team plan, you will receive a license key. You can use the license key to activate your plan. Here is how to do it:
|
||||||
|
|
||||||
|
1. Log in to your Slash instance as an Admin user.
|
||||||
|
2. Go to Settings > Subscription. `https://your-slash-instance.com/setting/subscription`
|
||||||
|
3. You will see a form to enter your license key. Enter your license key and click the **Upload license** button.
|
||||||
|
4. If the license key is valid, your plan will be activated.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Can I use the Free plan in my team?
|
||||||
|
|
||||||
|
Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you should upgrade to the Pro plan.
|
||||||
|
|
||||||
|
### How many devices can the license key be used on?
|
||||||
|
|
||||||
|
It's unlimited for now, but please do not abuse it.
|
||||||
|
|
||||||
|
### Can I get a refund if Slash doesn't meet my needs?
|
||||||
|
|
||||||
|
Yes, absolutely! You can contact us with `yourselfhosted@gmail.com`. I will refund you as soon as possible.
|
||||||
|
|
||||||
|
### Is there a Lifetime license?
|
||||||
|
|
||||||
|
As software requires someone to maintain it, so we won't sell a lifetime service, since humans are not immortal yet. But if you really want it, please contact us `yourselfhosted@gmail.com`.
|
@ -8,36 +8,25 @@ Slash provides a browser extension to help you use your shortcuts in the search
|
|||||||
|
|
||||||
For Chromuim based browsers, you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
|
For Chromuim based browsers, you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
|
||||||
|
|
||||||
For Firefox, we don't support the Firefox Add-ons platform yet. And we are working on it.
|
For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/your-slash/).
|
||||||
|
|
||||||
### Generate an access token
|
### Prerequisites
|
||||||
|
|
||||||
1. Go to your Slash instance and sign in with your account.
|
- You need to have a Slash instance running.
|
||||||
|
- Sign in with your account on the Slash instance.
|
||||||
2. Go to the settings page and click on the "Create" button to create an access token.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
3. Copy the access token and save it somewhere safe.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Configure the extension
|
### Configure the extension
|
||||||
|
|
||||||
|
The extension needs to know the instance url of your Slash. You can configure it by following the steps below:
|
||||||
|
|
||||||
1. Click on the extension icon and click on the "Settings" button.
|
1. Click on the extension icon and click on the "Settings" button.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
2. Enter your Slash's domain and paste the access token you generated in the previous step.
|
2. Enter the instance url of your Slash and then "Save".
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
3. Click on the "Save" button to save the settings.
|
|
||||||
|
|
||||||
4. Click on the extension icon again, you will see a list of your shortcuts.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Use your shortcuts in the search bar
|
### Use your shortcuts in the search bar
|
||||||
|
|
||||||
You can use your shortcuts in the search bar of your browser. For example, if you have a shortcut named `gh` for [GitHub](https://github.com), you can type `s/gh` in the search bar and press `Enter` to go to [GitHub](https://github.com).
|
You can use your shortcuts in the search bar of your browser. For example, if you have a shortcut named `gh` for [GitHub](https://github.com), you can type `s/gh` in the search bar and press `Enter` to go to [GitHub](https://github.com).
|
||||||
|
@ -16,7 +16,7 @@ docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash
|
|||||||
|
|
||||||
This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory.
|
This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory.
|
||||||
|
|
||||||
## Upgrade
|
### Upgrade
|
||||||
|
|
||||||
To upgrade Slash to latest version, stop and remove the old container first:
|
To upgrade Slash to latest version, stop and remove the old container first:
|
||||||
|
|
||||||
@ -37,3 +37,45 @@ docker pull yourselfhosted/slash:latest
|
|||||||
```
|
```
|
||||||
|
|
||||||
Finally, restart Slash by following the steps in [Docker Run](#docker-run).
|
Finally, restart Slash by following the steps in [Docker Run](#docker-run).
|
||||||
|
|
||||||
|
## Docker Compose Run
|
||||||
|
|
||||||
|
Assume that docker compose is deployed in the `/opt/slash` directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/slash && cd /opt/slash
|
||||||
|
curl -#LO https://github.com/yourselfhosted/slash/raw/main/docker-compose.yml
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start Slash in the background and expose it on port `5231`. Data is stored in Docker Volume `slash_slash`. You can customize the port and backup your volume.
|
||||||
|
|
||||||
|
## Use PostgreSQL as Database
|
||||||
|
|
||||||
|
Slash supports the following database types:
|
||||||
|
|
||||||
|
- SQLite (default)
|
||||||
|
- PostgreSQL
|
||||||
|
|
||||||
|
### Using PostgreSQL
|
||||||
|
|
||||||
|
To switch to PostgreSQL, you can use the following steps:
|
||||||
|
|
||||||
|
- **--driver** _postgres_ : This argument specifies that Slash should use the `postgres` driver instead of the default `sqlite`.
|
||||||
|
|
||||||
|
- **--dsn** _postgresql://postgres:PASSWORD@localhost:5432/slash_ : Provides the connection details for your PostgreSQL server.
|
||||||
|
|
||||||
|
You can start Slash with Docker using the following command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash yourselfhosted/slash:latest --driver postgres --dsn 'postgresql://postgres:PASSWORD@localhost:5432/slash'
|
||||||
|
```
|
||||||
|
|
||||||
|
Additionally, you can set these configurations via environment variables:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
SLASH_DRIVER=postgres
|
||||||
|
SLASH_DSN=postgresql://root:password@localhost:5432/slash
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that if the PostgreSQL server is not configured to support SSL connections you will need to add `?sslmode=disable` to the DSN.
|
||||||
|
7
docs/privacy-policy.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Privacy Policy
|
||||||
|
|
||||||
|
Slash does not collect, store, or share any data from you.
|
||||||
|
|
||||||
|
You can use our application freely and without concern about your personal data being tracked or stored.
|
||||||
|
|
||||||
|
Our primary goal is to provide you with a secure and private experience.
|
Before Width: | Height: | Size: 83 KiB |
@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "slash-extension",
|
|
||||||
"displayName": "Slash",
|
|
||||||
"version": "0.1.3",
|
|
||||||
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "plasmo dev",
|
|
||||||
"build": "plasmo build",
|
|
||||||
"package": "plasmo package",
|
|
||||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
|
||||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@bufbuild/protobuf": "^1.3.0",
|
|
||||||
"@emotion/react": "^11.11.1",
|
|
||||||
"@emotion/styled": "^11.11.0",
|
|
||||||
"@mui/joy": "5.0.0-beta.0",
|
|
||||||
"@plasmohq/storage": "^1.7.2",
|
|
||||||
"axios": "^1.4.0",
|
|
||||||
"classnames": "^2.3.2",
|
|
||||||
"lodash-es": "^4.17.21",
|
|
||||||
"lucide-react": "^0.264.0",
|
|
||||||
"plasmo": "0.82.0",
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-dom": "18.2.0",
|
|
||||||
"react-hot-toast": "^2.4.1",
|
|
||||||
"zustand": "^4.4.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@trivago/prettier-plugin-sort-imports": "4.1.0",
|
|
||||||
"@types/chrome": "0.0.241",
|
|
||||||
"@types/lodash-es": "^4.17.8",
|
|
||||||
"@types/node": "20.4.2",
|
|
||||||
"@types/react": "18.2.15",
|
|
||||||
"@types/react-dom": "18.2.7",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
|
||||||
"@typescript-eslint/parser": "^6.2.0",
|
|
||||||
"autoprefixer": "^10.4.14",
|
|
||||||
"eslint": "^8.46.0",
|
|
||||||
"eslint-config-prettier": "^8.3.0",
|
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
|
||||||
"eslint-plugin-react": "^7.27.1",
|
|
||||||
"postcss": "^8.4.27",
|
|
||||||
"prettier": "2.6.2",
|
|
||||||
"tailwindcss": "^3.3.3",
|
|
||||||
"typescript": "5.1.6"
|
|
||||||
},
|
|
||||||
"manifest": {
|
|
||||||
"omnibox": {
|
|
||||||
"keyword": "s"
|
|
||||||
},
|
|
||||||
"permissions": [
|
|
||||||
"tabs",
|
|
||||||
"storage"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
7114
extension/pnpm-lock.yaml
generated
@ -1,173 +0,0 @@
|
|||||||
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
|
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import { CreateShortcutResponse, OpenGraphMetadata, Visibility } from "@/types/proto/api/v2/shortcut_service_pb";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
const generateTempName = (length = 6) => {
|
|
||||||
let result = "";
|
|
||||||
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
const charactersLength = characters.length;
|
|
||||||
let counter = 0;
|
|
||||||
while (counter < length) {
|
|
||||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
|
||||||
counter += 1;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreateShortcutsButton = () => {
|
|
||||||
const [domain] = useStorage("domain");
|
|
||||||
const [accessToken] = useStorage("access_token");
|
|
||||||
const [shortcuts, setShortcuts] = useStorage("shortcuts");
|
|
||||||
const [state, setState] = useState<State>({
|
|
||||||
name: "",
|
|
||||||
title: "",
|
|
||||||
link: "",
|
|
||||||
});
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showModal) {
|
|
||||||
document.body.style.height = "384px";
|
|
||||||
} else {
|
|
||||||
document.body.style.height = "auto";
|
|
||||||
}
|
|
||||||
}, [showModal]);
|
|
||||||
|
|
||||||
const handleCreateShortcutButtonClick = async () => {
|
|
||||||
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
|
||||||
if (tabs.length === 0) {
|
|
||||||
toast.error("No active tab found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tab = tabs[0];
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
name: generateTempName().toLowerCase() + "-temp",
|
|
||||||
title: tab.title || "",
|
|
||||||
link: tab.url || "",
|
|
||||||
}));
|
|
||||||
setShowModal(true);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
name: e.target.value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
title: e.target.value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLinkInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
link: e.target.value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveBtnClick = async () => {
|
|
||||||
if (isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!state.name) {
|
|
||||||
toast.error("Name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
data: { shortcut },
|
|
||||||
} = await axios.post<CreateShortcutResponse>(
|
|
||||||
`${domain}/api/v2/shortcuts`,
|
|
||||||
{
|
|
||||||
name: state.name,
|
|
||||||
title: state.title,
|
|
||||||
link: state.link,
|
|
||||||
visibility: Visibility.PRIVATE,
|
|
||||||
ogMetadata: OpenGraphMetadata.fromJsonString("{}"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
setShortcuts([shortcut, ...shortcuts]);
|
|
||||||
toast.success("Shortcut created successfully");
|
|
||||||
setShowModal(false);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error(error.response.data.message);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<IconButton color="primary" variant="solid" size="sm" onClick={() => handleCreateShortcutButtonClick()}>
|
|
||||||
<Icon.Plus className="w-5 h-auto" />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<Modal container={() => document.body} open={showModal} onClose={() => setShowModal(false)}>
|
|
||||||
<ModalDialog>
|
|
||||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
|
||||||
<span className="text-base font-medium">Create Shortcut</span>
|
|
||||||
<Button size="sm" variant="plain" onClick={() => setShowModal(false)}>
|
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-hidden px-2 w-full flex flex-col justify-start items-center">
|
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
|
||||||
<span className="block w-16 shrink-0">Name</span>
|
|
||||||
<Input className="grow" type="text" placeholder="Unique shortcut name" value={state.name} onChange={handleNameInputChange} />
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
|
||||||
<span className="block w-16 shrink-0">Title</span>
|
|
||||||
<Input className="grow" type="text" placeholder="Shortcut title" value={state.title} onChange={handleTitleInputChange} />
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
|
||||||
<span className="block w-16 shrink-0">Link</span>
|
|
||||||
<Input
|
|
||||||
className="grow"
|
|
||||||
type="text"
|
|
||||||
placeholder="https://github.com/boojack/slash"
|
|
||||||
value={state.link}
|
|
||||||
onChange={handleLinkInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
|
|
||||||
<Button color="neutral" variant="plain" onClick={() => setShowModal(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="primary" disabled={isLoading} loading={isLoading} onClick={handleSaveBtnClick}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalDialog>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateShortcutsButton;
|
|
@ -1,45 +0,0 @@
|
|||||||
import { IconButton } from "@mui/joy";
|
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import { ListShortcutsResponse } from "@/types/proto/api/v2/shortcut_service_pb";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
const PullShortcutsButton = () => {
|
|
||||||
const [domain] = useStorage("domain");
|
|
||||||
const [accessToken] = useStorage("access_token");
|
|
||||||
const [, setShortcuts] = useStorage("shortcuts");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (domain && accessToken) {
|
|
||||||
handlePullShortcuts(true);
|
|
||||||
}
|
|
||||||
}, [domain, accessToken]);
|
|
||||||
|
|
||||||
const handlePullShortcuts = async (silence = false) => {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
data: { shortcuts },
|
|
||||||
} = await axios.get<ListShortcutsResponse>(`${domain}/api/v2/shortcuts`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setShortcuts(shortcuts);
|
|
||||||
if (!silence) {
|
|
||||||
toast.success("Shortcuts pulled");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to pull shortcuts, error: " + error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton color="neutral" variant="plain" size="sm" onClick={() => handlePullShortcuts()}>
|
|
||||||
<Icon.RefreshCcw className="w-4 h-auto" />
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PullShortcutsButton;
|
|
@ -1,77 +0,0 @@
|
|||||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
|
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import useFaviconStore from "../stores/favicon";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
shortcut: Shortcut;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShortcutView = (props: Props) => {
|
|
||||||
const { shortcut } = props;
|
|
||||||
const faviconStore = useFaviconStore();
|
|
||||||
const [domain] = useStorage<string>("domain", "");
|
|
||||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
|
||||||
if (url) {
|
|
||||||
setFavicon(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [shortcut.link]);
|
|
||||||
|
|
||||||
const handleShortcutLinkClick = () => {
|
|
||||||
const shortcutLink = `${domain}/s/${shortcut.name}`;
|
|
||||||
chrome.tabs.create({ url: shortcutLink });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="w-full flex flex-row justify-start items-center">
|
|
||||||
<span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
|
||||||
{favicon ? (
|
|
||||||
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
|
||||||
) : (
|
|
||||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div className="ml-1 w-[calc(100%-20px)] flex flex-col justify-start items-start">
|
|
||||||
<div className="w-full flex flex-row justify-start items-center">
|
|
||||||
<button
|
|
||||||
className={classNames(
|
|
||||||
"max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
|
|
||||||
)}
|
|
||||||
onClick={handleShortcutLinkClick}
|
|
||||||
>
|
|
||||||
<div className="truncate">
|
|
||||||
<span>{shortcut.title}</span>
|
|
||||||
{shortcut.title ? (
|
|
||||||
<span className="text-gray-400">(s/{shortcut.name})</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="text-gray-400">s/</span>
|
|
||||||
<span className="truncate">{shortcut.name}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
|
||||||
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortcutView;
|
|
@ -1,18 +0,0 @@
|
|||||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
|
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import ShortcutView from "./ShortcutView";
|
|
||||||
|
|
||||||
const ShortcutsContainer = () => {
|
|
||||||
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", (v) => (v ? v : []));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames("w-full grid grid-cols-2 gap-2")}>
|
|
||||||
{shortcuts.map((shortcut) => {
|
|
||||||
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortcutsContainer;
|
|
@ -1,14 +0,0 @@
|
|||||||
import { Storage } from "@plasmohq/storage";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const storage = new Storage();
|
|
||||||
|
|
||||||
export const getUrlFavicon = async (url: string) => {
|
|
||||||
const domain = await storage.getItem<string>("domain");
|
|
||||||
const accessToken = await storage.getItem<string>("access_token");
|
|
||||||
return axios.get<string>(`${domain}/api/v1/url/favicon?url=${url}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
@ -1,5 +0,0 @@
|
|||||||
import { isNull, isUndefined } from "lodash-es";
|
|
||||||
|
|
||||||
export const isNullorUndefined = (value: any) => {
|
|
||||||
return isNull(value) || isUndefined(value);
|
|
||||||
};
|
|
@ -1,134 +0,0 @@
|
|||||||
import type { Shortcut } from "./types/proto/api/v2/shortcut_service_pb";
|
|
||||||
import { Button, Divider, Input } from "@mui/joy";
|
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Toaster, toast } from "react-hot-toast";
|
|
||||||
import Icon from "./components/Icon";
|
|
||||||
import Logo from "./components/Logo";
|
|
||||||
import PullShortcutsButton from "./components/PullShortcutsButton";
|
|
||||||
import ShortcutsContainer from "./components/ShortcutsContainer";
|
|
||||||
import "./style.css";
|
|
||||||
|
|
||||||
interface SettingState {
|
|
||||||
domain: string;
|
|
||||||
accessToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IndexOptions = () => {
|
|
||||||
const [domain, setDomain] = useStorage<string>("domain", (v) => (v ? v : ""));
|
|
||||||
const [accessToken, setAccessToken] = useStorage<string>("access_token", (v) => (v ? v : ""));
|
|
||||||
const [settingState, setSettingState] = useState<SettingState>({
|
|
||||||
domain,
|
|
||||||
accessToken,
|
|
||||||
});
|
|
||||||
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
|
|
||||||
const isInitialized = domain && accessToken;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSettingState({
|
|
||||||
domain,
|
|
||||||
accessToken,
|
|
||||||
});
|
|
||||||
}, [domain, accessToken]);
|
|
||||||
|
|
||||||
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
|
|
||||||
setSettingState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
...partialSettingState,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveSetting = () => {
|
|
||||||
setDomain(settingState.domain);
|
|
||||||
setAccessToken(settingState.accessToken);
|
|
||||||
toast.success("Setting saved");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="w-full flex flex-row justify-center items-center">
|
|
||||||
<a
|
|
||||||
className="bg-yellow-100 mt-12 py-2 px-3 rounded-full border flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600"
|
|
||||||
href="https://github.com/boojack/slash#browser-extension"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Icon.HelpCircle className="w-4 h-auto" />
|
|
||||||
<span className="mx-1 text-sm">Need help? Check out the docs</span>
|
|
||||||
<Icon.ExternalLink className="w-4 h-auto" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start mt-12">
|
|
||||||
<h2 className="flex flex-row justify-start items-center mb-6 text-2xl">
|
|
||||||
<Logo className="w-10 h-auto mr-2" />
|
|
||||||
<span>Slash</span>
|
|
||||||
<span className="mx-2 text-gray-400">/</span>
|
|
||||||
<span>Setting</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-4">
|
|
||||||
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
|
||||||
<span>Domain</span>
|
|
||||||
{domain !== "" && (
|
|
||||||
<a
|
|
||||||
className="text-sm flex flex-row justify-start items-center hover:underline hover:text-blue-600"
|
|
||||||
href={domain}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<span className="mr-1">Go to my Slash</span>
|
|
||||||
<Icon.ExternalLink className="w-4 h-auto" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="The domain of your Slash instance"
|
|
||||||
value={settingState.domain}
|
|
||||||
onChange={(e) => setPartialSettingState({ domain: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
|
||||||
<span className="mb-2 text-base">Access Token</span>
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="The access token of your Slash instance"
|
|
||||||
value={settingState.accessToken}
|
|
||||||
onChange={(e) => setPartialSettingState({ accessToken: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full mt-6">
|
|
||||||
<Button onClick={handleSaveSetting}>Save</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isInitialized && (
|
|
||||||
<>
|
|
||||||
<Divider className="!my-6" />
|
|
||||||
|
|
||||||
<h2 className="flex flex-row justify-start items-center mb-4">
|
|
||||||
<span className="text-lg">Shortcuts</span>
|
|
||||||
<span className="text-gray-500 mr-1">({shortcuts.length})</span>
|
|
||||||
<PullShortcutsButton />
|
|
||||||
</h2>
|
|
||||||
<ShortcutsContainer />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Toaster position="top-center" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IndexOptions;
|
|
@ -1,109 +0,0 @@
|
|||||||
import { Button, Divider, IconButton } from "@mui/joy";
|
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
|
||||||
import { Toaster } from "react-hot-toast";
|
|
||||||
import CreateShortcutsButton from "@/components/CreateShortcutsButton";
|
|
||||||
import Icon from "@/components/Icon";
|
|
||||||
import Logo from "@/components/Logo";
|
|
||||||
import PullShortcutsButton from "@/components/PullShortcutsButton";
|
|
||||||
import ShortcutsContainer from "@/components/ShortcutsContainer";
|
|
||||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
|
|
||||||
import "./style.css";
|
|
||||||
|
|
||||||
const IndexPopup = () => {
|
|
||||||
const [domain] = useStorage<string>("domain", "");
|
|
||||||
const [accessToken] = useStorage<string>("access_token", "");
|
|
||||||
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
|
|
||||||
const isInitialized = domain && accessToken;
|
|
||||||
|
|
||||||
const handleSettingButtonClick = () => {
|
|
||||||
chrome.runtime.openOptionsPage();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefreshButtonClick = () => {
|
|
||||||
chrome.runtime.reload();
|
|
||||||
chrome.browserAction.setPopup({ popup: "" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full min-w-[512px] px-4 pt-4">
|
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
|
||||||
<div className="flex flex-row justify-start items-center">
|
|
||||||
<Logo className="w-6 h-auto mr-2" />
|
|
||||||
<span className="">Slash</span>
|
|
||||||
{isInitialized && (
|
|
||||||
<>
|
|
||||||
<span className="mx-1 text-gray-400">/</span>
|
|
||||||
<span>Shortcuts</span>
|
|
||||||
<span className="text-gray-500 mr-0.5">({shortcuts.length})</span>
|
|
||||||
<PullShortcutsButton />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>{isInitialized && <CreateShortcutsButton />}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full mt-4">
|
|
||||||
{isInitialized ? (
|
|
||||||
<>
|
|
||||||
{shortcuts.length !== 0 ? (
|
|
||||||
<ShortcutsContainer />
|
|
||||||
) : (
|
|
||||||
<div className="w-full flex flex-col justify-center items-center">
|
|
||||||
<p>No shortcut found.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider className="!mt-4 !mb-2 opacity-40" />
|
|
||||||
|
|
||||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
|
||||||
<div className="flex flex-row justify-start items-center">
|
|
||||||
<IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
|
|
||||||
<Icon.Settings className="w-5 h-auto text-gray-500" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="plain"
|
|
||||||
color="neutral"
|
|
||||||
component="a"
|
|
||||||
href="https://github.com/boojack/slash"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Icon.Github className="w-5 h-auto text-gray-500" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row justify-end items-center">
|
|
||||||
<a
|
|
||||||
className="text-sm flex flex-row justify-start items-center text-gray-500 hover:underline hover:text-blue-600"
|
|
||||||
href={domain}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<span className="mr-1">Go to my Slash</span>
|
|
||||||
<Icon.ExternalLink className="w-4 h-auto" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="w-full flex flex-col justify-start items-center">
|
|
||||||
<p>No domain and access token found.</p>
|
|
||||||
<div className="w-full flex flex-row justify-center items-center py-4">
|
|
||||||
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
|
|
||||||
<Icon.Settings className="w-5 h-auto mr-1" /> Setting
|
|
||||||
</Button>
|
|
||||||
<span className="mx-2">Or</span>
|
|
||||||
<Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}>
|
|
||||||
<Icon.RefreshCcw className="w-5 h-auto mr-1" /> Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Toaster position="top-right" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IndexPopup;
|
|
@ -1,41 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
import { getUrlFavicon } from "../helpers/api";
|
|
||||||
|
|
||||||
interface FaviconState {
|
|
||||||
cache: {
|
|
||||||
[key: string]: string;
|
|
||||||
};
|
|
||||||
getOrFetchUrlFavicon: (url: string) => Promise<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useFaviconStore = create<FaviconState>()(
|
|
||||||
persist(
|
|
||||||
(set, get) => ({
|
|
||||||
cache: {},
|
|
||||||
getOrFetchUrlFavicon: async (url: string) => {
|
|
||||||
const cache = get().cache;
|
|
||||||
if (cache[url]) {
|
|
||||||
return cache[url];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: favicon } = await getUrlFavicon(url);
|
|
||||||
if (favicon) {
|
|
||||||
cache[url] = favicon;
|
|
||||||
set(cache);
|
|
||||||
return favicon;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "favicon_cache",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default useFaviconStore;
|
|
25
extension/src/types/proto/api/v2/common_pb.d.ts
vendored
@ -1,25 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/common.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.RowStatus
|
|
||||||
*/
|
|
||||||
export declare enum RowStatus {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ROW_STATUS_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
ROW_STATUS_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: NORMAL = 1;
|
|
||||||
*/
|
|
||||||
NORMAL = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ARCHIVED = 2;
|
|
||||||
*/
|
|
||||||
ARCHIVED = 2,
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/common.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.RowStatus
|
|
||||||
*/
|
|
||||||
export const RowStatus = proto3.makeEnum(
|
|
||||||
"slash.api.v2.RowStatus",
|
|
||||||
[
|
|
||||||
{no: 0, name: "ROW_STATUS_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "NORMAL"},
|
|
||||||
{no: 2, name: "ARCHIVED"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/shortcut_service.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
|
||||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
|
||||||
import type { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.Visibility
|
|
||||||
*/
|
|
||||||
export declare enum Visibility {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: VISIBILITY_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
VISIBILITY_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: PRIVATE = 1;
|
|
||||||
*/
|
|
||||||
PRIVATE = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: WORKSPACE = 2;
|
|
||||||
*/
|
|
||||||
WORKSPACE = 2,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: PUBLIC = 3;
|
|
||||||
*/
|
|
||||||
PUBLIC = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.Shortcut
|
|
||||||
*/
|
|
||||||
export declare class Shortcut extends Message<Shortcut> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 creator_id = 2;
|
|
||||||
*/
|
|
||||||
creatorId: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 created_ts = 3;
|
|
||||||
*/
|
|
||||||
createdTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 updated_ts = 4;
|
|
||||||
*/
|
|
||||||
updatedTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.RowStatus row_status = 5;
|
|
||||||
*/
|
|
||||||
rowStatus: RowStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string name = 6;
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string link = 7;
|
|
||||||
*/
|
|
||||||
link: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string title = 8;
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated string tags = 9;
|
|
||||||
*/
|
|
||||||
tags: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 10;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.Visibility visibility = 11;
|
|
||||||
*/
|
|
||||||
visibility: Visibility;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.OpenGraphMetadata og_metadata = 12;
|
|
||||||
*/
|
|
||||||
ogMetadata?: OpenGraphMetadata;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<Shortcut>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.Shortcut";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static equals(a: Shortcut | PlainMessage<Shortcut> | undefined, b: Shortcut | PlainMessage<Shortcut> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.OpenGraphMetadata
|
|
||||||
*/
|
|
||||||
export declare class OpenGraphMetadata extends Message<OpenGraphMetadata> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string title = 1;
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 2;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string image = 3;
|
|
||||||
*/
|
|
||||||
image: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<OpenGraphMetadata>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.OpenGraphMetadata";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static equals(a: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined, b: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListShortcutsRequest
|
|
||||||
*/
|
|
||||||
export declare class ListShortcutsRequest extends Message<ListShortcutsRequest> {
|
|
||||||
constructor(data?: PartialMessage<ListShortcutsRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.ListShortcutsRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListShortcutsRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListShortcutsRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListShortcutsRequest;
|
|
||||||
|
|
||||||
static equals(a: ListShortcutsRequest | PlainMessage<ListShortcutsRequest> | undefined, b: ListShortcutsRequest | PlainMessage<ListShortcutsRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListShortcutsResponse
|
|
||||||
*/
|
|
||||||
export declare class ListShortcutsResponse extends Message<ListShortcutsResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated slash.api.v2.Shortcut shortcuts = 1;
|
|
||||||
*/
|
|
||||||
shortcuts: Shortcut[];
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<ListShortcutsResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.ListShortcutsResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListShortcutsResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListShortcutsResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListShortcutsResponse;
|
|
||||||
|
|
||||||
static equals(a: ListShortcutsResponse | PlainMessage<ListShortcutsResponse> | undefined, b: ListShortcutsResponse | PlainMessage<ListShortcutsResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetShortcutRequest
|
|
||||||
*/
|
|
||||||
export declare class GetShortcutRequest extends Message<GetShortcutRequest> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string name = 1;
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<GetShortcutRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.GetShortcutRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetShortcutRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetShortcutRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetShortcutRequest;
|
|
||||||
|
|
||||||
static equals(a: GetShortcutRequest | PlainMessage<GetShortcutRequest> | undefined, b: GetShortcutRequest | PlainMessage<GetShortcutRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetShortcutResponse
|
|
||||||
*/
|
|
||||||
export declare class GetShortcutResponse extends Message<GetShortcutResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
|
|
||||||
*/
|
|
||||||
shortcut?: Shortcut;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<GetShortcutResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.GetShortcutResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetShortcutResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetShortcutResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetShortcutResponse;
|
|
||||||
|
|
||||||
static equals(a: GetShortcutResponse | PlainMessage<GetShortcutResponse> | undefined, b: GetShortcutResponse | PlainMessage<GetShortcutResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateShortcutRequest
|
|
||||||
*/
|
|
||||||
export declare class CreateShortcutRequest extends Message<CreateShortcutRequest> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
|
|
||||||
*/
|
|
||||||
shortcut?: Shortcut;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<CreateShortcutRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.CreateShortcutRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateShortcutRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateShortcutRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateShortcutRequest;
|
|
||||||
|
|
||||||
static equals(a: CreateShortcutRequest | PlainMessage<CreateShortcutRequest> | undefined, b: CreateShortcutRequest | PlainMessage<CreateShortcutRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateShortcutResponse
|
|
||||||
*/
|
|
||||||
export declare class CreateShortcutResponse extends Message<CreateShortcutResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
|
|
||||||
*/
|
|
||||||
shortcut?: Shortcut;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<CreateShortcutResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.CreateShortcutResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateShortcutResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateShortcutResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateShortcutResponse;
|
|
||||||
|
|
||||||
static equals(a: CreateShortcutResponse | PlainMessage<CreateShortcutResponse> | undefined, b: CreateShortcutResponse | PlainMessage<CreateShortcutResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/shortcut_service.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
import { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.Visibility
|
|
||||||
*/
|
|
||||||
export const Visibility = proto3.makeEnum(
|
|
||||||
"slash.api.v2.Visibility",
|
|
||||||
[
|
|
||||||
{no: 0, name: "VISIBILITY_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "PRIVATE"},
|
|
||||||
{no: 2, name: "WORKSPACE"},
|
|
||||||
{no: 3, name: "PUBLIC"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.Shortcut
|
|
||||||
*/
|
|
||||||
export const Shortcut = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.Shortcut",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "creator_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 5, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
|
|
||||||
{ no: 6, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 7, name: "link", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 8, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 9, name: "tags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
|
|
||||||
{ no: 10, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 11, name: "visibility", kind: "enum", T: proto3.getEnumType(Visibility) },
|
|
||||||
{ no: 12, name: "og_metadata", kind: "message", T: OpenGraphMetadata },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.OpenGraphMetadata
|
|
||||||
*/
|
|
||||||
export const OpenGraphMetadata = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.OpenGraphMetadata",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 3, name: "image", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListShortcutsRequest
|
|
||||||
*/
|
|
||||||
export const ListShortcutsRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.ListShortcutsRequest",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListShortcutsResponse
|
|
||||||
*/
|
|
||||||
export const ListShortcutsResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.ListShortcutsResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "shortcuts", kind: "message", T: Shortcut, repeated: true },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetShortcutRequest
|
|
||||||
*/
|
|
||||||
export const GetShortcutRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.GetShortcutRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetShortcutResponse
|
|
||||||
*/
|
|
||||||
export const GetShortcutResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.GetShortcutResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateShortcutRequest
|
|
||||||
*/
|
|
||||||
export const CreateShortcutRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.CreateShortcutRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateShortcutResponse
|
|
||||||
*/
|
|
||||||
export const CreateShortcutResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.CreateShortcutResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/user_service.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage, Timestamp } from "@bufbuild/protobuf";
|
|
||||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
|
||||||
import type { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.Role
|
|
||||||
*/
|
|
||||||
export declare enum Role {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ROLE_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
ROLE_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ADMIN = 1;
|
|
||||||
*/
|
|
||||||
ADMIN = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: USER = 2;
|
|
||||||
*/
|
|
||||||
USER = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.User
|
|
||||||
*/
|
|
||||||
export declare class User extends Message<User> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.RowStatus row_status = 2;
|
|
||||||
*/
|
|
||||||
rowStatus: RowStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 created_ts = 3;
|
|
||||||
*/
|
|
||||||
createdTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 updated_ts = 4;
|
|
||||||
*/
|
|
||||||
updatedTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.Role role = 6;
|
|
||||||
*/
|
|
||||||
role: Role;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string email = 7;
|
|
||||||
*/
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string nickname = 8;
|
|
||||||
*/
|
|
||||||
nickname: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string password = 9;
|
|
||||||
*/
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<User>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.User";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): User;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): User;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): User;
|
|
||||||
|
|
||||||
static equals(a: User | PlainMessage<User> | undefined, b: User | PlainMessage<User> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetUserRequest
|
|
||||||
*/
|
|
||||||
export declare class GetUserRequest extends Message<GetUserRequest> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<GetUserRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.GetUserRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetUserRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetUserRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetUserRequest;
|
|
||||||
|
|
||||||
static equals(a: GetUserRequest | PlainMessage<GetUserRequest> | undefined, b: GetUserRequest | PlainMessage<GetUserRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetUserResponse
|
|
||||||
*/
|
|
||||||
export declare class GetUserResponse extends Message<GetUserResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.User user = 1;
|
|
||||||
*/
|
|
||||||
user?: User;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<GetUserResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.GetUserResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetUserResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetUserResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetUserResponse;
|
|
||||||
|
|
||||||
static equals(a: GetUserResponse | PlainMessage<GetUserResponse> | undefined, b: GetUserResponse | PlainMessage<GetUserResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserRequest
|
|
||||||
*/
|
|
||||||
export declare class CreateUserRequest extends Message<CreateUserRequest> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.User user = 1;
|
|
||||||
*/
|
|
||||||
user?: User;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<CreateUserRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.CreateUserRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserRequest;
|
|
||||||
|
|
||||||
static equals(a: CreateUserRequest | PlainMessage<CreateUserRequest> | undefined, b: CreateUserRequest | PlainMessage<CreateUserRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserResponse
|
|
||||||
*/
|
|
||||||
export declare class CreateUserResponse extends Message<CreateUserResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.User user = 1;
|
|
||||||
*/
|
|
||||||
user?: User;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<CreateUserResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.CreateUserResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserResponse;
|
|
||||||
|
|
||||||
static equals(a: CreateUserResponse | PlainMessage<CreateUserResponse> | undefined, b: CreateUserResponse | PlainMessage<CreateUserResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListUserAccessTokensRequest
|
|
||||||
*/
|
|
||||||
export declare class ListUserAccessTokensRequest extends Message<ListUserAccessTokensRequest> {
|
|
||||||
/**
|
|
||||||
* id is the user id.
|
|
||||||
*
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<ListUserAccessTokensRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.ListUserAccessTokensRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUserAccessTokensRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUserAccessTokensRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUserAccessTokensRequest;
|
|
||||||
|
|
||||||
static equals(a: ListUserAccessTokensRequest | PlainMessage<ListUserAccessTokensRequest> | undefined, b: ListUserAccessTokensRequest | PlainMessage<ListUserAccessTokensRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListUserAccessTokensResponse
|
|
||||||
*/
|
|
||||||
export declare class ListUserAccessTokensResponse extends Message<ListUserAccessTokensResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated slash.api.v2.UserAccessToken access_tokens = 1;
|
|
||||||
*/
|
|
||||||
accessTokens: UserAccessToken[];
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<ListUserAccessTokensResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.ListUserAccessTokensResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUserAccessTokensResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUserAccessTokensResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUserAccessTokensResponse;
|
|
||||||
|
|
||||||
static equals(a: ListUserAccessTokensResponse | PlainMessage<ListUserAccessTokensResponse> | undefined, b: ListUserAccessTokensResponse | PlainMessage<ListUserAccessTokensResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserAccessTokenRequest
|
|
||||||
*/
|
|
||||||
export declare class CreateUserAccessTokenRequest extends Message<CreateUserAccessTokenRequest> {
|
|
||||||
/**
|
|
||||||
* id is the user id.
|
|
||||||
*
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.UserAccessToken user_access_token = 2;
|
|
||||||
*/
|
|
||||||
userAccessToken?: UserAccessToken;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<CreateUserAccessTokenRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.CreateUserAccessTokenRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static equals(a: CreateUserAccessTokenRequest | PlainMessage<CreateUserAccessTokenRequest> | undefined, b: CreateUserAccessTokenRequest | PlainMessage<CreateUserAccessTokenRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserAccessTokenResponse
|
|
||||||
*/
|
|
||||||
export declare class CreateUserAccessTokenResponse extends Message<CreateUserAccessTokenResponse> {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.api.v2.UserAccessToken access_token = 1;
|
|
||||||
*/
|
|
||||||
accessToken?: UserAccessToken;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<CreateUserAccessTokenResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.CreateUserAccessTokenResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static equals(a: CreateUserAccessTokenResponse | PlainMessage<CreateUserAccessTokenResponse> | undefined, b: CreateUserAccessTokenResponse | PlainMessage<CreateUserAccessTokenResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.DeleteUserAccessTokenRequest
|
|
||||||
*/
|
|
||||||
export declare class DeleteUserAccessTokenRequest extends Message<DeleteUserAccessTokenRequest> {
|
|
||||||
/**
|
|
||||||
* id is the user id.
|
|
||||||
*
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* access_token is the access token to delete.
|
|
||||||
*
|
|
||||||
* @generated from field: string access_token = 2;
|
|
||||||
*/
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<DeleteUserAccessTokenRequest>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.DeleteUserAccessTokenRequest";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenRequest;
|
|
||||||
|
|
||||||
static equals(a: DeleteUserAccessTokenRequest | PlainMessage<DeleteUserAccessTokenRequest> | undefined, b: DeleteUserAccessTokenRequest | PlainMessage<DeleteUserAccessTokenRequest> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.DeleteUserAccessTokenResponse
|
|
||||||
*/
|
|
||||||
export declare class DeleteUserAccessTokenResponse extends Message<DeleteUserAccessTokenResponse> {
|
|
||||||
constructor(data?: PartialMessage<DeleteUserAccessTokenResponse>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.DeleteUserAccessTokenResponse";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenResponse;
|
|
||||||
|
|
||||||
static equals(a: DeleteUserAccessTokenResponse | PlainMessage<DeleteUserAccessTokenResponse> | undefined, b: DeleteUserAccessTokenResponse | PlainMessage<DeleteUserAccessTokenResponse> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.UserAccessToken
|
|
||||||
*/
|
|
||||||
export declare class UserAccessToken extends Message<UserAccessToken> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string access_token = 1;
|
|
||||||
*/
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 2;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: google.protobuf.Timestamp issued_at = 3;
|
|
||||||
*/
|
|
||||||
issuedAt?: Timestamp;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: google.protobuf.Timestamp expires_at = 4;
|
|
||||||
*/
|
|
||||||
expiresAt?: Timestamp;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<UserAccessToken>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.api.v2.UserAccessToken";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): UserAccessToken;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): UserAccessToken;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): UserAccessToken;
|
|
||||||
|
|
||||||
static equals(a: UserAccessToken | PlainMessage<UserAccessToken> | undefined, b: UserAccessToken | PlainMessage<UserAccessToken> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file api/v2/user_service.proto (package slash.api.v2, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3, Timestamp } from "@bufbuild/protobuf";
|
|
||||||
import { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.api.v2.Role
|
|
||||||
*/
|
|
||||||
export const Role = proto3.makeEnum(
|
|
||||||
"slash.api.v2.Role",
|
|
||||||
[
|
|
||||||
{no: 0, name: "ROLE_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "ADMIN"},
|
|
||||||
{no: 2, name: "USER"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.User
|
|
||||||
*/
|
|
||||||
export const User = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.User",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
|
|
||||||
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 6, name: "role", kind: "enum", T: proto3.getEnumType(Role) },
|
|
||||||
{ no: 7, name: "email", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 8, name: "nickname", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 9, name: "password", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetUserRequest
|
|
||||||
*/
|
|
||||||
export const GetUserRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.GetUserRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.GetUserResponse
|
|
||||||
*/
|
|
||||||
export const GetUserResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.GetUserResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "user", kind: "message", T: User },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserRequest
|
|
||||||
*/
|
|
||||||
export const CreateUserRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.CreateUserRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "user", kind: "message", T: User },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserResponse
|
|
||||||
*/
|
|
||||||
export const CreateUserResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.CreateUserResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "user", kind: "message", T: User },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListUserAccessTokensRequest
|
|
||||||
*/
|
|
||||||
export const ListUserAccessTokensRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.ListUserAccessTokensRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.ListUserAccessTokensResponse
|
|
||||||
*/
|
|
||||||
export const ListUserAccessTokensResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.ListUserAccessTokensResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_tokens", kind: "message", T: UserAccessToken, repeated: true },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserAccessTokenRequest
|
|
||||||
*/
|
|
||||||
export const CreateUserAccessTokenRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.CreateUserAccessTokenRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "user_access_token", kind: "message", T: UserAccessToken },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.CreateUserAccessTokenResponse
|
|
||||||
*/
|
|
||||||
export const CreateUserAccessTokenResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.CreateUserAccessTokenResponse",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_token", kind: "message", T: UserAccessToken },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.DeleteUserAccessTokenRequest
|
|
||||||
*/
|
|
||||||
export const DeleteUserAccessTokenRequest = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.DeleteUserAccessTokenRequest",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.DeleteUserAccessTokenResponse
|
|
||||||
*/
|
|
||||||
export const DeleteUserAccessTokenResponse = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.DeleteUserAccessTokenResponse",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.api.v2.UserAccessToken
|
|
||||||
*/
|
|
||||||
export const UserAccessToken = proto3.makeMessageType(
|
|
||||||
"slash.api.v2.UserAccessToken",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 3, name: "issued_at", kind: "message", T: Timestamp },
|
|
||||||
{ no: 4, name: "expires_at", kind: "message", T: Timestamp },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
32
extension/src/types/proto/store/activity_pb.d.ts
vendored
@ -1,32 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/activity.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
|
||||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.ActivityShorcutCreatePayload
|
|
||||||
*/
|
|
||||||
export declare class ActivityShorcutCreatePayload extends Message<ActivityShorcutCreatePayload> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 shortcut_id = 1;
|
|
||||||
*/
|
|
||||||
shortcutId: number;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<ActivityShorcutCreatePayload>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.ActivityShorcutCreatePayload";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ActivityShorcutCreatePayload;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ActivityShorcutCreatePayload;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ActivityShorcutCreatePayload;
|
|
||||||
|
|
||||||
static equals(a: ActivityShorcutCreatePayload | PlainMessage<ActivityShorcutCreatePayload> | undefined, b: ActivityShorcutCreatePayload | PlainMessage<ActivityShorcutCreatePayload> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/activity.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.ActivityShorcutCreatePayload
|
|
||||||
*/
|
|
||||||
export const ActivityShorcutCreatePayload = proto3.makeMessageType(
|
|
||||||
"slash.store.ActivityShorcutCreatePayload",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "shortcut_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
25
extension/src/types/proto/store/common_pb.d.ts
vendored
@ -1,25 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/common.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.RowStatus
|
|
||||||
*/
|
|
||||||
export declare enum RowStatus {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ROW_STATUS_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
ROW_STATUS_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: NORMAL = 1;
|
|
||||||
*/
|
|
||||||
NORMAL = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: ARCHIVED = 2;
|
|
||||||
*/
|
|
||||||
ARCHIVED = 2,
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/common.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.RowStatus
|
|
||||||
*/
|
|
||||||
export const RowStatus = proto3.makeEnum(
|
|
||||||
"slash.store.RowStatus",
|
|
||||||
[
|
|
||||||
{no: 0, name: "ROW_STATUS_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "NORMAL"},
|
|
||||||
{no: 2, name: "ARCHIVED"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
147
extension/src/types/proto/store/shortcut_pb.d.ts
vendored
@ -1,147 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/shortcut.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
|
||||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
|
||||||
import type { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.Visibility
|
|
||||||
*/
|
|
||||||
export declare enum Visibility {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: VISIBILITY_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
VISIBILITY_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: PRIVATE = 1;
|
|
||||||
*/
|
|
||||||
PRIVATE = 1,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: WORKSPACE = 2;
|
|
||||||
*/
|
|
||||||
WORKSPACE = 2,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: PUBLIC = 3;
|
|
||||||
*/
|
|
||||||
PUBLIC = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.Shortcut
|
|
||||||
*/
|
|
||||||
export declare class Shortcut extends Message<Shortcut> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 id = 1;
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 creator_id = 2;
|
|
||||||
*/
|
|
||||||
creatorId: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 created_ts = 3;
|
|
||||||
*/
|
|
||||||
createdTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: int64 updated_ts = 4;
|
|
||||||
*/
|
|
||||||
updatedTs: bigint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.RowStatus row_status = 5;
|
|
||||||
*/
|
|
||||||
rowStatus: RowStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string name = 6;
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string link = 7;
|
|
||||||
*/
|
|
||||||
link: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string title = 8;
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated string tags = 9;
|
|
||||||
*/
|
|
||||||
tags: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 10;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.Visibility visibility = 11;
|
|
||||||
*/
|
|
||||||
visibility: Visibility;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.OpenGraphMetadata og_metadata = 12;
|
|
||||||
*/
|
|
||||||
ogMetadata?: OpenGraphMetadata;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<Shortcut>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.Shortcut";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Shortcut;
|
|
||||||
|
|
||||||
static equals(a: Shortcut | PlainMessage<Shortcut> | undefined, b: Shortcut | PlainMessage<Shortcut> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.OpenGraphMetadata
|
|
||||||
*/
|
|
||||||
export declare class OpenGraphMetadata extends Message<OpenGraphMetadata> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string title = 1;
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 2;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string image = 3;
|
|
||||||
*/
|
|
||||||
image: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<OpenGraphMetadata>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.OpenGraphMetadata";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
|
||||||
|
|
||||||
static equals(a: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined, b: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/shortcut.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
import { RowStatus } from "./common_pb.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.Visibility
|
|
||||||
*/
|
|
||||||
export const Visibility = proto3.makeEnum(
|
|
||||||
"slash.store.Visibility",
|
|
||||||
[
|
|
||||||
{no: 0, name: "VISIBILITY_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "PRIVATE"},
|
|
||||||
{no: 2, name: "WORKSPACE"},
|
|
||||||
{no: 3, name: "PUBLIC"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.Shortcut
|
|
||||||
*/
|
|
||||||
export const Shortcut = proto3.makeMessageType(
|
|
||||||
"slash.store.Shortcut",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "creator_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
|
||||||
{ no: 5, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
|
|
||||||
{ no: 6, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 7, name: "link", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 8, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 9, name: "tags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
|
|
||||||
{ no: 10, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 11, name: "visibility", kind: "enum", T: proto3.getEnumType(Visibility) },
|
|
||||||
{ no: 12, name: "og_metadata", kind: "message", T: OpenGraphMetadata },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.OpenGraphMetadata
|
|
||||||
*/
|
|
||||||
export const OpenGraphMetadata = proto3.makeMessageType(
|
|
||||||
"slash.store.OpenGraphMetadata",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 3, name: "image", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
116
extension/src/types/proto/store/user_setting_pb.d.ts
vendored
@ -1,116 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/user_setting.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
|
||||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.UserSettingKey
|
|
||||||
*/
|
|
||||||
export declare enum UserSettingKey {
|
|
||||||
/**
|
|
||||||
* @generated from enum value: USER_SETTING_KEY_UNSPECIFIED = 0;
|
|
||||||
*/
|
|
||||||
USER_SETTING_KEY_UNSPECIFIED = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum value: USER_SETTING_ACCESS_TOKENS = 1;
|
|
||||||
*/
|
|
||||||
USER_SETTING_ACCESS_TOKENS = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.UserSetting
|
|
||||||
*/
|
|
||||||
export declare class UserSetting extends Message<UserSetting> {
|
|
||||||
/**
|
|
||||||
* @generated from field: int32 user_id = 1;
|
|
||||||
*/
|
|
||||||
userId: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.UserSettingKey key = 2;
|
|
||||||
*/
|
|
||||||
key: UserSettingKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from oneof slash.store.UserSetting.value
|
|
||||||
*/
|
|
||||||
value: {
|
|
||||||
/**
|
|
||||||
* @generated from field: slash.store.AccessTokensUserSetting access_tokens_user_setting = 3;
|
|
||||||
*/
|
|
||||||
value: AccessTokensUserSetting;
|
|
||||||
case: "accessTokensUserSetting";
|
|
||||||
} | { case: undefined; value?: undefined };
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<UserSetting>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.UserSetting";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): UserSetting;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): UserSetting;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): UserSetting;
|
|
||||||
|
|
||||||
static equals(a: UserSetting | PlainMessage<UserSetting> | undefined, b: UserSetting | PlainMessage<UserSetting> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.AccessTokensUserSetting
|
|
||||||
*/
|
|
||||||
export declare class AccessTokensUserSetting extends Message<AccessTokensUserSetting> {
|
|
||||||
/**
|
|
||||||
* @generated from field: repeated slash.store.AccessTokensUserSetting.AccessToken access_tokens = 1;
|
|
||||||
*/
|
|
||||||
accessTokens: AccessTokensUserSetting_AccessToken[];
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<AccessTokensUserSetting>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.AccessTokensUserSetting";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): AccessTokensUserSetting;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): AccessTokensUserSetting;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): AccessTokensUserSetting;
|
|
||||||
|
|
||||||
static equals(a: AccessTokensUserSetting | PlainMessage<AccessTokensUserSetting> | undefined, b: AccessTokensUserSetting | PlainMessage<AccessTokensUserSetting> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.AccessTokensUserSetting.AccessToken
|
|
||||||
*/
|
|
||||||
export declare class AccessTokensUserSetting_AccessToken extends Message<AccessTokensUserSetting_AccessToken> {
|
|
||||||
/**
|
|
||||||
* @generated from field: string access_token = 1;
|
|
||||||
*/
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string description = 2;
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
constructor(data?: PartialMessage<AccessTokensUserSetting_AccessToken>);
|
|
||||||
|
|
||||||
static readonly runtime: typeof proto3;
|
|
||||||
static readonly typeName = "slash.store.AccessTokensUserSetting.AccessToken";
|
|
||||||
static readonly fields: FieldList;
|
|
||||||
|
|
||||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): AccessTokensUserSetting_AccessToken;
|
|
||||||
|
|
||||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): AccessTokensUserSetting_AccessToken;
|
|
||||||
|
|
||||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): AccessTokensUserSetting_AccessToken;
|
|
||||||
|
|
||||||
static equals(a: AccessTokensUserSetting_AccessToken | PlainMessage<AccessTokensUserSetting_AccessToken> | undefined, b: AccessTokensUserSetting_AccessToken | PlainMessage<AccessTokensUserSetting_AccessToken> | undefined): boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
|||||||
// @generated by protoc-gen-es v1.3.0
|
|
||||||
// @generated from file store/user_setting.proto (package slash.store, syntax proto3)
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { proto3 } from "@bufbuild/protobuf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from enum slash.store.UserSettingKey
|
|
||||||
*/
|
|
||||||
export const UserSettingKey = proto3.makeEnum(
|
|
||||||
"slash.store.UserSettingKey",
|
|
||||||
[
|
|
||||||
{no: 0, name: "USER_SETTING_KEY_UNSPECIFIED"},
|
|
||||||
{no: 1, name: "USER_SETTING_ACCESS_TOKENS"},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.UserSetting
|
|
||||||
*/
|
|
||||||
export const UserSetting = proto3.makeMessageType(
|
|
||||||
"slash.store.UserSetting",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "user_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
|
||||||
{ no: 2, name: "key", kind: "enum", T: proto3.getEnumType(UserSettingKey) },
|
|
||||||
{ no: 3, name: "access_tokens_user_setting", kind: "message", T: AccessTokensUserSetting, oneof: "value" },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.AccessTokensUserSetting
|
|
||||||
*/
|
|
||||||
export const AccessTokensUserSetting = proto3.makeMessageType(
|
|
||||||
"slash.store.AccessTokensUserSetting",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_tokens", kind: "message", T: AccessTokensUserSetting_AccessToken, repeated: true },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from message slash.store.AccessTokensUserSetting.AccessToken
|
|
||||||
*/
|
|
||||||
export const AccessTokensUserSetting_AccessToken = proto3.makeMessageType(
|
|
||||||
"slash.store.AccessTokensUserSetting.AccessToken",
|
|
||||||
() => [
|
|
||||||
{ no: 1, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
|
||||||
],
|
|
||||||
{localName: "AccessTokensUserSetting_AccessToken"},
|
|
||||||
);
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "plasmo/templates/tsconfig.base",
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
],
|
|
||||||
"include": [
|
|
||||||
".plasmo/index.d.ts",
|
|
||||||
"./**/*.ts",
|
|
||||||
"./**/*.tsx"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"./src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"baseUrl": "."
|
|
||||||
}
|
|
||||||
}
|
|
@ -36,3 +36,5 @@ keys.json
|
|||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
.tsbuildinfo
|
.tsbuildinfo
|
||||||
|
|
||||||
|
src/types/proto
|
@ -4,5 +4,5 @@ module.exports = {
|
|||||||
semi: true,
|
semi: true,
|
||||||
singleQuote: false,
|
singleQuote: false,
|
||||||
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||||
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!less).+)", "^[./]", "^(.+).css"],
|
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!css).+)", "^[./]", "^[../]", "^(.+).css"],
|
||||||
};
|
};
|
21
frontend/extension/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 yourselfhosted
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
BIN
frontend/extension/assets/icon.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
53
frontend/extension/package.json
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "slash-extension",
|
||||||
|
"displayName": "Slash",
|
||||||
|
"version": "1.0.11",
|
||||||
|
"description": "An open source, self-hosted platform for sharing and managing your most frequently used links. Save and share your links very easily.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "plasmo dev",
|
||||||
|
"build": "plasmo build",
|
||||||
|
"package": "plasmo package",
|
||||||
|
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||||
|
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.13.3",
|
||||||
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@mui/joy": "5.0.0-beta.48",
|
||||||
|
"@plasmohq/storage": "^1.12.0",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"lucide-react": "^0.454.0",
|
||||||
|
"plasmo": "^0.89.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hot-toast": "^2.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
|
"@types/chrome": "^0.0.280",
|
||||||
|
"@types/node": "^22.8.6",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
|
"eslint-plugin-react": "^7.37.2",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"permissions": [
|
||||||
|
"activeTab",
|
||||||
|
"storage",
|
||||||
|
"webRequest"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"*://*/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
8692
frontend/extension/pnpm-lock.yaml
generated
Normal file
@ -1,33 +1,25 @@
|
|||||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
|
|
||||||
import { Storage } from "@plasmohq/storage";
|
import { Storage } from "@plasmohq/storage";
|
||||||
|
|
||||||
const storage = new Storage();
|
const storage = new Storage();
|
||||||
const urlRegex = /https?:\/\/s\/(.+)/;
|
const urlRegex = /https?:\/\/s\/(.+)/;
|
||||||
|
|
||||||
chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => {
|
chrome.webRequest.onBeforeRequest.addListener(
|
||||||
if (!tab.url) {
|
(param) => {
|
||||||
return;
|
(async () => {
|
||||||
}
|
if (!param.url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const shortcutName = getShortcutNameFromUrl(tab.url);
|
const shortcutName = getShortcutNameFromUrl(param.url);
|
||||||
if (shortcutName) {
|
if (shortcutName) {
|
||||||
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
const instanceUrl = (await storage.getItem<string>("instance_url")) || "";
|
||||||
const shortcut = shortcuts.find((shortcut) => shortcut.name === shortcutName);
|
const url = new URL(`/s/${shortcutName}`, instanceUrl);
|
||||||
if (!shortcut) {
|
return chrome.tabs.update({ url: url.toString() });
|
||||||
return;
|
}
|
||||||
}
|
})();
|
||||||
return chrome.tabs.update(tabId, { url: shortcut.link });
|
},
|
||||||
}
|
{ urls: ["*://s/*", "*://*/search*"] },
|
||||||
});
|
);
|
||||||
|
|
||||||
chrome.omnibox.onInputEntered.addListener(async (text) => {
|
|
||||||
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
|
||||||
const shortcut = shortcuts.find((shortcut) => shortcut.name === text);
|
|
||||||
if (!shortcut) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return chrome.tabs.update({ url: shortcut.link });
|
|
||||||
});
|
|
||||||
|
|
||||||
const getShortcutNameFromUrl = (urlString: string) => {
|
const getShortcutNameFromUrl = (urlString: string) => {
|
||||||
const matchResult = urlRegex.exec(urlString);
|
const matchResult = urlRegex.exec(urlString);
|
||||||
@ -39,19 +31,19 @@ const getShortcutNameFromUrl = (urlString: string) => {
|
|||||||
|
|
||||||
const getShortcutNameFromSearchUrl = (urlString: string) => {
|
const getShortcutNameFromSearchUrl = (urlString: string) => {
|
||||||
const url = new URL(urlString);
|
const url = new URL(urlString);
|
||||||
if ((url.hostname === "www.google.com" || url.hostname === "www.bing.com") && url.pathname === "/search") {
|
if ((url.hostname.endsWith("google.com") || url.hostname.endsWith("bing.com")) && url.pathname === "/search") {
|
||||||
const params = new URLSearchParams(url.search);
|
const params = new URLSearchParams(url.search);
|
||||||
const shortcutName = params.get("q");
|
const shortcutName = params.get("q");
|
||||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||||
return shortcutName.slice(2);
|
return shortcutName.slice(2);
|
||||||
}
|
}
|
||||||
} else if (url.hostname === "www.baidu.com" && url.pathname === "/s") {
|
} else if (url.hostname.endsWith("baidu.com") && url.pathname === "/s") {
|
||||||
const params = new URLSearchParams(url.search);
|
const params = new URLSearchParams(url.search);
|
||||||
const shortcutName = params.get("wd");
|
const shortcutName = params.get("wd");
|
||||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||||
return shortcutName.slice(2);
|
return shortcutName.slice(2);
|
||||||
}
|
}
|
||||||
} else if (url.hostname === "duckduckgo.com" && url.pathname === "/") {
|
} else if (url.hostname.endsWith("duckduckgo.com") && url.pathname === "/") {
|
||||||
const params = new URLSearchParams(url.search);
|
const params = new URLSearchParams(url.search);
|
||||||
const shortcutName = params.get("q");
|
const shortcutName = params.get("q");
|
||||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
@ -1,12 +1,12 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import LogoBase64 from "data-base64:../..//assets/icon.png";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Logo = ({ className }: Props) => {
|
const Logo = ({ className }: Props) => {
|
||||||
return <img className={classNames(className)} src={LogoBase64} alt="" />;
|
return <Icon.CircleSlash className={classNames("dark:text-gray-500", className)} strokeWidth={1.5} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Logo;
|
export default Logo;
|
18
frontend/extension/src/context/context.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
instanceUrl?: string;
|
||||||
|
setInstanceUrl: (instanceUrl: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StorageContext = createContext<Context>({
|
||||||
|
instanceUrl: undefined,
|
||||||
|
setInstanceUrl: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const useStorageContext = () => {
|
||||||
|
const context = useContext(StorageContext);
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useStorageContext;
|
4
frontend/extension/src/context/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import useStorageContext from "./context";
|
||||||
|
import StorageContextProvider from "./provider";
|
||||||
|
|
||||||
|
export { useStorageContext, StorageContextProvider };
|
41
frontend/extension/src/context/provider.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Storage } from "@plasmohq/storage";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StorageContext } from "./context";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StorageContextProvider = ({ children }: Props) => {
|
||||||
|
const storage = new Storage();
|
||||||
|
const [instanceUrl, setInstanceUrl] = useState<string | undefined>(undefined);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const instanceUrl = await storage.get("instance_url");
|
||||||
|
|
||||||
|
setInstanceUrl(instanceUrl);
|
||||||
|
setIsInitialized(true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
storage.watch({
|
||||||
|
instance_url: (c) => {
|
||||||
|
setInstanceUrl(c.newValue);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StorageContext.Provider
|
||||||
|
value={{
|
||||||
|
instanceUrl,
|
||||||
|
setInstanceUrl: (instanceUrl: string) => storage.set("instance_url", instanceUrl),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isInitialized && children}
|
||||||
|
</StorageContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StorageContextProvider;
|
105
frontend/extension/src/options.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { Button, CssVarsProvider, Input } from "@mui/joy";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Toaster, toast } from "react-hot-toast";
|
||||||
|
import Icon from "./components/Icon";
|
||||||
|
import Logo from "./components/Logo";
|
||||||
|
import { StorageContextProvider, useStorageContext } from "./context";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
interface SettingState {
|
||||||
|
instanceUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IndexOptions = () => {
|
||||||
|
const context = useStorageContext();
|
||||||
|
const [settingState, setSettingState] = useState<SettingState>({
|
||||||
|
instanceUrl: context.instanceUrl || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSettingState({
|
||||||
|
instanceUrl: context.instanceUrl || "",
|
||||||
|
});
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
|
||||||
|
setSettingState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
...partialSettingState,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSetting = () => {
|
||||||
|
context.setInstanceUrl(settingState.instanceUrl);
|
||||||
|
toast.success("Setting saved");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-4">
|
||||||
|
<div className="w-full flex flex-row justify-center items-center">
|
||||||
|
<a
|
||||||
|
className="bg-yellow-100 dark:bg-yellow-500 dark:opacity-70 mt-12 py-2 px-3 rounded-full border dark:border-yellow-600 flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600"
|
||||||
|
href="https://github.com/yourselfhosted/slash#browser-extension"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Icon.HelpCircle className="w-4 h-auto" />
|
||||||
|
<span className="mx-1 text-sm">Need help? Check out the docs</span>
|
||||||
|
<Icon.ExternalLink className="w-4 h-auto" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start py-12">
|
||||||
|
<h2 className="flex flex-row justify-start items-center mb-6 text-2xl dark:text-gray-400">
|
||||||
|
<Logo className="w-10 h-auto mr-2" />
|
||||||
|
<span>Slash</span>
|
||||||
|
<span className="mx-2 text-gray-400">/</span>
|
||||||
|
<span>Setting</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-4">
|
||||||
|
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="dark:text-gray-400">Instance URL</span>
|
||||||
|
{context.instanceUrl !== "" && (
|
||||||
|
<a
|
||||||
|
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
||||||
|
href={context.instanceUrl}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span className="mr-1">Go to my Slash</span>
|
||||||
|
<Icon.ExternalLink className="w-4 h-auto" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="The url of your Slash instance. e.g., https://slash.example.com"
|
||||||
|
value={settingState.instanceUrl}
|
||||||
|
onChange={(e) => setPartialSettingState({ instanceUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full mt-6 flex flex-row justify-end">
|
||||||
|
<Button onClick={handleSaveSetting}>Save</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Options = () => {
|
||||||
|
return (
|
||||||
|
<StorageContextProvider>
|
||||||
|
<CssVarsProvider>
|
||||||
|
<IndexOptions />
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</CssVarsProvider>
|
||||||
|
</StorageContextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Options;
|
95
frontend/extension/src/popup.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { Button, CssVarsProvider } from "@mui/joy";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import { StorageContextProvider, useStorageContext } from "./context";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
const IndexPopup = () => {
|
||||||
|
const context = useStorageContext();
|
||||||
|
const isInitialized = context.instanceUrl;
|
||||||
|
|
||||||
|
const handleSettingButtonClick = () => {
|
||||||
|
chrome.runtime.openOptionsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshButtonClick = () => {
|
||||||
|
chrome.runtime.reload();
|
||||||
|
chrome.browserAction.setPopup({ popup: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full min-w-[512px] px-4 pt-4">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<div className="flex flex-row justify-start items-center dark:text-gray-400">
|
||||||
|
<Logo className="w-6 h-auto mr-1" />
|
||||||
|
<span className="">Slash</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full mt-4">
|
||||||
|
{isInitialized ? (
|
||||||
|
<>
|
||||||
|
<p className="w-full mb-2">
|
||||||
|
<span>Your instance URL is </span>
|
||||||
|
<a
|
||||||
|
className="inline-flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
||||||
|
href={context.instanceUrl}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span className="mr-1">{context.instanceUrl}</span>
|
||||||
|
<Icon.ExternalLink className="w-4 h-auto" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center mb-2">
|
||||||
|
<div className="flex flex-row justify-start items-center gap-2">
|
||||||
|
<Button size="sm" variant="outlined" color="neutral" onClick={handleSettingButtonClick}>
|
||||||
|
<Icon.Settings className="w-5 h-auto text-gray-500 dark:text-gray-400 mr-1" />
|
||||||
|
Setting
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outlined"
|
||||||
|
color="neutral"
|
||||||
|
component="a"
|
||||||
|
href="https://github.com/yourselfhosted/slash"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400 mr-1" />
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex flex-col justify-start items-center">
|
||||||
|
<Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" />
|
||||||
|
<p className="dark:text-gray-400">Please set your instance URL first.</p>
|
||||||
|
<div className="w-full flex flex-row justify-center items-center py-4">
|
||||||
|
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
|
||||||
|
<Icon.Settings className="w-5 h-auto mr-1" /> Go to Setting
|
||||||
|
</Button>
|
||||||
|
<span className="mx-2 dark:text-gray-400">Or</span>
|
||||||
|
<Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}>
|
||||||
|
<Icon.RefreshCcw className="w-5 h-auto mr-1" /> Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Popup = () => {
|
||||||
|
return (
|
||||||
|
<StorageContextProvider>
|
||||||
|
<CssVarsProvider>
|
||||||
|
<IndexPopup />
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</CssVarsProvider>
|
||||||
|
</StorageContextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Popup;
|
@ -5,7 +5,7 @@
|
|||||||
body,
|
body,
|
||||||
html,
|
html,
|
||||||
#root {
|
#root {
|
||||||
@apply text-base;
|
@apply text-base dark:bg-zinc-900;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
||||||
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
"Noto Color Emoji";
|
"Noto Color Emoji";
|
11
frontend/extension/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "plasmo/templates/tsconfig.base",
|
||||||
|
"exclude": ["node_modules"],
|
||||||
|
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"baseUrl": "."
|
||||||
|
}
|
||||||
|
}
|