Compare commits
268 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
d939bb8250 | |||
946548b33a | |||
d97a7e736d | |||
e5d5ba5cbc | |||
ce4232c9f5 | |||
bc6a72561c | |||
b9e5e7f2af | |||
96ab5b226d | |||
9c6f85e938 | |||
f1e3eace1a | |||
6f26523a11 | |||
304a29a18c | |||
3e5fa5573e | |||
93ed3c81ff | |||
0efd495f56 | |||
ae56f6df8c | |||
df51720310 | |||
1194099667 | |||
e936aaced1 | |||
0ee999a30a | |||
1211136037 | |||
73061034b2 | |||
07d1839112 | |||
876872f363 | |||
11e062549a | |||
6a9fcb1c18 | |||
07365fda73 | |||
2264b64007 | |||
bb389ad429 | |||
b6967abd08 | |||
f886bd7eb8 | |||
b638d9cdf4 | |||
8af0675247 | |||
fd09b18033 | |||
129a9cf48c | |||
feadf879dd | |||
9c134f4c8f | |||
b624576269 | |||
2d980380e5 | |||
fda2a3436d | |||
6f96e5e0c8 | |||
dadf42c09b | |||
e855f8c5ad | |||
01ec5900d4 | |||
850fbbaa36 | |||
820b8fc379 | |||
a90279221c | |||
ad988575b3 | |||
994a90c8fb | |||
f33dcba284 | |||
84ddafeb84 | |||
d8903875d3 | |||
fb3267d139 | |||
aaed0a747f | |||
9a491e2a82 | |||
e798e5e82b | |||
87841828ff | |||
f28d23eae7 | |||
606652f7a2 | |||
6395b698b9 | |||
f83c21cc93 | |||
b365355610 | |||
98d4bb40b2 | |||
fcf5981b97 | |||
977ac76928 | |||
66f9c2b568 | |||
e3ce79917d | |||
61cec67ec0 | |||
d6dccb1f95 | |||
c26834e9cd | |||
59a75c89eb | |||
dfe47b9b7e | |||
759ca1c6fd | |||
74200f468c | |||
23d84299e4 | |||
47e0fcd43c | |||
0c4ed55a76 | |||
db842a2c78 | |||
e6ece43231 | |||
714889433f | |||
80c6464208 | |||
1f9c87b81b | |||
4cc2de8e82 | |||
fab3d0033c | |||
6f9df9dfd7 | |||
f5463af7db | |||
a44b6494bf | |||
1ce4b91433 | |||
4139520181 | |||
890bc27982 | |||
a379614cd9 | |||
c18bbfd0bb | |||
d798b2c5fb | |||
4e3ca8ceb4 | |||
96a68ab117 | |||
0eea0a92db | |||
4a47010608 | |||
fa504a88e5 | |||
de51e1a8d3 | |||
49cc1e9755 | |||
ce5c4b65d3 | |||
cee6c7c401 | |||
b6839d2b7d | |||
0ebf03eb9b | |||
21eab35e45 | |||
fd1168e1dc | |||
5ee32d2e78 | |||
2db9c1e850 | |||
953ec3dbc0 | |||
fc28473aee | |||
c42c543618 | |||
72106d13de | |||
6bbf2df8e0 | |||
d42d3fbe10 | |||
6dfccb9509 | |||
66876452e1 | |||
6b107924aa | |||
b84620c057 | |||
c30b6adb8e | |||
c8fea442d6 | |||
a36a99e53d | |||
86078b097d | |||
11205566ac | |||
709118464b | |||
792b60c480 | |||
1418fc2209 | |||
53c1d8fa91 | |||
b32fdbfc0a | |||
db2aebcf57 | |||
b4e23fc8a0 | |||
7ab66113ac | |||
2909676ed3 | |||
5af9236c19 | |||
04c0f47559 | |||
a91997683b | |||
014dd7d660 | |||
a1b633e4db | |||
57496c9b46 | |||
c4f38f1de6 | |||
e7cf0c2f79 | |||
15ffd0738c | |||
21ff8ba797 | |||
b2ce071ef0 | |||
65545c78c6 | |||
4279151238 | |||
3d3f55a931 | |||
85569c032a | |||
bd9daddaef | |||
af31875e6a | |||
a0766159f2 | |||
316617c396 | |||
402b766872 | |||
8fade614d2 | |||
d8c980f56f | |||
b36572c5be | |||
fcd72e1f98 | |||
1cbab78989 | |||
28df6e35fb | |||
12172f11c0 | |||
00c7abc38d | |||
0cceed51f8 | |||
d866d5b53b | |||
05bc21b660 | |||
9455824a2d | |||
0b659ba124 | |||
d82d3701dd | |||
5db3506cba | |||
c00f7d0852 | |||
d900ca060a | |||
b179f7b441 | |||
506e740438 | |||
731ad57fd2 | |||
9fd7d6bd34 | |||
7ca5c92769 | |||
96d44bd651 | |||
ee9e092129 | |||
f0334d5755 | |||
1084381bbf | |||
7d90b47875 |
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
*/*/node_modules
|
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -5,7 +5,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to suggest an idea for Shortify!
|
Thanks for taking the time to suggest an idea for Slash!
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Is your feature request related to a problem?
|
label: Is your feature request related to a problem?
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
name: Test
|
name: Backend Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -15,7 +15,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: 1.19
|
go-version: 1.21
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Verify go.mod is tidy
|
- name: Verify go.mod is tidy
|
||||||
@ -28,50 +28,13 @@ jobs:
|
|||||||
args: -v
|
args: -v
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|
||||||
eslint-checks:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: "18"
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
|
||||||
- run: pnpm install
|
|
||||||
working-directory: web
|
|
||||||
- name: Run eslint check
|
|
||||||
run: pnpm lint
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
frontend-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: "18"
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
|
||||||
- run: pnpm install
|
|
||||||
working-directory: web
|
|
||||||
- name: Run frontend build
|
|
||||||
run: pnpm build
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
go-tests:
|
go-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: 1.19
|
go-version: 1.21
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Run all tests
|
- name: Run all tests
|
@ -24,7 +24,7 @@ jobs:
|
|||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: stevenlgtm
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -41,4 +41,4 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: stevenlgtm/shortify:latest, stevenlgtm/shortify:${{ env.VERSION }}
|
tags: yourselfhosted/slash:latest, yourselfhosted/slash:${{ env.VERSION }}
|
||||||
|
@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: stevenlgtm
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -34,4 +34,4 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: stevenlgtm/shortify:test
|
tags: yourselfhosted/slash:test
|
||||||
|
54
.github/workflows/extension-test.yml
vendored
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
name: Extension Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release/v*.*.*"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "frontend/extension/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
eslint-checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: bufbuild/buf-setup-action@v1
|
||||||
|
- run: buf generate
|
||||||
|
working-directory: proto
|
||||||
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
|
- run: pnpm install
|
||||||
|
working-directory: frontend/extension
|
||||||
|
- name: Run eslint check
|
||||||
|
run: pnpm lint
|
||||||
|
working-directory: frontend/extension
|
||||||
|
|
||||||
|
extension-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: bufbuild/buf-setup-action@v1
|
||||||
|
- run: buf generate
|
||||||
|
working-directory: proto
|
||||||
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
|
- run: pnpm install
|
||||||
|
working-directory: frontend/extension
|
||||||
|
- name: Run extension build
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: frontend/extension
|
54
.github/workflows/frontend-test.yml
vendored
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
name: Frontend Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release/v*.*.*"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "frontend/web/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
eslint-checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: bufbuild/buf-setup-action@v1
|
||||||
|
- run: buf generate
|
||||||
|
working-directory: proto
|
||||||
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
|
- run: pnpm install
|
||||||
|
working-directory: frontend/web
|
||||||
|
- name: Run eslint check
|
||||||
|
run: pnpm lint
|
||||||
|
working-directory: frontend/web
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: bufbuild/buf-setup-action@v1
|
||||||
|
- run: buf generate
|
||||||
|
working-directory: proto
|
||||||
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
|
- run: pnpm install
|
||||||
|
working-directory: frontend/web
|
||||||
|
- name: Run frontend build
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: frontend/web
|
34
.github/workflows/proto-linter.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
name: Proto linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release/v*.*.*"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release/*.*.*"
|
||||||
|
paths:
|
||||||
|
- "proto/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-protos:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup buf
|
||||||
|
uses: bufbuild/buf-setup-action@v1
|
||||||
|
- name: buf lint
|
||||||
|
uses: bufbuild/buf-lint-action@v1
|
||||||
|
with:
|
||||||
|
input: "proto"
|
||||||
|
- name: buf format
|
||||||
|
run: |
|
||||||
|
if [[ $(buf format -d) ]]; then
|
||||||
|
echo "Run 'buf format -w'"
|
||||||
|
exit 1
|
||||||
|
fi
|
5
.gitignore
vendored
@ -4,12 +4,9 @@
|
|||||||
# temp folder
|
# temp folder
|
||||||
tmp
|
tmp
|
||||||
|
|
||||||
# Frontend asset
|
|
||||||
web/dist
|
|
||||||
|
|
||||||
# build folder
|
# build folder
|
||||||
build
|
build
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
extension
|
node_modules
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
|
- errcheck
|
||||||
- goimports
|
- goimports
|
||||||
- revive
|
- revive
|
||||||
- govet
|
- govet
|
||||||
@ -10,17 +11,30 @@ 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
|
||||||
|
- logger.Sync
|
||||||
|
- pgInstance.Stop
|
||||||
- fmt.Printf
|
- fmt.Printf
|
||||||
- fmt.Print
|
- Enter(.*)_(.*)
|
||||||
|
- Exit(.*)_(.*)
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
|
goimports:
|
||||||
|
# Put imports beginning with prefix after 3rd-party packages.
|
||||||
|
local-prefixes: github.com/boojack/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 +65,22 @@ linters-settings:
|
|||||||
disabled: true
|
disabled: true
|
||||||
- name: early-return
|
- name: early-return
|
||||||
disabled: true
|
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)?'
|
||||||
|
44
Dockerfile
@ -1,34 +1,54 @@
|
|||||||
|
# Build protobuf.
|
||||||
|
FROM golang:1.21-alpine AS protobuf
|
||||||
|
WORKDIR /protobuf-generate
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN GO111MODULE=on GOBIN=/usr/local/bin go install github.com/bufbuild/buf/cmd/buf@v1.26.1
|
||||||
|
|
||||||
|
WORKDIR /protobuf-generate/proto
|
||||||
|
|
||||||
|
RUN buf generate
|
||||||
|
|
||||||
# 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 ./frontend .
|
||||||
|
|
||||||
|
COPY --from=protobuf /protobuf-generate/frontend/web/src/types/proto ./web/src/types/proto
|
||||||
|
|
||||||
|
WORKDIR /frontend-build/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.21-alpine AS backend
|
||||||
WORKDIR /backend-build
|
WORKDIR /backend-build
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=frontend /frontend-build/dist ./server/dist
|
COPY --from=frontend /frontend-build/web/dist ./server/dist
|
||||||
|
|
||||||
RUN go build -o shortify ./cmd/shortify/main.go
|
RUN CGO_ENABLED=0 go build -o slash ./cmd/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/shortify
|
WORKDIR /usr/local/slash
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata
|
||||||
ENV TZ="UTC"
|
ENV TZ="UTC"
|
||||||
|
|
||||||
COPY --from=backend /backend-build/shortify /usr/local/shortify/
|
COPY --from=backend /backend-build/slash /usr/local/slash/
|
||||||
|
|
||||||
|
EXPOSE 5231
|
||||||
|
|
||||||
# Directory to store the data, which can be referenced as the mounting point.
|
# Directory to store the data, which can be referenced as the mounting point.
|
||||||
RUN mkdir -p /var/opt/shortify
|
RUN mkdir -p /var/opt/slash
|
||||||
|
VOLUME /var/opt/slash
|
||||||
|
|
||||||
ENTRYPOINT ["./shortify", "--mode", "prod", "--port", "5231"]
|
ENV SLASH_MODE="prod"
|
||||||
|
ENV SLASH_PORT="5231"
|
||||||
|
|
||||||
|
ENTRYPOINT ["./slash"]
|
||||||
|
40
README.md
@ -1,17 +1,45 @@
|
|||||||
# Shortify
|
# Slash
|
||||||
|
|
||||||
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
||||||
|
|
||||||
**Shortify** is a bookmarking and short link service that allows you to save and share links easily. It lets you store and categorize links, generate short URLs for easy sharing, search and filter your saved links, and access them from any device. It simplifies link organization, management, and collaboration, making it effortless to navigate and share web resources.
|
**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.
|
||||||
|
|
||||||
Let's Simplify, Share, and Save your links with **Shortify**.
|
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a> • <a href="https://discord.gg/QZqUuUAhDV">Discord</a>
|
||||||
|
|
||||||
|
<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://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github"/></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg"><b>🧩 Browser extension now available!</b></a></p>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Create customizable `/s/` short links for any URL.
|
||||||
|
- Share short links public or only with your teammates.
|
||||||
|
- View analytics on link traffic and sources.
|
||||||
|
- Easy access to your shortcuts with browser extension.
|
||||||
|
- Open source self-hosted solution.
|
||||||
|
|
||||||
## Deploy with Docker in seconds
|
## Deploy with Docker in seconds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name shortify -p 5231:5231 -v ~/.shortify/:/var/opt/shortify stevenlgtm/shortify:latest
|
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Demo
|
Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md).
|
||||||
|
|
||||||

|
## Browser Extension
|
||||||
|
|
||||||
|
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md).
|
||||||
|
64
api/auth/auth.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// issuer is the issuer of the jwt token.
|
||||||
|
Issuer = "slash"
|
||||||
|
// Signing key section. For now, this is only used for signing, not for verifying since we only
|
||||||
|
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
|
||||||
|
KeyID = "v1"
|
||||||
|
// AccessTokenAudienceName is the audience name of the access token.
|
||||||
|
AccessTokenAudienceName = "user.access-token"
|
||||||
|
AccessTokenDuration = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
||||||
|
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
||||||
|
CookieExpDuration = AccessTokenDuration - 1*time.Minute
|
||||||
|
// AccessTokenCookieName is the cookie name of access token.
|
||||||
|
AccessTokenCookieName = "slash.access-token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClaimsMessage struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAccessToken generates an access token.
|
||||||
|
// username is the email of the user.
|
||||||
|
func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret []byte) (string, error) {
|
||||||
|
return generateToken(username, userID, AccessTokenAudienceName, expirationTime, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken generates a jwt token.
|
||||||
|
func generateToken(username string, userID int32, audience string, expirationTime time.Time, secret []byte) (string, error) {
|
||||||
|
registeredClaims := jwt.RegisteredClaims{
|
||||||
|
Issuer: Issuer,
|
||||||
|
Audience: jwt.ClaimStrings{audience},
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Subject: fmt.Sprint(userID),
|
||||||
|
}
|
||||||
|
if !expirationTime.IsZero() {
|
||||||
|
registeredClaims.ExpiresAt = jwt.NewNumericDate(expirationTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &ClaimsMessage{
|
||||||
|
Name: username,
|
||||||
|
RegisteredClaims: registeredClaims,
|
||||||
|
})
|
||||||
|
token.Header["kid"] = KeyID
|
||||||
|
|
||||||
|
// Create the JWT string.
|
||||||
|
tokenString, err := token.SignedString(secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
@ -1,11 +1,11 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
type ActivityShorcutCreatePayload struct {
|
type ActivityShorcutCreatePayload struct {
|
||||||
ShortcutID int `json:"shortcutId"`
|
ShortcutID int32 `json:"shortcutId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActivityShorcutViewPayload struct {
|
type ActivityShorcutViewPayload struct {
|
||||||
ShortcutID int `json:"shortcutId"`
|
ShortcutID int32 `json:"shortcutId"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Referer string `json:"referer"`
|
Referer string `json:"referer"`
|
||||||
UserAgent string `json:"userAgent"`
|
UserAgent string `json:"userAgent"`
|
||||||
|
129
api/v1/analytics.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mssola/useragent"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
114
api/v1/auth.go
@ -1,15 +1,20 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
"github.com/boojack/shortify/server/auth"
|
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/api/auth"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SignInRequest struct {
|
type SignInRequest struct {
|
||||||
@ -48,24 +53,41 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|||||||
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
|
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||||
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
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))
|
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||||
})
|
})
|
||||||
|
|
||||||
g.POST("/auth/signup", func(c echo.Context) error {
|
g.POST("/auth/signup", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
disallowSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
||||||
Key: store.WorkspaceDisallowSignUp,
|
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
if disallowSignUpSetting != nil && disallowSignUpSetting.Value == "true" {
|
if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "sign up has been disabled")
|
return echo.NewHTTPError(http.StatusForbidden, "sign up has been disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||||
|
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
|
||||||
|
}
|
||||||
|
if len(userList) >= 5 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
signup := &SignUpRequest{}
|
signup := &SignUpRequest{}
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
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)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signup request, err: %s", err)).SetInternal(err)
|
||||||
@ -97,16 +119,90 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||||
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
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))
|
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||||
})
|
})
|
||||||
|
|
||||||
g.POST("/auth/logout", func(c echo.Context) error {
|
g.POST("/auth/logout", func(c echo.Context) error {
|
||||||
auth.RemoveTokensAndCookies(c)
|
ctx := c.Request().Context()
|
||||||
|
RemoveTokensAndCookies(c)
|
||||||
|
accessToken := findAccessToken(c)
|
||||||
|
userID, _ := getUserIDFromAccessToken(accessToken, secret)
|
||||||
|
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||||
|
// Auto remove the current access token from the user access tokens.
|
||||||
|
if err == nil && len(userAccessTokens) != 0 {
|
||||||
|
accessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
|
||||||
|
for _, userAccessToken := range userAccessTokens {
|
||||||
|
if accessToken != userAccessToken.AccessToken {
|
||||||
|
accessTokens = append(accessTokens, userAccessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
|
UserId: userID,
|
||||||
|
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||||
|
Value: &storepb.UserSetting_AccessTokens{
|
||||||
|
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||||
|
AccessTokens: accessTokens,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
c.Response().WriteHeader(http.StatusOK)
|
c.Response().WriteHeader(http.StatusOK)
|
||||||
return nil
|
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_AccessTokens{
|
||||||
|
AccessTokens: &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)
|
||||||
|
}
|
||||||
|
185
api/v1/jwt.go
@ -3,36 +3,24 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/boojack/shortify/internal/util"
|
|
||||||
"github.com/boojack/shortify/server/auth"
|
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Context section
|
|
||||||
// The key name used to store user id in the context
|
// The key name used to store user id in the context
|
||||||
// user id is extracted from the jwt token subject field.
|
// user id is extracted from the jwt token subject field.
|
||||||
userIDContextKey = "user-id"
|
userIDContextKey = "user-id"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getUserIDContextKey() string {
|
|
||||||
return userIDContextKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claims creates a struct that will be encoded to a JWT.
|
|
||||||
// We add jwt.RegisteredClaims as an embedded type, to provide fields such as name.
|
|
||||||
type Claims struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||||
authHeader := c.Request().Header.Get("Authorization")
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
@ -60,38 +48,60 @@ func findAccessToken(c echo.Context) string {
|
|||||||
return accessToken
|
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.
|
// JWTMiddleware validates the access token.
|
||||||
// If the access token is about to expire or has expired and the request has a valid refresh token, it
|
func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||||
// will try to generate new access token and refresh token.
|
|
||||||
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
path := c.Path()
|
ctx := c.Request().Context()
|
||||||
|
path := c.Request().URL.Path
|
||||||
method := c.Request().Method
|
method := c.Request().Method
|
||||||
|
|
||||||
if defaultAuthSkipper(c) {
|
// Pass auth and profile endpoints.
|
||||||
|
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
token := findAccessToken(c)
|
accessToken := findAccessToken(c)
|
||||||
if token == "" {
|
if accessToken == "" {
|
||||||
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
||||||
if util.HasPrefixes(path, "/api/v1/workspace/profile", "/s/*") && method == http.MethodGet {
|
if util.HasPrefixes(path, "/s/") && method == http.MethodGet {
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := &Claims{}
|
userID, err := getUserIDFromAccessToken(accessToken, secret)
|
||||||
accessToken, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
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(accessToken, 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 getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
|
||||||
|
claims := &auth.ClaimsMessage{}
|
||||||
|
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
}
|
}
|
||||||
@ -102,101 +112,22 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
}
|
}
|
||||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||||
})
|
})
|
||||||
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))
|
|
||||||
}
|
|
||||||
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var ve *jwt.ValidationError
|
return 0, errors.Wrap(err, "Invalid or expired access token")
|
||||||
if errors.As(err, &ve) {
|
|
||||||
// If expiration error is the only error, we will clear the err
|
|
||||||
// and generate new access token and refresh token
|
|
||||||
if ve.Errors == jwt.ValidationErrorExpired {
|
|
||||||
generateToken = true
|
|
||||||
}
|
}
|
||||||
} else {
|
// We either have a valid access token or we will attempt to generate new access token.
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, err := strconv.Atoi(claims.Subject)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.")
|
return 0, errors.Wrap(err, "Malformed ID in the token")
|
||||||
}
|
|
||||||
|
|
||||||
// Even if there is no error, we still need to make sure the user still exists.
|
|
||||||
user, err := server.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))
|
|
||||||
}
|
|
||||||
|
|
||||||
if generateToken {
|
|
||||||
generateTokenFunc := func() error {
|
|
||||||
rc, err := c.Cookie(auth.RefreshTokenCookieName)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Missing refresh token.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parses token and checks if it's valid.
|
|
||||||
refreshTokenClaims := &Claims{}
|
|
||||||
refreshToken, err := jwt.ParseWithClaims(rc.Value, refreshTokenClaims, func(t *jwt.Token) (any, error) {
|
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
|
||||||
return nil, errors.Errorf("unexpected refresh token signing method=%v, expected %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 refresh token kid=%v", t.Header["kid"])
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if err == jwt.ErrSignatureInvalid {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Invalid refresh token signature.")
|
|
||||||
}
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !audienceContains(refreshTokenClaims.Audience, auth.RefreshTokenAudienceName) {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
|
||||||
fmt.Sprintf("Invalid refresh token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
|
||||||
refreshTokenClaims.Audience,
|
|
||||||
auth.RefreshTokenAudienceName,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a valid refresh token, we will generate new access token and refresh token
|
|
||||||
if refreshToken != nil && refreshToken.Valid {
|
|
||||||
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// It may happen that we still have a valid access token, but we encounter issue when trying to generate new token
|
|
||||||
// In such case, we won't return the error.
|
|
||||||
if err := generateTokenFunc(); err != nil && !accessToken.Valid {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stores userID into context.
|
|
||||||
c.Set(getUserIDContextKey(), userID)
|
|
||||||
return next(c)
|
|
||||||
}
|
}
|
||||||
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultAuthSkipper(c echo.Context) bool {
|
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||||
path := c.Path()
|
for _, userAccessToken := range userAccessTokens {
|
||||||
return util.HasPrefixes(path, "/api/v1/auth")
|
if accessTokenString == userAccessToken.AccessToken {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,16 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
||||||
@ -28,12 +32,12 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
|||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with name: %s", shortcutName))
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with name: %s", shortcutName))
|
||||||
}
|
}
|
||||||
if shortcut.Visibility != store.VisibilityPublic {
|
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
}
|
}
|
||||||
if shortcut.Visibility == store.VisibilityPrivate && shortcut.CreatorID != userID {
|
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -42,16 +46,49 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isValidURLString(shortcut.Link) {
|
return redirectToShortcut(c, shortcut)
|
||||||
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
|
||||||
}
|
|
||||||
return c.String(http.StatusOK, shortcut.Link)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *store.Shortcut) error {
|
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{
|
payload := &ActivityShorcutViewPayload{
|
||||||
ShortcutID: shortcut.ID,
|
ShortcutID: shortcut.Id,
|
||||||
IP: c.RealIP(),
|
IP: c.RealIP(),
|
||||||
Referer: c.Request().Referer(),
|
Referer: c.Request().Referer(),
|
||||||
UserAgent: c.Request().UserAgent(),
|
UserAgent: c.Request().UserAgent(),
|
||||||
|
@ -5,13 +5,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/boojack/slash/internal/util"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Visibility is the type of a shortcut visibility.
|
// Visibility is the type of a shortcut visibility.
|
||||||
@ -30,11 +31,17 @@ func (v Visibility) String() string {
|
|||||||
return string(v)
|
return string(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenGraphMetadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
type Shortcut struct {
|
type Shortcut struct {
|
||||||
ID int `json:"id"`
|
ID int32 `json:"id"`
|
||||||
|
|
||||||
// Standard fields
|
// Standard fields
|
||||||
CreatorID int `json:"creatorId"`
|
CreatorID int32 `json:"creatorId"`
|
||||||
Creator *User `json:"creator"`
|
Creator *User `json:"creator"`
|
||||||
CreatedTs int64 `json:"createdTs"`
|
CreatedTs int64 `json:"createdTs"`
|
||||||
UpdatedTs int64 `json:"updatedTs"`
|
UpdatedTs int64 `json:"updatedTs"`
|
||||||
@ -43,33 +50,39 @@ type Shortcut struct {
|
|||||||
// Domain specific fields
|
// Domain specific fields
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Visibility Visibility `json:"visibility"`
|
Visibility Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
View int `json:"view"`
|
View int `json:"view"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateShortcutRequest struct {
|
type CreateShortcutRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Visibility Visibility `json:"visibility"`
|
Visibility Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PatchShortcutRequest struct {
|
type PatchShortcutRequest struct {
|
||||||
RowStatus *RowStatus `json:"rowStatus"`
|
RowStatus *RowStatus `json:"rowStatus"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Link *string `json:"link"`
|
Link *string `json:"link"`
|
||||||
|
Title *string `json:"title"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Visibility *Visibility `json:"visibility"`
|
Visibility *Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||||
g.POST("/shortcut", func(c echo.Context) error {
|
g.POST("/shortcut", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
@ -78,14 +91,24 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
shortcut, err := s.Store.CreateShortcut(ctx, &store.Shortcut{
|
shortcut := &storepb.Shortcut{
|
||||||
CreatorID: userID,
|
CreatorId: userID,
|
||||||
Name: strings.ToLower(create.Name),
|
Name: create.Name,
|
||||||
Link: create.Link,
|
Link: create.Link,
|
||||||
|
Title: create.Title,
|
||||||
Description: create.Description,
|
Description: create.Description,
|
||||||
Visibility: convertVisibilityToStore(create.Visibility),
|
Visibility: convertVisibilityToStorepb(create.Visibility),
|
||||||
Tag: strings.Join(create.Tags, " "),
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -94,7 +117,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -103,11 +126,11 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
@ -127,7 +150,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||||
}
|
}
|
||||||
if shortcut.CreatorID != userID && currentUser.Role != store.RoleAdmin {
|
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,15 +158,12 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
if err := json.NewDecoder(c.Request().Body).Decode(patch); err != nil {
|
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)
|
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{
|
shortcutUpdate := &store.UpdateShortcut{
|
||||||
ID: shortcutID,
|
ID: shortcutID,
|
||||||
Name: patch.Name,
|
Name: patch.Name,
|
||||||
Link: patch.Link,
|
Link: patch.Link,
|
||||||
|
Title: patch.Title,
|
||||||
Description: patch.Description,
|
Description: patch.Description,
|
||||||
}
|
}
|
||||||
if patch.RowStatus != nil {
|
if patch.RowStatus != nil {
|
||||||
@ -156,12 +176,19 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
tag := strings.Join(patch.Tags, " ")
|
tag := strings.Join(patch.Tags, " ")
|
||||||
shortcutUpdate.Tag = &tag
|
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)
|
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -170,24 +197,17 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.GET("/shortcut", func(c echo.Context) error {
|
g.GET("/shortcut", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
|
|
||||||
find := &store.FindShortcut{}
|
find := &store.FindShortcut{}
|
||||||
if creatorIDStr := c.QueryParam("creatorId"); creatorIDStr != "" {
|
|
||||||
creatorID, err := strconv.Atoi(creatorIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("unwanted creator id string: %s", creatorIDStr))
|
|
||||||
}
|
|
||||||
find.CreatorID = &creatorID
|
|
||||||
}
|
|
||||||
if tag := c.QueryParam("tag"); tag != "" {
|
if tag := c.QueryParam("tag"); tag != "" {
|
||||||
find.Tag = &tag
|
find.Tag = &tag
|
||||||
}
|
}
|
||||||
|
|
||||||
list := []*store.Shortcut{}
|
list := []*storepb.Shortcut{}
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -205,7 +225,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
shortcutMessageList := []*Shortcut{}
|
shortcutMessageList := []*Shortcut{}
|
||||||
for _, shortcut := range list {
|
for _, shortcut := range list {
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -216,7 +236,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.GET("/shortcut/:id", func(c echo.Context) error {
|
g.GET("/shortcut/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
shortcutID, err := strconv.Atoi(c.Param("id"))
|
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -231,7 +251,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||||
}
|
}
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -240,11 +260,11 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
shortcutID, err := strconv.Atoi(c.Param("id"))
|
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
@ -264,41 +284,18 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||||
}
|
}
|
||||||
if shortcut.CreatorID != userID && currentUser.Role != store.RoleAdmin {
|
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
||||||
ID: shortcutID,
|
if err != nil {
|
||||||
}); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, true)
|
return c.JSON(http.StatusOK, true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *store.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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -310,6 +307,9 @@ func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "Failed to get creator")
|
return nil, errors.Wrap(err, "Failed to get creator")
|
||||||
}
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("Creator not found")
|
||||||
|
}
|
||||||
shortcut.Creator = convertUserFromStore(user)
|
shortcut.Creator = convertUserFromStore(user)
|
||||||
|
|
||||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
@ -325,35 +325,57 @@ func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut)
|
|||||||
return shortcut, nil
|
return shortcut, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertVisibilityToStore(visibility Visibility) store.Visibility {
|
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
|
||||||
switch visibility {
|
|
||||||
case VisibilityPrivate:
|
|
||||||
return store.VisibilityPrivate
|
|
||||||
case VisibilityWorkspace:
|
|
||||||
return store.VisibilityWorkspace
|
|
||||||
case VisibilityPublic:
|
|
||||||
return store.VisibilityPublic
|
|
||||||
default:
|
|
||||||
return store.VisibilityPrivate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
|
|
||||||
tags := []string{}
|
|
||||||
if shortcut.Tag != "" {
|
|
||||||
tags = append(tags, strings.Split(shortcut.Tag, " ")...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Shortcut{
|
return &Shortcut{
|
||||||
ID: shortcut.ID,
|
ID: shortcut.Id,
|
||||||
CreatedTs: shortcut.CreatedTs,
|
CreatedTs: shortcut.CreatedTs,
|
||||||
UpdatedTs: shortcut.UpdatedTs,
|
UpdatedTs: shortcut.UpdatedTs,
|
||||||
CreatorID: shortcut.CreatorID,
|
CreatorID: shortcut.CreatorId,
|
||||||
|
RowStatus: RowStatus(shortcut.RowStatus.String()),
|
||||||
Name: shortcut.Name,
|
Name: shortcut.Name,
|
||||||
Link: shortcut.Link,
|
Link: shortcut.Link,
|
||||||
|
Title: shortcut.Title,
|
||||||
Description: shortcut.Description,
|
Description: shortcut.Description,
|
||||||
Visibility: Visibility(shortcut.Visibility),
|
Visibility: Visibility(shortcut.Visibility.String()),
|
||||||
RowStatus: RowStatus(shortcut.RowStatus),
|
Tags: shortcut.Tags,
|
||||||
Tags: 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
|
||||||
|
}
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
|
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
|
||||||
|
// GET /url/favicon?url=...
|
||||||
g.GET("/url/favicon", func(c echo.Context) error {
|
g.GET("/url/favicon", func(c echo.Context) error {
|
||||||
url := c.QueryParam("url")
|
url := c.QueryParam("url")
|
||||||
icons, err := favicon.Find(url)
|
icons, err := favicon.Find(url)
|
||||||
|
135
api/v1/user.go
@ -5,12 +5,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/internal/util"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -39,7 +41,7 @@ func (r Role) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `json:"id"`
|
ID int32 `json:"id"`
|
||||||
|
|
||||||
// Standard fields
|
// Standard fields
|
||||||
CreatedTs int64 `json:"createdTs"`
|
CreatedTs int64 `json:"createdTs"`
|
||||||
@ -56,18 +58,18 @@ type CreateUserRequest struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Role Role `json:"-"`
|
Role Role `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (create CreateUserRequest) Validate() error {
|
func (create CreateUserRequest) Validate() error {
|
||||||
if create.Email != "" && !validateEmail(create.Email) {
|
if create.Email != "" && !validateEmail(create.Email) {
|
||||||
return fmt.Errorf("invalid email format")
|
return errors.New("invalid email format")
|
||||||
}
|
}
|
||||||
if create.Nickname != "" && len(create.Nickname) < 3 {
|
if create.Nickname != "" && len(create.Nickname) < 3 {
|
||||||
return fmt.Errorf("nickname is too short, minimum length is 3")
|
return errors.New("nickname is too short, minimum length is 3")
|
||||||
}
|
}
|
||||||
if len(create.Password) < 3 {
|
if len(create.Password) < 3 {
|
||||||
return fmt.Errorf("password is too short, minimum length is 3")
|
return errors.New("password is too short, minimum length is 3")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -78,13 +80,66 @@ type PatchUserRequest struct {
|
|||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
Nickname *string `json:"nickname"`
|
Nickname *string `json:"nickname"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
}
|
Role *Role `json:"role"`
|
||||||
|
|
||||||
type UserDelete struct {
|
|
||||||
ID int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||||
|
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
|
||||||
|
}
|
||||||
|
if len(userList) >= 5 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
g.GET("/user", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
@ -102,7 +157,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
// GET /api/user/me is used to check if the user is logged in.
|
// GET /api/user/me is used to check if the user is logged in.
|
||||||
g.GET("/user/me", func(c echo.Context) error {
|
g.GET("/user/me", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
||||||
}
|
}
|
||||||
@ -119,7 +174,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.GET("/user/:id", func(c echo.Context) error {
|
g.GET("/user/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, err := strconv.Atoi(c.Param("id"))
|
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -136,15 +191,24 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.PATCH("/user/:id", func(c echo.Context) error {
|
g.PATCH("/user/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, err := strconv.Atoi(c.Param("id"))
|
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
if currentUserID != userID {
|
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)
|
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,13 +218,12 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateUser := &store.UpdateUser{
|
updateUser := &store.UpdateUser{
|
||||||
ID: currentUserID,
|
ID: userID,
|
||||||
}
|
}
|
||||||
if userPatch.Email != nil {
|
if userPatch.Email != nil {
|
||||||
if !validateEmail(*userPatch.Email) {
|
if !validateEmail(*userPatch.Email) {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUser.Email = userPatch.Email
|
updateUser.Email = userPatch.Email
|
||||||
}
|
}
|
||||||
if userPatch.Nickname != nil {
|
if userPatch.Nickname != nil {
|
||||||
@ -175,6 +238,24 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
passwordHashStr := string(passwordHash)
|
passwordHashStr := string(passwordHash)
|
||||||
updateUser.PasswordHash = &passwordHashStr
|
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)
|
user, err := s.Store.UpdateUser(ctx, updateUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -186,7 +267,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.DELETE("/user/:id", func(c echo.Context) error {
|
g.DELETE("/user/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
@ -203,10 +284,22 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := strconv.Atoi(c.Param("id"))
|
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
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{
|
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
|
@ -2,7 +2,8 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserSettingKey string
|
type UserSettingKey string
|
||||||
@ -13,11 +14,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// String returns the string format of UserSettingKey type.
|
// String returns the string format of UserSettingKey type.
|
||||||
func (key UserSettingKey) String() string {
|
func (k UserSettingKey) String() string {
|
||||||
if key == UserSettingLocaleKey {
|
return string(k)
|
||||||
return "locale"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -27,7 +25,7 @@ var (
|
|||||||
type UserSetting struct {
|
type UserSetting struct {
|
||||||
UserID int
|
UserID int
|
||||||
Key UserSettingKey `json:"key"`
|
Key UserSettingKey `json:"key"`
|
||||||
// Value is a JSON string with basic value
|
// Value is a JSON string with basic value.
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,7 +40,7 @@ func (upsert UserSettingUpsert) Validate() error {
|
|||||||
localeValue := "en"
|
localeValue := "en"
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal user setting locale value")
|
return errors.New("failed to unmarshal user setting locale value")
|
||||||
}
|
}
|
||||||
|
|
||||||
invalid := true
|
invalid := true
|
||||||
@ -53,10 +51,10 @@ func (upsert UserSettingUpsert) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if invalid {
|
if invalid {
|
||||||
return fmt.Errorf("invalid user setting locale value")
|
return errors.New("invalid user setting locale value")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("invalid user setting key")
|
return errors.New("invalid user setting key")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
12
api/v1/v1.go
@ -1,21 +1,24 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/boojack/shortify/server/profile"
|
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type APIV1Service struct {
|
type APIV1Service struct {
|
||||||
Profile *profile.Profile
|
Profile *profile.Profile
|
||||||
Store *store.Store
|
Store *store.Store
|
||||||
|
LicenseService *license.LicenseService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAPIV1Service(profile *profile.Profile, store *store.Store) *APIV1Service {
|
func NewAPIV1Service(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *APIV1Service {
|
||||||
return &APIV1Service{
|
return &APIV1Service{
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
Store: store,
|
Store: store,
|
||||||
|
LicenseService: licenseService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,6 +32,7 @@ func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
|||||||
s.registerAuthRoutes(apiV1Group, secret)
|
s.registerAuthRoutes(apiV1Group, secret)
|
||||||
s.registerUserRoutes(apiV1Group)
|
s.registerUserRoutes(apiV1Group)
|
||||||
s.registerShortcutRoutes(apiV1Group)
|
s.registerShortcutRoutes(apiV1Group)
|
||||||
|
s.registerAnalyticsRoutes(apiV1Group)
|
||||||
|
|
||||||
redirectorGroup := apiGroup.Group("/s")
|
redirectorGroup := apiGroup.Group("/s")
|
||||||
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
@ -1,39 +1,16 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/profile"
|
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
type WorkspaceProfile struct {
|
||||||
Profile *profile.Profile `json:"profile"`
|
Profile *profile.Profile `json:"profile"`
|
||||||
DisallowSignUp bool `json:"disallowSignUp"`
|
DisallowSignUp bool `json:"disallowSignUp"`
|
||||||
@ -47,87 +24,16 @@ func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
|
|||||||
DisallowSignUp: false,
|
DisallowSignUp: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
disallowSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
||||||
Key: store.WorkspaceDisallowSignUp,
|
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
if disallowSignUpSetting != nil {
|
if enableSignUpSetting != nil {
|
||||||
workspaceProfile.DisallowSignUp = disallowSignUpSetting.Value == "true"
|
workspaceProfile.DisallowSignUp = !enableSignUpSetting.GetEnableSignup()
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, workspaceProfile)
|
return c.JSON(http.StatusOK, workspaceProfile)
|
||||||
})
|
})
|
||||||
|
|
||||||
g.POST("/workspace/setting", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
|
||||||
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(getUserIDContextKey()).(int)
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
174
api/v2/acl.go
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextKey is the key type of context value.
|
||||||
|
type ContextKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The key name used to store user id in the context
|
||||||
|
// user id is extracted from the jwt token subject field.
|
||||||
|
userIDContextKey ContextKey = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
// GRPCAuthInterceptor is the auth interceptor for gRPC server.
|
||||||
|
type GRPCAuthInterceptor struct {
|
||||||
|
Store *store.Store
|
||||||
|
secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGRPCAuthInterceptor returns a new API auth interceptor.
|
||||||
|
func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthInterceptor {
|
||||||
|
return &GRPCAuthInterceptor{
|
||||||
|
Store: store,
|
||||||
|
secret: secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticationInterceptor is the unary interceptor for gRPC API.
|
||||||
|
func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, request any, serverInfo *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context")
|
||||||
|
}
|
||||||
|
accessToken, err := getTokenFromMetadata(md)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "failed to get access token from metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := in.authenticate(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
if isUnauthorizeAllowedMethod(serverInfo.FullMethod) {
|
||||||
|
return handler(ctx, request)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user, err := in.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to get user")
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "user ID %q not exists in the access token", userID)
|
||||||
|
}
|
||||||
|
if isOnlyForAdminAllowedMethod(serverInfo.FullMethod) && user.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "user ID %q is not admin", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores userID into context.
|
||||||
|
childCtx := context.WithValue(ctx, userIDContextKey, userID)
|
||||||
|
return handler(childCtx, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken string) (int32, error) {
|
||||||
|
if accessToken == "" {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "access token not found")
|
||||||
|
}
|
||||||
|
claims := &auth.ClaimsMessage{}
|
||||||
|
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "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(in.secret), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token kid=%v", t.Header["kid"])
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "Invalid or expired access token")
|
||||||
|
}
|
||||||
|
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated,
|
||||||
|
"invalid access token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||||
|
claims.Audience,
|
||||||
|
auth.AccessTokenAudienceName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "malformed ID %q in the access token", claims.Subject)
|
||||||
|
}
|
||||||
|
user, err := in.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "failed to find user ID %q in the access token", userID)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "user ID %q not exists in the access token", userID)
|
||||||
|
}
|
||||||
|
if user.RowStatus == store.Archived {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "user ID %q has been deactivated by administrators", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTokens, err := in.Store.GetUserAccessTokens(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrapf(err, "failed to get user access tokens")
|
||||||
|
}
|
||||||
|
if !validateAccessToken(accessToken, accessTokens) {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "invalid access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTokenFromMetadata(md metadata.MD) (string, error) {
|
||||||
|
// Try to get the token from the authorization header first.
|
||||||
|
authorizationHeaders := md.Get("Authorization")
|
||||||
|
if len(authorizationHeaders) > 0 {
|
||||||
|
authHeaderParts := strings.Fields(authorizationHeaders[0])
|
||||||
|
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||||
|
return "", errors.Errorf("authorization header format must be Bearer {token}")
|
||||||
|
}
|
||||||
|
return authHeaderParts[1], nil
|
||||||
|
}
|
||||||
|
// Try to get the token from the cookie header.
|
||||||
|
var accessToken string
|
||||||
|
for _, t := range append(md.Get("grpcgateway-cookie"), md.Get("cookie")...) {
|
||||||
|
header := http.Header{}
|
||||||
|
header.Add("Cookie", t)
|
||||||
|
request := http.Request{Header: header}
|
||||||
|
if v, _ := request.Cookie(auth.AccessTokenCookieName); v != nil {
|
||||||
|
accessToken = v.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
||||||
|
for _, v := range audience {
|
||||||
|
if v == token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||||
|
for _, userAccessToken := range userAccessTokens {
|
||||||
|
if accessTokenString == userAccessToken.AccessToken {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
26
api/v2/acl_config.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
var allowedMethodsWhenUnauthorized = map[string]bool{
|
||||||
|
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
"/slash.api.v2.UserService/DeleteUser": true,
|
||||||
|
"/slash.api.v2.WorkspaceService/UpdateWorkspaceSetting": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
|
||||||
|
func isOnlyForAdminAllowedMethod(methodName string) bool {
|
||||||
|
return allowedMethodsOnlyForAdmin[methodName]
|
||||||
|
}
|
17
api/v2/common.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
185
api/v2/shortcut_service.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) DeleteShortcut(ctx context.Context, request *apiv2pb.DeleteShortcutRequest) (*apiv2pb.DeleteShortcutResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
||||||
|
ID: shortcut.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to delete shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.DeleteShortcutResponse{}
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
50
api/v2/subscription_service.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SubscriptionService struct {
|
||||||
|
apiv2pb.UnimplementedSubscriptionServiceServer
|
||||||
|
|
||||||
|
Profile *profile.Profile
|
||||||
|
Store *store.Store
|
||||||
|
LicenseService *license.LicenseService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSubscriptionService creates a new SubscriptionService.
|
||||||
|
func NewSubscriptionService(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *SubscriptionService {
|
||||||
|
return &SubscriptionService{
|
||||||
|
Profile: profile,
|
||||||
|
Store: store,
|
||||||
|
LicenseService: licenseService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionService) GetSubscription(ctx context.Context, _ *apiv2pb.GetSubscriptionRequest) (*apiv2pb.GetSubscriptionResponse, error) {
|
||||||
|
subscription, err := s.LicenseService.LoadSubscription(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
||||||
|
}
|
||||||
|
return &apiv2pb.GetSubscriptionResponse{
|
||||||
|
Subscription: subscription,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionService) UpdateSubscription(ctx context.Context, request *apiv2pb.UpdateSubscriptionRequest) (*apiv2pb.UpdateSubscriptionResponse, error) {
|
||||||
|
subscription, err := s.LicenseService.UpdateSubscription(ctx, request.LicenseKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
||||||
|
}
|
||||||
|
return &apiv2pb.UpdateSubscriptionResponse{
|
||||||
|
Subscription: subscription,
|
||||||
|
}, nil
|
||||||
|
}
|
336
api/v2/user_service.go
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
|
||||||
|
"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/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserService struct {
|
||||||
|
apiv2pb.UnimplementedUserServiceServer
|
||||||
|
|
||||||
|
Secret string
|
||||||
|
Store *store.Store
|
||||||
|
LicenseService *license.LicenseService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserService creates a new UserService.
|
||||||
|
func NewUserService(secret string, store *store.Store, licenseService *license.LicenseService) *UserService {
|
||||||
|
return &UserService{
|
||||||
|
Secret: secret,
|
||||||
|
Store: store,
|
||||||
|
LicenseService: licenseService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, error) {
|
||||||
|
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userMessages := []*apiv2pb.User{}
|
||||||
|
for _, user := range users {
|
||||||
|
userMessages = append(userMessages, convertUserFromStore(user))
|
||||||
|
}
|
||||||
|
response := &apiv2pb.ListUsersResponse{
|
||||||
|
Users: userMessages,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||||
|
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
||||||
|
}
|
||||||
|
if len(userList) >= 5 {
|
||||||
|
return nil, status.Errorf(codes.ResourceExhausted, "maximum number of users reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if userID != request.User.Id {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "UpdateMask is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
userUpdate := &store.UpdateUser{
|
||||||
|
ID: request.User.Id,
|
||||||
|
}
|
||||||
|
for _, path := range request.UpdateMask {
|
||||||
|
if path == "email" {
|
||||||
|
userUpdate.Email = &request.User.Email
|
||||||
|
} else if path == "nickname" {
|
||||||
|
userUpdate.Nickname = &request.User.Nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, err := s.Store.UpdateUser(ctx, userUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
|
||||||
|
}
|
||||||
|
return &apiv2pb.UpdateUserResponse{
|
||||||
|
User: convertUserFromStore(user),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) DeleteUser(ctx context.Context, request *apiv2pb.DeleteUserRequest) (*apiv2pb.DeleteUserResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if userID == request.Id {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "cannot delete yourself")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||||
|
ID: request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.DeleteUserResponse{}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Time{}
|
||||||
|
if request.ExpiresAt != nil {
|
||||||
|
expiresAt = request.ExpiresAt.AsTime()
|
||||||
|
}
|
||||||
|
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, expiresAt, []byte(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.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.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_AccessTokens{
|
||||||
|
AccessTokens: &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_AccessTokens{
|
||||||
|
AccessTokens: &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
|
||||||
|
}
|
||||||
|
}
|
148
api/v2/user_setting_service.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserSettingService struct {
|
||||||
|
apiv2pb.UnimplementedUserSettingServiceServer
|
||||||
|
|
||||||
|
Store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserSettingService creates a new UserSettingService.
|
||||||
|
func NewUserSettingService(store *store.Store) *UserSettingService {
|
||||||
|
return &UserSettingService{
|
||||||
|
Store: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserSettingService) GetUserSetting(ctx context.Context, request *apiv2pb.GetUserSettingRequest) (*apiv2pb.GetUserSettingResponse, error) {
|
||||||
|
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
||||||
|
}
|
||||||
|
return &apiv2pb.GetUserSettingResponse{
|
||||||
|
UserSetting: userSetting,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserSettingService) UpdateUserSetting(ctx context.Context, request *apiv2pb.UpdateUserSettingRequest) (*apiv2pb.UpdateUserSettingResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
for _, path := range request.UpdateMask {
|
||||||
|
if path == "locale" {
|
||||||
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
|
UserId: userID,
|
||||||
|
Key: storepb.UserSettingKey_USER_SETTING_LOCALE,
|
||||||
|
Value: &storepb.UserSetting_Locale{
|
||||||
|
Locale: convertUserSettingLocaleToStore(request.UserSetting.Locale),
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update user setting: %v", err)
|
||||||
|
}
|
||||||
|
} else if path == "color_theme" {
|
||||||
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
|
UserId: userID,
|
||||||
|
Key: storepb.UserSettingKey_USER_SETTING_COLOR_THEME,
|
||||||
|
Value: &storepb.UserSetting_ColorTheme{
|
||||||
|
ColorTheme: convertUserSettingColorThemeToStore(request.UserSetting.ColorTheme),
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update user setting: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "invalid path: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
||||||
|
}
|
||||||
|
return &apiv2pb.UpdateUserSettingResponse{
|
||||||
|
UserSetting: userSetting,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserSetting(ctx context.Context, s *store.Store, userID int32) (*apiv2pb.UserSetting, error) {
|
||||||
|
userSettings, err := s.ListUserSettings(ctx, &store.FindUserSetting{
|
||||||
|
UserID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to find user setting")
|
||||||
|
}
|
||||||
|
|
||||||
|
userSetting := &apiv2pb.UserSetting{
|
||||||
|
Id: userID,
|
||||||
|
Locale: apiv2pb.UserSetting_LOCALE_EN,
|
||||||
|
ColorTheme: apiv2pb.UserSetting_COLOR_THEME_SYSTEM,
|
||||||
|
}
|
||||||
|
for _, setting := range userSettings {
|
||||||
|
if setting.Key == storepb.UserSettingKey_USER_SETTING_LOCALE {
|
||||||
|
userSetting.Locale = convertUserSettingLocaleFromStore(setting.GetLocale())
|
||||||
|
} else if setting.Key == storepb.UserSettingKey_USER_SETTING_COLOR_THEME {
|
||||||
|
userSetting.ColorTheme = convertUserSettingColorThemeFromStore(setting.GetColorTheme())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userSetting, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertUserSettingLocaleToStore(locale apiv2pb.UserSetting_Locale) storepb.LocaleUserSetting {
|
||||||
|
switch locale {
|
||||||
|
case apiv2pb.UserSetting_LOCALE_EN:
|
||||||
|
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN
|
||||||
|
case apiv2pb.UserSetting_LOCALE_ZH:
|
||||||
|
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH
|
||||||
|
default:
|
||||||
|
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertUserSettingLocaleFromStore(locale storepb.LocaleUserSetting) apiv2pb.UserSetting_Locale {
|
||||||
|
switch locale {
|
||||||
|
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN:
|
||||||
|
return apiv2pb.UserSetting_LOCALE_EN
|
||||||
|
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH:
|
||||||
|
return apiv2pb.UserSetting_LOCALE_ZH
|
||||||
|
default:
|
||||||
|
return apiv2pb.UserSetting_LOCALE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertUserSettingColorThemeToStore(colorTheme apiv2pb.UserSetting_ColorTheme) storepb.ColorThemeUserSetting {
|
||||||
|
switch colorTheme {
|
||||||
|
case apiv2pb.UserSetting_COLOR_THEME_SYSTEM:
|
||||||
|
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM
|
||||||
|
case apiv2pb.UserSetting_COLOR_THEME_LIGHT:
|
||||||
|
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT
|
||||||
|
case apiv2pb.UserSetting_COLOR_THEME_DARK:
|
||||||
|
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK
|
||||||
|
default:
|
||||||
|
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertUserSettingColorThemeFromStore(colorTheme storepb.ColorThemeUserSetting) apiv2pb.UserSetting_ColorTheme {
|
||||||
|
switch colorTheme {
|
||||||
|
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM:
|
||||||
|
return apiv2pb.UserSetting_COLOR_THEME_SYSTEM
|
||||||
|
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT:
|
||||||
|
return apiv2pb.UserSetting_COLOR_THEME_LIGHT
|
||||||
|
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK:
|
||||||
|
return apiv2pb.UserSetting_COLOR_THEME_DARK
|
||||||
|
default:
|
||||||
|
return apiv2pb.UserSetting_COLOR_THEME_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
100
api/v2/v2.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
|
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/reflection"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIV2Service struct {
|
||||||
|
Secret string
|
||||||
|
Profile *profile.Profile
|
||||||
|
Store *store.Store
|
||||||
|
LicenseService *license.LicenseService
|
||||||
|
|
||||||
|
grpcServer *grpc.Server
|
||||||
|
grpcServerPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, licenseService *license.LicenseService, grpcServerPort int) *APIV2Service {
|
||||||
|
authProvider := NewGRPCAuthInterceptor(store, secret)
|
||||||
|
grpcServer := grpc.NewServer(
|
||||||
|
grpc.ChainUnaryInterceptor(
|
||||||
|
authProvider.AuthenticationInterceptor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
apiv2pb.RegisterSubscriptionServiceServer(grpcServer, NewSubscriptionService(profile, store, licenseService))
|
||||||
|
apiv2pb.RegisterWorkspaceServiceServer(grpcServer, NewWorkspaceService(profile, store, licenseService))
|
||||||
|
apiv2pb.RegisterUserServiceServer(grpcServer, NewUserService(secret, store, licenseService))
|
||||||
|
apiv2pb.RegisterUserSettingServiceServer(grpcServer, NewUserSettingService(store))
|
||||||
|
apiv2pb.RegisterShortcutServiceServer(grpcServer, NewShortcutService(secret, store))
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
|
return &APIV2Service{
|
||||||
|
Secret: secret,
|
||||||
|
Profile: profile,
|
||||||
|
Store: store,
|
||||||
|
LicenseService: licenseService,
|
||||||
|
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.RegisterSubscriptionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterWorkspaceServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterUserSettingServiceHandler(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))
|
||||||
|
|
||||||
|
// GRPC web proxy.
|
||||||
|
options := []grpcweb.Option{
|
||||||
|
grpcweb.WithCorsForRegisteredEndpointsOnly(false),
|
||||||
|
grpcweb.WithOriginFunc(func(origin string) bool {
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
wrappedGrpc := grpcweb.WrapServer(s.grpcServer, options...)
|
||||||
|
e.Any("/slash.api.v2.*", echo.WrapHandler(wrappedGrpc))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
166
api/v2/workspace_service.go
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceService struct {
|
||||||
|
apiv2pb.UnimplementedWorkspaceServiceServer
|
||||||
|
|
||||||
|
Profile *profile.Profile
|
||||||
|
Store *store.Store
|
||||||
|
LicenseService *license.LicenseService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkspaceService creates a new WorkspaceService.
|
||||||
|
func NewWorkspaceService(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *WorkspaceService {
|
||||||
|
return &WorkspaceService{
|
||||||
|
Profile: profile,
|
||||||
|
Store: store,
|
||||||
|
LicenseService: licenseService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) GetWorkspaceProfile(ctx context.Context, _ *apiv2pb.GetWorkspaceProfileRequest) (*apiv2pb.GetWorkspaceProfileResponse, error) {
|
||||||
|
profile := &apiv2pb.WorkspaceProfile{
|
||||||
|
Mode: s.Profile.Mode,
|
||||||
|
Plan: apiv2pb.PlanType_FREE,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load subscription plan from license service.
|
||||||
|
subscription, err := s.LicenseService.GetSubscription(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get subscription: %v", err)
|
||||||
|
}
|
||||||
|
profile.Plan = subscription.Plan
|
||||||
|
|
||||||
|
workspaceSetting, err := s.GetWorkspaceSetting(ctx, &apiv2pb.GetWorkspaceSettingRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
||||||
|
}
|
||||||
|
if workspaceSetting != nil {
|
||||||
|
setting := workspaceSetting.GetSetting()
|
||||||
|
profile.EnableSignup = setting.GetEnableSignup()
|
||||||
|
profile.CustomStyle = setting.GetCustomStyle()
|
||||||
|
profile.CustomScript = setting.GetCustomScript()
|
||||||
|
}
|
||||||
|
return &apiv2pb.GetWorkspaceProfileResponse{
|
||||||
|
Profile: profile,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) GetWorkspaceSetting(ctx context.Context, _ *apiv2pb.GetWorkspaceSettingRequest) (*apiv2pb.GetWorkspaceSettingResponse, error) {
|
||||||
|
isAdmin := false
|
||||||
|
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if ok {
|
||||||
|
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.Role == store.RoleAdmin {
|
||||||
|
isAdmin = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workspaceSettings, err := s.Store.ListWorkspaceSettings(ctx, &store.FindWorkspaceSetting{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to list workspace settings: %v", err)
|
||||||
|
}
|
||||||
|
workspaceSetting := &apiv2pb.WorkspaceSetting{}
|
||||||
|
for _, v := range workspaceSettings {
|
||||||
|
if v.Key == storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP {
|
||||||
|
workspaceSetting.EnableSignup = v.GetEnableSignup()
|
||||||
|
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE {
|
||||||
|
workspaceSetting.CustomStyle = v.GetCustomStyle()
|
||||||
|
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT {
|
||||||
|
workspaceSetting.CustomScript = v.GetCustomScript()
|
||||||
|
} else if isAdmin {
|
||||||
|
// For some settings, only admin can get the value.
|
||||||
|
if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY {
|
||||||
|
workspaceSetting.LicenseKey = v.GetLicenseKey()
|
||||||
|
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_RESOURCE_RELATIVE_PATH {
|
||||||
|
workspaceSetting.ResourceRelativePath = v.GetResourceRelativePath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &apiv2pb.GetWorkspaceSettingResponse{
|
||||||
|
Setting: workspaceSetting,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) UpdateWorkspaceSetting(ctx context.Context, request *apiv2pb.UpdateWorkspaceSettingRequest) (*apiv2pb.UpdateWorkspaceSettingResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range request.UpdateMask {
|
||||||
|
if path == "license_key" {
|
||||||
|
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||||
|
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY,
|
||||||
|
Value: &storepb.WorkspaceSetting_LicenseKey{
|
||||||
|
LicenseKey: request.Setting.LicenseKey,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||||
|
}
|
||||||
|
} else if path == "enable_signup" {
|
||||||
|
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||||
|
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||||
|
Value: &storepb.WorkspaceSetting_EnableSignup{
|
||||||
|
EnableSignup: request.Setting.EnableSignup,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||||
|
}
|
||||||
|
} else if path == "resource_relative_path" {
|
||||||
|
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||||
|
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_RESOURCE_RELATIVE_PATH,
|
||||||
|
Value: &storepb.WorkspaceSetting_ResourceRelativePath{
|
||||||
|
ResourceRelativePath: request.Setting.ResourceRelativePath,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||||
|
}
|
||||||
|
} else if path == "custom_style" {
|
||||||
|
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeCustomeStyle) {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "feature custom style is not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||||
|
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE,
|
||||||
|
Value: &storepb.WorkspaceSetting_CustomStyle{
|
||||||
|
CustomStyle: request.Setting.CustomStyle,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||||
|
}
|
||||||
|
} else if path == "custom_script" {
|
||||||
|
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||||
|
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT,
|
||||||
|
Value: &storepb.WorkspaceSetting_CustomScript{
|
||||||
|
CustomScript: request.Setting.CustomScript,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "invalid path: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspaceSettingResponse, err := s.GetWorkspaceSetting(ctx, &apiv2pb.GetWorkspaceSettingRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
||||||
|
}
|
||||||
|
return &apiv2pb.UpdateWorkspaceSettingResponse{
|
||||||
|
Setting: getWorkspaceSettingResponse.Setting,
|
||||||
|
}, nil
|
||||||
|
}
|
@ -1,137 +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/shortify/server"
|
|
||||||
_profile "github.com/boojack/shortify/server/profile"
|
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
"github.com/boojack/shortify/store/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
greetingBanner = `
|
|
||||||
███████╗██╗ ██╗ ██████╗ ██████╗ ████████╗██╗███████╗██╗ ██╗
|
|
||||||
██╔════╝██║ ██║██╔═══██╗██╔══██╗╚══██╔══╝██║██╔════╝╚██╗ ██╔╝
|
|
||||||
███████╗███████║██║ ██║██████╔╝ ██║ ██║█████╗ ╚████╔╝
|
|
||||||
╚════██║██╔══██║██║ ██║██╔══██╗ ██║ ██║██╔══╝ ╚██╔╝
|
|
||||||
███████║██║ ██║╚██████╔╝██║ ██║ ██║ ██║██║ ██║
|
|
||||||
╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
profile *_profile.Profile
|
|
||||||
mode string
|
|
||||||
port int
|
|
||||||
data string
|
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
|
||||||
Use: "shortify",
|
|
||||||
Short: "",
|
|
||||||
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", "dev", `mode of server, can be "prod" or "dev"`)
|
|
||||||
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", "dev")
|
|
||||||
viper.SetDefault("port", 8082)
|
|
||||||
viper.SetEnvPrefix("shortify")
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
142
cmd/slash/main.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/internal/log"
|
||||||
|
"github.com/boojack/slash/server"
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
"github.com/boojack/slash/store/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
greetingBanner = `Welcome to Slash!`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
serverProfile *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(serverProfile)
|
||||||
|
if err := db.Open(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
log.Error("failed to open database", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storeInstance := store.New(db.DBInstance, serverProfile)
|
||||||
|
s, err := server.NewServer(ctx, serverProfile, storeInstance)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
log.Error("failed to create server", zap.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
|
||||||
|
log.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||||
|
s.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
printGreetings()
|
||||||
|
|
||||||
|
if err := s.Start(ctx); err != nil {
|
||||||
|
if err != http.ErrServerClosed {
|
||||||
|
log.Error("failed to start server", zap.Error(err))
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for CTRL-C.
|
||||||
|
<-ctx.Done()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func Execute() error {
|
||||||
|
defer log.Sync()
|
||||||
|
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
|
||||||
|
serverProfile, err = profile.GetProfile()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to get profile", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
println("---")
|
||||||
|
println("Server profile")
|
||||||
|
println("dsn:", serverProfile.DSN)
|
||||||
|
println("port:", serverProfile.Port)
|
||||||
|
println("mode:", serverProfile.Mode)
|
||||||
|
println("version:", serverProfile.Version)
|
||||||
|
println("---")
|
||||||
|
}
|
||||||
|
|
||||||
|
func printGreetings() {
|
||||||
|
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/boojack/slash")
|
||||||
|
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/extension-usage/copy-access-token.png
Normal file
After Width: | Height: | Size: 155 KiB |
BIN
docs/assets/extension-usage/create-access-token.png
Normal file
After Width: | Height: | Size: 154 KiB |
BIN
docs/assets/extension-usage/extension-screenshot.png
Normal file
After Width: | Height: | Size: 77 KiB |
BIN
docs/assets/extension-usage/extension-setting-button.png
Normal file
After Width: | Height: | Size: 44 KiB |
BIN
docs/assets/extension-usage/extension-setting-page.png
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
docs/assets/extension-usage/shortcut-url.png
Normal file
After Width: | Height: | Size: 38 KiB |
45
docs/install-browser-extension.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# The Browser Extension of Slash
|
||||||
|
|
||||||
|
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
### Install the extension
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Generate an access token
|
||||||
|
|
||||||
|
1. Go to your Slash instance and sign in with your account.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|

|
59
docs/install.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Self-hosting Slash with Docker
|
||||||
|
|
||||||
|
Slash is designed for self-hosting through Docker. No Docker expertise is required to launch your own instance. Just basic understanding of command line and networking.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
The only requirement is a server with Docker installed.
|
||||||
|
|
||||||
|
## Docker Run
|
||||||
|
|
||||||
|
To deploy Slash using docker run, just one command is needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
To upgrade Slash to latest version, stop and remove the old container first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop slash && docker rm slash
|
||||||
|
```
|
||||||
|
|
||||||
|
It's recommended but optional to backup database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r ~/.slash/slash_prod.db ~/.slash/slash_prod.db.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pull the latest image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull yourselfhosted/slash:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
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/boojack/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.
|
||||||
|
|
||||||
|
### Upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/slash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
40
frontend/extension/.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
#cache
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*
|
||||||
|
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
.plasmo
|
||||||
|
|
||||||
|
# bpp - http://bpp.browser.market/
|
||||||
|
keys.json
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
.tsbuildinfo
|
||||||
|
|
||||||
|
src/types/proto
|
8
frontend/extension/.prettierrc.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
printWidth: 140,
|
||||||
|
useTabs: false,
|
||||||
|
semi: true,
|
||||||
|
singleQuote: false,
|
||||||
|
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||||
|
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!css).+)", "^[./]", "^[../]", "^(.+).css"],
|
||||||
|
};
|
1
frontend/extension/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Slash Browser Extension
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
58
frontend/extension/package.json
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "slash-extension",
|
||||||
|
"displayName": "Slash",
|
||||||
|
"version": "0.1.4",
|
||||||
|
"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": {
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/joy": "5.0.0-beta.0",
|
||||||
|
"@plasmohq/storage": "^1.8.0",
|
||||||
|
"axios": "^1.5.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.9",
|
||||||
|
"@types/node": "20.4.2",
|
||||||
|
"@types/react": "18.2.15",
|
||||||
|
"@types/react-dom": "18.2.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||||
|
"@typescript-eslint/parser": "^6.7.0",
|
||||||
|
"autoprefixer": "^10.4.15",
|
||||||
|
"eslint": "^8.49.0",
|
||||||
|
"eslint-config-prettier": "^8.10.0",
|
||||||
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
|
"eslint-plugin-react": "^7.33.2",
|
||||||
|
"long": "^5.2.3",
|
||||||
|
"postcss": "^8.4.29",
|
||||||
|
"prettier": "2.6.2",
|
||||||
|
"protobufjs": "^7.2.5",
|
||||||
|
"tailwindcss": "^3.3.3",
|
||||||
|
"typescript": "5.1.6"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"omnibox": {
|
||||||
|
"keyword": "s"
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"tabs",
|
||||||
|
"storage"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
7357
frontend/extension/pnpm-lock.yaml
generated
Normal file
10
frontend/extension/postcss.config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
/**
|
||||||
|
* @type {import('postcss').ProcessOptions}
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
62
frontend/extension/src/background.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import { Storage } from "@plasmohq/storage";
|
||||||
|
|
||||||
|
const storage = new Storage();
|
||||||
|
const urlRegex = /https?:\/\/s\/(.+)/;
|
||||||
|
|
||||||
|
chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => {
|
||||||
|
if (!tab.url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutName = getShortcutNameFromUrl(tab.url);
|
||||||
|
if (shortcutName) {
|
||||||
|
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
||||||
|
const shortcut = shortcuts.find((shortcut) => shortcut.name === shortcutName);
|
||||||
|
if (!shortcut) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return chrome.tabs.update(tabId, { url: shortcut.link });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 matchResult = urlRegex.exec(urlString);
|
||||||
|
if (matchResult === null) {
|
||||||
|
return getShortcutNameFromSearchUrl(urlString);
|
||||||
|
}
|
||||||
|
return matchResult[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getShortcutNameFromSearchUrl = (urlString: string) => {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
if ((url.hostname === "www.google.com" || url.hostname === "www.bing.com") && url.pathname === "/search") {
|
||||||
|
const params = new URLSearchParams(url.search);
|
||||||
|
const shortcutName = params.get("q");
|
||||||
|
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||||
|
return shortcutName.slice(2);
|
||||||
|
}
|
||||||
|
} else if (url.hostname === "www.baidu.com" && url.pathname === "/s") {
|
||||||
|
const params = new URLSearchParams(url.search);
|
||||||
|
const shortcutName = params.get("wd");
|
||||||
|
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||||
|
return shortcutName.slice(2);
|
||||||
|
}
|
||||||
|
} else if (url.hostname === "duckduckgo.com" && url.pathname === "/") {
|
||||||
|
const params = new URLSearchParams(url.search);
|
||||||
|
const shortcutName = params.get("q");
|
||||||
|
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||||
|
return shortcutName.slice(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
173
frontend/extension/src/components/CreateShortcutsButton.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
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";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const generateTempName = (length = 6) => {
|
||||||
|
let result = "";
|
||||||
|
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
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() + "-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.fromPartial({}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 className="w-3/4">
|
||||||
|
<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 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-12 mr-2 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-12 mr-2 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-12 mr-2 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;
|
12
frontend/extension/src/components/Logo.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import LogoBase64 from "data-base64:../..//assets/icon.png";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Logo = ({ className }: Props) => {
|
||||||
|
return <img className={classNames(className)} src={LogoBase64} alt="" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logo;
|
45
frontend/extension/src/components/PullShortcutsButton.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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";
|
||||||
|
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;
|
77
frontend/extension/src/components/ShortcutView.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
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;
|
18
frontend/extension/src/components/ShortcutsContainer.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
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;
|
14
frontend/extension/src/helpers/api.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
5
frontend/extension/src/helpers/utils.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { isNull, isUndefined } from "lodash-es";
|
||||||
|
|
||||||
|
export const isNullorUndefined = (value: any) => {
|
||||||
|
return isNull(value) || isUndefined(value);
|
||||||
|
};
|
134
frontend/extension/src/options.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
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;
|
109
frontend/extension/src/popup.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
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 "./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;
|
41
frontend/extension/src/stores/favicon.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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;
|
@ -5,8 +5,21 @@
|
|||||||
body,
|
body,
|
||||||
html,
|
html,
|
||||||
#root {
|
#root {
|
||||||
@apply text-base w-full h-full;
|
@apply text-base;
|
||||||
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
}
|
8
frontend/extension/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
mode: "jit",
|
||||||
|
darkMode: "class",
|
||||||
|
content: ["./**/*.tsx"],
|
||||||
|
plugins: [],
|
||||||
|
};
|
20
frontend/extension/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "plasmo/templates/tsconfig.base",
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
".plasmo/index.d.ts",
|
||||||
|
"./**/*.ts",
|
||||||
|
"./**/*.tsx",
|
||||||
|
"../types"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"baseUrl": "."
|
||||||
|
}
|
||||||
|
}
|
3
frontend/locales/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Translation files
|
||||||
|
|
||||||
|
This directory contains the translation files for the frontend including web and browser extension.
|
42
frontend/locales/en.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"about": "About",
|
||||||
|
"loading": "Loading",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"create": "Create",
|
||||||
|
"download": "Download",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"language": "Language",
|
||||||
|
"search": "Search",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"self": "Analytics",
|
||||||
|
"top-sources": "Top sources",
|
||||||
|
"source": "Source",
|
||||||
|
"visitors": "Visitors",
|
||||||
|
"devices": "Devices",
|
||||||
|
"browser": "Browser",
|
||||||
|
"browsers": "Browsers",
|
||||||
|
"operating-system": "Operating System"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"visibility": {
|
||||||
|
"private": {
|
||||||
|
"self": "Private",
|
||||||
|
"description": "Only you can access"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "Workspace",
|
||||||
|
"description": "Workspace members can access"
|
||||||
|
},
|
||||||
|
"public": {
|
||||||
|
"self": "Public",
|
||||||
|
"description": "Visible to everyone on the internet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
frontend/locales/zh.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"about": "关于",
|
||||||
|
"loading": "加载中",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"create": "创建",
|
||||||
|
"download": "下载",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"language": "语言",
|
||||||
|
"search": "搜索",
|
||||||
|
"email": "邮箱",
|
||||||
|
"password": "密码"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"self": "分析",
|
||||||
|
"top-sources": "热门来源",
|
||||||
|
"source": "来源",
|
||||||
|
"visitors": "访客数",
|
||||||
|
"devices": "设备",
|
||||||
|
"browser": "浏览器",
|
||||||
|
"browsers": "浏览器",
|
||||||
|
"operating-system": "操作系统"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"visibility": {
|
||||||
|
"private": {
|
||||||
|
"self": "私有的",
|
||||||
|
"description": "仅您可以访问"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "工作区",
|
||||||
|
"description": "工作区成员可以访问"
|
||||||
|
},
|
||||||
|
"public": {
|
||||||
|
"self": "公开的",
|
||||||
|
"description": "对任何人可见"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
33
frontend/web/.eslintrc.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true
|
||||||
|
},
|
||||||
|
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": ["react", "@typescript-eslint", "prettier"],
|
||||||
|
"ignorePatterns": ["node_modules", "dist", "public"],
|
||||||
|
"rules": {
|
||||||
|
"prettier/prettier": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"endOfLine": "auto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": ["off"],
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/jsx-no-target-blank": "off"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
web/.gitignore → frontend/web/.gitignore
vendored
@ -3,3 +3,4 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
src/types/proto
|
8
frontend/web/.prettierrc.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
printWidth: 140,
|
||||||
|
useTabs: false,
|
||||||
|
semi: true,
|
||||||
|
singleQuote: false,
|
||||||
|
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||||
|
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!less).+)", "^[./]", "^(.+).less"],
|
||||||
|
};
|
1
frontend/web/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Slash
|
@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" href="/logo.png" type="image/*" />
|
<link rel="icon" href="/logo.png" type="image/*" />
|
||||||
<meta name="theme-color" content="#FFFFFF" />
|
<meta name="theme-color" content="#FFFFFF" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||||
<title>Shortify</title>
|
<title>Slash</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
54
frontend/web/package.json
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "slash",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"serve": "vite preview",
|
||||||
|
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||||
|
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/joy": "5.0.0-beta.7",
|
||||||
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
|
"axios": "^0.27.2",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"copy-to-clipboard": "^3.3.3",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
|
"i18next": "^23.5.1",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"lucide-react": "^0.263.1",
|
||||||
|
"nice-grpc-web": "^3.3.1",
|
||||||
|
"qrcode.react": "^3.1.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-i18next": "^13.2.2",
|
||||||
|
"react-redux": "^8.1.2",
|
||||||
|
"react-router-dom": "^6.16.0",
|
||||||
|
"react-use": "^17.4.0",
|
||||||
|
"tailwindcss": "^3.3.3",
|
||||||
|
"zustand": "^4.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||||
|
"@types/lodash-es": "^4.17.9",
|
||||||
|
"@types/react": "^18.2.22",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
||||||
|
"@typescript-eslint/parser": "^6.7.2",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.50.0",
|
||||||
|
"eslint-config-prettier": "^8.10.0",
|
||||||
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
|
"eslint-plugin-react": "^7.33.2",
|
||||||
|
"long": "^5.2.3",
|
||||||
|
"postcss": "^8.4.30",
|
||||||
|
"prettier": "2.6.2",
|
||||||
|
"protobufjs": "^7.2.5",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^4.4.9"
|
||||||
|
}
|
||||||
|
}
|
2230
web/pnpm-lock.yaml → frontend/web/pnpm-lock.yaml
generated
BIN
frontend/web/public/logo.png
Normal file
After Width: | Height: | Size: 83 KiB |
75
frontend/web/src/App.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { useColorScheme } from "@mui/joy";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import DemoBanner from "./components/DemoBanner";
|
||||||
|
import useUserStore from "./stores/v1/user";
|
||||||
|
import useWorkspaceStore from "./stores/v1/workspace";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { mode: colorScheme } = useColorScheme();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([workspaceStore.fetchWorkspaceProfile(), workspaceStore.fetchWorkspaceSetting(), userStore.fetchCurrentUser()]);
|
||||||
|
} catch (error) {
|
||||||
|
// do nth
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
styleEl.innerHTML = workspaceStore.setting.customStyle;
|
||||||
|
styleEl.setAttribute("type", "text/css");
|
||||||
|
document.body.insertAdjacentElement("beforeend", styleEl);
|
||||||
|
}, [workspaceStore.setting.customStyle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (colorScheme === "light") {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
} else if (colorScheme === "dark") {
|
||||||
|
root.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
if (darkMediaQuery.matches) {
|
||||||
|
root.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
|
||||||
|
if (e.matches) {
|
||||||
|
root.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
darkMediaQuery.addEventListener("change", handleColorSchemeChange);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("failed to initial color scheme listener", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
darkMediaQuery.removeEventListener("change", handleColorSchemeChange);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [colorScheme]);
|
||||||
|
|
||||||
|
return !loading ? (
|
||||||
|
<>
|
||||||
|
<DemoBanner />
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
@ -1,4 +1,5 @@
|
|||||||
import { Button, Link, Modal, ModalDialog } from "@mui/joy";
|
import { Button, Link, Modal, ModalDialog } from "@mui/joy";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -7,24 +8,24 @@ interface Props {
|
|||||||
|
|
||||||
const AboutDialog: React.FC<Props> = (props: Props) => {
|
const AboutDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<span className="text-lg font-medium">About</span>
|
<span className="text-lg font-medium">{t("common.about")}</span>
|
||||||
<Button variant="plain" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-full w-80 sm:w-96">
|
<div className="max-w-full w-80 sm:w-96">
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Shortify</span> is a bookmarking and short link service that allows you to save and share links
|
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform.
|
||||||
easily.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span className="mr-2">See more in:</span>
|
<span className="mr-2">See more in</span>
|
||||||
<Link variant="plain" href="https://github.com/boojack/shortify">
|
<Link variant="plain" href="https://github.com/boojack/slash" target="_blank">
|
||||||
GitHub
|
GitHub
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
@ -54,7 +54,7 @@ const Alert: React.FC<Props> = (props: Props) => {
|
|||||||
<div className="w-80">
|
<div className="w-80">
|
||||||
<p className="content-text mb-4">{content}</p>
|
<p className="content-text mb-4">{content}</p>
|
||||||
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
||||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
<Button variant="plain" color="neutral" onClick={handleCloseBtnClick}>
|
||||||
{closeBtnText}
|
{closeBtnText}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color={style} onClick={handleConfirmBtnClick}>
|
<Button color={style} onClick={handleConfirmBtnClick}>
|
131
frontend/web/src/components/AnalyticsView.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import * as api from "../helpers/api";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcutId: ShortcutId;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||||
|
const { shortcutId, className } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||||
|
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
||||||
|
setAnalytics(data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames("w-full", className)}>
|
||||||
|
{analytics ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="w-full h-8 px-2 dark:text-gray-500">{t("analytics.top-sources")}</p>
|
||||||
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg dark:ring-zinc-800">
|
||||||
|
<div className="w-full divide-y divide-gray-300 dark:divide-zinc-700">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="py-2 px-2 text-left font-semibold text-sm text-gray-500">{t("analytics.source")}</span>
|
||||||
|
<span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">{t("analytics.visitors")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
||||||
|
{analytics.referenceData.map((reference) => (
|
||||||
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900 dark:text-gray-500">
|
||||||
|
{reference.name ? (
|
||||||
|
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
||||||
|
{reference.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"Direct"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="w-full h-8 px-2 flex flex-row justify-between items-center">
|
||||||
|
<span className="dark:text-gray-500">{t("analytics.devices")}</span>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
|
selectedDeviceTab === "browser"
|
||||||
|
? "border-blue-600 text-blue-600"
|
||||||
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:border-zinc-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedDeviceTab("browser")}
|
||||||
|
>
|
||||||
|
{t("analytics.browser")}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-200 font-mono mx-1 dark:text-gray-500">/</span>
|
||||||
|
<button
|
||||||
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
|
selectedDeviceTab === "os"
|
||||||
|
? "border-blue-600 text-blue-600"
|
||||||
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:border-zinc-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedDeviceTab("os")}
|
||||||
|
>
|
||||||
|
OS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg dark:ring-zinc-800">
|
||||||
|
{selectedDeviceTab === "browser" ? (
|
||||||
|
<div className="w-full divide-y divide-gray-300 dark:divide-zinc-700">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">{t("analytics.browsers")}</span>
|
||||||
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
||||||
|
{analytics.browserData.map((reference) => (
|
||||||
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate dark:text-gray-500">
|
||||||
|
{reference.name || "Unknown"}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full divide-y divide-gray-300">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">{t("analytics.operating-system")}</span>
|
||||||
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full divide-y divide-gray-200">
|
||||||
|
{analytics.deviceData.map((device) => (
|
||||||
|
<div key={device.name} className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span>
|
||||||
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
||||||
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnalyticsView;
|
9
frontend/web/src/components/BetaBadge.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const BetaBadge = () => {
|
||||||
|
return (
|
||||||
|
<div className="text-xs border px-1 text-gray-500 bg-gray-100 rounded-full dark:bg-zinc-800 dark:border-zinc-700">
|
||||||
|
<span>Beta</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BetaBadge;
|
@ -1,8 +1,9 @@
|
|||||||
import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { userService } from "../services";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -11,6 +12,8 @@ interface Props {
|
|||||||
|
|
||||||
const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const userStore = useUserStore();
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
@ -43,9 +46,8 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
requestState.setLoading();
|
requestState.setLoading();
|
||||||
try {
|
try {
|
||||||
const user = userService.getState().user as User;
|
userStore.patchUser({
|
||||||
await userService.patchUser({
|
id: userStore.getCurrentUser().id,
|
||||||
id: user.id,
|
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
@ -77,10 +79,10 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
||||||
<Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}>
|
<Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}>
|
||||||
Cancel
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
Save
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
137
frontend/web/src/components/CreateAccessTokenDialog.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { userServiceClient } from "@/grpcweb";
|
||||||
|
import useLoading from "../hooks/useLoading";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationOptions = [
|
||||||
|
{
|
||||||
|
label: "8 hours",
|
||||||
|
value: 3600 * 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "1 month",
|
||||||
|
value: 3600 * 24 * 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Never",
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
description: string;
|
||||||
|
expiration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { onClose, onConfirm } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const [state, setState] = useState({
|
||||||
|
description: "",
|
||||||
|
expiration: 3600 * 8,
|
||||||
|
});
|
||||||
|
const requestState = useLoading(false);
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
description: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
expiration: Number(e.target.value),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBtnClick = async () => {
|
||||||
|
if (!state.description) {
|
||||||
|
toast.error("Description is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userServiceClient.createUserAccessToken({
|
||||||
|
id: currentUser.id,
|
||||||
|
description: state.description,
|
||||||
|
expiresAt: state.expiration ? new Date(Date.now() + state.expiration * 1000) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.response.data.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||||
|
<span className="text-lg font-medium">Create Access Token</span>
|
||||||
|
<Button variant="plain" onClick={onClose}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Description <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Some description"
|
||||||
|
value={state.description}
|
||||||
|
onChange={handleDescriptionInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Expiration <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
|
<RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}>
|
||||||
|
{expirationOptions.map((option) => (
|
||||||
|
<Radio key={option.value} value={option.value} checked={state.expiration === option.value} label={option.label} />
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
|
{t("common.create")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateAccessTokenDialog;
|
347
frontend/web/src/components/CreateShortcutDialog.tsx
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
||||||
|
import classnames from "classnames";
|
||||||
|
import { isUndefined } from "lodash-es";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useLoading from "../hooks/useLoading";
|
||||||
|
import { shortcutService } from "../services";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcutId?: ShortcutId;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
shortcutCreate: ShortcutCreate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilities: Visibility[] = ["PRIVATE", "WORKSPACE", "PUBLIC"];
|
||||||
|
|
||||||
|
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { onClose, onConfirm, shortcutId } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
shortcutCreate: {
|
||||||
|
name: "",
|
||||||
|
link: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
visibility: "PRIVATE",
|
||||||
|
tags: [],
|
||||||
|
openGraphMetadata: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
image: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
|
||||||
|
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||||
|
const [tag, setTag] = useState<string>("");
|
||||||
|
const requestState = useLoading(false);
|
||||||
|
const isCreating = isUndefined(shortcutId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shortcutId) {
|
||||||
|
const shortcut = shortcutService.getShortcutById(shortcutId);
|
||||||
|
if (shortcut) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
name: shortcut.name,
|
||||||
|
link: shortcut.link,
|
||||||
|
title: shortcut.title,
|
||||||
|
description: shortcut.description,
|
||||||
|
visibility: shortcut.visibility,
|
||||||
|
openGraphMetadata: shortcut.openGraphMetadata,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setTag(shortcut.tags.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [shortcutId]);
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
name: e.target.value.replace(/\s+/g, "-"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
link: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
title: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
visibility: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
description: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const text = e.target.value as string;
|
||||||
|
setTag(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
|
image: e.target.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
|
title: e.target.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
|
description: e.target.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBtnClick = async () => {
|
||||||
|
if (!state.shortcutCreate.name) {
|
||||||
|
toast.error("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (shortcutId) {
|
||||||
|
await shortcutService.patchShortcut({
|
||||||
|
id: shortcutId,
|
||||||
|
name: state.shortcutCreate.name,
|
||||||
|
link: state.shortcutCreate.link,
|
||||||
|
title: state.shortcutCreate.title,
|
||||||
|
description: state.shortcutCreate.description,
|
||||||
|
visibility: state.shortcutCreate.visibility,
|
||||||
|
tags: tag.split(" ").filter(Boolean),
|
||||||
|
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await shortcutService.createShortcut({
|
||||||
|
...state.shortcutCreate,
|
||||||
|
tags: tag.split(" ").filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.response.data.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||||
|
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
|
||||||
|
<Button variant="plain" onClick={onClose}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto overflow-x-hidden">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">Name</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Unique shortcut name"
|
||||||
|
value={state.shortcutCreate.name}
|
||||||
|
onChange={handleNameInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">Destination URL</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://github.com/boojack/slash"
|
||||||
|
value={state.shortcutCreate.link}
|
||||||
|
onChange={handleLinkInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">Tags</span>
|
||||||
|
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">Visibility</span>
|
||||||
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
|
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
||||||
|
{visibilities.map((visibility) => (
|
||||||
|
<Radio key={visibility} value={visibility} label={t(`shortcut.visibility.${visibility.toLowerCase()}.self`)} />
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md">
|
||||||
|
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Divider className="text-gray-500">Optional</Divider>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3 dark:border-zinc-800">
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
|
||||||
|
showAdditionalFields ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : ""
|
||||||
|
)}
|
||||||
|
onClick={() => setShowAdditionalFields(!showAdditionalFields)}
|
||||||
|
>
|
||||||
|
<span className="text-sm">Additional fields</span>
|
||||||
|
<button className="w-7 h-7 p-1 rounded-md">
|
||||||
|
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showAdditionalFields ? "transform rotate-180" : "")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showAdditionalFields && (
|
||||||
|
<div className="w-full px-2 py-1">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Title</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Title"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.title}
|
||||||
|
onChange={handleTitleInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Description</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Github repo for slash"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.description}
|
||||||
|
onChange={handleDescriptionInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden dark:border-zinc-800">
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
|
||||||
|
showOpenGraphMetadata ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : ""
|
||||||
|
)}
|
||||||
|
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
|
||||||
|
>
|
||||||
|
<span className="text-sm flex flex-row justify-start items-center">
|
||||||
|
Social media metadata
|
||||||
|
<Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" />
|
||||||
|
</span>
|
||||||
|
<button className="w-7 h-7 p-1 rounded-md">
|
||||||
|
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showOpenGraphMetadata && (
|
||||||
|
<div className="w-full px-2 py-1">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Image URL</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://the.link.to/the/image.png"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.openGraphMetadata.image}
|
||||||
|
onChange={handleOpenGraphMetadataImageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Title</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.openGraphMetadata.title}
|
||||||
|
onChange={handleOpenGraphMetadataTitleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Description</span>
|
||||||
|
<Textarea
|
||||||
|
className="w-full"
|
||||||
|
placeholder="An open source, self-hosted bookmarks and link sharing platform."
|
||||||
|
size="sm"
|
||||||
|
maxRows={3}
|
||||||
|
value={state.shortcutCreate.openGraphMetadata.description}
|
||||||
|
onChange={handleOpenGraphMetadataDescriptionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateShortcutDialog;
|
202
frontend/web/src/components/CreateUserDialog.tsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
||||||
|
import { isUndefined } from "lodash-es";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useLoading from "../hooks/useLoading";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user?: User;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
userCreate: UserCreate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles: Role[] = ["USER", "ADMIN"];
|
||||||
|
|
||||||
|
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { onClose, onConfirm, user } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
userCreate: {
|
||||||
|
email: "",
|
||||||
|
nickname: "",
|
||||||
|
password: "",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const requestState = useLoading(false);
|
||||||
|
const isCreating = isUndefined(user);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname,
|
||||||
|
password: "",
|
||||||
|
role: user.role,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
email: e.target.value.toLowerCase(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNicknameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
nickname: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
password: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
role: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBtnClick = async () => {
|
||||||
|
if (isCreating && (!state.userCreate.email || !state.userCreate.nickname || !state.userCreate.password)) {
|
||||||
|
toast.error("Please fill all inputs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (user) {
|
||||||
|
const userPatch: UserPatch = {
|
||||||
|
id: user.id,
|
||||||
|
};
|
||||||
|
if (user.email !== state.userCreate.email) {
|
||||||
|
userPatch.email = state.userCreate.email;
|
||||||
|
}
|
||||||
|
if (user.nickname !== state.userCreate.nickname) {
|
||||||
|
userPatch.nickname = state.userCreate.nickname;
|
||||||
|
}
|
||||||
|
if (user.role !== state.userCreate.role) {
|
||||||
|
userPatch.role = state.userCreate.role;
|
||||||
|
}
|
||||||
|
await userStore.patchUser(userPatch);
|
||||||
|
} else {
|
||||||
|
await userStore.createUser(state.userCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.response.data.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||||
|
<span className="text-lg font-medium">{isCreating ? "Create User" : "Edit User"}</span>
|
||||||
|
<Button variant="plain" onClick={onClose}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Email <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="email"
|
||||||
|
placeholder="Unique user email"
|
||||||
|
value={state.userCreate.email}
|
||||||
|
onChange={handleEmailInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Nickname <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nickname"
|
||||||
|
value={state.userCreate.nickname}
|
||||||
|
onChange={handleNicknameInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isCreating && (
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Password <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="password"
|
||||||
|
placeholder=""
|
||||||
|
value={state.userCreate.password}
|
||||||
|
onChange={handlePasswordInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Role <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
|
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<Radio key={role} value={role} label={role} />
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateUserDialog;
|
27
frontend/web/src/components/DemoBanner.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const DemoBanner: React.FC = () => {
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const shouldShow = workspaceStore.profile.mode === "demo";
|
||||||
|
|
||||||
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
|
||||||
|
<div className="w-full max-w-6xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
|
||||||
|
<span>✨🔗 Slash - An open source, self-hosted bookmarks and link sharing platform</span>
|
||||||
|
<a
|
||||||
|
className="shadow flex flex-row justify-center items-center px-2 py-1 rounded-md text-sm sm:text-base text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
href="https://github.com/boojack/slash#deploy-with-docker-in-seconds"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
<Icon.ExternalLink className="w-4 h-auto ml-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DemoBanner;
|
@ -1,9 +1,9 @@
|
|||||||
import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { userService } from "../services";
|
import useUserStore from "../stores/v1/user";
|
||||||
import { useAppSelector } from "../stores";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -12,9 +12,11 @@ interface Props {
|
|||||||
|
|
||||||
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
const user = useAppSelector((state) => state.user.user as User);
|
const { t } = useTranslation();
|
||||||
const [email, setEmail] = useState(user.email);
|
const userStore = useUserStore();
|
||||||
const [nickname, setNickname] = useState(user.nickname);
|
const currentUser = userStore.getCurrentUser();
|
||||||
|
const [email, setEmail] = useState(currentUser.email);
|
||||||
|
const [nickname, setNickname] = useState(currentUser.nickname);
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
|
|
||||||
const handleCloseBtnClick = () => {
|
const handleCloseBtnClick = () => {
|
||||||
@ -39,14 +41,13 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
requestState.setLoading();
|
requestState.setLoading();
|
||||||
try {
|
try {
|
||||||
const user = userService.getState().user as User;
|
await userStore.patchUser({
|
||||||
await userService.patchUser({
|
id: currentUser.id,
|
||||||
id: user.id,
|
|
||||||
email,
|
email,
|
||||||
nickname,
|
nickname,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
toast("Password changed");
|
toast("User information updated");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.response.data.message);
|
toast.error(error.response.data.message);
|
||||||
@ -74,10 +75,10 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
||||||
<Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}>
|
<Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}>
|
||||||
Cancel
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
Save
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
43
frontend/web/src/components/FilterView.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
|
||||||
|
const FilterView = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const filter = viewStore.filter;
|
||||||
|
const shouldShowFilters = filter.tag !== undefined || filter.visibility !== undefined;
|
||||||
|
|
||||||
|
if (!shouldShowFilters) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row justify-start items-center mb-4 pl-2">
|
||||||
|
<span className="text-gray-400">Filters:</span>
|
||||||
|
{filter.tag && (
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
|
||||||
|
onClick={() => viewStore.setFilter({ tag: undefined })}
|
||||||
|
>
|
||||||
|
<Icon.Tag className="w-4 h-auto mr-1" />
|
||||||
|
<span className="max-w-[8rem] truncate">#{filter.tag}</span>
|
||||||
|
<Icon.X className="w-4 h-auto ml-1" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filter.visibility && (
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
|
||||||
|
onClick={() => viewStore.setFilter({ visibility: undefined })}
|
||||||
|
>
|
||||||
|
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
|
||||||
|
{t(`shortcut.visibility.${filter.visibility.toLowerCase()}.self`)}
|
||||||
|
<Icon.X className="w-4 h-auto ml-1" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterView;
|
63
frontend/web/src/components/GenerateQRCodeDialog.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Button, Modal, ModalDialog } from "@mui/joy";
|
||||||
|
import { QRCodeCanvas } from "qrcode.react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { shortcut, onClose } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
|
||||||
|
const handleCloseBtnClick = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadQRCodeClick = () => {
|
||||||
|
const canvas = containerRef.current?.querySelector("canvas");
|
||||||
|
if (!canvas) {
|
||||||
|
toast.error("Failed to get qr code canvas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = "filename.png";
|
||||||
|
link.href = canvas.toDataURL();
|
||||||
|
link.click();
|
||||||
|
handleCloseBtnClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="flex flex-row justify-between items-center w-64 mb-4">
|
||||||
|
<span className="text-lg font-medium">QR Code</span>
|
||||||
|
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
|
||||||
|
<QRCodeCanvas value={shortcutLink} size={128} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-center items-center px-4">
|
||||||
|
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
||||||
|
<Icon.Download className="w-4 h-auto mr-1" />
|
||||||
|
{t("common.download")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenerateQRCodeDialog;
|
89
frontend/web/src/components/Header.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { Avatar } from "@mui/joy";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
|
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||||
|
import * as api from "../helpers/api";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
import AboutDialog from "./AboutDialog";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import Dropdown from "./common/Dropdown";
|
||||||
|
|
||||||
|
const Header: React.FC = () => {
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||||
|
const profile = workspaceStore.profile;
|
||||||
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
|
|
||||||
|
const handleSignOutButtonClick = async () => {
|
||||||
|
await api.signout();
|
||||||
|
window.location.href = "/auth";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full bg-gray-50 dark:bg-zinc-900 border-b border-b-gray-200 dark:border-b-zinc-800">
|
||||||
|
<div className="w-full max-w-6xl mx-auto px-3 md:px-12 py-5 flex flex-row justify-between items-center">
|
||||||
|
<div className="flex flex-row justify-start items-center shrink mr-2">
|
||||||
|
<Link to="/" className="text-lg cursor-pointer flex flex-row justify-start items-center dark:text-gray-400">
|
||||||
|
<img id="logo-img" src="/logo.png" className="w-8 h-auto mr-2 -mt-0.5 dark:opacity-80" alt="" />
|
||||||
|
Slash
|
||||||
|
</Link>
|
||||||
|
{profile.plan === PlanType.PRO && (
|
||||||
|
<span className="ml-1 text-xs px-1.5 leading-5 border rounded-full bg-blue-600 border-blue-700 text-white shadow dark:opacity-70">
|
||||||
|
PRO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="flex flex-row justify-end items-center cursor-pointer">
|
||||||
|
<Avatar size="sm" variant="plain" />
|
||||||
|
<span className="dark:text-gray-400">{currentUser.nickname}</span>
|
||||||
|
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
actionsClassName="!w-32"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/setting/general"
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Icon.User className="w-4 h-auto mr-2" /> Profile
|
||||||
|
</Link>
|
||||||
|
{isAdmin && (
|
||||||
|
<Link
|
||||||
|
to="/setting/workspace"
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Icon.Settings className="w-4 h-auto mr-2" /> Setting
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
onClick={() => setShowAboutDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.Info className="w-4 h-auto mr-2" /> About
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
onClick={() => handleSignOutButtonClick()}
|
||||||
|
>
|
||||||
|
<Icon.LogOut className="w-4 h-auto mr-2" /> Sign out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
></Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAboutDialog && <AboutDialog onClose={() => setShowAboutDialog(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
3
frontend/web/src/components/Icon.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import * as Icon from "lucide-react";
|
||||||
|
|
||||||
|
export default Icon;
|
68
frontend/web/src/components/Navigator.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { useAppSelector } from "../stores";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const Navigator = () => {
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
|
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
|
||||||
|
const currentTab = viewStore.filter.tab || `tab:all`;
|
||||||
|
const sortedTagMap = sortTags(tags);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
|
||||||
|
currentTab === "tab:all"
|
||||||
|
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
|
||||||
|
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
|
||||||
|
)}
|
||||||
|
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
|
||||||
|
>
|
||||||
|
<Icon.CircleSlash className="w-4 h-auto mr-1" />
|
||||||
|
<span className="font-normal">All</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
|
||||||
|
currentTab === "tab:mine"
|
||||||
|
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
|
||||||
|
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
|
||||||
|
)}
|
||||||
|
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
|
||||||
|
>
|
||||||
|
<Icon.User className="w-4 h-auto mr-1" />
|
||||||
|
<span className="font-normal">Mine</span>
|
||||||
|
</button>
|
||||||
|
{Array.from(sortedTagMap.keys()).map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
|
||||||
|
currentTab === `tag:${tag}`
|
||||||
|
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
|
||||||
|
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
|
||||||
|
)}
|
||||||
|
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
|
||||||
|
>
|
||||||
|
<Icon.Hash className="w-4 h-auto mr-0.5" />
|
||||||
|
<span className="max-w-[8rem] truncate font-normal">{tag}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortTags = (tags: string[]): Map<string, number> => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
const count = map.get(tag) || 0;
|
||||||
|
map.set(tag, count + 1);
|
||||||
|
}
|
||||||
|
const sortedMap = new Map([...map.entries()].sort((a, b) => b[1] - a[1]));
|
||||||
|
return sortedMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navigator;
|
93
frontend/web/src/components/ShortcutActionsDropdown.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { shortcutService } from "../services";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
import { showCommonDialog } from "./Alert";
|
||||||
|
import CreateShortcutDialog from "./CreateShortcutDialog";
|
||||||
|
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import Dropdown from "./common/Dropdown";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutActionsDropdown = (props: Props) => {
|
||||||
|
const { shortcut } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
|
||||||
|
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||||
|
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||||
|
|
||||||
|
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
||||||
|
showCommonDialog({
|
||||||
|
title: "Delete Shortcut",
|
||||||
|
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
|
||||||
|
style: "danger",
|
||||||
|
onConfirm: async () => {
|
||||||
|
await shortcutService.deleteShortcutById(shortcut.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const gotoAnalytics = () => {
|
||||||
|
navigate(`/shortcut/${shortcut.id}#analytics`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
actionsClassName="!w-32 dark:text-gray-500"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{havePermission && (
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
|
||||||
|
onClick={() => setShowEditDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
|
||||||
|
onClick={() => setShowQRCodeDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.QrCode className="w-4 h-auto mr-2" /> QR Code
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
|
||||||
|
onClick={gotoAnalytics}
|
||||||
|
>
|
||||||
|
<Icon.BarChart2 className="w-4 h-auto mr-2" /> {t("analytics.self")}
|
||||||
|
</button>
|
||||||
|
{havePermission && (
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteShortcutButtonClick(shortcut);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
></Dropdown>
|
||||||
|
|
||||||
|
{showEditDialog && (
|
||||||
|
<CreateShortcutDialog
|
||||||
|
shortcutId={shortcut.id}
|
||||||
|
onClose={() => setShowEditDialog(false)}
|
||||||
|
onConfirm={() => setShowEditDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutActionsDropdown;
|
147
frontend/web/src/components/ShortcutCard.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { Tooltip } from "@mui/joy";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
|
import useFaviconStore from "../stores/v1/favicon";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||||
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutView = (props: Props) => {
|
||||||
|
const { shortcut } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const faviconStore = useFaviconStore();
|
||||||
|
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||||
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
||||||
|
if (url) {
|
||||||
|
setFavicon(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [shortcut.link]);
|
||||||
|
|
||||||
|
const handleCopyButtonClick = () => {
|
||||||
|
copy(shortcutLink);
|
||||||
|
toast.success("Shortcut link copied to clipboard.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
|
||||||
|
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-8 h-8 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" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<div className="ml-1 w-[calc(100%-24px)] flex flex-col justify-start items-start">
|
||||||
|
<div className="w-full flex flex-row justify-start items-center">
|
||||||
|
<a
|
||||||
|
className={classNames(
|
||||||
|
"max-w-[calc(100%-36px)] flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
href={shortcutLink}
|
||||||
|
>
|
||||||
|
<div className="truncate">
|
||||||
|
<span>{shortcut.title}</span>
|
||||||
|
{shortcut.title ? (
|
||||||
|
<span className="text-gray-400">(s/{shortcut.name})</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
||||||
|
<span className="truncate dark:text-gray-400">{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>
|
||||||
|
</a>
|
||||||
|
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||||
|
<button
|
||||||
|
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
|
||||||
|
onClick={() => handleCopyButtonClick()}
|
||||||
|
>
|
||||||
|
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className="pl-1 pr-4 w-full text-sm truncate text-gray-400 dark:text-gray-500 hover:underline"
|
||||||
|
href={shortcut.link}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{shortcut.link}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-full pt-2 flex flex-row justify-end items-start">
|
||||||
|
<ShortcutActionsDropdown shortcut={shortcut} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 w-full flex flex-row justify-start items-start gap-2 truncate">
|
||||||
|
{shortcut.tags.map((tag) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="max-w-[8rem] truncate text-gray-400 dark:text-gray-500 text-sm font-mono leading-4 cursor-pointer hover:opacity-80"
|
||||||
|
onClick={() => viewStore.setFilter({ tag: tag })}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm font-mono leading-4 italic">No tags</span>}
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex mt-2 gap-2">
|
||||||
|
<Tooltip title="Creator" variant="solid" placement="top" arrow>
|
||||||
|
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm dark:border-zinc-800">
|
||||||
|
<Icon.User className="w-4 h-auto mr-1" />
|
||||||
|
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
||||||
|
<div
|
||||||
|
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm dark:border-zinc-800"
|
||||||
|
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
|
||||||
|
>
|
||||||
|
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
|
||||||
|
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
||||||
|
<Link
|
||||||
|
to={`/shortcut/${shortcut.id}#analytics`}
|
||||||
|
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<Icon.BarChart2 className="w-4 h-auto mr-1" />
|
||||||
|
{shortcut.view} visits
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutView;
|
79
frontend/web/src/components/ShortcutView.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
|
import useFaviconStore from "../stores/v1/favicon";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutView = (props: Props) => {
|
||||||
|
const { shortcut } = props;
|
||||||
|
const faviconStore = useFaviconStore();
|
||||||
|
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||||
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
||||||
|
if (url) {
|
||||||
|
setFavicon(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [shortcut.link]);
|
||||||
|
|
||||||
|
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 dark:border-zinc-800 dark:hover:bg-zinc-800"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
|
||||||
|
<Link to={`/shortcut/${shortcut.id}`} 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" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<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">
|
||||||
|
<a
|
||||||
|
className={classNames(
|
||||||
|
"max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
|
||||||
|
)}
|
||||||
|
href={shortcutLink}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<div className="truncate">
|
||||||
|
<span>{shortcut.title}</span>
|
||||||
|
{shortcut.title ? (
|
||||||
|
<span className="text-gray-400">(s/{shortcut.name})</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
||||||
|
<span className="truncate dark:text-gray-400">{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>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-end items-center">
|
||||||
|
<ShortcutActionsDropdown shortcut={shortcut} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutView;
|
30
frontend/web/src/components/ShortcutsContainer.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import ShortcutCard from "./ShortcutCard";
|
||||||
|
import ShortcutView from "./ShortcutView";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcutList: Shortcut[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutsContainer: React.FC<Props> = (props: Props) => {
|
||||||
|
const { shortcutList } = props;
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const displayStyle = viewStore.displayStyle || "full";
|
||||||
|
const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"w-full grid grid-cols-1 gap-y-2 sm:gap-2",
|
||||||
|
displayStyle === "full" ? "sm:grid-cols-2" : "grid-cols-2 sm:grid-cols-4 gap-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{shortcutList.map((shortcut) => {
|
||||||
|
return <ShortcutItemView key={shortcut.id} shortcut={shortcut} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutsContainer;
|