165 Commits

Author SHA1 Message Date
f815804f68 Merge branch 'main' into release/0.5.0 2023-11-12 16:45:03 +08:00
e89358cb0a chore: fix error message 2023-11-12 16:39:19 +08:00
bbe2bdffe3 docs: update getting started 2023-11-12 16:38:26 +08:00
70f6f30d69 Merge branch 'main' into release/0.5.0 2023-11-12 15:49:33 +08:00
af9655eeaf chore: fix user message 2023-11-12 15:49:00 +08:00
12cf0f8a8c chore: check selected shortcuts 2023-11-12 14:04:17 +08:00
916423cc89 chore: check selected shortcuts 2023-11-12 14:02:39 +08:00
3932cabeac chore: fix creator initial 2023-11-12 14:01:28 +08:00
dddb643bed chore: fix creator initial 2023-11-12 14:01:00 +08:00
0f7a771e85 chore: update collection metric 2023-11-12 13:41:36 +08:00
f8d36ae1ef chore: update collection metric 2023-11-12 13:41:02 +08:00
8d8b892d2a chore: update shortcut details 2023-11-12 13:29:07 +08:00
8a4e07120f chore: update collection details 2023-11-12 12:57:39 +08:00
8de658709c docs: add getting started placeholder 2023-11-12 12:29:00 +08:00
cb3e3bfaef chore: update shortcut view 2023-11-12 11:34:09 +08:00
4a25fbb2f6 chore(deps): bump axios from 0.27.2 to 1.6.0 in /frontend/web (#43)
Bumps [axios](https://github.com/axios/axios) from 0.27.2 to 1.6.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.27.2...v1.6.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-12 10:47:58 +08:00
83970d5d55 feat: add collection views 2023-11-12 10:47:49 +08:00
626b0df21c chore: update acl config 2023-11-12 01:43:56 +08:00
8f608dc522 chore: fix collection service 2023-11-11 21:06:52 +08:00
8f982c5695 chore: update lucide 2023-11-10 22:57:01 +08:00
94baa04bb1 chore: update license 2023-11-10 22:56:48 +08:00
1505e9fa56 chore: fix extension build 2023-11-10 11:18:58 +08:00
cab701f11b chore: impl collection service 2023-11-10 11:15:53 +08:00
a3743d7ac6 chore: add collection service definition 2023-11-10 11:02:12 +08:00
7715905204 feat: impl collection store 2023-11-10 10:41:52 +08:00
f770149066 chore: update collection store definition 2023-11-10 10:18:55 +08:00
f3f2218e91 chore(frontend): update shortcut store 2023-11-10 10:11:02 +08:00
b3e766926d chore: update shortcut definition 2023-11-10 09:54:05 +08:00
6ed9ecffde chore: update page max width 2023-11-10 09:20:07 +08:00
c8d8c4e40c chore: update contact 2023-11-10 09:09:00 +08:00
4f94927b5c chore: use protobuf timestamp 2023-11-10 09:02:28 +08:00
f5f8616f2e feat: add update shortcut api 2023-11-10 08:51:19 +08:00
033c007654 chore: add collection table definition 2023-11-10 08:19:30 +08:00
0fb5377226 chore: upgrade frontend deps 2023-11-09 20:45:18 +08:00
f0afa13b8d chore: update extension with web request listener 2023-11-09 19:10:10 +08:00
53df3a9c1c chore: go mod tidy 2023-11-07 07:43:35 +08:00
8faaf8ced1 chore: update logger 2023-11-07 07:43:18 +08:00
67c3bbf1ee chore: update deps 2023-11-07 07:36:50 +08:00
68745ba9e0 chore: upgrade backend deps 2023-10-31 09:07:22 +08:00
015336b8c3 chore: combine v2 services 2023-10-31 08:53:40 +08:00
82ac6ab985 chore: update test 2023-10-31 08:43:30 +08:00
898ca70ad1 chore: upgrade frontend deps 2023-10-31 08:35:32 +08:00
5b2a8394d7 chore: update golangci-lint 2023-10-21 16:05:34 +08:00
16e17bffb3 chore: fix update resource api 2023-10-21 15:56:27 +08:00
015040cc1d chore: upgrade version 2023-10-17 23:02:07 +08:00
c8869e67c7 chore: update font family 2023-10-17 22:59:39 +08:00
a9ae7d2e96 chore: update frontend deps 2023-10-17 22:30:19 +08:00
db9034ccf9 chore: update buf deps 2023-10-17 22:01:00 +08:00
4d1705dca5 chore: update bin folder 2023-10-17 21:56:24 +08:00
3225e7c47b chore: update server structure 2023-10-17 21:54:22 +08:00
328397612c chore(deps): bump golang.org/x/net from 0.12.0 to 0.17.0 (#39)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.12.0 to 0.17.0.
- [Commits](https://github.com/golang/net/compare/v0.12.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-12 11:05:52 -05:00
c846cde5b4 chore: update readme 2023-10-05 08:32:26 +08:00
5c2cb99866 chore: update logo image 2023-10-05 08:31:39 +08:00
742c7da2eb fix: i18n AMO links (#38) 2023-10-01 08:20:34 -05:00
88b247410f chore: update readme about extension 2023-10-01 12:53:56 +08:00
01417943fb chore: update readme about extension 2023-10-01 12:50:34 +08:00
09f7c33135 chore: update version 2023-09-30 22:57:47 +08:00
fe3b78f844 chore: update i18n 2023-09-30 22:57:30 +08:00
0fd54426e6 chore: add create shortcut button when not found 2023-09-30 22:03:18 +08:00
690e14e4ed chore: update zh i18n 2023-09-30 09:03:52 +08:00
7795b17fd1 chore: add tags quick selector 2023-09-30 01:48:29 +08:00
c7dd4dc3eb chore: update extension version 2023-09-30 01:29:46 +08:00
6ee6a5166e chore: update dev server config 2023-09-30 01:23:25 +08:00
8c753e9557 feat: impl dark mode for extension 2023-09-30 01:22:40 +08:00
6126701025 chore: update favicon getter 2023-09-29 21:34:23 +08:00
8ef7d5f0d0 chore: update shortcut response styles 2023-09-29 21:17:06 +08:00
fa8d2f6639 chore: remove resource relative path setting 2023-09-29 19:55:13 +08:00
8cd976791e feat: get url favicon from google s2 2023-09-29 19:37:44 +08:00
010271c668 chore: update frontend deps 2023-09-29 19:10:01 +08:00
383d4f27f0 chore: fix subscription link 2023-09-25 06:18:59 +08:00
cb9786ef7c chore: fix title style in dark mode 2023-09-25 06:08:43 +08:00
e936bb6f15 chore: update subscription permission 2023-09-25 06:08:30 +08:00
60c440ae10 chore: fix check enable signup 2023-09-24 23:46:49 +08:00
fc8808ce04 chore: fix enable signup default value 2023-09-24 23:29:20 +08:00
e88327f2a3 chore: update resource service folder 2023-09-24 22:55:31 +08:00
159dfc9446 chore: add useNavigateTo hook 2023-09-24 21:21:34 +08:00
f78b072bb8 chore: update version 2023-09-24 19:44:31 +08:00
24fe368974 feat: implement subscription setting 2023-09-24 19:44:09 +08:00
46fa546a7d chore: update workspace setting store 2023-09-24 19:31:23 +08:00
96f6fa4257 chore: update user checks 2023-09-24 10:17:05 +08:00
8436d86661 chore: update workspace definition 2023-09-24 09:47:46 +08:00
a1d1e0f0f2 chore: update get workspace profile 2023-09-24 09:42:05 +08:00
0907ad2681 chore: update user setting store 2023-09-23 22:06:39 +08:00
e1b8bc607b chore: update web deps 2023-09-23 10:18:04 +08:00
528ecf72a3 chore: split setting pages 2023-09-23 09:02:10 +08:00
f0ffe2e419 chore: update workspace store 2023-09-23 08:54:27 +08:00
0df3164654 chore: update workspace setting fields 2023-09-23 01:59:21 +08:00
b97fb13929 chore: update styles 2023-09-23 01:25:08 +08:00
3488cd04c0 feat: implement dark mode 2023-09-23 01:09:45 +08:00
07e0bb2d4c chore: update golangci-lint config 2023-09-22 22:15:46 +08:00
a58ebd27ca chore: update grpcweb api error handler 2023-09-22 22:15:40 +08:00
d0a25e3ab2 chore: add feature matrix 2023-09-22 08:15:18 +08:00
92fba82927 chore: update server services 2023-09-22 07:44:44 +08:00
790a8a2e17 chore: fix linter 2023-09-22 00:37:04 +08:00
4e3d727b58 chore: add request cache 2023-09-22 00:13:11 +08:00
41eea8b571 feat: add subscription service 2023-09-21 23:09:38 +08:00
8f17abdbf0 feat: use get workspace profile in frontend 2023-09-21 08:46:48 +08:00
58cb5c7e2e chore: add get workspace profile api 2023-09-21 08:39:39 +08:00
271c133913 chore: rename workspace service 2023-09-21 08:25:55 +08:00
763205a89b chore: update workspace setting keys 2023-09-21 08:21:26 +08:00
e82e61d54d chore: update get workspace setting 2023-09-20 23:33:04 +08:00
0af4903657 feat: add custom style workspace setting 2023-09-20 23:13:30 +08:00
7f020eade9 fix: create access token 2023-09-20 21:56:56 +08:00
ebe54d1131 feat: impl grpcweb in frontend 2023-09-20 21:49:13 +08:00
9e8de4644a chore: fix github actions 2023-09-20 21:35:05 +08:00
a372d07c4b refactor: update ts proto generator 2023-09-20 21:28:36 +08:00
dd5cce63c5 chore: update generate access token 2023-09-20 21:15:30 +08:00
3c4155e6a1 chore: update frontend deps 2023-09-17 16:22:30 +08:00
6cb493b4a1 chore: rename context name 2023-09-17 16:21:11 +08:00
75d152922e feat: impl part of dark mode 2023-09-12 22:35:17 +08:00
908f95772d chore: update user settings 2023-09-12 22:03:06 +08:00
8992d48b3e feat: use workspace setting service in frontend 2023-09-12 21:34:05 +08:00
aa247ccef2 chore: impl workspace setting service 2023-09-12 21:02:33 +08:00
0ba373373d chore: fix proto linter 2023-09-12 08:51:31 +08:00
e843594a02 chore: initial workspace setting proto definition 2023-09-12 08:50:23 +08:00
032d9c1220 chore: update readme 2023-09-12 08:36:57 +08:00
e5e50b6874 chore: update readme 2023-09-12 08:36:10 +08:00
a7858075d8 chore: pnpm update 2023-09-12 08:25:43 +08:00
cff6c54b52 chore: add beta badge component 2023-09-06 00:06:04 +08:00
5e6190b181 feat: add color theme selector 2023-09-05 23:59:24 +08:00
b50e809125 feat: add color theme user setting definition 2023-09-05 23:48:32 +08:00
7348f47ef8 chore: update i18n locales 2023-09-05 22:10:23 +08:00
126e4a62f8 chore: update install guide 2023-09-05 21:00:06 +08:00
78282dab4d feat: support docker compose deployment (#18) 2023-09-05 20:59:19 +08:00
4a50248fbc chore: remove shorcut lower name 2023-09-05 20:55:02 +08:00
4f0a8cdc0a chore: implement i18n setting 2023-09-04 23:41:41 +08:00
a49a708fc5 chore: update extension tests 2023-09-03 14:52:30 +08:00
bb99341aba chore: add readme to locales 2023-09-03 14:50:25 +08:00
0ce934413a chore: update proto types for extension 2023-09-03 14:48:19 +08:00
65e366fdf1 chore: update ts definition generator 2023-09-03 14:42:12 +08:00
2fcd496fd2 chore: update ts definition generator 2023-09-03 14:35:50 +08:00
7cde25bdb5 chore: update user setting locale definition 2023-09-03 13:32:22 +08:00
35c396a88f feat: implement user setting service 2023-09-03 13:28:18 +08:00
a970d85e14 chore: add user setting service definition 2023-09-03 13:06:03 +08:00
4733e4796d chore: update user setting key 2023-09-03 10:43:28 +08:00
7c4ccbef3f chore: add auto backup workspace setting 2023-09-02 14:17:23 +08:00
b8f31cfd25 chore: add cron package 2023-09-02 14:11:23 +08:00
98cb5a2292 feat: add user locale setting 2023-09-02 14:10:03 +08:00
96c1901dce chore: regenerate pnpm-lock.yaml 2023-09-02 14:06:25 +08:00
b807417885 chore: upgrade deps version 2023-08-27 16:20:05 +08:00
6495c2081d chore: update readme features 2023-08-24 22:32:14 +08:00
0f92ccb22d chore: update readme 2023-08-24 21:48:52 +08:00
bdf7f327d2 fix: build extension action 2023-08-24 21:04:58 +08:00
efc3815edf chore: fix frontend build 2023-08-24 06:58:52 +08:00
f5817c575c chore: update frontend folder 2023-08-23 09:13:42 +08:00
40814a801a chore: update readme badges 2023-08-23 00:04:48 +08:00
e0f805f679 chore(i18n): new Crowdin updates (#29)
* Update source file en.json

* New translations en.json (English)

* New translations zh.json (English)

* Update source file en.json

* New translations zh.json (Chinese Simplified)

* Update source file zh.json

* New translations zh.json (Chinese Simplified)
2023-08-22 23:58:43 +08:00
c4fcfbd6aa chore(i18n): new Crowdin updates (#28)
* New translations en.json (Chinese Simplified)

* New translations en.json (English)
2023-08-22 23:45:37 +08:00
86d17188e1 Update Crowdin configuration file 2023-08-22 23:39:20 +08:00
88f8c00088 Update Crowdin configuration file 2023-08-22 23:30:21 +08:00
8612715371 chore: upgrade version to v0.4.3 2023-08-22 23:05:04 +08:00
e91050c803 chore: add delete user button 2023-08-22 23:02:14 +08:00
ec2ec74e31 feat: impl delete shortcut apiv2 2023-08-22 22:47:39 +08:00
bfb640f201 chore: update dialog width in extension 2023-08-22 09:27:49 +08:00
34f8a97309 feat: impl delete user apiv2 2023-08-22 09:15:27 +08:00
1c58702716 chore: fix linter warning 2023-08-21 02:03:16 +08:00
bd31c19a15 chore: fix typo 2023-08-21 01:55:16 +08:00
7e0ada6161 chore: add list users api 2023-08-21 01:53:52 +08:00
b5d6036fcf chore: update logger 2023-08-21 01:48:20 +08:00
0fcee9baf2 chore: update font family 2023-08-21 01:33:09 +08:00
f6fefdb8e6 chore: update joy-ui version 2023-08-21 00:15:41 +08:00
0ec06423e5 chore: update compact mode switch 2023-08-21 00:15:32 +08:00
8f028e4054 chore: add empty tags check 2023-08-21 00:07:11 +08:00
ae3b632f53 chore: remove analytics dialog 2023-08-18 22:52:57 +08:00
bafb17015c chore: update part of i18n 2023-08-18 09:00:42 +08:00
285 changed files with 19610 additions and 7695 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
*/*/node_modules

View File

@ -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

View File

@ -8,7 +8,7 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
paths: paths:
- "extension/**" - "frontend/extension/**"
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: "extension/pnpm-lock.yaml" cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
- run: pnpm install - run: pnpm install
working-directory: extension working-directory: frontend/extension
- run: pnpm type-gen
working-directory: frontend/extension
- name: Run eslint check - name: Run eslint check
run: pnpm lint run: pnpm lint
working-directory: extension working-directory: frontend/extension
extension-build: extension-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -41,9 +42,11 @@ jobs:
with: with:
node-version: "18" node-version: "18"
cache: pnpm cache: pnpm
cache-dependency-path: "extension/pnpm-lock.yaml" cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
- run: pnpm install - run: pnpm install
working-directory: extension working-directory: frontend/extension
- run: pnpm type-gen
working-directory: frontend/extension
- name: Run extension build - name: Run extension build
run: pnpm build run: pnpm build
working-directory: extension working-directory: frontend/extension

View File

@ -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
View File

@ -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

View File

@ -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)?'

View File

@ -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
View File

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

View File

@ -2,20 +2,29 @@
<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>
![demo](./resources/demo.png) ![demo](./resources/demo.png)
## Background
In today's workplace, essential information is often scattered across the cloud in the form of links. We understand the frustration of endlessly searching through emails, messages, and websites just to find the right link. Links are notorious for being unwieldy, complex, and easily lost in the shuffle. Remembering and sharing them can be a challenge.
That's why we developed Slash, a solution that transforms these links into easily accessible, discoverable, and shareable shortcuts(e.g., `s/shortcut`). Say goodbye to link chaos and welcome the organizational ease of Slash into your daily online workflow.
## Features ## Features
- Create customizable `/s/` short links for any URL. - Create customizable `/s/` short links for any URL.
- Share short links privately or with teammates. - Share short links public or only with your teammates.
- View analytics on link traffic and sources. - View analytics on link traffic and sources.
- Easy access to your shortcuts with browser extension. - Easy access to your shortcuts with browser extension.
- Open source self-hosted solution. - Open source self-hosted solution.
@ -34,8 +43,12 @@ Slash provides a browser extension to help you use your shortcuts in the search
![browser-extension-example](./resources/browser-extension-example.png) ![browser-extension-example](./resources/browser-extension-example.png)
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md).
### Chromium based browsers ### Chromium based browsers
For Chromium based browsers(Chrome, Edge, Arc, ...), you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg). For Chromium based browsers(Chrome, Edge, Arc, ...), you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md). ### Firefox
For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/your-slash/).

View File

@ -31,8 +31,8 @@ type ClaimsMessage struct {
// GenerateAccessToken generates an access token. // GenerateAccessToken generates an access token.
// username is the email of the user. // username is the email of the user.
func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret string) (string, error) { func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret []byte) (string, error) {
return generateToken(username, userID, AccessTokenAudienceName, expirationTime, []byte(secret)) return generateToken(username, userID, AccessTokenAudienceName, expirationTime, secret)
} }
// generateToken generates a jwt token. // generateToken generates a jwt token.
@ -43,7 +43,7 @@ func generateToken(username string, userID int32, audience string, expirationTim
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: fmt.Sprint(userID), Subject: fmt.Sprint(userID),
} }
if expirationTime.After(time.Now()) { if !expirationTime.IsZero() {
registeredClaims.ExpiresAt = jwt.NewNumericDate(expirationTime) registeredClaims.ExpiresAt = jwt.NewNumericDate(expirationTime)
} }

View File

@ -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
} }

View File

@ -7,12 +7,15 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/boojack/slash/api/auth"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/pkg/errors" "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 {
@ -51,7 +54,7 @@ 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")
} }
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), secret) accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
if err != nil { 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)
} }
@ -61,21 +64,32 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
cookieExp := time.Now().Add(auth.CookieExpDuration) cookieExp := time.Now().Add(auth.CookieExpDuration)
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp) 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)
@ -107,7 +121,7 @@ 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)
} }
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), secret) accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
if err != nil { 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)
} }
@ -117,11 +131,37 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
cookieExp := time.Now().Add(auth.CookieExpDuration) cookieExp := time.Now().Add(auth.CookieExpDuration)
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp) 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
}) })
@ -140,8 +180,8 @@ func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{ if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID, UserId: user.ID,
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS, Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokensUserSetting{ Value: &storepb.UserSetting_AccessTokens{
AccessTokensUserSetting: &storepb.AccessTokensUserSetting{ AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: userAccessTokens, AccessTokens: userAccessTokens,
}, },
}, },

View File

@ -5,19 +5,20 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/boojack/slash/api/auth" "github.com/boojack/slash/api/auth"
"github.com/boojack/slash/internal/util" "github.com/boojack/slash/internal/util"
storepb "github.com/boojack/slash/proto/gen/store" storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store" "github.com/boojack/slash/store"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
) )
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"
) )
func extractTokenFromHeader(c echo.Context) (string, error) { func extractTokenFromHeader(c echo.Context) (string, error) {
@ -35,27 +36,18 @@ 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.
accessToken, _ := extractTokenFromHeader(c)
if accessToken == "" {
// Check the cookie.
cookie, _ := c.Cookie(auth.AccessTokenCookieName) cookie, _ := c.Cookie(auth.AccessTokenCookieName)
if cookie != nil { if cookie != nil {
accessToken = cookie.Value accessToken = cookie.Value
} }
if accessToken == "" {
accessToken, _ = extractTokenFromHeader(c)
} }
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.
func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc { func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@ -68,45 +60,25 @@ func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.H
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 := &auth.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 {
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token")) return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
}
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
}
// We either have a valid access token or we will attempt to generate new access token.
userID, err := util.ConvertStringToInt32(claims.Subject)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.").WithInternal(err)
} }
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID) accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err) return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
} }
if !validateAccessToken(token, accessTokens) { if !validateAccessToken(accessToken, accessTokens) {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.") return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
} }
@ -122,11 +94,35 @@ func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.H
} }
// 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 { func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
for _, userAccessToken := range userAccessTokens { for _, userAccessToken := range userAccessTokens {
if accessTokenString == userAccessToken.AccessToken { if accessTokenString == userAccessToken.AccessToken {

View File

@ -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)
}) })
} }

View File

@ -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")
} }
@ -92,7 +94,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
shortcut := &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,
@ -120,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)
}) })
@ -129,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")
} }
@ -157,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,
@ -200,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")
} }
@ -267,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")
} }

View File

@ -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)
})
}

View File

@ -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")
} }

View File

@ -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

View File

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

View File

@ -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,
}
} }

View File

@ -5,16 +5,17 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/boojack/slash/api/auth"
"github.com/boojack/slash/internal/util"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
"github.com/golang-jwt/jwt/v4" "github.com/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"
) )
// ContextKey is the key type of context value. // ContextKey is the key type of context value.
@ -23,7 +24,7 @@ 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.
@ -71,25 +72,17 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re
return nil, status.Errorf(codes.PermissionDenied, "user ID %q is not admin", userID) return nil, status.Errorf(codes.PermissionDenied, "user ID %q is not admin", userID)
} }
userAccessTokens, err := in.Store.GetUserAccessTokens(ctx, userID)
if err != nil {
return nil, errors.Wrap(err, "failed to get user access tokens")
}
if !validateAccessToken(accessToken, userAccessTokens) {
return nil, status.Errorf(codes.Unauthenticated, "invalid access token")
}
// 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 := &auth.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)
} }
@ -128,6 +121,14 @@ 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
} }

View File

@ -2,7 +2,12 @@ package v2
import "strings" import "strings"
var allowedMethodsWhenUnauthorized = map[string]bool{} 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. // isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized.
func isUnauthorizeAllowedMethod(methodName string) bool { func isUnauthorizeAllowedMethod(methodName string) bool {
@ -14,6 +19,8 @@ func isUnauthorizeAllowedMethod(methodName string) bool {
var allowedMethodsOnlyForAdmin = map[string]bool{ var allowedMethodsOnlyForAdmin = map[string]bool{
"/slash.api.v2.UserService/CreateUser": true, "/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. // isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.

View 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),
}
}

View File

@ -2,33 +2,23 @@ package v2
import ( import (
"context" "context"
"fmt"
"strings"
"time"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
"github.com/pkg/errors" "github.com/pkg/errors"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson" "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"
) )
type ShortcutService struct { func (s *APIV2Service) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
apiv2pb.UnimplementedShortcutServiceServer userID := ctx.Value(userIDContextKey).(int32)
Secret string
Store *store.Store
}
// NewShortcutService creates a new Shortcut service.
func NewShortcutService(secret string, store *store.Store) *ShortcutService {
return &ShortcutService{
Secret: secret,
Store: store,
}
}
func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
userID := ctx.Value(UserIDContextKey).(int32)
find := &store.FindShortcut{} find := &store.FindShortcut{}
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic} find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find) visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
@ -46,7 +36,11 @@ func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShor
shortcutList = append(shortcutList, visibleShortcutList...) shortcutList = append(shortcutList, visibleShortcutList...)
shortcuts := []*apiv2pb.Shortcut{} shortcuts := []*apiv2pb.Shortcut{}
for _, shortcut := range shortcutList { for _, shortcut := range shortcutList {
shortcuts = append(shortcuts, convertShortcutFromStorepb(shortcut)) 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{ response := &apiv2pb.ListShortcutsResponse{
@ -55,9 +49,9 @@ func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShor
return response, nil return response, nil
} }
func (s *ShortcutService) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) { func (s *APIV2Service) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) {
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{ shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
Name: &request.Name, ID: &request.Id,
}) })
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err) return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
@ -66,19 +60,29 @@ func (s *ShortcutService) GetShortcut(ctx context.Context, request *apiv2pb.GetS
return nil, status.Errorf(codes.NotFound, "shortcut not found") return nil, status.Errorf(codes.NotFound, "shortcut not found")
} }
userID := ctx.Value(UserIDContextKey).(int32) userID, ok := ctx.Value(userIDContextKey).(int32)
if ok {
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID { if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied") return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
} }
shortcutMessage := convertShortcutFromStorepb(shortcut) } 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{ response := &apiv2pb.GetShortcutResponse{
Shortcut: shortcutMessage, Shortcut: composedShortcut,
} }
return response, nil return response, nil
} }
func (s *ShortcutService) CreateShortcut(ctx context.Context, request *apiv2pb.CreateShortcutRequest) (*apiv2pb.CreateShortcutResponse, error) { func (s *APIV2Service) CreateShortcut(ctx context.Context, request *apiv2pb.CreateShortcutRequest) (*apiv2pb.CreateShortcutResponse, error) {
userID := ctx.Value(UserIDContextKey).(int32) userID := ctx.Value(userIDContextKey).(int32)
shortcut := &storepb.Shortcut{ shortcut := &storepb.Shortcut{
CreatorId: userID, CreatorId: userID,
Name: request.Shortcut.Name, Name: request.Shortcut.Name,
@ -104,13 +108,113 @@ func (s *ShortcutService) CreateShortcut(ctx context.Context, request *apiv2pb.C
return nil, status.Errorf(codes.Internal, "failed to create activity, err: %v", err) 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{ response := &apiv2pb.CreateShortcutResponse{
Shortcut: convertShortcutFromStorepb(shortcut), Shortcut: composedShortcut,
} }
return response, nil return response, nil
} }
func (s *ShortcutService) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error { 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{ payload := &storepb.ActivityShorcutCreatePayload{
ShortcutId: shortcut.Id, ShortcutId: shortcut.Id,
} }
@ -131,12 +235,12 @@ func (s *ShortcutService) createShortcutCreateActivity(ctx context.Context, shor
return nil return nil
} }
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *apiv2pb.Shortcut { func (s *APIV2Service) convertShortcutFromStorepb(ctx context.Context, shortcut *storepb.Shortcut) (*apiv2pb.Shortcut, error) {
return &apiv2pb.Shortcut{ composedShortcut := &apiv2pb.Shortcut{
Id: shortcut.Id, Id: shortcut.Id,
CreatorId: shortcut.CreatorId, CreatorId: shortcut.CreatorId,
CreatedTs: shortcut.CreatedTs, CreatedTime: timestamppb.New(time.Unix(shortcut.CreatedTs, 0)),
UpdatedTs: shortcut.UpdatedTs, UpdatedTime: timestamppb.New(time.Unix(shortcut.UpdatedTs, 0)),
RowStatus: apiv2pb.RowStatus(shortcut.RowStatus), RowStatus: apiv2pb.RowStatus(shortcut.RowStatus),
Name: shortcut.Name, Name: shortcut.Name,
Link: shortcut.Link, Link: shortcut.Link,
@ -150,4 +254,16 @@ func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *apiv2pb.Shortcut {
Image: shortcut.OgMetadata.Image, 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
} }

View 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
}

View File

@ -2,11 +2,8 @@ package v2
import ( import (
"context" "context"
"time"
"github.com/boojack/slash/api/auth"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -14,24 +11,31 @@ import (
"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" "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 {
Secret string return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
Store *store.Store
}
// NewUserService creates a new UserService.
func NewUserService(secret string, store *store.Store) *UserService {
return &UserService{
Secret: secret,
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,
}) })
@ -49,12 +53,22 @@ func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserReque
return response, nil return response, nil
} }
func (s *UserService) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) { func (s *APIV2Service) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost) passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to hash password: %v", err) 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{ user, err := s.Store.CreateUser(ctx, &store.User{
Email: request.User.Email, Email: request.User.Email,
Nickname: request.User.Nickname, Nickname: request.User.Nickname,
@ -70,8 +84,52 @@ func (s *UserService) CreateUser(ctx context.Context, request *apiv2pb.CreateUse
return response, nil return response, nil
} }
func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) { func (s *APIV2Service) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
userID := ctx.Value(UserIDContextKey).(int32) 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 { if userID != request.Id {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied") return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
} }
@ -112,8 +170,8 @@ func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb
} }
// Sort by issued time in descending order. // Sort by issued time in descending order.
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) bool { slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) int {
return i.IssuedAt.Seconds > j.IssuedAt.Seconds return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
}) })
response := &apiv2pb.ListUserAccessTokensResponse{ response := &apiv2pb.ListUserAccessTokensResponse{
AccessTokens: accessTokens, AccessTokens: accessTokens,
@ -121,8 +179,8 @@ func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb
return response, nil return response, nil
} }
func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) { func (s *APIV2Service) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
userID := ctx.Value(UserIDContextKey).(int32) userID := ctx.Value(userIDContextKey).(int32)
if userID != request.Id { if userID != request.Id {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied") return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
} }
@ -137,7 +195,11 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
return nil, status.Errorf(codes.NotFound, "user not found") return nil, status.Errorf(codes.NotFound, "user not found")
} }
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, request.UserAccessToken.ExpiresAt.AsTime(), s.Secret) 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 { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err) return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
} }
@ -159,13 +221,13 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
} }
// Upsert the access token to user setting store. // Upsert the access token to user setting store.
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, request.UserAccessToken.Description); err != nil { 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) return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
} }
userAccessToken := &apiv2pb.UserAccessToken{ userAccessToken := &apiv2pb.UserAccessToken{
AccessToken: accessToken, AccessToken: accessToken,
Description: request.UserAccessToken.Description, Description: request.Description,
IssuedAt: timestamppb.New(claims.IssuedAt.Time), IssuedAt: timestamppb.New(claims.IssuedAt.Time),
} }
if claims.ExpiresAt != nil { if claims.ExpiresAt != nil {
@ -177,8 +239,8 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
return response, nil return response, nil
} }
func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) { func (s *APIV2Service) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
userID := ctx.Value(UserIDContextKey).(int32) userID := ctx.Value(userIDContextKey).(int32)
if userID != request.Id { if userID != request.Id {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied") return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
} }
@ -197,8 +259,8 @@ func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2p
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{ if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: userID, UserId: userID,
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS, Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokensUserSetting{ Value: &storepb.UserSetting_AccessTokens{
AccessTokensUserSetting: &storepb.AccessTokensUserSetting{ AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: updatedUserAccessTokens, AccessTokens: updatedUserAccessTokens,
}, },
}, },
@ -209,7 +271,7 @@ func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2p
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
} }
func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error { func (s *APIV2Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error {
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID) userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to get user access tokens") return errors.Wrap(err, "failed to get user access tokens")
@ -222,8 +284,8 @@ func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{ if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID, UserId: user.ID,
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS, Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokensUserSetting{ Value: &storepb.UserSetting_AccessTokens{
AccessTokensUserSetting: &storepb.AccessTokensUserSetting{ AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: userAccessTokens, AccessTokens: userAccessTokens,
}, },
}, },
@ -237,8 +299,8 @@ 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,

View 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
}
}

View File

@ -4,41 +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 {
apiv2pb.UnimplementedWorkspaceServiceServer
apiv2pb.UnimplementedSubscriptionServiceServer
apiv2pb.UnimplementedUserServiceServer
apiv2pb.UnimplementedUserSettingServiceServer
apiv2pb.UnimplementedShortcutServiceServer
apiv2pb.UnimplementedCollectionServiceServer
Secret string Secret string
Profile *profile.Profile Profile *profile.Profile
Store *store.Store 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(secret, store)) apiV2Service := &APIV2Service{
apiv2pb.RegisterShortcutServiceServer(grpcServer, NewShortcutService(secret, store))
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 {
@ -58,14 +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 { if err := apiv2pb.RegisterShortcutServiceHandler(context.Background(), gwMux, conn); err != nil {
return err 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
View 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
}

View File

@ -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,7 +26,7 @@ const (
) )
var ( var (
profile *_profile.Profile serverProfile *profile.Profile
mode string mode string
port int port int
data string data string
@ -33,21 +36,24 @@ var (
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
View 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:

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

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

View File

@ -8,7 +8,7 @@ Slash provides a browser extension to help you use your shortcuts in the search
For Chromuim based browsers, you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg). For Chromuim based browsers, you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
For Firefox, we don't support the Firefox Add-ons platform yet. And we are working on it. For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/your-slash/).
### Generate an access token ### Generate an access token

View File

@ -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
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

View File

@ -1,57 +0,0 @@
{
"name": "slash-extension",
"displayName": "Slash",
"version": "0.1.3",
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
"scripts": {
"dev": "plasmo dev",
"build": "plasmo build",
"package": "plasmo package",
"lint": "eslint --ext .js,.ts,.tsx, src",
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
},
"dependencies": {
"@bufbuild/protobuf": "^1.3.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/joy": "5.0.0-beta.0",
"@plasmohq/storage": "^1.7.2",
"axios": "^1.4.0",
"classnames": "^2.3.2",
"lodash-es": "^4.17.21",
"lucide-react": "^0.264.0",
"plasmo": "0.82.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hot-toast": "^2.4.1",
"zustand": "^4.4.1"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "4.1.0",
"@types/chrome": "0.0.241",
"@types/lodash-es": "^4.17.8",
"@types/node": "20.4.2",
"@types/react": "18.2.15",
"@types/react-dom": "18.2.7",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.46.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.27.1",
"postcss": "^8.4.27",
"prettier": "2.6.2",
"tailwindcss": "^3.3.3",
"typescript": "5.1.6"
},
"manifest": {
"omnibox": {
"keyword": "s"
},
"permissions": [
"tabs",
"storage"
]
}
}

View File

@ -1,14 +0,0 @@
import { Storage } from "@plasmohq/storage";
import axios from "axios";
const storage = new Storage();
export const getUrlFavicon = async (url: string) => {
const domain = await storage.getItem<string>("domain");
const accessToken = await storage.getItem<string>("access_token");
return axios.get<string>(`${domain}/api/v1/url/favicon?url=${url}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
};

View File

@ -1,5 +0,0 @@
import { isNull, isUndefined } from "lodash-es";
export const isNullorUndefined = (value: any) => {
return isNull(value) || isUndefined(value);
};

View File

@ -1,134 +0,0 @@
import type { Shortcut } from "./types/proto/api/v2/shortcut_service_pb";
import { Button, Divider, Input } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook";
import { useEffect, useState } from "react";
import { Toaster, toast } from "react-hot-toast";
import Icon from "./components/Icon";
import Logo from "./components/Logo";
import PullShortcutsButton from "./components/PullShortcutsButton";
import ShortcutsContainer from "./components/ShortcutsContainer";
import "./style.css";
interface SettingState {
domain: string;
accessToken: string;
}
const IndexOptions = () => {
const [domain, setDomain] = useStorage<string>("domain", (v) => (v ? v : ""));
const [accessToken, setAccessToken] = useStorage<string>("access_token", (v) => (v ? v : ""));
const [settingState, setSettingState] = useState<SettingState>({
domain,
accessToken,
});
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
const isInitialized = domain && accessToken;
useEffect(() => {
setSettingState({
domain,
accessToken,
});
}, [domain, accessToken]);
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
setSettingState((prevState) => ({
...prevState,
...partialSettingState,
}));
};
const handleSaveSetting = () => {
setDomain(settingState.domain);
setAccessToken(settingState.accessToken);
toast.success("Setting saved");
};
return (
<>
<div className="w-full">
<div className="w-full flex flex-row justify-center items-center">
<a
className="bg-yellow-100 mt-12 py-2 px-3 rounded-full border flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600"
href="https://github.com/boojack/slash#browser-extension"
target="_blank"
>
<Icon.HelpCircle className="w-4 h-auto" />
<span className="mx-1 text-sm">Need help? Check out the docs</span>
<Icon.ExternalLink className="w-4 h-auto" />
</a>
</div>
<div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start mt-12">
<h2 className="flex flex-row justify-start items-center mb-6 text-2xl">
<Logo className="w-10 h-auto mr-2" />
<span>Slash</span>
<span className="mx-2 text-gray-400">/</span>
<span>Setting</span>
</h2>
<div className="w-full flex flex-col justify-start items-start">
<div className="w-full flex flex-col justify-start items-start mb-4">
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
<span>Domain</span>
{domain !== "" && (
<a
className="text-sm flex flex-row justify-start items-center hover:underline hover:text-blue-600"
href={domain}
target="_blank"
>
<span className="mr-1">Go to my Slash</span>
<Icon.ExternalLink className="w-4 h-auto" />
</a>
)}
</div>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="The domain of your Slash instance"
value={settingState.domain}
onChange={(e) => setPartialSettingState({ domain: e.target.value })}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="mb-2 text-base">Access Token</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="The access token of your Slash instance"
value={settingState.accessToken}
onChange={(e) => setPartialSettingState({ accessToken: e.target.value })}
/>
</div>
</div>
<div className="w-full mt-6">
<Button onClick={handleSaveSetting}>Save</Button>
</div>
</div>
{isInitialized && (
<>
<Divider className="!my-6" />
<h2 className="flex flex-row justify-start items-center mb-4">
<span className="text-lg">Shortcuts</span>
<span className="text-gray-500 mr-1">({shortcuts.length})</span>
<PullShortcutsButton />
</h2>
<ShortcutsContainer />
</>
)}
</div>
</div>
<Toaster position="top-center" />
</>
);
};
export default IndexOptions;

View File

@ -1,109 +0,0 @@
import { Button, Divider, IconButton } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook";
import { Toaster } from "react-hot-toast";
import CreateShortcutsButton from "@/components/CreateShortcutsButton";
import Icon from "@/components/Icon";
import Logo from "@/components/Logo";
import PullShortcutsButton from "@/components/PullShortcutsButton";
import ShortcutsContainer from "@/components/ShortcutsContainer";
import { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
import "./style.css";
const IndexPopup = () => {
const [domain] = useStorage<string>("domain", "");
const [accessToken] = useStorage<string>("access_token", "");
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
const isInitialized = domain && accessToken;
const handleSettingButtonClick = () => {
chrome.runtime.openOptionsPage();
};
const handleRefreshButtonClick = () => {
chrome.runtime.reload();
chrome.browserAction.setPopup({ popup: "" });
};
return (
<>
<div className="w-full min-w-[512px] px-4 pt-4">
<div className="w-full flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center">
<Logo className="w-6 h-auto mr-2" />
<span className="">Slash</span>
{isInitialized && (
<>
<span className="mx-1 text-gray-400">/</span>
<span>Shortcuts</span>
<span className="text-gray-500 mr-0.5">({shortcuts.length})</span>
<PullShortcutsButton />
</>
)}
</div>
<div>{isInitialized && <CreateShortcutsButton />}</div>
</div>
<div className="w-full mt-4">
{isInitialized ? (
<>
{shortcuts.length !== 0 ? (
<ShortcutsContainer />
) : (
<div className="w-full flex flex-col justify-center items-center">
<p>No shortcut found.</p>
</div>
)}
<Divider className="!mt-4 !mb-2 opacity-40" />
<div className="w-full flex flex-row justify-between items-center mb-2">
<div className="flex flex-row justify-start items-center">
<IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
<Icon.Settings className="w-5 h-auto text-gray-500" />
</IconButton>
<IconButton
size="sm"
variant="plain"
color="neutral"
component="a"
href="https://github.com/boojack/slash"
target="_blank"
>
<Icon.Github className="w-5 h-auto text-gray-500" />
</IconButton>
</div>
<div className="flex flex-row justify-end items-center">
<a
className="text-sm flex flex-row justify-start items-center text-gray-500 hover:underline hover:text-blue-600"
href={domain}
target="_blank"
>
<span className="mr-1">Go to my Slash</span>
<Icon.ExternalLink className="w-4 h-auto" />
</a>
</div>
</div>
</>
) : (
<div className="w-full flex flex-col justify-start items-center">
<p>No domain and access token found.</p>
<div className="w-full flex flex-row justify-center items-center py-4">
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
<Icon.Settings className="w-5 h-auto mr-1" /> Setting
</Button>
<span className="mx-2">Or</span>
<Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}>
<Icon.RefreshCcw className="w-5 h-auto mr-1" /> Refresh
</Button>
</div>
</div>
)}
</div>
</div>
<Toaster position="top-right" />
</>
);
};
export default IndexPopup;

View File

@ -1,41 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { getUrlFavicon } from "../helpers/api";
interface FaviconState {
cache: {
[key: string]: string;
};
getOrFetchUrlFavicon: (url: string) => Promise<string>;
}
const useFaviconStore = create<FaviconState>()(
persist(
(set, get) => ({
cache: {},
getOrFetchUrlFavicon: async (url: string) => {
const cache = get().cache;
if (cache[url]) {
return cache[url];
}
try {
const { data: favicon } = await getUrlFavicon(url);
if (favicon) {
cache[url] = favicon;
set(cache);
return favicon;
}
} catch (error) {
// do nothing
}
return "";
},
}),
{
name: "favicon_cache",
}
)
);
export default useFaviconStore;

View File

@ -1,25 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file api/v2/common.proto (package slash.api.v2, syntax proto3)
/* eslint-disable */
// @ts-nocheck
/**
* @generated from enum slash.api.v2.RowStatus
*/
export declare enum RowStatus {
/**
* @generated from enum value: ROW_STATUS_UNSPECIFIED = 0;
*/
ROW_STATUS_UNSPECIFIED = 0,
/**
* @generated from enum value: NORMAL = 1;
*/
NORMAL = 1,
/**
* @generated from enum value: ARCHIVED = 2;
*/
ARCHIVED = 2,
}

View File

@ -1,19 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file api/v2/common.proto (package slash.api.v2, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import { proto3 } from "@bufbuild/protobuf";
/**
* @generated from enum slash.api.v2.RowStatus
*/
export const RowStatus = proto3.makeEnum(
"slash.api.v2.RowStatus",
[
{no: 0, name: "ROW_STATUS_UNSPECIFIED"},
{no: 1, name: "NORMAL"},
{no: 2, name: "ARCHIVED"},
],
);

View File

@ -1,286 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file api/v2/shortcut_service.proto (package slash.api.v2, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
import { Message, proto3 } from "@bufbuild/protobuf";
import type { RowStatus } from "./common_pb.js";
/**
* @generated from enum slash.api.v2.Visibility
*/
export declare enum Visibility {
/**
* @generated from enum value: VISIBILITY_UNSPECIFIED = 0;
*/
VISIBILITY_UNSPECIFIED = 0,
/**
* @generated from enum value: PRIVATE = 1;
*/
PRIVATE = 1,
/**
* @generated from enum value: WORKSPACE = 2;
*/
WORKSPACE = 2,
/**
* @generated from enum value: PUBLIC = 3;
*/
PUBLIC = 3,
}
/**
* @generated from message slash.api.v2.Shortcut
*/
export declare class Shortcut extends Message<Shortcut> {
/**
* @generated from field: int32 id = 1;
*/
id: number;
/**
* @generated from field: int32 creator_id = 2;
*/
creatorId: number;
/**
* @generated from field: int64 created_ts = 3;
*/
createdTs: bigint;
/**
* @generated from field: int64 updated_ts = 4;
*/
updatedTs: bigint;
/**
* @generated from field: slash.api.v2.RowStatus row_status = 5;
*/
rowStatus: RowStatus;
/**
* @generated from field: string name = 6;
*/
name: string;
/**
* @generated from field: string link = 7;
*/
link: string;
/**
* @generated from field: string title = 8;
*/
title: string;
/**
* @generated from field: repeated string tags = 9;
*/
tags: string[];
/**
* @generated from field: string description = 10;
*/
description: string;
/**
* @generated from field: slash.api.v2.Visibility visibility = 11;
*/
visibility: Visibility;
/**
* @generated from field: slash.api.v2.OpenGraphMetadata og_metadata = 12;
*/
ogMetadata?: OpenGraphMetadata;
constructor(data?: PartialMessage<Shortcut>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.Shortcut";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Shortcut;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Shortcut;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Shortcut;
static equals(a: Shortcut | PlainMessage<Shortcut> | undefined, b: Shortcut | PlainMessage<Shortcut> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.OpenGraphMetadata
*/
export declare class OpenGraphMetadata extends Message<OpenGraphMetadata> {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: string description = 2;
*/
description: string;
/**
* @generated from field: string image = 3;
*/
image: string;
constructor(data?: PartialMessage<OpenGraphMetadata>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.OpenGraphMetadata";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): OpenGraphMetadata;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
static equals(a: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined, b: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.ListShortcutsRequest
*/
export declare class ListShortcutsRequest extends Message<ListShortcutsRequest> {
constructor(data?: PartialMessage<ListShortcutsRequest>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.ListShortcutsRequest";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListShortcutsRequest;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListShortcutsRequest;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListShortcutsRequest;
static equals(a: ListShortcutsRequest | PlainMessage<ListShortcutsRequest> | undefined, b: ListShortcutsRequest | PlainMessage<ListShortcutsRequest> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.ListShortcutsResponse
*/
export declare class ListShortcutsResponse extends Message<ListShortcutsResponse> {
/**
* @generated from field: repeated slash.api.v2.Shortcut shortcuts = 1;
*/
shortcuts: Shortcut[];
constructor(data?: PartialMessage<ListShortcutsResponse>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.ListShortcutsResponse";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListShortcutsResponse;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListShortcutsResponse;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListShortcutsResponse;
static equals(a: ListShortcutsResponse | PlainMessage<ListShortcutsResponse> | undefined, b: ListShortcutsResponse | PlainMessage<ListShortcutsResponse> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.GetShortcutRequest
*/
export declare class GetShortcutRequest extends Message<GetShortcutRequest> {
/**
* @generated from field: string name = 1;
*/
name: string;
constructor(data?: PartialMessage<GetShortcutRequest>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.GetShortcutRequest";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetShortcutRequest;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetShortcutRequest;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetShortcutRequest;
static equals(a: GetShortcutRequest | PlainMessage<GetShortcutRequest> | undefined, b: GetShortcutRequest | PlainMessage<GetShortcutRequest> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.GetShortcutResponse
*/
export declare class GetShortcutResponse extends Message<GetShortcutResponse> {
/**
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
*/
shortcut?: Shortcut;
constructor(data?: PartialMessage<GetShortcutResponse>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.GetShortcutResponse";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetShortcutResponse;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetShortcutResponse;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetShortcutResponse;
static equals(a: GetShortcutResponse | PlainMessage<GetShortcutResponse> | undefined, b: GetShortcutResponse | PlainMessage<GetShortcutResponse> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.CreateShortcutRequest
*/
export declare class CreateShortcutRequest extends Message<CreateShortcutRequest> {
/**
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
*/
shortcut?: Shortcut;
constructor(data?: PartialMessage<CreateShortcutRequest>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.CreateShortcutRequest";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateShortcutRequest;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateShortcutRequest;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateShortcutRequest;
static equals(a: CreateShortcutRequest | PlainMessage<CreateShortcutRequest> | undefined, b: CreateShortcutRequest | PlainMessage<CreateShortcutRequest> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.CreateShortcutResponse
*/
export declare class CreateShortcutResponse extends Message<CreateShortcutResponse> {
/**
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
*/
shortcut?: Shortcut;
constructor(data?: PartialMessage<CreateShortcutResponse>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.CreateShortcutResponse";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateShortcutResponse;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateShortcutResponse;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateShortcutResponse;
static equals(a: CreateShortcutResponse | PlainMessage<CreateShortcutResponse> | undefined, b: CreateShortcutResponse | PlainMessage<CreateShortcutResponse> | undefined): boolean;
}

View File

@ -1,112 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file api/v2/shortcut_service.proto (package slash.api.v2, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import { proto3 } from "@bufbuild/protobuf";
import { RowStatus } from "./common_pb.js";
/**
* @generated from enum slash.api.v2.Visibility
*/
export const Visibility = proto3.makeEnum(
"slash.api.v2.Visibility",
[
{no: 0, name: "VISIBILITY_UNSPECIFIED"},
{no: 1, name: "PRIVATE"},
{no: 2, name: "WORKSPACE"},
{no: 3, name: "PUBLIC"},
],
);
/**
* @generated from message slash.api.v2.Shortcut
*/
export const Shortcut = proto3.makeMessageType(
"slash.api.v2.Shortcut",
() => [
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 2, name: "creator_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
{ no: 5, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
{ no: 6, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 7, name: "link", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 8, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 9, name: "tags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
{ no: 10, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 11, name: "visibility", kind: "enum", T: proto3.getEnumType(Visibility) },
{ no: 12, name: "og_metadata", kind: "message", T: OpenGraphMetadata },
],
);
/**
* @generated from message slash.api.v2.OpenGraphMetadata
*/
export const OpenGraphMetadata = proto3.makeMessageType(
"slash.api.v2.OpenGraphMetadata",
() => [
{ no: 1, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 3, name: "image", kind: "scalar", T: 9 /* ScalarType.STRING */ },
],
);
/**
* @generated from message slash.api.v2.ListShortcutsRequest
*/
export const ListShortcutsRequest = proto3.makeMessageType(
"slash.api.v2.ListShortcutsRequest",
[],
);
/**
* @generated from message slash.api.v2.ListShortcutsResponse
*/
export const ListShortcutsResponse = proto3.makeMessageType(
"slash.api.v2.ListShortcutsResponse",
() => [
{ no: 1, name: "shortcuts", kind: "message", T: Shortcut, repeated: true },
],
);
/**
* @generated from message slash.api.v2.GetShortcutRequest
*/
export const GetShortcutRequest = proto3.makeMessageType(
"slash.api.v2.GetShortcutRequest",
() => [
{ no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
],
);
/**
* @generated from message slash.api.v2.GetShortcutResponse
*/
export const GetShortcutResponse = proto3.makeMessageType(
"slash.api.v2.GetShortcutResponse",
() => [
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
],
);
/**
* @generated from message slash.api.v2.CreateShortcutRequest
*/
export const CreateShortcutRequest = proto3.makeMessageType(
"slash.api.v2.CreateShortcutRequest",
() => [
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
],
);
/**
* @generated from message slash.api.v2.CreateShortcutResponse
*/
export const CreateShortcutResponse = proto3.makeMessageType(
"slash.api.v2.CreateShortcutResponse",
() => [
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
],
);

View File

@ -1,380 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file api/v2/user_service.proto (package slash.api.v2, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage, Timestamp } from "@bufbuild/protobuf";
import { Message, proto3 } from "@bufbuild/protobuf";
import type { RowStatus } from "./common_pb.js";
/**
* @generated from enum slash.api.v2.Role
*/
export declare enum Role {
/**
* @generated from enum value: ROLE_UNSPECIFIED = 0;
*/
ROLE_UNSPECIFIED = 0,
/**
* @generated from enum value: ADMIN = 1;
*/
ADMIN = 1,
/**
* @generated from enum value: USER = 2;
*/
USER = 2,
}
/**
* @generated from message slash.api.v2.User
*/
export declare class User extends Message<User> {
/**
* @generated from field: int32 id = 1;
*/
id: number;
/**
* @generated from field: slash.api.v2.RowStatus row_status = 2;
*/
rowStatus: RowStatus;
/**
* @generated from field: int64 created_ts = 3;
*/
createdTs: bigint;
/**
* @generated from field: int64 updated_ts = 4;
*/
updatedTs: bigint;
/**
* @generated from field: slash.api.v2.Role role = 6;
*/
role: Role;
/**
* @generated from field: string email = 7;
*/
email: string;
/**
* @generated from field: string nickname = 8;
*/
nickname: string;
/**
* @generated from field: string password = 9;
*/
password: string;
constructor(data?: PartialMessage<User>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.User";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): User;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): User;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): User;
static equals(a: User | PlainMessage<User> | undefined, b: User | PlainMessage<User> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.GetUserRequest
*/
export declare class GetUserRequest extends Message<GetUserRequest> {
/**
* @generated from field: int32 id = 1;
*/
id: number;
constructor(data?: PartialMessage<GetUserRequest>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.GetUserRequest";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetUserRequest;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetUserRequest;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetUserRequest;
static equals(a: GetUserRequest | PlainMessage<GetUserRequest> | undefined, b: GetUserRequest | PlainMessage<GetUserRequest> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.GetUserResponse
*/
export declare class GetUserResponse extends Message<GetUserResponse> {
/**
* @generated from field: slash.api.v2.User user = 1;
*/
user?: User;
constructor(data?: PartialMessage<GetUserResponse>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.GetUserResponse";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetUserResponse;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetUserResponse;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetUserResponse;
static equals(a: GetUserResponse | PlainMessage<GetUserResponse> | undefined, b: GetUserResponse | PlainMessage<GetUserResponse> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.CreateUserRequest
*/
export declare class CreateUserRequest extends Message<CreateUserRequest> {
/**
* @generated from field: slash.api.v2.User user = 1;
*/
user?: User;
constructor(data?: PartialMessage<CreateUserRequest>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.CreateUserRequest";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserRequest;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserRequest;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserRequest;
static equals(a: CreateUserRequest | PlainMessage<CreateUserRequest> | undefined, b: CreateUserRequest | PlainMessage<CreateUserRequest> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.CreateUserResponse
*/
export declare class CreateUserResponse extends Message<CreateUserResponse> {
/**
* @generated from field: slash.api.v2.User user = 1;
*/
user?: User;
constructor(data?: PartialMessage<CreateUserResponse>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.CreateUserResponse";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserResponse;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserResponse;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserResponse;
static equals(a: CreateUserResponse | PlainMessage<CreateUserResponse> | undefined, b: CreateUserResponse | PlainMessage<CreateUserResponse> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.ListUserAccessTokensRequest
*/
export declare class ListUserAccessTokensRequest extends Message<ListUserAccessTokensRequest> {
/**
* id is the user id.
*
* @generated from field: int32 id = 1;
*/
id: number;
constructor(data?: PartialMessage<ListUserAccessTokensRequest>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.ListUserAccessTokensRequest";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUserAccessTokensRequest;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUserAccessTokensRequest;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUserAccessTokensRequest;
static equals(a: ListUserAccessTokensRequest | PlainMessage<ListUserAccessTokensRequest> | undefined, b: ListUserAccessTokensRequest | PlainMessage<ListUserAccessTokensRequest> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.ListUserAccessTokensResponse
*/
export declare class ListUserAccessTokensResponse extends Message<ListUserAccessTokensResponse> {
/**
* @generated from field: repeated slash.api.v2.UserAccessToken access_tokens = 1;
*/
accessTokens: UserAccessToken[];
constructor(data?: PartialMessage<ListUserAccessTokensResponse>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.ListUserAccessTokensResponse";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUserAccessTokensResponse;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUserAccessTokensResponse;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUserAccessTokensResponse;
static equals(a: ListUserAccessTokensResponse | PlainMessage<ListUserAccessTokensResponse> | undefined, b: ListUserAccessTokensResponse | PlainMessage<ListUserAccessTokensResponse> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.CreateUserAccessTokenRequest
*/
export declare class CreateUserAccessTokenRequest extends Message<CreateUserAccessTokenRequest> {
/**
* id is the user id.
*
* @generated from field: int32 id = 1;
*/
id: number;
/**
* @generated from field: slash.api.v2.UserAccessToken user_access_token = 2;
*/
userAccessToken?: UserAccessToken;
constructor(data?: PartialMessage<CreateUserAccessTokenRequest>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.CreateUserAccessTokenRequest";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserAccessTokenRequest;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserAccessTokenRequest;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserAccessTokenRequest;
static equals(a: CreateUserAccessTokenRequest | PlainMessage<CreateUserAccessTokenRequest> | undefined, b: CreateUserAccessTokenRequest | PlainMessage<CreateUserAccessTokenRequest> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.CreateUserAccessTokenResponse
*/
export declare class CreateUserAccessTokenResponse extends Message<CreateUserAccessTokenResponse> {
/**
* @generated from field: slash.api.v2.UserAccessToken access_token = 1;
*/
accessToken?: UserAccessToken;
constructor(data?: PartialMessage<CreateUserAccessTokenResponse>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.CreateUserAccessTokenResponse";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserAccessTokenResponse;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserAccessTokenResponse;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserAccessTokenResponse;
static equals(a: CreateUserAccessTokenResponse | PlainMessage<CreateUserAccessTokenResponse> | undefined, b: CreateUserAccessTokenResponse | PlainMessage<CreateUserAccessTokenResponse> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.DeleteUserAccessTokenRequest
*/
export declare class DeleteUserAccessTokenRequest extends Message<DeleteUserAccessTokenRequest> {
/**
* id is the user id.
*
* @generated from field: int32 id = 1;
*/
id: number;
/**
* access_token is the access token to delete.
*
* @generated from field: string access_token = 2;
*/
accessToken: string;
constructor(data?: PartialMessage<DeleteUserAccessTokenRequest>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.DeleteUserAccessTokenRequest";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserAccessTokenRequest;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenRequest;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenRequest;
static equals(a: DeleteUserAccessTokenRequest | PlainMessage<DeleteUserAccessTokenRequest> | undefined, b: DeleteUserAccessTokenRequest | PlainMessage<DeleteUserAccessTokenRequest> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.DeleteUserAccessTokenResponse
*/
export declare class DeleteUserAccessTokenResponse extends Message<DeleteUserAccessTokenResponse> {
constructor(data?: PartialMessage<DeleteUserAccessTokenResponse>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.DeleteUserAccessTokenResponse";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserAccessTokenResponse;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenResponse;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenResponse;
static equals(a: DeleteUserAccessTokenResponse | PlainMessage<DeleteUserAccessTokenResponse> | undefined, b: DeleteUserAccessTokenResponse | PlainMessage<DeleteUserAccessTokenResponse> | undefined): boolean;
}
/**
* @generated from message slash.api.v2.UserAccessToken
*/
export declare class UserAccessToken extends Message<UserAccessToken> {
/**
* @generated from field: string access_token = 1;
*/
accessToken: string;
/**
* @generated from field: string description = 2;
*/
description: string;
/**
* @generated from field: google.protobuf.Timestamp issued_at = 3;
*/
issuedAt?: Timestamp;
/**
* @generated from field: google.protobuf.Timestamp expires_at = 4;
*/
expiresAt?: Timestamp;
constructor(data?: PartialMessage<UserAccessToken>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.api.v2.UserAccessToken";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): UserAccessToken;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): UserAccessToken;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): UserAccessToken;
static equals(a: UserAccessToken | PlainMessage<UserAccessToken> | undefined, b: UserAccessToken | PlainMessage<UserAccessToken> | undefined): boolean;
}

View File

@ -1,150 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file api/v2/user_service.proto (package slash.api.v2, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import { proto3, Timestamp } from "@bufbuild/protobuf";
import { RowStatus } from "./common_pb.js";
/**
* @generated from enum slash.api.v2.Role
*/
export const Role = proto3.makeEnum(
"slash.api.v2.Role",
[
{no: 0, name: "ROLE_UNSPECIFIED"},
{no: 1, name: "ADMIN"},
{no: 2, name: "USER"},
],
);
/**
* @generated from message slash.api.v2.User
*/
export const User = proto3.makeMessageType(
"slash.api.v2.User",
() => [
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 2, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
{ no: 6, name: "role", kind: "enum", T: proto3.getEnumType(Role) },
{ no: 7, name: "email", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 8, name: "nickname", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 9, name: "password", kind: "scalar", T: 9 /* ScalarType.STRING */ },
],
);
/**
* @generated from message slash.api.v2.GetUserRequest
*/
export const GetUserRequest = proto3.makeMessageType(
"slash.api.v2.GetUserRequest",
() => [
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
],
);
/**
* @generated from message slash.api.v2.GetUserResponse
*/
export const GetUserResponse = proto3.makeMessageType(
"slash.api.v2.GetUserResponse",
() => [
{ no: 1, name: "user", kind: "message", T: User },
],
);
/**
* @generated from message slash.api.v2.CreateUserRequest
*/
export const CreateUserRequest = proto3.makeMessageType(
"slash.api.v2.CreateUserRequest",
() => [
{ no: 1, name: "user", kind: "message", T: User },
],
);
/**
* @generated from message slash.api.v2.CreateUserResponse
*/
export const CreateUserResponse = proto3.makeMessageType(
"slash.api.v2.CreateUserResponse",
() => [
{ no: 1, name: "user", kind: "message", T: User },
],
);
/**
* @generated from message slash.api.v2.ListUserAccessTokensRequest
*/
export const ListUserAccessTokensRequest = proto3.makeMessageType(
"slash.api.v2.ListUserAccessTokensRequest",
() => [
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
],
);
/**
* @generated from message slash.api.v2.ListUserAccessTokensResponse
*/
export const ListUserAccessTokensResponse = proto3.makeMessageType(
"slash.api.v2.ListUserAccessTokensResponse",
() => [
{ no: 1, name: "access_tokens", kind: "message", T: UserAccessToken, repeated: true },
],
);
/**
* @generated from message slash.api.v2.CreateUserAccessTokenRequest
*/
export const CreateUserAccessTokenRequest = proto3.makeMessageType(
"slash.api.v2.CreateUserAccessTokenRequest",
() => [
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 2, name: "user_access_token", kind: "message", T: UserAccessToken },
],
);
/**
* @generated from message slash.api.v2.CreateUserAccessTokenResponse
*/
export const CreateUserAccessTokenResponse = proto3.makeMessageType(
"slash.api.v2.CreateUserAccessTokenResponse",
() => [
{ no: 1, name: "access_token", kind: "message", T: UserAccessToken },
],
);
/**
* @generated from message slash.api.v2.DeleteUserAccessTokenRequest
*/
export const DeleteUserAccessTokenRequest = proto3.makeMessageType(
"slash.api.v2.DeleteUserAccessTokenRequest",
() => [
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 2, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
],
);
/**
* @generated from message slash.api.v2.DeleteUserAccessTokenResponse
*/
export const DeleteUserAccessTokenResponse = proto3.makeMessageType(
"slash.api.v2.DeleteUserAccessTokenResponse",
[],
);
/**
* @generated from message slash.api.v2.UserAccessToken
*/
export const UserAccessToken = proto3.makeMessageType(
"slash.api.v2.UserAccessToken",
() => [
{ no: 1, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 3, name: "issued_at", kind: "message", T: Timestamp },
{ no: 4, name: "expires_at", kind: "message", T: Timestamp },
],
);

View File

@ -1,32 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file store/activity.proto (package slash.store, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
import { Message, proto3 } from "@bufbuild/protobuf";
/**
* @generated from message slash.store.ActivityShorcutCreatePayload
*/
export declare class ActivityShorcutCreatePayload extends Message<ActivityShorcutCreatePayload> {
/**
* @generated from field: int32 shortcut_id = 1;
*/
shortcutId: number;
constructor(data?: PartialMessage<ActivityShorcutCreatePayload>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.store.ActivityShorcutCreatePayload";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ActivityShorcutCreatePayload;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ActivityShorcutCreatePayload;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ActivityShorcutCreatePayload;
static equals(a: ActivityShorcutCreatePayload | PlainMessage<ActivityShorcutCreatePayload> | undefined, b: ActivityShorcutCreatePayload | PlainMessage<ActivityShorcutCreatePayload> | undefined): boolean;
}

View File

@ -1,17 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file store/activity.proto (package slash.store, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import { proto3 } from "@bufbuild/protobuf";
/**
* @generated from message slash.store.ActivityShorcutCreatePayload
*/
export const ActivityShorcutCreatePayload = proto3.makeMessageType(
"slash.store.ActivityShorcutCreatePayload",
() => [
{ no: 1, name: "shortcut_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
],
);

View File

@ -1,25 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file store/common.proto (package slash.store, syntax proto3)
/* eslint-disable */
// @ts-nocheck
/**
* @generated from enum slash.store.RowStatus
*/
export declare enum RowStatus {
/**
* @generated from enum value: ROW_STATUS_UNSPECIFIED = 0;
*/
ROW_STATUS_UNSPECIFIED = 0,
/**
* @generated from enum value: NORMAL = 1;
*/
NORMAL = 1,
/**
* @generated from enum value: ARCHIVED = 2;
*/
ARCHIVED = 2,
}

View File

@ -1,19 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file store/common.proto (package slash.store, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import { proto3 } from "@bufbuild/protobuf";
/**
* @generated from enum slash.store.RowStatus
*/
export const RowStatus = proto3.makeEnum(
"slash.store.RowStatus",
[
{no: 0, name: "ROW_STATUS_UNSPECIFIED"},
{no: 1, name: "NORMAL"},
{no: 2, name: "ARCHIVED"},
],
);

View File

@ -1,147 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file store/shortcut.proto (package slash.store, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
import { Message, proto3 } from "@bufbuild/protobuf";
import type { RowStatus } from "./common_pb.js";
/**
* @generated from enum slash.store.Visibility
*/
export declare enum Visibility {
/**
* @generated from enum value: VISIBILITY_UNSPECIFIED = 0;
*/
VISIBILITY_UNSPECIFIED = 0,
/**
* @generated from enum value: PRIVATE = 1;
*/
PRIVATE = 1,
/**
* @generated from enum value: WORKSPACE = 2;
*/
WORKSPACE = 2,
/**
* @generated from enum value: PUBLIC = 3;
*/
PUBLIC = 3,
}
/**
* @generated from message slash.store.Shortcut
*/
export declare class Shortcut extends Message<Shortcut> {
/**
* @generated from field: int32 id = 1;
*/
id: number;
/**
* @generated from field: int32 creator_id = 2;
*/
creatorId: number;
/**
* @generated from field: int64 created_ts = 3;
*/
createdTs: bigint;
/**
* @generated from field: int64 updated_ts = 4;
*/
updatedTs: bigint;
/**
* @generated from field: slash.store.RowStatus row_status = 5;
*/
rowStatus: RowStatus;
/**
* @generated from field: string name = 6;
*/
name: string;
/**
* @generated from field: string link = 7;
*/
link: string;
/**
* @generated from field: string title = 8;
*/
title: string;
/**
* @generated from field: repeated string tags = 9;
*/
tags: string[];
/**
* @generated from field: string description = 10;
*/
description: string;
/**
* @generated from field: slash.store.Visibility visibility = 11;
*/
visibility: Visibility;
/**
* @generated from field: slash.store.OpenGraphMetadata og_metadata = 12;
*/
ogMetadata?: OpenGraphMetadata;
constructor(data?: PartialMessage<Shortcut>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.store.Shortcut";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Shortcut;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Shortcut;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Shortcut;
static equals(a: Shortcut | PlainMessage<Shortcut> | undefined, b: Shortcut | PlainMessage<Shortcut> | undefined): boolean;
}
/**
* @generated from message slash.store.OpenGraphMetadata
*/
export declare class OpenGraphMetadata extends Message<OpenGraphMetadata> {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: string description = 2;
*/
description: string;
/**
* @generated from field: string image = 3;
*/
image: string;
constructor(data?: PartialMessage<OpenGraphMetadata>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.store.OpenGraphMetadata";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): OpenGraphMetadata;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
static equals(a: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined, b: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined): boolean;
}

View File

@ -1,54 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file store/shortcut.proto (package slash.store, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import { proto3 } from "@bufbuild/protobuf";
import { RowStatus } from "./common_pb.js";
/**
* @generated from enum slash.store.Visibility
*/
export const Visibility = proto3.makeEnum(
"slash.store.Visibility",
[
{no: 0, name: "VISIBILITY_UNSPECIFIED"},
{no: 1, name: "PRIVATE"},
{no: 2, name: "WORKSPACE"},
{no: 3, name: "PUBLIC"},
],
);
/**
* @generated from message slash.store.Shortcut
*/
export const Shortcut = proto3.makeMessageType(
"slash.store.Shortcut",
() => [
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 2, name: "creator_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
{ no: 5, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
{ no: 6, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 7, name: "link", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 8, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 9, name: "tags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
{ no: 10, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 11, name: "visibility", kind: "enum", T: proto3.getEnumType(Visibility) },
{ no: 12, name: "og_metadata", kind: "message", T: OpenGraphMetadata },
],
);
/**
* @generated from message slash.store.OpenGraphMetadata
*/
export const OpenGraphMetadata = proto3.makeMessageType(
"slash.store.OpenGraphMetadata",
() => [
{ no: 1, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 3, name: "image", kind: "scalar", T: 9 /* ScalarType.STRING */ },
],
);

View File

@ -1,116 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file store/user_setting.proto (package slash.store, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
import { Message, proto3 } from "@bufbuild/protobuf";
/**
* @generated from enum slash.store.UserSettingKey
*/
export declare enum UserSettingKey {
/**
* @generated from enum value: USER_SETTING_KEY_UNSPECIFIED = 0;
*/
USER_SETTING_KEY_UNSPECIFIED = 0,
/**
* @generated from enum value: USER_SETTING_ACCESS_TOKENS = 1;
*/
USER_SETTING_ACCESS_TOKENS = 1,
}
/**
* @generated from message slash.store.UserSetting
*/
export declare class UserSetting extends Message<UserSetting> {
/**
* @generated from field: int32 user_id = 1;
*/
userId: number;
/**
* @generated from field: slash.store.UserSettingKey key = 2;
*/
key: UserSettingKey;
/**
* @generated from oneof slash.store.UserSetting.value
*/
value: {
/**
* @generated from field: slash.store.AccessTokensUserSetting access_tokens_user_setting = 3;
*/
value: AccessTokensUserSetting;
case: "accessTokensUserSetting";
} | { case: undefined; value?: undefined };
constructor(data?: PartialMessage<UserSetting>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.store.UserSetting";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): UserSetting;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): UserSetting;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): UserSetting;
static equals(a: UserSetting | PlainMessage<UserSetting> | undefined, b: UserSetting | PlainMessage<UserSetting> | undefined): boolean;
}
/**
* @generated from message slash.store.AccessTokensUserSetting
*/
export declare class AccessTokensUserSetting extends Message<AccessTokensUserSetting> {
/**
* @generated from field: repeated slash.store.AccessTokensUserSetting.AccessToken access_tokens = 1;
*/
accessTokens: AccessTokensUserSetting_AccessToken[];
constructor(data?: PartialMessage<AccessTokensUserSetting>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.store.AccessTokensUserSetting";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): AccessTokensUserSetting;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): AccessTokensUserSetting;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): AccessTokensUserSetting;
static equals(a: AccessTokensUserSetting | PlainMessage<AccessTokensUserSetting> | undefined, b: AccessTokensUserSetting | PlainMessage<AccessTokensUserSetting> | undefined): boolean;
}
/**
* @generated from message slash.store.AccessTokensUserSetting.AccessToken
*/
export declare class AccessTokensUserSetting_AccessToken extends Message<AccessTokensUserSetting_AccessToken> {
/**
* @generated from field: string access_token = 1;
*/
accessToken: string;
/**
* @generated from field: string description = 2;
*/
description: string;
constructor(data?: PartialMessage<AccessTokensUserSetting_AccessToken>);
static readonly runtime: typeof proto3;
static readonly typeName = "slash.store.AccessTokensUserSetting.AccessToken";
static readonly fields: FieldList;
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): AccessTokensUserSetting_AccessToken;
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): AccessTokensUserSetting_AccessToken;
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): AccessTokensUserSetting_AccessToken;
static equals(a: AccessTokensUserSetting_AccessToken | PlainMessage<AccessTokensUserSetting_AccessToken> | undefined, b: AccessTokensUserSetting_AccessToken | PlainMessage<AccessTokensUserSetting_AccessToken> | undefined): boolean;
}

View File

@ -1,52 +0,0 @@
// @generated by protoc-gen-es v1.3.0
// @generated from file store/user_setting.proto (package slash.store, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import { proto3 } from "@bufbuild/protobuf";
/**
* @generated from enum slash.store.UserSettingKey
*/
export const UserSettingKey = proto3.makeEnum(
"slash.store.UserSettingKey",
[
{no: 0, name: "USER_SETTING_KEY_UNSPECIFIED"},
{no: 1, name: "USER_SETTING_ACCESS_TOKENS"},
],
);
/**
* @generated from message slash.store.UserSetting
*/
export const UserSetting = proto3.makeMessageType(
"slash.store.UserSetting",
() => [
{ no: 1, name: "user_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
{ no: 2, name: "key", kind: "enum", T: proto3.getEnumType(UserSettingKey) },
{ no: 3, name: "access_tokens_user_setting", kind: "message", T: AccessTokensUserSetting, oneof: "value" },
],
);
/**
* @generated from message slash.store.AccessTokensUserSetting
*/
export const AccessTokensUserSetting = proto3.makeMessageType(
"slash.store.AccessTokensUserSetting",
() => [
{ no: 1, name: "access_tokens", kind: "message", T: AccessTokensUserSetting_AccessToken, repeated: true },
],
);
/**
* @generated from message slash.store.AccessTokensUserSetting.AccessToken
*/
export const AccessTokensUserSetting_AccessToken = proto3.makeMessageType(
"slash.store.AccessTokensUserSetting.AccessToken",
() => [
{ no: 1, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
],
{localName: "AccessTokensUserSetting_AccessToken"},
);

View File

@ -36,3 +36,5 @@ keys.json
# typescript # typescript
.tsbuildinfo .tsbuildinfo
src/types/proto

View File

@ -4,5 +4,5 @@ module.exports = {
semi: true, semi: true,
singleQuote: false, singleQuote: false,
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")], plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!less).+)", "^[./]", "^(.+).css"], importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!css).+)", "^[./]", "^[../]", "^(.+).css"],
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View 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": [
"*://*/*"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,43 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
import { Storage } from "@plasmohq/storage"; import { Storage } from "@plasmohq/storage";
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
const storage = new Storage(); const storage = new Storage();
const urlRegex = /https?:\/\/s\/(.+)/; const urlRegex = /https?:\/\/s\/(.+)/;
chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => { chrome.webRequest.onBeforeRequest.addListener(
if (!tab.url) { (param) => {
(async () => {
if (!param.url) {
return; return;
} }
const shortcutName = getShortcutNameFromUrl(tab.url); const shortcutName = getShortcutNameFromUrl(param.url);
if (shortcutName) { if (shortcutName) {
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || []; const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
const shortcut = shortcuts.find((shortcut) => shortcut.name === shortcutName); const shortcut = shortcuts.find((shortcut) => shortcut.name === shortcutName);
if (!shortcut) { if (!shortcut) {
return; return;
} }
return chrome.tabs.update(tabId, { url: shortcut.link }); return chrome.tabs.update({ url: shortcut.link });
} }
}); })();
},
{ urls: ["*://s/*", "*://*/search*"] }
);
chrome.omnibox.onInputEntered.addListener(async (text) => { chrome.omnibox.onInputEntered.addListener(async (text, disposition) => {
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || []; const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
const shortcut = shortcuts.find((shortcut) => shortcut.name === text); const shortcut = shortcuts.find((shortcut) => shortcut.name === text);
if (!shortcut) { if (!shortcut) {
return; return;
} }
return chrome.tabs.update({ url: shortcut.link }); 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 getShortcutNameFromUrl = (urlString: string) => {

View File

@ -3,12 +3,13 @@ import { useStorage } from "@plasmohq/storage/hook";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { CreateShortcutResponse, OpenGraphMetadata, Visibility } from "@/types/proto/api/v2/shortcut_service_pb"; import { Visibility } from "@/types/proto/api/v2/common";
import { CreateShortcutResponse, OpenGraphMetadata } from "@/types/proto/api/v2/shortcut_service";
import Icon from "./Icon"; import Icon from "./Icon";
const generateTempName = (length = 6) => { const generateTempName = (length = 6) => {
let result = ""; let result = "";
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length; const charactersLength = characters.length;
let counter = 0; let counter = 0;
while (counter < length) { while (counter < length) {
@ -53,7 +54,7 @@ const CreateShortcutsButton = () => {
const tab = tabs[0]; const tab = tabs[0];
setState((state) => ({ setState((state) => ({
...state, ...state,
name: generateTempName().toLowerCase() + "-temp", name: generateTempName() + "-temp",
title: tab.title || "", title: tab.title || "",
link: tab.url || "", link: tab.url || "",
})); }));
@ -102,7 +103,7 @@ const CreateShortcutsButton = () => {
title: state.title, title: state.title,
link: state.link, link: state.link,
visibility: Visibility.PRIVATE, visibility: Visibility.PRIVATE,
ogMetadata: OpenGraphMetadata.fromJsonString("{}"), ogMetadata: OpenGraphMetadata.fromPartial({}),
}, },
{ {
headers: { headers: {
@ -128,24 +129,24 @@ const CreateShortcutsButton = () => {
</IconButton> </IconButton>
<Modal container={() => document.body} open={showModal} onClose={() => setShowModal(false)}> <Modal container={() => document.body} open={showModal} onClose={() => setShowModal(false)}>
<ModalDialog> <ModalDialog className="w-3/4">
<div className="w-full flex flex-row justify-between items-center mb-2"> <div className="w-full flex flex-row justify-between items-center mb-2">
<span className="text-base font-medium">Create Shortcut</span> <span className="text-base font-medium">Create Shortcut</span>
<Button size="sm" variant="plain" onClick={() => setShowModal(false)}> <Button size="sm" variant="plain" onClick={() => setShowModal(false)}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />
</Button> </Button>
</div> </div>
<div className="overflow-x-hidden px-2 w-full flex flex-col justify-start items-center"> <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"> <div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-16 shrink-0">Name</span> <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} /> <Input className="grow" type="text" placeholder="Unique shortcut name" value={state.name} onChange={handleNameInputChange} />
</div> </div>
<div className="w-full flex flex-row justify-start items-center mb-2"> <div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-16 shrink-0">Title</span> <span className="block w-12 mr-2 shrink-0">Title</span>
<Input className="grow" type="text" placeholder="Shortcut title" value={state.title} onChange={handleTitleInputChange} /> <Input className="grow" type="text" placeholder="Shortcut title" value={state.title} onChange={handleTitleInputChange} />
</div> </div>
<div className="w-full flex flex-row justify-start items-center mb-2"> <div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-16 shrink-0">Link</span> <span className="block w-12 mr-2 shrink-0">Link</span>
<Input <Input
className="grow" className="grow"
type="text" type="text"

View File

@ -3,7 +3,7 @@ import { useStorage } from "@plasmohq/storage/hook";
import axios from "axios"; import axios from "axios";
import { useEffect } from "react"; import { useEffect } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { ListShortcutsResponse } from "@/types/proto/api/v2/shortcut_service_pb"; import { ListShortcutsResponse } from "@/types/proto/api/v2/shortcut_service";
import Icon from "./Icon"; import Icon from "./Icon";
const PullShortcutsButton = () => { const PullShortcutsButton = () => {

View File

@ -1,8 +1,7 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
import { useStorage } from "@plasmohq/storage/hook"; import { useStorage } from "@plasmohq/storage/hook";
import classNames from "classnames"; import classNames from "classnames";
import { useEffect, useState } from "react"; import { getFaviconWithGoogleS2 } from "@/helpers/utils";
import useFaviconStore from "../stores/favicon"; import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -11,17 +10,8 @@ interface Props {
const ShortcutView = (props: Props) => { const ShortcutView = (props: Props) => {
const { shortcut } = props; const { shortcut } = props;
const faviconStore = useFaviconStore();
const [domain] = useStorage<string>("domain", ""); const [domain] = useStorage<string>("domain", "");
const [favicon, setFavicon] = useState<string | undefined>(undefined); const favicon = getFaviconWithGoogleS2(shortcut.link);
useEffect(() => {
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
if (url) {
setFavicon(url);
}
});
}, [shortcut.link]);
const handleShortcutLinkClick = () => { const handleShortcutLinkClick = () => {
const shortcutLink = `${domain}/s/${shortcut.name}`; const shortcutLink = `${domain}/s/${shortcut.name}`;
@ -32,13 +22,13 @@ const ShortcutView = (props: Props) => {
<> <>
<div <div
className={classNames( 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" "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"> <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")}> <span className={classNames("w-5 h-5 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" />
)} )}
@ -52,13 +42,13 @@ const ShortcutView = (props: Props) => {
onClick={handleShortcutLinkClick} onClick={handleShortcutLinkClick}
> >
<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>

View File

@ -1,6 +1,6 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb";
import { useStorage } from "@plasmohq/storage/hook"; import { useStorage } from "@plasmohq/storage/hook";
import classNames from "classnames"; import classNames from "classnames";
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import ShortcutView from "./ShortcutView"; import ShortcutView from "./ShortcutView";
const ShortcutsContainer = () => { const ShortcutsContainer = () => {

View 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;
}
};

View 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;

View 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;

View 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;

View File

@ -5,7 +5,7 @@
body, body,
html, html,
#root { #root {
@apply text-base; @apply text-base dark:bg-zinc-900;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei", font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji"; "Noto Color Emoji";

View File

@ -6,13 +6,14 @@
"include": [ "include": [
".plasmo/index.d.ts", ".plasmo/index.d.ts",
"./**/*.ts", "./**/*.ts",
"./**/*.tsx" "./**/*.tsx",
"../types"
], ],
"compilerOptions": { "compilerOptions": {
"paths": { "paths": {
"@/*": [ "@/*": [
"./src/*" "./src/*"
] ],
}, },
"baseUrl": "." "baseUrl": "."
} }

View 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
View 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
View 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": "允许其他用户注册新账号"
}
}
}
}

View File

@ -3,3 +3,4 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
src/types/proto

56
frontend/web/package.json Normal file
View 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"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

75
frontend/web/src/App.tsx Normal file
View 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;

View File

@ -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>

View File

@ -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>

View 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;

View File

@ -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>

View 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;

View File

@ -1,7 +1,8 @@
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy"; import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
import axios from "axios";
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 { userServiceClient } from "@/grpcweb";
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";
@ -33,6 +34,7 @@ interface State {
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => { const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm } = props; const { onClose, onConfirm } = props;
const { t } = useTranslation();
const currentUser = useUserStore().getCurrentUser(); const currentUser = useUserStore().getCurrentUser();
const [state, setState] = useState({ const [state, setState] = useState({
description: "", description: "",
@ -66,9 +68,10 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
} }
try { try {
await axios.post(`/api/v2/users/${currentUser.id}/access_tokens`, { await userServiceClient.createUserAccessToken({
id: currentUser.id,
description: state.description, description: state.description,
expiresAt: new Date(Date.now() + state.expiration * 1000), expiresAt: state.expiration ? new Date(Date.now() + state.expiration * 1000) : undefined,
}); });
if (onConfirm) { if (onConfirm) {
@ -112,17 +115,17 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
<div className="w-full flex flex-row justify-start items-center text-base"> <div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}> <RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}>
{expirationOptions.map((option) => ( {expirationOptions.map((option) => (
<Radio key={option.value} value={option.value} label={option.label} /> <Radio key={option.value} value={option.value} checked={state.expiration === option.value} label={option.label} />
))} ))}
</RadioGroup> </RadioGroup>
</div> </div>
</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}>
Create {t("common.create")}
</Button> </Button>
</div> </div>
</div> </div>

View 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;

Some files were not shown because too many files have changed in this diff Show More