Compare commits
219 Commits
Author | SHA1 | Date | |
---|---|---|---|
f815804f68 | |||
e89358cb0a | |||
bbe2bdffe3 | |||
70f6f30d69 | |||
af9655eeaf | |||
12cf0f8a8c | |||
916423cc89 | |||
3932cabeac | |||
dddb643bed | |||
0f7a771e85 | |||
f8d36ae1ef | |||
8d8b892d2a | |||
8a4e07120f | |||
8de658709c | |||
cb3e3bfaef | |||
4a25fbb2f6 | |||
83970d5d55 | |||
626b0df21c | |||
8f608dc522 | |||
8f982c5695 | |||
94baa04bb1 | |||
1505e9fa56 | |||
cab701f11b | |||
a3743d7ac6 | |||
7715905204 | |||
f770149066 | |||
f3f2218e91 | |||
b3e766926d | |||
6ed9ecffde | |||
c8d8c4e40c | |||
4f94927b5c | |||
f5f8616f2e | |||
033c007654 | |||
0fb5377226 | |||
f0afa13b8d | |||
53df3a9c1c | |||
8faaf8ced1 | |||
67c3bbf1ee | |||
68745ba9e0 | |||
015336b8c3 | |||
82ac6ab985 | |||
898ca70ad1 | |||
5b2a8394d7 | |||
16e17bffb3 | |||
015040cc1d | |||
c8869e67c7 | |||
a9ae7d2e96 | |||
db9034ccf9 | |||
4d1705dca5 | |||
3225e7c47b | |||
328397612c | |||
c846cde5b4 | |||
5c2cb99866 | |||
742c7da2eb | |||
88b247410f | |||
01417943fb | |||
09f7c33135 | |||
fe3b78f844 | |||
0fd54426e6 | |||
690e14e4ed | |||
7795b17fd1 | |||
c7dd4dc3eb | |||
6ee6a5166e | |||
8c753e9557 | |||
6126701025 | |||
8ef7d5f0d0 | |||
fa8d2f6639 | |||
8cd976791e | |||
010271c668 | |||
383d4f27f0 | |||
cb9786ef7c | |||
e936bb6f15 | |||
60c440ae10 | |||
fc8808ce04 | |||
e88327f2a3 | |||
159dfc9446 | |||
f78b072bb8 | |||
24fe368974 | |||
46fa546a7d | |||
96f6fa4257 | |||
8436d86661 | |||
a1d1e0f0f2 | |||
0907ad2681 | |||
e1b8bc607b | |||
528ecf72a3 | |||
f0ffe2e419 | |||
0df3164654 | |||
b97fb13929 | |||
3488cd04c0 | |||
07e0bb2d4c | |||
a58ebd27ca | |||
d0a25e3ab2 | |||
92fba82927 | |||
790a8a2e17 | |||
4e3d727b58 | |||
41eea8b571 | |||
8f17abdbf0 | |||
58cb5c7e2e | |||
271c133913 | |||
763205a89b | |||
e82e61d54d | |||
0af4903657 | |||
7f020eade9 | |||
ebe54d1131 | |||
9e8de4644a | |||
a372d07c4b | |||
dd5cce63c5 | |||
3c4155e6a1 | |||
6cb493b4a1 | |||
75d152922e | |||
908f95772d | |||
8992d48b3e | |||
aa247ccef2 | |||
0ba373373d | |||
e843594a02 | |||
032d9c1220 | |||
e5e50b6874 | |||
a7858075d8 | |||
cff6c54b52 | |||
5e6190b181 | |||
b50e809125 | |||
7348f47ef8 | |||
126e4a62f8 | |||
78282dab4d | |||
4a50248fbc | |||
4f0a8cdc0a | |||
a49a708fc5 | |||
bb99341aba | |||
0ce934413a | |||
65e366fdf1 | |||
2fcd496fd2 | |||
7cde25bdb5 | |||
35c396a88f | |||
a970d85e14 | |||
4733e4796d | |||
7c4ccbef3f | |||
b8f31cfd25 | |||
98cb5a2292 | |||
96c1901dce | |||
b807417885 | |||
6495c2081d | |||
0f92ccb22d | |||
bdf7f327d2 | |||
efc3815edf | |||
f5817c575c | |||
40814a801a | |||
e0f805f679 | |||
c4fcfbd6aa | |||
86d17188e1 | |||
88f8c00088 | |||
8612715371 | |||
e91050c803 | |||
ec2ec74e31 | |||
bfb640f201 | |||
34f8a97309 | |||
1c58702716 | |||
bd31c19a15 | |||
7e0ada6161 | |||
b5d6036fcf | |||
0fcee9baf2 | |||
f6fefdb8e6 | |||
0ec06423e5 | |||
8f028e4054 | |||
ae3b632f53 | |||
bafb17015c | |||
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 |
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
*/*/node_modules
|
9
.github/workflows/backend-tests.yml
vendored
@ -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
|
||||||
@ -25,7 +25,8 @@ jobs:
|
|||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v3
|
||||||
with:
|
with:
|
||||||
args: -v
|
version: v1.54.1
|
||||||
|
args: --verbose --timeout=3m
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|
||||||
go-tests:
|
go-tests:
|
||||||
@ -34,7 +35,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: Run all tests
|
- name: Run all tests
|
||||||
|
52
.github/workflows/extension-test.yml
vendored
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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: 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
|
||||||
|
- run: pnpm type-gen
|
||||||
|
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: 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
|
||||||
|
- run: pnpm type-gen
|
||||||
|
working-directory: frontend/extension
|
||||||
|
- name: Run extension build
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: frontend/extension
|
21
.github/workflows/frontend-test.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: Test
|
name: Frontend Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -8,7 +8,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "web/**"
|
- "frontend/web/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
eslint-checks:
|
eslint-checks:
|
||||||
@ -18,17 +18,18 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 8
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: web
|
working-directory: frontend/web
|
||||||
|
- run: pnpm type-gen
|
||||||
|
working-directory: frontend/web
|
||||||
- name: Run eslint check
|
- name: Run eslint check
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
working-directory: web
|
working-directory: frontend/web
|
||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -41,9 +42,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: web
|
working-directory: frontend/web
|
||||||
|
- run: pnpm type-gen
|
||||||
|
working-directory: frontend/web
|
||||||
- name: Run frontend build
|
- name: Run frontend build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: web
|
working-directory: frontend/web
|
||||||
|
5
.gitignore
vendored
@ -4,10 +4,9 @@
|
|||||||
# temp folder
|
# temp folder
|
||||||
tmp
|
tmp
|
||||||
|
|
||||||
# Frontend asset
|
|
||||||
web/dist
|
|
||||||
|
|
||||||
# build folder
|
# build folder
|
||||||
build
|
build
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
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)?'
|
||||||
|
16
Dockerfile
@ -1,26 +1,26 @@
|
|||||||
# Build frontend dist.
|
# Build frontend dist.
|
||||||
FROM node:18.12.1-alpine3.16 AS frontend
|
FROM node:18-alpine AS frontend
|
||||||
WORKDIR /frontend-build
|
WORKDIR /frontend-build
|
||||||
|
|
||||||
COPY ./web/package.json ./web/pnpm-lock.yaml ./
|
COPY . .
|
||||||
|
|
||||||
RUN corepack enable && pnpm i --frozen-lockfile
|
WORKDIR /frontend-build/frontend/web
|
||||||
|
|
||||||
COPY ./web/ .
|
RUN corepack enable && pnpm i --frozen-lockfile && pnpm type-gen
|
||||||
|
|
||||||
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/frontend/web/dist ./server/dist
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -o slash ./cmd/slash/main.go
|
RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
|
||||||
|
|
||||||
# Make workspace with above generated files.
|
# Make workspace with above generated files.
|
||||||
FROM alpine:3.16 AS monolithic
|
FROM alpine:latest AS monolithic
|
||||||
WORKDIR /usr/local/slash
|
WORKDIR /usr/local/slash
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata
|
||||||
|
145
LICENSE
@ -1,5 +1,5 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
@ -7,17 +7,15 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
|||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@ -72,7 +60,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
|||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
@ -631,40 +629,33 @@ to attach them to the start of each source file to most effectively
|
|||||||
state the exclusion of warranty; and each file should have at least
|
state the exclusion of warranty; and each file should have at least
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<Slash>
|
||||||
|
Copyright (C) <2023> <Steven>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
interface could display a "Source" link that leads users to an archive
|
||||||
This is free software, and you are welcome to redistribute it
|
of the code. There are many ways you could offer source, and different
|
||||||
under certain conditions; type `show c' for details.
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
36
README.md
@ -2,21 +2,31 @@
|
|||||||
|
|
||||||
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
||||||
|
|
||||||
**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.
|
**Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them with custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
|
||||||
|
|
||||||
|
🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/)
|
||||||
|
|
||||||
|
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a> • <a href="https://discord.gg/QZqUuUAhDV">Discord</a>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a>
|
||||||
<a href="https://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>
|
||||||
<a href="https://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github" /></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

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

|
||||||
|
|
||||||
|
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md).
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
|
||||||
|
For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/your-slash/).
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -16,9 +19,46 @@ const (
|
|||||||
|
|
||||||
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
// 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.
|
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
||||||
// Suppose we have a valid refresh token, we will refresh the token in cases:
|
|
||||||
// 1. The access token has already expired, we refresh the token so that the ongoing request can pass through.
|
|
||||||
CookieExpDuration = AccessTokenDuration - 1*time.Minute
|
CookieExpDuration = AccessTokenDuration - 1*time.Minute
|
||||||
// AccessTokenCookieName is the cookie name of access token.
|
// AccessTokenCookieName is the cookie name of access token.
|
||||||
AccessTokenCookieName = "slash.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
|
||||||
|
}
|
||||||
|
@ -6,10 +6,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mssola/useragent"
|
"github.com/mssola/useragent"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReferenceInfo struct {
|
type ReferenceInfo struct {
|
||||||
@ -77,6 +79,7 @@ func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
|||||||
browserMap[browserName]++
|
browserMap[browserName]++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("shortcut analytics")
|
||||||
return c.JSON(http.StatusOK, &AnalysisData{
|
return c.JSON(http.StatusOK, &AnalysisData{
|
||||||
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
||||||
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
||||||
@ -93,8 +96,8 @@ func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
|
|||||||
Count: value,
|
Count: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) bool {
|
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) int {
|
||||||
return i.Count > j.Count
|
return i.Count - j.Count
|
||||||
})
|
})
|
||||||
return referenceInfoSlice
|
return referenceInfoSlice
|
||||||
}
|
}
|
||||||
@ -107,8 +110,8 @@ func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
|
|||||||
Count: value,
|
Count: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) bool {
|
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) int {
|
||||||
return i.Count > j.Count
|
return i.Count - j.Count
|
||||||
})
|
})
|
||||||
return deviceInfoSlice
|
return deviceInfoSlice
|
||||||
}
|
}
|
||||||
@ -121,8 +124,8 @@ func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
|
|||||||
Count: value,
|
Count: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) bool {
|
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) int {
|
||||||
return i.Count > j.Count
|
return i.Count - j.Count
|
||||||
})
|
})
|
||||||
return browserInfoSlice
|
return browserInfoSlice
|
||||||
}
|
}
|
||||||
|
113
api/v1/auth.go
@ -1,13 +1,21 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/boojack/slash/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/metric"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SignInRequest struct {
|
type SignInRequest struct {
|
||||||
@ -46,24 +54,42 @@ 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 := 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)
|
||||||
|
metric.Enqueue("user sign in")
|
||||||
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)
|
||||||
@ -95,16 +121,91 @@ 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 := 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)
|
||||||
|
metric.Enqueue("user sign up")
|
||||||
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 {
|
||||||
|
ctx := c.Request().Context()
|
||||||
RemoveTokensAndCookies(c)
|
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)
|
||||||
|
}
|
||||||
|
179
api/v1/jwt.go
@ -4,92 +4,23 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
|
||||||
"github.com/boojack/slash/internal/util"
|
|
||||||
"github.com/boojack/slash/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 (
|
||||||
// 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type claimsMessage struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAccessToken generates an access token for web.
|
|
||||||
func GenerateAccessToken(username string, userID int32, secret string) (string, error) {
|
|
||||||
expirationTime := time.Now().Add(auth.AccessTokenDuration)
|
|
||||||
return generateToken(username, userID, auth.AccessTokenAudienceName, expirationTime, []byte(secret))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
|
|
||||||
func GenerateTokensAndSetCookies(c echo.Context, user *store.User, secret string) error {
|
|
||||||
accessToken, err := GenerateAccessToken(user.Email, user.ID, secret)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "failed to generate access token")
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveTokensAndCookies removes the jwt token and refresh 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateToken generates a jwt token.
|
|
||||||
func generateToken(username string, userID int32, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
|
||||||
// Create the JWT claims, which includes the username and expiry time.
|
|
||||||
claims := &claimsMessage{
|
|
||||||
Name: username,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Audience: jwt.ClaimStrings{aud},
|
|
||||||
// In JWT, the expiry time is expressed as unix milliseconds.
|
|
||||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
Issuer: auth.Issuer,
|
|
||||||
Subject: fmt.Sprint(userID),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
token.Header["kid"] = auth.KeyID
|
|
||||||
|
|
||||||
// Create the JWT string.
|
|
||||||
tokenString, err := token.SignedString(secret)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokenString, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 == "" {
|
||||||
@ -105,31 +36,20 @@ func extractTokenFromHeader(c echo.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func findAccessToken(c echo.Context) string {
|
func findAccessToken(c echo.Context) string {
|
||||||
accessToken := ""
|
// Check the HTTP request header first.
|
||||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
accessToken, _ := extractTokenFromHeader(c)
|
||||||
if cookie != nil {
|
|
||||||
accessToken = cookie.Value
|
|
||||||
}
|
|
||||||
if accessToken == "" {
|
if accessToken == "" {
|
||||||
accessToken, _ = extractTokenFromHeader(c)
|
// Check the cookie.
|
||||||
|
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||||
|
if cookie != nil {
|
||||||
|
accessToken = cookie.Value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
path := c.Request().URL.Path
|
path := c.Request().URL.Path
|
||||||
@ -140,44 +60,30 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
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, "/s/*") && method == http.MethodGet {
|
if util.HasPrefixes(path, "/s/", "/api/v1/user/") && 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 := &claimsMessage{}
|
userID, err := getUserIDFromAccessToken(accessToken, secret)
|
||||||
_, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
|
||||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
|
||||||
}
|
|
||||||
if kid, ok := t.Header["kid"].(string); ok {
|
|
||||||
if kid == "v1" {
|
|
||||||
return []byte(secret), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RemoveTokensAndCookies(c)
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
|
||||||
}
|
|
||||||
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.").WithInternal(err)
|
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.
|
// Even if there is no error, we still need to make sure the user still exists.
|
||||||
user, err := server.Store.GetUser(ctx, &store.FindUser{
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
ID: &userID,
|
ID: &userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -188,7 +94,40 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stores userID into context.
|
// Stores userID into context.
|
||||||
c.Set(UserIDContextKey, userID)
|
c.Set(userIDContextKey, userID)
|
||||||
return next(c)
|
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 {
|
||||||
|
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
|
}
|
||||||
|
if kid, ok := t.Header["kid"].(string); ok {
|
||||||
|
if kid == "v1" {
|
||||||
|
return []byte(secret), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "Invalid or expired access token")
|
||||||
|
}
|
||||||
|
// We either have a valid access token or we will attempt to generate new access token.
|
||||||
|
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "Malformed ID in the token")
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||||
|
for _, userAccessToken := range userAccessTokens {
|
||||||
|
if accessTokenString == userAccessToken.AccessToken {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
@ -8,10 +8,12 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
|
||||||
"github.com/boojack/slash/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/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
||||||
@ -29,10 +31,10 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with name: %s", shortcutName))
|
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/404?shortcut=%s", shortcutName))
|
||||||
}
|
}
|
||||||
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
}
|
}
|
||||||
@ -45,6 +47,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("shortcut redirect")
|
||||||
return redirectToShortcut(c, shortcut)
|
return redirectToShortcut(c, shortcut)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -7,11 +7,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/slash/internal/util"
|
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/internal/util"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Visibility is the type of a shortcut visibility.
|
// Visibility is the type of a shortcut visibility.
|
||||||
@ -81,7 +83,7 @@ type PatchShortcutRequest struct {
|
|||||||
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(UserIDContextKey).(int32)
|
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")
|
||||||
}
|
}
|
||||||
@ -90,20 +92,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, &storepb.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,
|
Title: create.Title,
|
||||||
Description: create.Description,
|
Description: create.Description,
|
||||||
Visibility: convertVisibilityToStorepb(create.Visibility),
|
Visibility: convertVisibilityToStorepb(create.Visibility),
|
||||||
Tags: create.Tags,
|
Tags: create.Tags,
|
||||||
OgMetadata: &storepb.OpenGraphMetadata{
|
OgMetadata: &storepb.OpenGraphMetadata{},
|
||||||
|
}
|
||||||
|
if create.OpenGraphMetadata != nil {
|
||||||
|
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
||||||
Title: create.OpenGraphMetadata.Title,
|
Title: create.OpenGraphMetadata.Title,
|
||||||
Description: create.OpenGraphMetadata.Description,
|
Description: create.OpenGraphMetadata.Description,
|
||||||
Image: create.OpenGraphMetadata.Image,
|
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)
|
||||||
}
|
}
|
||||||
@ -116,6 +122,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
metric.Enqueue("shortcut create")
|
||||||
return c.JSON(http.StatusOK, shortcutMessage)
|
return c.JSON(http.StatusOK, shortcutMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -125,7 +132,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
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(UserIDContextKey).(int32)
|
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")
|
||||||
}
|
}
|
||||||
@ -153,10 +160,6 @@ 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,
|
||||||
@ -196,7 +199,7 @@ 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(UserIDContextKey).(int32)
|
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")
|
||||||
}
|
}
|
||||||
@ -263,7 +266,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
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(UserIDContextKey).(int32)
|
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")
|
||||||
}
|
}
|
||||||
@ -349,6 +352,8 @@ func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
|
|||||||
switch visibility {
|
switch visibility {
|
||||||
case VisibilityPublic:
|
case VisibilityPublic:
|
||||||
return storepb.Visibility_PUBLIC
|
return storepb.Visibility_PUBLIC
|
||||||
|
case VisibilityWorkspace:
|
||||||
|
return storepb.Visibility_WORKSPACE
|
||||||
case VisibilityPrivate:
|
case VisibilityPrivate:
|
||||||
return storepb.Visibility_PRIVATE
|
return storepb.Visibility_PRIVATE
|
||||||
default:
|
default:
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"go.deanishe.net/favicon"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
|
|
||||||
// GET /url/favicon?url=...
|
|
||||||
g.GET("/url/favicon", func(c echo.Context) error {
|
|
||||||
url := c.QueryParam("url")
|
|
||||||
icons, err := favicon.Find(url)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to find favicon, err: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
availableIcons := []*favicon.Icon{}
|
|
||||||
for _, icon := range icons {
|
|
||||||
if icon.Width == icon.Height {
|
|
||||||
availableIcons = append(availableIcons, icon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(availableIcons) == 0 {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "no favicon found")
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, availableIcons[0].URL)
|
|
||||||
})
|
|
||||||
}
|
|
@ -6,10 +6,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
|
||||||
"github.com/boojack/slash/internal/util"
|
|
||||||
"github.com/boojack/slash/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/metric"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -60,13 +64,13 @@ type CreateUserRequest struct {
|
|||||||
|
|
||||||
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
|
||||||
@ -83,7 +87,7 @@ type PatchUserRequest struct {
|
|||||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||||
g.POST("/user", func(c echo.Context) error {
|
g.POST("/user", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
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")
|
||||||
}
|
}
|
||||||
@ -100,6 +104,16 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
|
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{}
|
userCreate := &CreateUserRequest{}
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
||||||
@ -124,6 +138,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userMessage := convertUserFromStore(user)
|
userMessage := convertUserFromStore(user)
|
||||||
|
metric.Enqueue("user create")
|
||||||
return c.JSON(http.StatusOK, userMessage)
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -144,7 +159,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(UserIDContextKey).(int32)
|
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")
|
||||||
}
|
}
|
||||||
@ -173,7 +188,12 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
userMessage := convertUserFromStore(user)
|
||||||
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
userMessage.Email = ""
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
g.PATCH("/user/:id", func(c echo.Context) error {
|
g.PATCH("/user/:id", func(c echo.Context) error {
|
||||||
@ -182,7 +202,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
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(UserIDContextKey).(int32)
|
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")
|
||||||
}
|
}
|
||||||
@ -254,7 +274,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(UserIDContextKey).(int32)
|
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")
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,8 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserSettingKey string
|
type UserSettingKey string
|
||||||
@ -39,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
|
||||||
@ -50,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
|
||||||
|
19
api/v1/v1.go
@ -1,20 +1,24 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/boojack/slash/server/profile"
|
|
||||||
"github.com/boojack/slash/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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,7 +27,6 @@ func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
|||||||
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return JWTMiddleware(s, next, secret)
|
return JWTMiddleware(s, next, secret)
|
||||||
})
|
})
|
||||||
s.registerURLUtilRoutes(apiV1Group)
|
|
||||||
s.registerWorkspaceRoutes(apiV1Group)
|
s.registerWorkspaceRoutes(apiV1Group)
|
||||||
s.registerAuthRoutes(apiV1Group, secret)
|
s.registerAuthRoutes(apiV1Group, secret)
|
||||||
s.registerUserRoutes(apiV1Group)
|
s.registerUserRoutes(apiV1Group)
|
||||||
|
@ -1,39 +1,16 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkspaceSetting struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceSettingUpsert struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (upsert WorkspaceSettingUpsert) Validate() error {
|
|
||||||
if upsert.Key == store.WorkspaceDisallowSignUp.String() {
|
|
||||||
value := false
|
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to unmarshal workspace setting disallow signup value")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("invalid workspace setting key")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceProfile struct {
|
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(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil || user.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
upsert := &WorkspaceSettingUpsert{}
|
|
||||||
if err := json.NewDecoder(c.Request().Body).Decode(upsert); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if err := upsert.Validate(); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request body, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceSetting, err := s.Store.UpsertWorkspaceSetting(ctx, &store.WorkspaceSetting{
|
|
||||||
Key: store.WorkspaceSettingKey(upsert.Key),
|
|
||||||
Value: upsert.Value,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert workspace setting, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, convertWorkspaceSettingFromStore(workspaceSetting))
|
|
||||||
})
|
|
||||||
|
|
||||||
g.GET("/workspace/setting", func(c echo.Context) error {
|
|
||||||
ctx := c.Request().Context()
|
|
||||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
|
||||||
if !ok {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &userID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
if user == nil || user.Role != store.RoleAdmin {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := s.Store.ListWorkspaceSettings(ctx, &store.FindWorkspaceSetting{})
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list workspace settings, err: %s", err)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceSettingList := []*WorkspaceSetting{}
|
|
||||||
for _, workspaceSetting := range list {
|
|
||||||
workspaceSettingList = append(workspaceSettingList, convertWorkspaceSettingFromStore(workspaceSetting))
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, workspaceSettingList)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertWorkspaceSettingFromStore(workspaceSetting *store.WorkspaceSetting) *WorkspaceSetting {
|
|
||||||
return &WorkspaceSetting{
|
|
||||||
Key: workspaceSetting.Key.String(),
|
|
||||||
Value: workspaceSetting.Value,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -3,52 +3,40 @@ package v2
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
|
||||||
"github.com/boojack/slash/internal/util"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
var authenticationAllowlistMethods = map[string]bool{
|
|
||||||
"/memos.api.v2.UserService/GetUser": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAuthenticationAllowed returns whether the method is exempted from authentication.
|
|
||||||
func IsAuthenticationAllowed(fullMethodName string) bool {
|
|
||||||
if strings.HasPrefix(fullMethodName, "/grpc.reflection") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return authenticationAllowlistMethods[fullMethodName]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContextKey is the key type of context value.
|
// ContextKey is the key type of context value.
|
||||||
type ContextKey int
|
type ContextKey int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// 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 ContextKey = iota
|
userIDContextKey ContextKey = iota
|
||||||
)
|
)
|
||||||
|
|
||||||
// GRPCAuthInterceptor is the auth interceptor for gRPC server.
|
// GRPCAuthInterceptor is the auth interceptor for gRPC server.
|
||||||
type GRPCAuthInterceptor struct {
|
type GRPCAuthInterceptor struct {
|
||||||
store *store.Store
|
Store *store.Store
|
||||||
secret string
|
secret string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGRPCAuthInterceptor returns a new API auth interceptor.
|
// NewGRPCAuthInterceptor returns a new API auth interceptor.
|
||||||
func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthInterceptor {
|
func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthInterceptor {
|
||||||
return &GRPCAuthInterceptor{
|
return &GRPCAuthInterceptor{
|
||||||
store: store,
|
Store: store,
|
||||||
secret: secret,
|
secret: secret,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,30 +47,42 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context")
|
return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context")
|
||||||
}
|
}
|
||||||
accessTokenStr, err := getTokenFromMetadata(md)
|
accessToken, err := getTokenFromMetadata(md)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, err.Error())
|
return nil, status.Errorf(codes.Unauthenticated, "failed to get access token from metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := in.authenticate(ctx, accessTokenStr)
|
userID, err := in.authenticate(ctx, accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if IsAuthenticationAllowed(serverInfo.FullMethod) {
|
if isUnauthorizeAllowedMethod(serverInfo.FullMethod) {
|
||||||
return handler(ctx, request)
|
return handler(ctx, request)
|
||||||
}
|
}
|
||||||
return nil, err
|
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.
|
// Stores userID into context.
|
||||||
childCtx := context.WithValue(ctx, UserIDContextKey, userID)
|
childCtx := context.WithValue(ctx, userIDContextKey, userID)
|
||||||
return handler(childCtx, request)
|
return handler(childCtx, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr string) (int32, error) {
|
func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken string) (int32, error) {
|
||||||
if accessTokenStr == "" {
|
if accessToken == "" {
|
||||||
return 0, status.Errorf(codes.Unauthenticated, "access token not found")
|
return 0, status.Errorf(codes.Unauthenticated, "access token not found")
|
||||||
}
|
}
|
||||||
claims := &claimsMessage{}
|
claims := &auth.ClaimsMessage{}
|
||||||
_, err := jwt.ParseWithClaims(accessTokenStr, claims, func(t *jwt.Token) (any, error) {
|
_, 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, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
}
|
}
|
||||||
@ -108,7 +108,7 @@ func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, status.Errorf(codes.Unauthenticated, "malformed ID %q in the access token", claims.Subject)
|
return 0, status.Errorf(codes.Unauthenticated, "malformed ID %q in the access token", claims.Subject)
|
||||||
}
|
}
|
||||||
user, err := in.store.GetUser(ctx, &store.FindUser{
|
user, err := in.Store.GetUser(ctx, &store.FindUser{
|
||||||
ID: &userID,
|
ID: &userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -121,19 +121,28 @@ func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr
|
|||||||
return 0, status.Errorf(codes.Unauthenticated, "user ID %q has been deactivated by administrators", userID)
|
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
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTokenFromMetadata(md metadata.MD) (string, error) {
|
func getTokenFromMetadata(md metadata.MD) (string, error) {
|
||||||
|
// Try to get the token from the authorization header first.
|
||||||
authorizationHeaders := md.Get("Authorization")
|
authorizationHeaders := md.Get("Authorization")
|
||||||
if len(md.Get("Authorization")) > 0 {
|
if len(authorizationHeaders) > 0 {
|
||||||
authHeaderParts := strings.Fields(authorizationHeaders[0])
|
authHeaderParts := strings.Fields(authorizationHeaders[0])
|
||||||
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||||
return "", errors.Errorf("authorization header format must be Bearer {token}")
|
return "", errors.Errorf("authorization header format must be Bearer {token}")
|
||||||
}
|
}
|
||||||
return authHeaderParts[1], nil
|
return authHeaderParts[1], nil
|
||||||
}
|
}
|
||||||
// check the HTTP cookie
|
// Try to get the token from the cookie header.
|
||||||
var accessToken string
|
var accessToken string
|
||||||
for _, t := range append(md.Get("grpcgateway-cookie"), md.Get("cookie")...) {
|
for _, t := range append(md.Get("grpcgateway-cookie"), md.Get("cookie")...) {
|
||||||
header := http.Header{}
|
header := http.Header{}
|
||||||
@ -155,40 +164,11 @@ func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type claimsMessage struct {
|
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||||
Name string `json:"name"`
|
for _, userAccessToken := range userAccessTokens {
|
||||||
jwt.RegisteredClaims
|
if accessTokenString == userAccessToken.AccessToken {
|
||||||
}
|
return true
|
||||||
|
}
|
||||||
// GenerateAccessToken generates an access token for web.
|
|
||||||
func GenerateAccessToken(username string, userID int, secret string) (string, error) {
|
|
||||||
expirationTime := time.Now().Add(auth.AccessTokenDuration)
|
|
||||||
return generateToken(username, userID, auth.AccessTokenAudienceName, expirationTime, []byte(secret))
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateToken(username string, userID int, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
|
||||||
// Create the JWT claims, which includes the username and expiry time.
|
|
||||||
claims := &claimsMessage{
|
|
||||||
Name: username,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Audience: jwt.ClaimStrings{aud},
|
|
||||||
// In JWT, the expiry time is expressed as unix milliseconds.
|
|
||||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
Issuer: auth.Issuer,
|
|
||||||
Subject: strconv.Itoa(userID),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
token.Header["kid"] = auth.KeyID
|
|
||||||
|
|
||||||
// Create the JWT string.
|
|
||||||
tokenString, err := token.SignedString(secret)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokenString, nil
|
|
||||||
}
|
}
|
29
api/v2/acl_config.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
var allowedMethodsWhenUnauthorized = map[string]bool{
|
||||||
|
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
|
||||||
|
"/slash.api.v2.WorkspaceService/GetWorkspaceSetting": true,
|
||||||
|
"/slash.api.v2.ShortcutService/GetShortcut": true,
|
||||||
|
"/slash.api.v2.CollectionService/GetCollectionByName": 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]
|
||||||
|
}
|
205
api/v2/collection_service.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV2Service) ListCollections(ctx context.Context, _ *apiv2pb.ListCollectionsRequest) (*apiv2pb.ListCollectionsResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
find := &store.FindCollection{}
|
||||||
|
find.CreatorID = &userID
|
||||||
|
collections, err := s.Store.ListCollections(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
convertedCollections := []*apiv2pb.Collection{}
|
||||||
|
for _, collection := range collections {
|
||||||
|
convertedCollections = append(convertedCollections, convertCollectionFromStore(collection))
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &apiv2pb.ListCollectionsResponse{
|
||||||
|
Collections: convertedCollections,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetCollection(ctx context.Context, request *apiv2pb.GetCollectionRequest) (*apiv2pb.GetCollectionResponse, error) {
|
||||||
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection by name: %v", err)
|
||||||
|
}
|
||||||
|
if collection == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if collection.Visibility == storepb.Visibility_PRIVATE && collection.CreatorId != userID {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
response := &apiv2pb.GetCollectionResponse{
|
||||||
|
Collection: convertCollectionFromStore(collection),
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetCollectionByName(ctx context.Context, request *apiv2pb.GetCollectionByNameRequest) (*apiv2pb.GetCollectionByNameResponse, error) {
|
||||||
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
|
Name: &request.Name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection by name: %v", err)
|
||||||
|
}
|
||||||
|
if collection == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if ok {
|
||||||
|
if collection.Visibility == storepb.Visibility_PRIVATE && collection.CreatorId != userID {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if collection.Visibility != storepb.Visibility_PUBLIC {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response := &apiv2pb.GetCollectionByNameResponse{
|
||||||
|
Collection: convertCollectionFromStore(collection),
|
||||||
|
}
|
||||||
|
metric.Enqueue("collection view")
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) CreateCollection(ctx context.Context, request *apiv2pb.CreateCollectionRequest) (*apiv2pb.CreateCollectionResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
collection := &storepb.Collection{
|
||||||
|
CreatorId: userID,
|
||||||
|
Name: request.Collection.Name,
|
||||||
|
Title: request.Collection.Title,
|
||||||
|
Description: request.Collection.Description,
|
||||||
|
ShortcutIds: request.Collection.ShortcutIds,
|
||||||
|
Visibility: storepb.Visibility(request.Collection.Visibility),
|
||||||
|
}
|
||||||
|
collection, err := s.Store.CreateCollection(ctx, collection)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to create collection, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &apiv2pb.CreateCollectionResponse{
|
||||||
|
Collection: convertCollectionFromStore(collection),
|
||||||
|
}
|
||||||
|
metric.Enqueue("collection create")
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) UpdateCollection(ctx context.Context, request *apiv2pb.UpdateCollectionRequest) (*apiv2pb.UpdateCollectionResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
|
ID: &request.Collection.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection by name: %v", err)
|
||||||
|
}
|
||||||
|
if collection == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
|
}
|
||||||
|
if collection.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
update := &store.UpdateCollection{
|
||||||
|
ID: collection.Id,
|
||||||
|
}
|
||||||
|
for _, path := range request.UpdateMask.Paths {
|
||||||
|
switch path {
|
||||||
|
case "name":
|
||||||
|
update.Name = &request.Collection.Name
|
||||||
|
case "title":
|
||||||
|
update.Title = &request.Collection.Title
|
||||||
|
case "description":
|
||||||
|
update.Description = &request.Collection.Description
|
||||||
|
case "shortcut_ids":
|
||||||
|
update.ShortcutIDs = request.Collection.ShortcutIds
|
||||||
|
case "visibility":
|
||||||
|
visibility := store.Visibility(request.Collection.Visibility.String())
|
||||||
|
update.Visibility = &visibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collection, err = s.Store.UpdateCollection(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update collection, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &apiv2pb.UpdateCollectionResponse{
|
||||||
|
Collection: convertCollectionFromStore(collection),
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) DeleteCollection(ctx context.Context, request *apiv2pb.DeleteCollectionRequest) (*apiv2pb.DeleteCollectionResponse, 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)
|
||||||
|
}
|
||||||
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection by name: %v", err)
|
||||||
|
}
|
||||||
|
if collection == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
|
}
|
||||||
|
if collection.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Store.DeleteCollection(ctx, &store.DeleteCollection{
|
||||||
|
ID: collection.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to delete collection, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.DeleteCollectionResponse{}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertCollectionFromStore(collection *storepb.Collection) *apiv2pb.Collection {
|
||||||
|
return &apiv2pb.Collection{
|
||||||
|
Id: collection.Id,
|
||||||
|
CreatorId: collection.CreatorId,
|
||||||
|
CreatedTime: timestamppb.New(time.Unix(collection.CreatedTs, 0)),
|
||||||
|
UpdatedTime: timestamppb.New(time.Unix(collection.UpdatedTs, 0)),
|
||||||
|
Name: collection.Name,
|
||||||
|
Title: collection.Title,
|
||||||
|
Description: collection.Description,
|
||||||
|
ShortcutIds: collection.ShortcutIds,
|
||||||
|
Visibility: apiv2pb.Visibility(collection.Visibility),
|
||||||
|
}
|
||||||
|
}
|
269
api/v2/shortcut_service.go
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV2Service) 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 {
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
shortcuts = append(shortcuts, composedShortcut)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &apiv2pb.ListShortcutsResponse{
|
||||||
|
Shortcuts: shortcuts,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) {
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
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, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if ok {
|
||||||
|
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.GetShortcutResponse{
|
||||||
|
Shortcut: composedShortcut,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.CreateShortcutResponse{
|
||||||
|
Shortcut: composedShortcut,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) UpdateShortcut(ctx context.Context, request *apiv2pb.UpdateShortcutRequest) (*apiv2pb.UpdateShortcutResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
ID: &request.Shortcut.Id,
|
||||||
|
})
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
update := &store.UpdateShortcut{}
|
||||||
|
for _, path := range request.UpdateMask.Paths {
|
||||||
|
switch path {
|
||||||
|
case "link":
|
||||||
|
update.Link = &request.Shortcut.Link
|
||||||
|
case "title":
|
||||||
|
update.Title = &request.Shortcut.Title
|
||||||
|
case "tags":
|
||||||
|
tag := strings.Join(request.Shortcut.Tags, " ")
|
||||||
|
update.Tag = &tag
|
||||||
|
case "description":
|
||||||
|
update.Description = &request.Shortcut.Description
|
||||||
|
case "visibility":
|
||||||
|
visibility := store.Visibility(request.Shortcut.Visibility.String())
|
||||||
|
update.Visibility = &visibility
|
||||||
|
case "og_metadata":
|
||||||
|
if request.Shortcut.OgMetadata != nil {
|
||||||
|
update.OpenGraphMetadata = &store.OpenGraphMetadata{
|
||||||
|
Title: request.Shortcut.OgMetadata.Title,
|
||||||
|
Description: request.Shortcut.OgMetadata.Description,
|
||||||
|
Image: request.Shortcut.OgMetadata.Image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortcut, err = s.Store.UpdateShortcut(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.UpdateShortcutResponse{
|
||||||
|
Shortcut: composedShortcut,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) 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{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
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 *APIV2Service) 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 (s *APIV2Service) convertShortcutFromStorepb(ctx context.Context, shortcut *storepb.Shortcut) (*apiv2pb.Shortcut, error) {
|
||||||
|
composedShortcut := &apiv2pb.Shortcut{
|
||||||
|
Id: shortcut.Id,
|
||||||
|
CreatorId: shortcut.CreatorId,
|
||||||
|
CreatedTime: timestamppb.New(time.Unix(shortcut.CreatedTs, 0)),
|
||||||
|
UpdatedTime: timestamppb.New(time.Unix(shortcut.UpdatedTs, 0)),
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
|
Type: store.ActivityShortcutView,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", composedShortcut.Id)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Failed to list activities")
|
||||||
|
}
|
||||||
|
composedShortcut.ViewCount = int32(len(activityList))
|
||||||
|
|
||||||
|
return composedShortcut, nil
|
||||||
|
}
|
30
api/v2/subscription_service.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV2Service) 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 *APIV2Service) 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
|
||||||
|
}
|
@ -2,32 +2,45 @@ package v2
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"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 {
|
func (s *APIV2Service) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, error) {
|
||||||
apiv2pb.UnimplementedUserServiceServer
|
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
Store *store.Store
|
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserService creates a new UserService.
|
|
||||||
func NewUserService(store *store.Store) *UserService {
|
|
||||||
return &UserService{
|
|
||||||
Store: store,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func (s *APIV2Service) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
ID: &request.Id,
|
ID: &request.Id,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list tags: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to find user: %v", err)
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||||
@ -40,15 +53,257 @@ func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserReque
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) 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 *APIV2Service) 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.Paths) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "UpdateMask is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
userUpdate := &store.UpdateUser{
|
||||||
|
ID: request.User.Id,
|
||||||
|
}
|
||||||
|
for _, path := range request.UpdateMask.Paths {
|
||||||
|
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 *APIV2Service) 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 *APIV2Service) 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) int {
|
||||||
|
return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
|
||||||
|
})
|
||||||
|
response := &apiv2pb.ListUserAccessTokensResponse{
|
||||||
|
AccessTokens: accessTokens,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) 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 *APIV2Service) 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 *APIV2Service) 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 {
|
func convertUserFromStore(user *store.User) *apiv2pb.User {
|
||||||
return &apiv2pb.User{
|
return &apiv2pb.User{
|
||||||
Id: int32(user.ID),
|
Id: int32(user.ID),
|
||||||
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
||||||
CreatedTs: user.CreatedTs,
|
CreatedTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
|
||||||
UpdatedTs: user.UpdatedTs,
|
UpdatedTime: timestamppb.New(time.Unix(user.UpdatedTs, 0)),
|
||||||
Role: convertUserRoleFromStore(user.Role),
|
Role: convertUserRoleFromStore(user.Role),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Nickname: user.Nickname,
|
Nickname: user.Nickname,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
135
api/v2/user_setting_service.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV2Service) 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 *APIV2Service) UpdateUserSetting(ctx context.Context, request *apiv2pb.UpdateUserSettingRequest) (*apiv2pb.UpdateUserSettingResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
for _, path := range request.UpdateMask.Paths {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
70
api/v2/v2.go
@ -4,40 +4,61 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
"github.com/boojack/slash/server/profile"
|
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"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 {
|
type APIV2Service struct {
|
||||||
Secret string
|
apiv2pb.UnimplementedWorkspaceServiceServer
|
||||||
Profile *profile.Profile
|
apiv2pb.UnimplementedSubscriptionServiceServer
|
||||||
Store *store.Store
|
apiv2pb.UnimplementedUserServiceServer
|
||||||
|
apiv2pb.UnimplementedUserSettingServiceServer
|
||||||
|
apiv2pb.UnimplementedShortcutServiceServer
|
||||||
|
apiv2pb.UnimplementedCollectionServiceServer
|
||||||
|
|
||||||
|
Secret string
|
||||||
|
Profile *profile.Profile
|
||||||
|
Store *store.Store
|
||||||
|
LicenseService *license.LicenseService
|
||||||
|
|
||||||
grpcServer *grpc.Server
|
grpcServer *grpc.Server
|
||||||
grpcServerPort int
|
grpcServerPort int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, grpcServerPort int) *APIV2Service {
|
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, licenseService *license.LicenseService, grpcServerPort int) *APIV2Service {
|
||||||
authProvider := NewGRPCAuthInterceptor(store, secret)
|
authProvider := NewGRPCAuthInterceptor(store, secret)
|
||||||
grpcServer := grpc.NewServer(
|
grpcServer := grpc.NewServer(
|
||||||
grpc.ChainUnaryInterceptor(
|
grpc.ChainUnaryInterceptor(
|
||||||
authProvider.AuthenticationInterceptor,
|
authProvider.AuthenticationInterceptor,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
apiv2pb.RegisterUserServiceServer(grpcServer, NewUserService(store))
|
apiV2Service := &APIV2Service{
|
||||||
|
|
||||||
return &APIV2Service{
|
|
||||||
Secret: secret,
|
Secret: secret,
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
Store: store,
|
Store: store,
|
||||||
|
LicenseService: licenseService,
|
||||||
grpcServer: grpcServer,
|
grpcServer: grpcServer,
|
||||||
grpcServerPort: grpcServerPort,
|
grpcServerPort: grpcServerPort,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apiv2pb.RegisterSubscriptionServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterWorkspaceServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterUserServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterUserSettingServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterShortcutServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterCollectionServiceServer(grpcServer, apiV2Service)
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
|
return apiV2Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
||||||
@ -57,11 +78,36 @@ func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
gwMux := grpcRuntime.NewServeMux()
|
gwMux := runtime.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 {
|
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterCollectionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
134
api/v2/workspace_service.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
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/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV2Service) 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 *APIV2Service) 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{
|
||||||
|
EnableSignup: true,
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &apiv2pb.GetWorkspaceSettingResponse{
|
||||||
|
Setting: workspaceSetting,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) UpdateWorkspaceSetting(ctx context.Context, request *apiv2pb.UpdateWorkspaceSettingRequest) (*apiv2pb.UpdateWorkspaceSettingResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range request.UpdateMask.Paths {
|
||||||
|
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 == "custom_style" {
|
||||||
|
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
|
||||||
|
}
|
@ -10,10 +10,13 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"go.uber.org/zap"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/internal/log"
|
||||||
"github.com/boojack/slash/server"
|
"github.com/boojack/slash/server"
|
||||||
_profile "github.com/boojack/slash/server/profile"
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/boojack/slash/store/db"
|
"github.com/boojack/slash/store/db"
|
||||||
)
|
)
|
||||||
@ -23,31 +26,34 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
profile *_profile.Profile
|
serverProfile *profile.Profile
|
||||||
mode string
|
mode string
|
||||||
port int
|
port int
|
||||||
data string
|
data string
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "slash",
|
Use: "slash",
|
||||||
Short: `An open source, self-hosted bookmarks and link sharing platform.`,
|
Short: `An open source, self-hosted bookmarks and link sharing platform.`,
|
||||||
Run: func(_cmd *cobra.Command, _args []string) {
|
Run: func(_cmd *cobra.Command, _args []string) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
db := db.NewDB(profile)
|
db := db.NewDB(serverProfile)
|
||||||
if err := db.Open(ctx); err != nil {
|
if err := db.Open(ctx); err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
fmt.Printf("failed to open db, error: %+v\n", err)
|
log.Error("failed to open database", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
storeInstance := store.New(db.DBInstance, profile)
|
storeInstance := store.New(db.DBInstance, serverProfile)
|
||||||
s, err := server.NewServer(ctx, profile, storeInstance)
|
s, err := server.NewServer(ctx, serverProfile, storeInstance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
fmt.Printf("failed to create server, error: %+v\n", err)
|
log.Error("failed to create server", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nolint
|
||||||
|
metric.NewMetricClient(s.Secret, *serverProfile)
|
||||||
|
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
||||||
// The default signal sent by the `kill` command is SIGTERM,
|
// The default signal sent by the `kill` command is SIGTERM,
|
||||||
@ -55,16 +61,16 @@ var (
|
|||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
go func() {
|
go func() {
|
||||||
sig := <-c
|
sig := <-c
|
||||||
fmt.Printf("%s received.\n", sig.String())
|
log.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||||
s.Shutdown(ctx)
|
s.Shutdown(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
println(greetingBanner)
|
printGreetings()
|
||||||
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
|
||||||
if err := s.Start(ctx); err != nil {
|
if err := s.Start(ctx); err != nil {
|
||||||
if err != http.ErrServerClosed {
|
if err != http.ErrServerClosed {
|
||||||
fmt.Printf("failed to start server, error: %+v\n", err)
|
log.Error("failed to start server", zap.Error(err))
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -76,6 +82,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Execute() error {
|
func Execute() error {
|
||||||
|
defer log.Sync()
|
||||||
return rootCmd.Execute()
|
return rootCmd.Execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,18 +114,27 @@ func init() {
|
|||||||
func initConfig() {
|
func initConfig() {
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
var err error
|
var err error
|
||||||
profile, err = _profile.GetProfile()
|
serverProfile, err = profile.GetProfile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("failed to get profile, error: %+v\n", err)
|
log.Error("failed to get profile", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
println("---")
|
println("---")
|
||||||
println("Server profile")
|
println("Server profile")
|
||||||
println("dsn:", profile.DSN)
|
println("dsn:", serverProfile.DSN)
|
||||||
println("port:", profile.Port)
|
println("port:", serverProfile.Port)
|
||||||
println("mode:", profile.Mode)
|
println("mode:", serverProfile.Mode)
|
||||||
println("version:", profile.Version)
|
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("---")
|
println("---")
|
||||||
}
|
}
|
||||||
|
|
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 |
43
docs/getting-started/collections.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Slash Collections
|
||||||
|
|
||||||
|
**Slash Collections** introduces a feature to help you better organize and manage related Shortcuts within the Slash Shortcuts platform.
|
||||||
|
|
||||||
|
## What is a Collection?
|
||||||
|
|
||||||
|
A Collection is like a virtual folder where you can group and organize your related Shortcuts. It acts as a container that holds Shortcuts together for a specific purpose or theme. Let's break down the key attributes:
|
||||||
|
|
||||||
|
- **Name:** Your chosen label for the Collection. This becomes a crucial part of the URL, enabling direct and quick access to the Collection. For example, if your Collection is named "work-projects", the direct access link would be `c/work-projects`. This user-defined name significantly enhances the accessibility and recognition of your Collections.
|
||||||
|
- **Title:** A brief title summarizing the Collection's content.
|
||||||
|
- **Description:** A short description explaining what the Collection is about.
|
||||||
|
- **Shortcuts:** The Shortcuts included in the Collection.
|
||||||
|
- **Visibility:** Settings to control who can access the Collection.
|
||||||
|
|
||||||
|
## What Problems Does It Solve?
|
||||||
|
|
||||||
|
Slash Collections tackle the challenge of efficiently managing and organizing related Shortcuts. By grouping Shortcuts into Collections, you can create a more structured and accessible workflow. This makes it easier to find, access, and share information based on specific themes or projects.
|
||||||
|
|
||||||
|
## How to Use Collections
|
||||||
|
|
||||||
|
### Creating a Collection
|
||||||
|
|
||||||
|
1. **Define the Collection:** Give your Collection a meaningful name and a descriptive title.
|
||||||
|
2. **Add Details:** Provide a brief description of the content within the Collection.
|
||||||
|
3. **Add Shortcuts:** Include relevant Shortcuts by selecting them from your existing list.
|
||||||
|
4. **Set Visibility:** Choose who should have access to the Collection.
|
||||||
|
5. **Save:** Once saved, your Collection is ready to use.
|
||||||
|
|
||||||
|
### Accessing Collections
|
||||||
|
|
||||||
|
Access a Collection directly by using the assigned name. For example, if your Collection is named "work-projects", the direct access link would be `{YOUR_DOMAIN}/c/work-projects`.
|
||||||
|
|
||||||
|
### Updating and Managing Collections
|
||||||
|
|
||||||
|
Modify Collection details, such as name, title, or included Shortcuts, to keep your organization streamlined and relevant.
|
||||||
|
|
||||||
|
### Sharing Collections
|
||||||
|
|
||||||
|
Share Collections by providing the assigned name to collaborators for easy access to grouped Shortcuts.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Slash Collections offer a user-friendly and organized way to group, manage, and share related Shortcuts. By utilizing the defined Collection attributes, users can seamlessly categorize and access information, promoting collaboration and improving overall productivity.
|
47
docs/getting-started/shortcuts.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Slash Shortcuts
|
||||||
|
|
||||||
|
**Slash Shortcuts** is a handy tool designed to make handling and sharing links in your digital workspace a breeze.
|
||||||
|
|
||||||
|
## What is a Shortcut?
|
||||||
|
|
||||||
|
A Shortcut is a simplified version of a link with essential details, making it easy to remember, organize, and share. Let's break down the key elements:
|
||||||
|
|
||||||
|
- **Name:** Your chosen label for the Shortcut. This becomes a crucial part of the URL, enabling direct and quick access to the Shortcut. For example, if your Shortcut is named "meet-john", the direct access link would be `s/meet-john`. This user-defined name significantly enhances the accessibility and recognition of your Shortcuts.
|
||||||
|
- **Link:** The original web link you want to streamline.
|
||||||
|
- **Title:** A quick overview of what's behind the link.
|
||||||
|
- **Tags:** Custom labels for easy sorting.
|
||||||
|
- **Description:** A short summary of the content.
|
||||||
|
- **Visibility:** Controls who can access the Shortcut.
|
||||||
|
|
||||||
|
## How to Use Shortcuts
|
||||||
|
|
||||||
|
### Creating a Shortcut
|
||||||
|
|
||||||
|
1. **Define the Link:** Paste the original link you want to simplify.
|
||||||
|
2. **Add Details:** Give it a name, tags, and a brief description for better organization.
|
||||||
|
3. **Set Visibility:** Choose who should be able to access the Shortcut.
|
||||||
|
4. **Save:** Once saved, your Shortcut is ready to go.
|
||||||
|
|
||||||
|
### Accessing Shortcuts
|
||||||
|
|
||||||
|
#### Direct Access
|
||||||
|
|
||||||
|
Effortlessly access your Shortcut's content directly by using the assigned name as part of the Slash Shortcuts format.
|
||||||
|
|
||||||
|
For example, if your Shortcut is named "meet-john", the direct access link would be `{YOUR_DOMAIN}/s/meet-john`. Simply enter this user-friendly shortcut into your browser to reach the associated content with ease.
|
||||||
|
|
||||||
|
#### Browser Extension Access
|
||||||
|
|
||||||
|
Install the Slash Shortcuts browser extension for even quicker access. Once installed, simply type `s/meet-john` into your browser's address bar, and the extension will seamlessly redirect you to the corresponding page.
|
||||||
|
|
||||||
|
### Updating and Managing Shortcuts
|
||||||
|
|
||||||
|
Adjust attributes like name and tags to update a Shortcut. Keep your Shortcuts organized based on categories and visibility settings.
|
||||||
|
|
||||||
|
### Sharing Shortcuts
|
||||||
|
|
||||||
|
Share Shortcuts by providing the assigned name to collaborators for easy access.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Shortcuts provide a simple way to manage, organize, and share links within your digital workspace. By using the defined Shortcut attributes, users can easily create, access, and share information, promoting collaboration and boosting productivity.
|
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, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/your-slash/).
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|

|
@ -16,7 +16,7 @@ docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash
|
|||||||
|
|
||||||
This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory.
|
This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory.
|
||||||
|
|
||||||
## Upgrade
|
### Upgrade
|
||||||
|
|
||||||
To upgrade Slash to latest version, stop and remove the old container first:
|
To upgrade Slash to latest version, stop and remove the old container first:
|
||||||
|
|
||||||
@ -37,3 +37,23 @@ docker pull yourselfhosted/slash:latest
|
|||||||
```
|
```
|
||||||
|
|
||||||
Finally, restart Slash by following the steps in [Docker Run](#docker-run).
|
Finally, restart Slash by following the steps in [Docker Run](#docker-run).
|
||||||
|
|
||||||
|
## Docker Compose Run
|
||||||
|
|
||||||
|
Assume that docker compose is deployed in the `/opt/slash` directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/slash && cd /opt/slash
|
||||||
|
curl -#LO https://github.com/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
|
BIN
frontend/extension/assets/icon.png
Normal file
After Width: | Height: | Size: 257 KiB |
64
frontend/extension/package.json
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "slash-extension",
|
||||||
|
"displayName": "Slash",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"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",
|
||||||
|
"type-gen": "cd ../../proto && buf generate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/joy": "5.0.0-beta.14",
|
||||||
|
"@plasmohq/storage": "^1.8.1",
|
||||||
|
"axios": "^1.6.1",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"lucide-react": "^0.264.0",
|
||||||
|
"plasmo": "^0.83.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"zustand": "^4.4.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@bufbuild/buf": "^1.27.2",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^4.2.1",
|
||||||
|
"@types/chrome": "^0.0.241",
|
||||||
|
"@types/lodash-es": "^4.17.11",
|
||||||
|
"@types/node": "^20.9.0",
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.53.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.31",
|
||||||
|
"prettier": "^2.8.8",
|
||||||
|
"protobufjs": "^7.2.5",
|
||||||
|
"tailwindcss": "^3.3.5",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"omnibox": {
|
||||||
|
"keyword": "s/"
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"activeTab",
|
||||||
|
"storage",
|
||||||
|
"webRequest"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"*://*/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
7484
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: {},
|
||||||
|
},
|
||||||
|
};
|
73
frontend/extension/src/background.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Storage } from "@plasmohq/storage";
|
||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
|
||||||
|
const storage = new Storage();
|
||||||
|
const urlRegex = /https?:\/\/s\/(.+)/;
|
||||||
|
|
||||||
|
chrome.webRequest.onBeforeRequest.addListener(
|
||||||
|
(param) => {
|
||||||
|
(async () => {
|
||||||
|
if (!param.url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutName = getShortcutNameFromUrl(param.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({ url: shortcut.link });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
{ urls: ["*://s/*", "*://*/search*"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
chrome.omnibox.onInputEntered.addListener(async (text, disposition) => {
|
||||||
|
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
||||||
|
const shortcut = shortcuts.find((shortcut) => shortcut.name === text);
|
||||||
|
if (!shortcut) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disposition === "currentTab") {
|
||||||
|
chrome.tabs.update({ url: shortcut.link });
|
||||||
|
} else if (disposition === "newForegroundTab") {
|
||||||
|
chrome.tabs.create({ url: shortcut.link });
|
||||||
|
} else if (disposition === "newBackgroundTab") {
|
||||||
|
chrome.tabs.create({ url: shortcut.link, active: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 "";
|
||||||
|
};
|
174
frontend/extension/src/components/CreateShortcutsButton.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
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 { Visibility } from "@/types/proto/api/v2/common";
|
||||||
|
import { CreateShortcutResponse, OpenGraphMetadata } 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;
|
67
frontend/extension/src/components/ShortcutView.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
|
||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutView = (props: Props) => {
|
||||||
|
const { shortcut } = props;
|
||||||
|
const [domain] = useStorage<string>("domain", "");
|
||||||
|
const favicon = getFaviconWithGoogleS2(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 dark:border-zinc-800 dark:hover:bg-zinc-800"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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-lg" 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 className="dark:text-gray-400">{shortcut.title}</span>
|
||||||
|
{shortcut.title ? (
|
||||||
|
<span className="text-gray-500">(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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutView;
|
18
frontend/extension/src/components/ShortcutsContainer.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
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/utils.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { isNull, isUndefined } from "lodash-es";
|
||||||
|
|
||||||
|
export const isNullorUndefined = (value: any) => {
|
||||||
|
return isNull(value) || isUndefined(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFaviconWithGoogleS2 = (url: string) => {
|
||||||
|
try {
|
||||||
|
const urlObject = new URL(url);
|
||||||
|
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`;
|
||||||
|
} catch (error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
43
frontend/extension/src/hooks/useColorTheme.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useColorScheme } from "@mui/joy";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const useColorTheme = () => {
|
||||||
|
const { mode: colorTheme, setMode: setColorTheme } = useColorScheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (colorTheme === "light") {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
} else if (colorTheme === "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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [colorTheme]);
|
||||||
|
|
||||||
|
return { colorTheme, setColorTheme };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useColorTheme;
|
179
frontend/extension/src/options.tsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/joy";
|
||||||
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Toaster, toast } from "react-hot-toast";
|
||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import Icon from "./components/Icon";
|
||||||
|
import Logo from "./components/Logo";
|
||||||
|
import PullShortcutsButton from "./components/PullShortcutsButton";
|
||||||
|
import ShortcutsContainer from "./components/ShortcutsContainer";
|
||||||
|
import useColorTheme from "./hooks/useColorTheme";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
interface SettingState {
|
||||||
|
domain: string;
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorThemeOptions = [
|
||||||
|
{
|
||||||
|
value: "system",
|
||||||
|
label: "System",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "light",
|
||||||
|
label: "Light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark",
|
||||||
|
label: "Dark",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const IndexOptions = () => {
|
||||||
|
const { colorTheme, setColorTheme } = useColorTheme();
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectColorTheme = async (colorTheme: string) => {
|
||||||
|
setColorTheme(colorTheme as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="w-full flex flex-row justify-center items-center">
|
||||||
|
<a
|
||||||
|
className="bg-yellow-100 dark:bg-yellow-500 dark:opacity-70 mt-12 py-2 px-3 rounded-full border dark:border-yellow-600 flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600"
|
||||||
|
href="https://github.com/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 dark:text-gray-400">
|
||||||
|
<Logo className="w-10 h-auto mr-2" />
|
||||||
|
<span>Slash</span>
|
||||||
|
<span className="mx-2 text-gray-400">/</span>
|
||||||
|
<span>Setting</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-4">
|
||||||
|
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="dark:text-gray-400">Domain</span>
|
||||||
|
{domain !== "" && (
|
||||||
|
<a
|
||||||
|
className="text-sm flex flex-row justify-start items-center dark:text-gray-400 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 dark:text-gray-400">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 flex flex-row justify-end">
|
||||||
|
<Button onClick={handleSaveSetting}>Save</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider className="!my-6" />
|
||||||
|
|
||||||
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Preference</p>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<div className="flex flex-row justify-start items-center gap-x-1">
|
||||||
|
<span className="dark:text-gray-400">Color Theme</span>
|
||||||
|
</div>
|
||||||
|
<Select defaultValue={colorTheme} onChange={(_, value) => handleSelectColorTheme(value)}>
|
||||||
|
{colorThemeOptions.map((option) => {
|
||||||
|
return (
|
||||||
|
<Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInitialized && (
|
||||||
|
<>
|
||||||
|
<Divider className="!my-6" />
|
||||||
|
|
||||||
|
<h2 className="flex flex-row justify-start items-center mb-4">
|
||||||
|
<span className="text-lg dark:text-gray-400">Shortcuts</span>
|
||||||
|
<span className="text-gray-500 mr-1">({shortcuts.length})</span>
|
||||||
|
<PullShortcutsButton />
|
||||||
|
</h2>
|
||||||
|
<ShortcutsContainer />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Options = () => {
|
||||||
|
return (
|
||||||
|
<CssVarsProvider>
|
||||||
|
<IndexOptions />
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</CssVarsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Options;
|
110
frontend/extension/src/popup.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { Button, CssVarsProvider, 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 type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import useColorTheme from "./hooks/useColorTheme";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
const IndexPopup = () => {
|
||||||
|
useColorTheme();
|
||||||
|
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 dark:text-gray-400">
|
||||||
|
<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 dark:text-gray-400" />
|
||||||
|
</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 dark:text-gray-400" />
|
||||||
|
</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 dark:text-gray-400 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">
|
||||||
|
<Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" />
|
||||||
|
<p className="dark:text-gray-400">Please set your domain and access token first.</p>
|
||||||
|
<div className="w-full flex flex-row justify-center items-center py-4">
|
||||||
|
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
|
||||||
|
<Icon.Settings className="w-5 h-auto mr-1" /> Setting
|
||||||
|
</Button>
|
||||||
|
<span className="mx-2 dark:text-gray-400">Or</span>
|
||||||
|
<Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}>
|
||||||
|
<Icon.RefreshCcw className="w-5 h-auto mr-1" /> Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Popup = () => {
|
||||||
|
return (
|
||||||
|
<CssVarsProvider>
|
||||||
|
<IndexPopup />
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</CssVarsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Popup;
|
@ -5,7 +5,7 @@
|
|||||||
body,
|
body,
|
||||||
html,
|
html,
|
||||||
#root {
|
#root {
|
||||||
@apply text-base w-full h-full;
|
@apply text-base dark:bg-zinc-900;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
||||||
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
"Noto Color Emoji";
|
"Noto Color Emoji";
|
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.
|
82
frontend/locales/en.json
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"account": "Account"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"sign-in": "Sign in",
|
||||||
|
"sign-up": "Sign up",
|
||||||
|
"sign-out": "Sign out",
|
||||||
|
"create-your-account": "Create your account"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"self": "Analytics",
|
||||||
|
"top-sources": "Top sources",
|
||||||
|
"source": "Source",
|
||||||
|
"visitors": "Visitors",
|
||||||
|
"devices": "Devices",
|
||||||
|
"browser": "Browser",
|
||||||
|
"browsers": "Browsers",
|
||||||
|
"operating-system": "Operating System"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"visits": "{{count}} visits",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "All",
|
||||||
|
"mine": "Mine",
|
||||||
|
"compact-mode": "Compact mode",
|
||||||
|
"order-by": "Order by",
|
||||||
|
"direction": "Direction"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"self": "User",
|
||||||
|
"nickname": "Nickname",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Role",
|
||||||
|
"profile": "Profile",
|
||||||
|
"action": {
|
||||||
|
"add-user": "Add user"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"self": "Setting",
|
||||||
|
"preference": {
|
||||||
|
"self": "Preference",
|
||||||
|
"color-theme": "Color theme"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "Workspace settings",
|
||||||
|
"custom-style": "Custom style",
|
||||||
|
"enable-user-signup": {
|
||||||
|
"self": "Enable user signup",
|
||||||
|
"description": "Once enabled, other users can signup."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
82
frontend/locales/zh.json
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"about": "关于",
|
||||||
|
"loading": "加载中",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"create": "创建",
|
||||||
|
"download": "下载",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"language": "语言",
|
||||||
|
"search": "搜索",
|
||||||
|
"email": "邮箱",
|
||||||
|
"password": "密码",
|
||||||
|
"account": "账号"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"sign-in": "登录",
|
||||||
|
"sign-up": "注册",
|
||||||
|
"sign-out": "退出登录",
|
||||||
|
"create-your-account": "创建账号"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"self": "分析",
|
||||||
|
"top-sources": "热门来源",
|
||||||
|
"source": "来源",
|
||||||
|
"visitors": "访客数",
|
||||||
|
"devices": "设备",
|
||||||
|
"browser": "浏览器",
|
||||||
|
"browsers": "浏览器",
|
||||||
|
"operating-system": "操作系统"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"visits": "{{count}} 次访问",
|
||||||
|
"visibility": {
|
||||||
|
"private": {
|
||||||
|
"self": "私有的",
|
||||||
|
"description": "仅您可以访问"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "工作区",
|
||||||
|
"description": "工作区成员可以访问"
|
||||||
|
},
|
||||||
|
"public": {
|
||||||
|
"self": "公开的",
|
||||||
|
"description": "对任何人可见"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "所有",
|
||||||
|
"mine": "我的",
|
||||||
|
"compact-mode": "紧凑模式",
|
||||||
|
"order-by": "排序方式",
|
||||||
|
"direction": "方向"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"self": "用户",
|
||||||
|
"nickname": "昵称",
|
||||||
|
"email": "邮箱",
|
||||||
|
"role": "角色",
|
||||||
|
"profile": "账号",
|
||||||
|
"action": {
|
||||||
|
"add-user": "添加用户"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"self": "设置",
|
||||||
|
"preference": {
|
||||||
|
"self": "偏好设置",
|
||||||
|
"color-theme": "主题"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"self": "系统设置",
|
||||||
|
"custom-style": "自定义样式",
|
||||||
|
"enable-user-signup": {
|
||||||
|
"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"],
|
||||||
|
};
|
56
frontend/web/package.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"type-gen": "cd ../../proto && buf generate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/joy": "5.0.0-beta.14",
|
||||||
|
"@reduxjs/toolkit": "^1.9.7",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"copy-to-clipboard": "^3.3.3",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
|
"i18next": "^23.6.0",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"lucide-react": "^0.292.0",
|
||||||
|
"nice-grpc-web": "^3.3.2",
|
||||||
|
"qrcode.react": "^3.1.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-i18next": "^13.3.1",
|
||||||
|
"react-redux": "^8.1.3",
|
||||||
|
"react-router-dom": "^6.18.0",
|
||||||
|
"react-use": "^17.4.0",
|
||||||
|
"tailwindcss": "^3.3.5",
|
||||||
|
"zustand": "^4.4.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@bufbuild/buf": "^1.27.2",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^4.2.1",
|
||||||
|
"@types/lodash-es": "^4.17.11",
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.4.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.53.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.31",
|
||||||
|
"prettier": "2.6.2",
|
||||||
|
"protobufjs": "^7.2.5",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^4.5.0"
|
||||||
|
}
|
||||||
|
}
|
2309
web/pnpm-lock.yaml → frontend/web/pnpm-lock.yaml
generated
BIN
frontend/web/public/logo.png
Normal file
After Width: | Height: | Size: 257 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,12 +8,13 @@ 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>
|
@ -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}>
|
@ -1,5 +1,6 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import * as api from "../helpers/api";
|
import * as api from "../helpers/api";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ interface Props {
|
|||||||
|
|
||||||
const AnalyticsView: React.FC<Props> = (props: Props) => {
|
const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||||
const { shortcutId, className } = props;
|
const { shortcutId, className } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||||
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
||||||
|
|
||||||
@ -24,17 +26,23 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
{analytics ? (
|
{analytics ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="w-full h-8 px-2">Top Sources</p>
|
<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">
|
<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">
|
<div className="w-full divide-y divide-gray-300 dark:divide-zinc-700">
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<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">Source</span>
|
<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">Visitors</span>
|
<span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">{t("analytics.visitors")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full divide-y divide-gray-200">
|
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
||||||
|
{analytics.referenceData.length === 0 && (
|
||||||
|
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
|
||||||
|
<Icon.PackageOpen className="w-6 h-auto" />
|
||||||
|
<p className="ml-2">No data found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{analytics.referenceData.map((reference) => (
|
{analytics.referenceData.map((reference) => (
|
||||||
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
<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">
|
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900 dark:text-gray-500">
|
||||||
{reference.name ? (
|
{reference.name ? (
|
||||||
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
||||||
{reference.name}
|
{reference.name}
|
||||||
@ -53,24 +61,24 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="w-full h-8 px-2 flex flex-row justify-between items-center">
|
<div className="w-full h-8 px-2 flex flex-row justify-between items-center">
|
||||||
<span>Devices</span>
|
<span className="dark:text-gray-500">{t("analytics.devices")}</span>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
selectedDeviceTab === "browser"
|
selectedDeviceTab === "browser"
|
||||||
? "border-blue-600 text-blue-600"
|
? "border-blue-600 text-blue-600"
|
||||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:border-zinc-700"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setSelectedDeviceTab("browser")}
|
onClick={() => setSelectedDeviceTab("browser")}
|
||||||
>
|
>
|
||||||
Browser
|
{t("analytics.browser")}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-200 font-mono mx-1">/</span>
|
<span className="text-gray-200 font-mono mx-1 dark:text-gray-500">/</span>
|
||||||
<button
|
<button
|
||||||
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
selectedDeviceTab === "os"
|
selectedDeviceTab === "os"
|
||||||
? "border-blue-600 text-blue-600"
|
? "border-blue-600 text-blue-600"
|
||||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:border-zinc-700"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setSelectedDeviceTab("os")}
|
onClick={() => setSelectedDeviceTab("os")}
|
||||||
>
|
>
|
||||||
@ -79,17 +87,25 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg dark:ring-zinc-800">
|
||||||
{selectedDeviceTab === "browser" ? (
|
{selectedDeviceTab === "browser" ? (
|
||||||
<div className="w-full divide-y divide-gray-300">
|
<div className="w-full divide-y divide-gray-300 dark:divide-zinc-700">
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<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">Browsers</span>
|
<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">Visitors</span>
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full divide-y divide-gray-200">
|
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
||||||
|
{analytics.browserData.length === 0 && (
|
||||||
|
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
|
||||||
|
<Icon.PackageOpen className="w-6 h-auto" />
|
||||||
|
<p className="ml-2">No data found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{analytics.browserData.map((reference) => (
|
{analytics.browserData.map((reference) => (
|
||||||
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
<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">{reference.name || "Unknown"}</span>
|
<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>
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -98,10 +114,16 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-full divide-y divide-gray-300">
|
<div className="w-full divide-y divide-gray-300">
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<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">Operating system</span>
|
<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">Visitors</span>
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full divide-y divide-gray-200">
|
<div className="w-full divide-y divide-gray-200">
|
||||||
|
{analytics.deviceData.length === 0 && (
|
||||||
|
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
|
||||||
|
<Icon.PackageOpen className="w-6 h-auto" />
|
||||||
|
<p className="ml-2">No data found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{analytics.deviceData.map((device) => (
|
{analytics.deviceData.map((device) => (
|
||||||
<div key={device.name} className="w-full flex flex-row justify-between items-center">
|
<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 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span>
|
||||||
@ -117,7 +139,7 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
<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" />
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
loading
|
{t("common.loading")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
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,6 +1,7 @@
|
|||||||
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 useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -11,6 +12,7 @@ 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 userStore = useUserStore();
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||||
@ -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>
|
119
frontend/web/src/components/CollectionView.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { 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 useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
|
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||||
|
import { useAppSelector } from "@/stores";
|
||||||
|
import useCollectionStore from "@/stores/v1/collection";
|
||||||
|
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||||
|
import { showCommonDialog } from "./Alert";
|
||||||
|
import CreateCollectionDialog from "./CreateCollectionDialog";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import ShortcutView from "./ShortcutView";
|
||||||
|
import Dropdown from "./common/Dropdown";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionView = (props: Props) => {
|
||||||
|
const { collection } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { sm } = useResponsiveWidth();
|
||||||
|
const navigateTo = useNavigateTo();
|
||||||
|
const collectionStore = useCollectionStore();
|
||||||
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
|
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
|
||||||
|
const shortcuts = collection.shortcutIds
|
||||||
|
.map((shortcutId) => shortcutList.find((shortcut) => shortcut?.id === shortcutId))
|
||||||
|
.filter(Boolean) as any as Shortcut[];
|
||||||
|
|
||||||
|
const handleCopyCollectionLink = () => {
|
||||||
|
copy(absolutifyLink(`/c/${collection.name}`));
|
||||||
|
toast.success("Collection link copied to clipboard.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCollectionButtonClick = () => {
|
||||||
|
showCommonDialog({
|
||||||
|
title: "Delete Collection",
|
||||||
|
content: `Are you sure to delete collection \`${collection.name}\`? You cannot undo this action.`,
|
||||||
|
style: "danger",
|
||||||
|
onConfirm: async () => {
|
||||||
|
await collectionStore.deleteCollection(collection.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShortcutClick = (shortcut: Shortcut) => {
|
||||||
|
navigateTo(`/shortcut/${shortcut.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classNames("w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-800")}>
|
||||||
|
<div className="bg-gray-100 dark:bg-zinc-800 px-3 py-2 w-full flex flex-row justify-between items-center rounded-t-lg">
|
||||||
|
<div className="w-auto flex flex-col justify-start items-start mr-2">
|
||||||
|
<div className="w-full truncate" onClick={handleCopyCollectionLink}>
|
||||||
|
<span className="leading-6 font-medium dark:text-gray-400">{collection.title}</span>
|
||||||
|
<span className="ml-1 leading-6 text-gray-500 dark:text-gray-400">(c/{collection.name})</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{collection.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-end items-center shrink-0">
|
||||||
|
<Link className="w-full text-gray-400 cursor-pointer hover:text-gray-500" to={`/c/${collection.name}`}>
|
||||||
|
<Icon.Share className="w-4 h-auto mr-2" />
|
||||||
|
</Link>
|
||||||
|
<Dropdown
|
||||||
|
actionsClassName="!w-28 dark:text-gray-500"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<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 text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteCollectionButtonClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
></Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full p-3 flex flex-row justify-start items-start flex-wrap gap-3">
|
||||||
|
{shortcuts.map((shortcut) => {
|
||||||
|
return (
|
||||||
|
<ShortcutView
|
||||||
|
key={shortcut.id}
|
||||||
|
className="!w-auto"
|
||||||
|
shortcut={shortcut}
|
||||||
|
alwaysShowLink={!sm}
|
||||||
|
onClick={() => handleShortcutClick(shortcut)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEditDialog && (
|
||||||
|
<CreateCollectionDialog
|
||||||
|
collectionId={collection.id}
|
||||||
|
onClose={() => setShowEditDialog(false)}
|
||||||
|
onConfirm={() => setShowEditDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionView;
|
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;
|
259
frontend/web/src/components/CreateCollectionDialog.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
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 { useAppSelector } from "@/stores";
|
||||||
|
import useCollectionStore from "@/stores/v1/collection";
|
||||||
|
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||||
|
import { Visibility } from "@/types/proto/api/v2/common";
|
||||||
|
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||||
|
import useLoading from "../hooks/useLoading";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import ShortcutView from "./ShortcutView";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collectionId?: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
collectionCreate: Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateCollectionDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { onClose, onConfirm, collectionId } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const collectionStore = useCollectionStore();
|
||||||
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
collectionCreate: Collection.fromPartial({
|
||||||
|
visibility: Visibility.PRIVATE,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]);
|
||||||
|
const requestState = useLoading(false);
|
||||||
|
const isCreating = isUndefined(collectionId);
|
||||||
|
const unselectedShortcuts = shortcutList
|
||||||
|
.filter((shortcut) => (state.collectionCreate.visibility === Visibility.PUBLIC ? shortcut.visibility === "PUBLIC" : true))
|
||||||
|
.filter((shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (collectionId) {
|
||||||
|
const collection = await collectionStore.getOrFetchCollectionById(collectionId);
|
||||||
|
if (collection) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
|
...collection,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setSelectedShortcuts(
|
||||||
|
collection.shortcutIds
|
||||||
|
.map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId))
|
||||||
|
.filter(Boolean) as Shortcut[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [collectionId]);
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
|
name: e.target.value.replace(/\s+/g, "-"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
|
title: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
|
visibility: Number(e.target.value),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
|
description: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBtnClick = async () => {
|
||||||
|
if (!state.collectionCreate.name || !state.collectionCreate.title) {
|
||||||
|
toast.error("Please fill in required fields.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedShortcuts.length === 0) {
|
||||||
|
toast.error("Please select at least one shortcut.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isCreating) {
|
||||||
|
await collectionStore.updateCollection(
|
||||||
|
{
|
||||||
|
id: collectionId,
|
||||||
|
name: state.collectionCreate.name,
|
||||||
|
title: state.collectionCreate.title,
|
||||||
|
description: state.collectionCreate.description,
|
||||||
|
visibility: state.collectionCreate.visibility,
|
||||||
|
shortcutIds: selectedShortcuts.map((shortcut) => shortcut.id),
|
||||||
|
},
|
||||||
|
["name", "title", "description", "visibility", "shortcut_ids"]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await collectionStore.createCollection({
|
||||||
|
...state.collectionCreate,
|
||||||
|
shortcutIds: selectedShortcuts.map((shortcut) => shortcut.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.details);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="text-lg font-medium">{isCreating ? "Create Collection" : "Edit Collection"}</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 w-80 sm:w-96 max-w-full">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Name <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Should be an unique name and will be put in url"
|
||||||
|
value={state.collectionCreate.name}
|
||||||
|
onChange={handleNameInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Title <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="A short title to describe your collection"
|
||||||
|
value={state.collectionCreate.title}
|
||||||
|
onChange={handleTitleInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">Description</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="A slightly longer description"
|
||||||
|
value={state.collectionCreate.description}
|
||||||
|
onChange={handleDescriptionInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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.collectionCreate.visibility} onChange={handleVisibilityInputChange}>
|
||||||
|
<Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} />
|
||||||
|
<Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.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.${convertVisibilityFromPb(state.collectionCreate.visibility).toLowerCase()}.description`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<p className="mb-2">
|
||||||
|
<span>Shortcuts</span>
|
||||||
|
<span className="opacity-60">({selectedShortcuts.length})</span>
|
||||||
|
{selectedShortcuts.length === 0 && <span className="ml-2 italic opacity-80 text-sm">Select a shortcut first</span>}
|
||||||
|
</p>
|
||||||
|
<div className="w-full py-1 px-px flex flex-row justify-start items-start flex-wrap overflow-hidden gap-2">
|
||||||
|
{selectedShortcuts.map((shortcut) => {
|
||||||
|
return (
|
||||||
|
<ShortcutView
|
||||||
|
key={shortcut.id}
|
||||||
|
className="!w-auto select-none max-w-[40%] cursor-pointer bg-gray-100 shadow dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400"
|
||||||
|
shortcut={shortcut}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedShortcuts([...selectedShortcuts.filter((selectedShortcut) => selectedShortcut.id !== shortcut.id)]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{unselectedShortcuts.map((shortcut) => {
|
||||||
|
return (
|
||||||
|
<ShortcutView
|
||||||
|
key={shortcut.id}
|
||||||
|
className="!w-auto select-none max-w-[40%] border-dashed cursor-pointer"
|
||||||
|
shortcut={shortcut}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedShortcuts([...selectedShortcuts, shortcut]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{selectedShortcuts.length + unselectedShortcuts.length === 0 && (
|
||||||
|
<div className="w-full flex flex-row justify-center items-center text-gray-400">
|
||||||
|
<Icon.PackageOpen className="w-6 h-auto" />
|
||||||
|
<p className="ml-2">No shortcuts found.</p>
|
||||||
|
</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 CreateCollectionDialog;
|
@ -1,15 +1,17 @@
|
|||||||
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import { isUndefined } from "lodash-es";
|
import { isUndefined, uniq } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAppSelector } from "@/stores";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { shortcutService } from "../services";
|
import { shortcutService } from "../services";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
shortcutId?: ShortcutId;
|
shortcutId?: ShortcutId;
|
||||||
|
initialShortcut?: Partial<Shortcut>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm?: () => void;
|
onConfirm?: () => void;
|
||||||
}
|
}
|
||||||
@ -21,8 +23,9 @@ interface State {
|
|||||||
const visibilities: Visibility[] = ["PRIVATE", "WORKSPACE", "PUBLIC"];
|
const visibilities: Visibility[] = ["PRIVATE", "WORKSPACE", "PUBLIC"];
|
||||||
|
|
||||||
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose, onConfirm, shortcutId } = props;
|
const { onClose, onConfirm, shortcutId, initialShortcut } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
shortcutCreate: {
|
shortcutCreate: {
|
||||||
name: "",
|
name: "",
|
||||||
@ -36,11 +39,13 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
description: "",
|
description: "",
|
||||||
image: "",
|
image: "",
|
||||||
},
|
},
|
||||||
|
...initialShortcut,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
|
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
|
||||||
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||||
const [tag, setTag] = useState<string>("");
|
const [tag, setTag] = useState<string>("");
|
||||||
|
const tagSuggestions = uniq(shortcutList.map((shortcut) => shortcut.tags).flat());
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
const isCreating = isUndefined(shortcutId);
|
const isCreating = isUndefined(shortcutId);
|
||||||
|
|
||||||
@ -74,7 +79,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
name: e.target.value.replace(/\s+/g, "-").toLowerCase(),
|
name: e.target.value.replace(/\s+/g, "-"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -149,9 +154,17 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTagSuggestionsClick = (suggestion: string) => {
|
||||||
|
if (tag === "") {
|
||||||
|
setTag(suggestion);
|
||||||
|
} else {
|
||||||
|
setTag(`${tag} ${suggestion}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveBtnClick = async () => {
|
const handleSaveBtnClick = async () => {
|
||||||
if (!state.shortcutCreate.name) {
|
if (!state.shortcutCreate.name || !state.shortcutCreate.link) {
|
||||||
toast.error("Name is required");
|
toast.error("Please fill in required fields.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,13 +177,13 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
title: state.shortcutCreate.title,
|
title: state.shortcutCreate.title,
|
||||||
description: state.shortcutCreate.description,
|
description: state.shortcutCreate.description,
|
||||||
visibility: state.shortcutCreate.visibility,
|
visibility: state.shortcutCreate.visibility,
|
||||||
tags: tag.split(" "),
|
tags: tag.split(" ").filter(Boolean),
|
||||||
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await shortcutService.createShortcut({
|
await shortcutService.createShortcut({
|
||||||
...state.shortcutCreate,
|
...state.shortcutCreate,
|
||||||
tags: tag.split(" "),
|
tags: tag.split(" ").filter(Boolean),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,7 +201,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96">
|
||||||
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
|
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</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" />
|
||||||
@ -196,19 +209,23 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto overflow-x-hidden">
|
<div className="overflow-y-auto overflow-x-hidden">
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Name</span>
|
<span className="mb-2">
|
||||||
|
Name <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Unique shortcut name"
|
placeholder="Should be an unique name and will be put in url"
|
||||||
value={state.shortcutCreate.name}
|
value={state.shortcutCreate.name}
|
||||||
onChange={handleNameInputChange}
|
onChange={handleNameInputChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Destination URL</span>
|
<span className="mb-2">
|
||||||
|
Destination URL <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
@ -220,6 +237,22 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Tags</span>
|
<span className="mb-2">Tags</span>
|
||||||
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} />
|
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} />
|
||||||
|
{tagSuggestions.length > 0 && (
|
||||||
|
<div className="w-full flex flex-row justify-start items-start mt-2">
|
||||||
|
<Icon.Asterisk className="w-4 h-auto shrink-0 mx-1 text-gray-400 dark:text-gray-500" />
|
||||||
|
<div className="w-auto flex flex-row justify-start items-start flex-wrap gap-x-2 gap-y-1">
|
||||||
|
{tagSuggestions.map((tag) => (
|
||||||
|
<span
|
||||||
|
className="text-gray-600 dark:text-gray-500 cursor-pointer max-w-[6rem] truncate block text-sm flex-nowrap leading-4 hover:text-black dark:hover:text-gray-400"
|
||||||
|
key={tag}
|
||||||
|
onClick={() => handleTagSuggestionsClick(tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Visibility</span>
|
<span className="mb-2">Visibility</span>
|
||||||
@ -230,16 +263,16 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 px-2 py-1 rounded-md">
|
<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`)}
|
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Divider className="text-gray-500">Optional</Divider>
|
<Divider className="text-gray-500">More</Divider>
|
||||||
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3">
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3 dark:border-zinc-800">
|
||||||
<div
|
<div
|
||||||
className={classnames(
|
className={classnames(
|
||||||
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100",
|
"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" : ""
|
showAdditionalFields ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : ""
|
||||||
)}
|
)}
|
||||||
onClick={() => setShowAdditionalFields(!showAdditionalFields)}
|
onClick={() => setShowAdditionalFields(!showAdditionalFields)}
|
||||||
>
|
>
|
||||||
@ -275,16 +308,17 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden">
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden dark:border-zinc-800">
|
||||||
<div
|
<div
|
||||||
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${
|
className={classnames(
|
||||||
showOpenGraphMetadata ? "bg-gray-100 border-b" : ""
|
"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)}
|
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
|
||||||
>
|
>
|
||||||
<span className="text-sm flex flex-row justify-start items-center">
|
<span className="text-sm flex flex-row justify-start items-center">
|
||||||
Social media metadata
|
Social media metadata
|
||||||
<Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" />
|
<Icon.Sparkles className="w-4 h-auto shrink-0 ml-1 text-blue-600 dark:text-blue-500" />
|
||||||
</span>
|
</span>
|
||||||
<button className="w-7 h-7 p-1 rounded-md">
|
<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" : "")} />
|
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} />
|
||||||
@ -331,10 +365,10 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
<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}>
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
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>
|
@ -2,6 +2,7 @@ import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
|||||||
import { isUndefined } from "lodash-es";
|
import { isUndefined } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, 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 useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -20,6 +21,7 @@ const roles: Role[] = ["USER", "ADMIN"];
|
|||||||
|
|
||||||
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose, onConfirm, user } = props;
|
const { onClose, onConfirm, user } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
userCreate: {
|
userCreate: {
|
||||||
@ -185,10 +187,10 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
<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}>
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
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>
|
@ -1,20 +1,16 @@
|
|||||||
import { globalService } from "../services";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const DemoBanner: React.FC = () => {
|
const DemoBanner: React.FC = () => {
|
||||||
const {
|
const workspaceStore = useWorkspaceStore();
|
||||||
workspaceProfile: {
|
const shouldShow = workspaceStore.profile.mode === "demo";
|
||||||
profile: { mode },
|
|
||||||
},
|
|
||||||
} = globalService.getState();
|
|
||||||
const shouldShow = mode === "demo";
|
|
||||||
|
|
||||||
if (!shouldShow) return null;
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
return (
|
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="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">
|
<div className="w-full max-w-8xl 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>
|
<span>✨🔗 Slash - An open source, self-hosted bookmarks and link sharing platform</span>
|
||||||
<a
|
<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"
|
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"
|
href="https://github.com/boojack/slash#deploy-with-docker-in-seconds"
|
@ -1,6 +1,7 @@
|
|||||||
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 useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -11,6 +12,7 @@ interface Props {
|
|||||||
|
|
||||||
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const currentUser = userStore.getCurrentUser();
|
const currentUser = userStore.getCurrentUser();
|
||||||
const [email, setEmail] = useState(currentUser.email);
|
const [email, setEmail] = useState(currentUser.email);
|
||||||
@ -64,19 +66,19 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Email</span>
|
<span className="mb-2">{t("common.email")}</span>
|
||||||
<Input className="w-full" type="text" value={email} onChange={handleEmailChanged} />
|
<Input className="w-full" type="text" value={email} onChange={handleEmailChanged} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Nickname</span>
|
<span className="mb-2">{t("user.nickname")}</span>
|
||||||
<Input className="w-full" type="text" value={nickname} onChange={handleNicknameChanged} />
|
<Input className="w-full" type="text" value={nickname} onChange={handleNicknameChanged} />
|
||||||
</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>
|
@ -2,6 +2,7 @@ import { Button, Modal, ModalDialog } from "@mui/joy";
|
|||||||
import { QRCodeCanvas } from "qrcode.react";
|
import { QRCodeCanvas } from "qrcode.react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { absolutifyLink } from "../helpers/utils";
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ interface Props {
|
|||||||
|
|
||||||
const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { shortcut, onClose } = props;
|
const { shortcut, onClose } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
|
||||||
@ -49,7 +51,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
|||||||
<div className="w-full flex flex-row justify-center items-center px-4">
|
<div className="w-full flex flex-row justify-center items-center px-4">
|
||||||
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
||||||
<Icon.Download className="w-4 h-auto mr-1" />
|
<Icon.Download className="w-4 h-auto mr-1" />
|
||||||
Download
|
{t("common.download")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
123
frontend/web/src/components/Header.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { Avatar } from "@mui/joy";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link, useLocation } 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 { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||||
|
const profile = workspaceStore.profile;
|
||||||
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
|
const shouldShowRouterSwitch = location.pathname === "/" || location.pathname === "/collections";
|
||||||
|
|
||||||
|
const handleSignOutButtonClick = async () => {
|
||||||
|
await api.signout();
|
||||||
|
window.location.href = "/auth";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full bg-gray-50 dark:bg-zinc-800 border-b border-b-gray-200 dark:border-b-zinc-800">
|
||||||
|
<div className="w-full max-w-8xl mx-auto px-3 md:px-12 py-3 flex flex-row justify-between items-center">
|
||||||
|
<div className="flex flex-row justify-start items-center shrink mr-2">
|
||||||
|
<Link to="/" className="cursor-pointer flex flex-row justify-start items-center dark:text-gray-400">
|
||||||
|
<img id="logo-img" src="/logo.png" className="w-7 h-auto mr-2 -mt-0.5 dark:opacity-80 rounded-full shadow" 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>
|
||||||
|
)}
|
||||||
|
{shouldShowRouterSwitch && (
|
||||||
|
<>
|
||||||
|
<span className="font-mono opacity-60 mx-1">/</span>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="flex flex-row justify-end items-center cursor-pointer">
|
||||||
|
<span className="dark:text-gray-400">{location.pathname === "/" ? "Shortcuts" : "Collections"}</span>
|
||||||
|
<Icon.ChevronsUpDown className="ml-1 w-4 h-auto text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
actionsClassName="!w-36 -left-4"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
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.SquareSlash className="w-5 h-auto mr-2 opacity-70" /> Shortcuts
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/collections"
|
||||||
|
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.LibrarySquare className="w-5 h-auto mr-2 opacity-70" /> Collections
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
></Dropdown>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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" /> {t("user.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" /> {t("settings.self")}
|
||||||
|
</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" /> {t("common.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" /> {t("auth.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;
|
5
frontend/web/src/components/Navigator.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const Navigator = () => {
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navigator;
|
@ -1,12 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
import { shortcutService } from "../services";
|
import { shortcutService } from "../services";
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import { showCommonDialog } from "./Alert";
|
import { showCommonDialog } from "./Alert";
|
||||||
import AnalyticsDialog from "./AnalyticsDialog";
|
|
||||||
import Dropdown from "./common/Dropdown";
|
|
||||||
import CreateShortcutDialog from "./CreateShortcutDialog";
|
import CreateShortcutDialog from "./CreateShortcutDialog";
|
||||||
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
import Dropdown from "./common/Dropdown";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
shortcut: Shortcut;
|
shortcut: Shortcut;
|
||||||
@ -14,10 +15,11 @@ interface Props {
|
|||||||
|
|
||||||
const ShortcutActionsDropdown = (props: Props) => {
|
const ShortcutActionsDropdown = (props: Props) => {
|
||||||
const { shortcut } = props;
|
const { shortcut } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigateTo = useNavigateTo();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
|
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
|
||||||
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||||
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
|
|
||||||
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||||
|
|
||||||
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
||||||
@ -31,40 +33,44 @@ const ShortcutActionsDropdown = (props: Props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const gotoAnalytics = () => {
|
||||||
|
navigateTo(`/shortcut/${shortcut.id}#analytics`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
actionsClassName="!w-32"
|
actionsClassName="!w-32 dark:text-gray-500"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{havePermission && (
|
{havePermission && (
|
||||||
<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"
|
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)}
|
onClick={() => setShowEditDialog(true)}
|
||||||
>
|
>
|
||||||
<Icon.Edit className="w-4 h-auto mr-2" /> Edit
|
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<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"
|
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)}
|
onClick={() => setShowQRCodeDialog(true)}
|
||||||
>
|
>
|
||||||
<Icon.QrCode className="w-4 h-auto mr-2" /> QR Code
|
<Icon.QrCode className="w-4 h-auto mr-2" /> QR Code
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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={() => setShowAnalyticsDialog(true)}
|
onClick={gotoAnalytics}
|
||||||
>
|
>
|
||||||
<Icon.BarChart2 className="w-4 h-auto mr-2" /> Analytics
|
<Icon.BarChart2 className="w-4 h-auto mr-2" /> {t("analytics.self")}
|
||||||
</button>
|
</button>
|
||||||
{havePermission && (
|
{havePermission && (
|
||||||
<button
|
<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"
|
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={() => {
|
onClick={() => {
|
||||||
handleDeleteShortcutButtonClick(shortcut);
|
handleDeleteShortcutButtonClick(shortcut);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon.Trash className="w-4 h-auto mr-2" /> Delete
|
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -80,8 +86,6 @@ const ShortcutActionsDropdown = (props: Props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||||
|
|
||||||
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
@ -1,14 +1,11 @@
|
|||||||
import { Tooltip } from "@mui/joy";
|
import { Tooltip } from "@mui/joy";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { absolutifyLink } from "../helpers/utils";
|
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||||
import useFaviconStore from "../stores/v1/favicon";
|
|
||||||
import useViewStore from "../stores/v1/view";
|
import useViewStore from "../stores/v1/view";
|
||||||
import AnalyticsDialog from "./AnalyticsDialog";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||||
import VisibilityIcon from "./VisibilityIcon";
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
@ -17,22 +14,12 @@ interface Props {
|
|||||||
shortcut: Shortcut;
|
shortcut: Shortcut;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortcutView = (props: Props) => {
|
const ShortcutCard = (props: Props) => {
|
||||||
const { shortcut } = props;
|
const { shortcut } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const viewStore = useViewStore();
|
const viewStore = useViewStore();
|
||||||
const faviconStore = useFaviconStore();
|
|
||||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
|
||||||
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
|
|
||||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||||
useEffect(() => {
|
|
||||||
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
|
||||||
if (url) {
|
|
||||||
setFavicon(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [shortcut.link]);
|
|
||||||
|
|
||||||
const handleCopyButtonClick = () => {
|
const handleCopyButtonClick = () => {
|
||||||
copy(shortcutLink);
|
copy(shortcutLink);
|
||||||
@ -41,12 +28,16 @@ const ShortcutView = (props: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classNames("group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow")}>
|
<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-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">
|
<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")}>
|
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-8 h-8 flex justify-center items-center overflow-clip shrink-0")}>
|
||||||
{favicon ? (
|
{favicon ? (
|
||||||
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
<img className="w-full h-auto rounded-lg" src={favicon} decoding="async" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||||
)}
|
)}
|
||||||
@ -55,19 +46,19 @@ const ShortcutView = (props: Props) => {
|
|||||||
<div className="w-full flex flex-row justify-start items-center">
|
<div className="w-full flex flex-row justify-start items-center">
|
||||||
<a
|
<a
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"max-w-[calc(100%-24px) flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow"
|
"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"
|
target="_blank"
|
||||||
href={shortcutLink}
|
href={shortcutLink}
|
||||||
>
|
>
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<span>{shortcut.title}</span>
|
<span className="dark:text-gray-400">{shortcut.title}</span>
|
||||||
{shortcut.title ? (
|
{shortcut.title ? (
|
||||||
<span className="text-gray-400">(s/{shortcut.name})</span>
|
<span className="text-gray-500">(s/{shortcut.name})</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-400">s/</span>
|
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
||||||
<span className="truncate">{shortcut.name}</span>
|
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -77,14 +68,18 @@ const ShortcutView = (props: Props) => {
|
|||||||
</a>
|
</a>
|
||||||
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||||
<button
|
<button
|
||||||
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow"
|
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()}
|
onClick={() => handleCopyButtonClick()}
|
||||||
>
|
>
|
||||||
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<a className="ml-1 w-full text-sm truncate text-gray-400 hover:underline" href={shortcut.link} target="_blank">
|
<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}
|
{shortcut.link}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -98,46 +93,38 @@ const ShortcutView = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="max-w-[8rem] truncate text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600"
|
className="max-w-[8rem] truncate text-gray-400 dark:text-gray-500 text-sm leading-4 cursor-pointer hover:opacity-80"
|
||||||
onClick={() => viewStore.setFilter({ tag: tag })}
|
onClick={() => viewStore.setFilter({ tag: tag })}
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm font-mono leading-4 italic">No tags</span>}
|
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm leading-4 italic">No tags</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex mt-2 gap-2">
|
<div className="w-full flex mt-2 gap-2 overflow-x-auto">
|
||||||
<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">
|
|
||||||
<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>
|
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
||||||
<div
|
<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"
|
className="w-auto px-2 leading-6 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap border rounded-full cursor-pointer text-gray-500 dark:text-gray-400 text-sm dark:border-zinc-700"
|
||||||
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
|
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
|
||||||
>
|
>
|
||||||
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
|
<VisibilityIcon className="w-4 h-auto mr-1 opacity-60" visibility={shortcut.visibility} />
|
||||||
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
||||||
<div
|
<Link
|
||||||
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm"
|
to={`/shortcut/${shortcut.id}#analytics`}
|
||||||
onClick={() => setShowAnalyticsDialog(true)}
|
className="w-auto px-2 leading-6 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap border rounded-full cursor-pointer text-gray-500 dark:text-gray-400 text-sm dark:border-zinc-700"
|
||||||
>
|
>
|
||||||
<Icon.BarChart2 className="w-4 h-auto mr-1" />
|
<Icon.BarChart2 className="w-4 h-auto mr-1 opacity-80" />
|
||||||
{shortcut.view} visits
|
{t("shortcut.visits", { count: shortcut.view })}
|
||||||
</div>
|
</Link>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShortcutView;
|
export default ShortcutCard;
|
67
frontend/web/src/components/ShortcutView.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
className?: string;
|
||||||
|
showActions?: boolean;
|
||||||
|
alwaysShowLink?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutView = (props: Props) => {
|
||||||
|
const { shortcut, className, showActions, alwaysShowLink, onClick } = props;
|
||||||
|
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"group w-full px-3 py-2 flex flex-row justify-start items-center border rounded-lg hover:bg-gray-100 dark:border-zinc-800 dark:hover:bg-zinc-800",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
||||||
|
{favicon ? (
|
||||||
|
<img className="w-full h-auto rounded-lg" src={favicon} decoding="async" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 w-full truncate">
|
||||||
|
{shortcut.title ? (
|
||||||
|
<>
|
||||||
|
<span className="dark:text-gray-400">{shortcut.title}</span>
|
||||||
|
<span className="text-gray-500">(s/{shortcut.name})</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
||||||
|
<span className="dark:text-gray-400">{shortcut.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
className={classNames(
|
||||||
|
"hidden group-hover:block ml-1 w-6 h-6 p-1 shrink-0 rounded-lg bg-gray-200 dark:bg-zinc-900 hover:opacity-80",
|
||||||
|
alwaysShowLink && "!block"
|
||||||
|
)}
|
||||||
|
to={`/s/${shortcut.name}`}
|
||||||
|
target="_blank"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Icon.ArrowUpRight className="w-4 h-auto text-gray-400 shrink-0" />
|
||||||
|
</Link>
|
||||||
|
{showActions && (
|
||||||
|
<div className="ml-1 flex flex-row justify-end items-center shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<ShortcutActionsDropdown shortcut={shortcut} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutView;
|
@ -1,4 +1,5 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { absolutifyLink } from "@/helpers/utils";
|
||||||
import useViewStore from "../stores/v1/view";
|
import useViewStore from "../stores/v1/view";
|
||||||
import ShortcutCard from "./ShortcutCard";
|
import ShortcutCard from "./ShortcutCard";
|
||||||
import ShortcutView from "./ShortcutView";
|
import ShortcutView from "./ShortcutView";
|
||||||
@ -13,15 +14,19 @@ const ShortcutsContainer: React.FC<Props> = (props: Props) => {
|
|||||||
const displayStyle = viewStore.displayStyle || "full";
|
const displayStyle = viewStore.displayStyle || "full";
|
||||||
const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard;
|
const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard;
|
||||||
|
|
||||||
|
const handleShortcutClick = (shortcut: Shortcut) => {
|
||||||
|
window.open(absolutifyLink(`/s/${shortcut.name}`));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"w-full grid grid-cols-1 gap-y-2 sm:gap-2",
|
"w-full grid grid-cols-1 gap-3 sm:gap-4",
|
||||||
displayStyle === "full" ? "sm:grid-cols-2" : "grid-cols-2 sm:grid-cols-4 gap-2"
|
displayStyle === "full" ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" : "grid-cols-2 sm:grid-cols-4"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{shortcutList.map((shortcut) => {
|
{shortcutList.map((shortcut) => {
|
||||||
return <ShortcutItemView key={shortcut.id} shortcut={shortcut} />;
|
return <ShortcutItemView key={shortcut.id} shortcut={shortcut} showActions={true} onClick={() => handleShortcutClick(shortcut)} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
@ -1,9 +1,11 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAppSelector } from "../stores";
|
import { useAppSelector } from "../stores";
|
||||||
import useViewStore from "../stores/v1/view";
|
import useViewStore from "../stores/v1/view";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const Navigator = () => {
|
const ShortcutsNavigator = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const viewStore = useViewStore();
|
const viewStore = useViewStore();
|
||||||
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
|
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
|
||||||
@ -14,30 +16,36 @@ const Navigator = () => {
|
|||||||
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
|
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
|
||||||
<button
|
<button
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
"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 text-white shadow" : ""
|
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" })}
|
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
|
||||||
>
|
>
|
||||||
<Icon.CircleSlash className="w-4 h-auto mr-1" />
|
<Icon.CircleSlash className="w-4 h-auto mr-1" />
|
||||||
<span className="font-normal">All</span>
|
<span className="font-normal">{t("filter.all")}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
"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 text-white shadow" : ""
|
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" })}
|
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
|
||||||
>
|
>
|
||||||
<Icon.User className="w-4 h-auto mr-1" />
|
<Icon.User className="w-4 h-auto mr-1" />
|
||||||
<span className="font-normal">Mine</span>
|
<span className="font-normal">{t("filter.mine")}</span>
|
||||||
</button>
|
</button>
|
||||||
{Array.from(sortedTagMap.keys()).map((tag) => (
|
{Array.from(sortedTagMap.keys()).map((tag) => (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
"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 text-white shadow" : ""
|
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 })}
|
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
|
||||||
>
|
>
|
||||||
@ -59,4 +67,4 @@ const sortTags = (tags: string[]): Map<string, number> => {
|
|||||||
return sortedMap;
|
return sortedMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Navigator;
|
export default ShortcutsNavigator;
|
33
frontend/web/src/components/SubscriptionFAQ.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import Accordion from "@mui/joy/Accordion";
|
||||||
|
import AccordionDetails from "@mui/joy/AccordionDetails";
|
||||||
|
import AccordionGroup from "@mui/joy/AccordionGroup";
|
||||||
|
import AccordionSummary from "@mui/joy/AccordionSummary";
|
||||||
|
|
||||||
|
const SubscriptionFAQ = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col justify-center items-center">
|
||||||
|
<h2 className="text-2xl font-semibold mb-8 dark:text-gray-400">Frequently Asked Questions</h2>
|
||||||
|
<AccordionGroup className="w-full max-w-2xl">
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary>Can I use the Free plan in my team?</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you can upgrade to the Pro
|
||||||
|
plan.
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary>How many devices can the license key be used on?</AccordionSummary>
|
||||||
|
<AccordionDetails>{`It's unlimited for now, but please don't abuse it.`}</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary>{`Can I get a refund if Slash doesn't meet my needs?`}</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
Yes, absolutely! You can send a email to me at `yourselfhosted@gmail.com`. I will refund you as soon as possible.
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
</AccordionGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionFAQ;
|