Compare commits
5 Commits
releases/v
...
v0.5.0
Author | SHA1 | Date | |
---|---|---|---|
f815804f68 | |||
70f6f30d69 | |||
12cf0f8a8c | |||
3932cabeac | |||
f8d36ae1ef |
26
.github/dependabot.yml
vendored
@ -1,26 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
- package-ecosystem: npm
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
directory: "/frontend/web"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
- package-ecosystem: npm
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
directory: "/frontend/extension"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
- package-ecosystem: "gomod"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
16
.github/workflows/backend-tests.yml
vendored
@ -12,10 +12,10 @@ jobs:
|
||||
go-static-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.23
|
||||
go-version: 1.21
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Verify go.mod is tidy
|
||||
@ -23,19 +23,19 @@ jobs:
|
||||
go mod tidy
|
||||
git diff --exit-code
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: v1.61.0
|
||||
version: v1.54.1
|
||||
args: --verbose --timeout=3m
|
||||
skip-cache: true
|
||||
|
||||
go-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.23
|
||||
go-version: 1.21
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Run all tests
|
||||
|
@ -1,85 +1,44 @@
|
||||
name: build-and-push-stable-image
|
||||
name: build-and-push-release-image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
# Match stable and rc versions, such as 'v1.0.0' or 'v0.23.0-rc.0'
|
||||
# - "v*.*.*"
|
||||
# - "v*.*.*-rc.*"
|
||||
- "v*.*.*-e"
|
||||
- "v*.*.*-rc.*-e"
|
||||
branches:
|
||||
# Run on pushing branches like `release/1.0.0`
|
||||
- "release/*.*.*"
|
||||
|
||||
jobs:
|
||||
build-and-push-stable-image:
|
||||
build-and-push-release-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Extract build args
|
||||
# Extract version number and check if it's an rc version
|
||||
# Extract version from branch name
|
||||
# Example: branch name `release/1.0.0` sets up env.VERSION=1.0.0
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_NAME}" =~ -rc ]]; then
|
||||
echo "PRE_RELEASE=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "PRE_RELEASE=false" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: aykhans
|
||||
username: yourselfhosted
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
install: true
|
||||
version: v0.9.1
|
||||
|
||||
# Metadata for stable versions
|
||||
- name: Docker meta for stable
|
||||
id: meta-stable
|
||||
if: env.PRE_RELEASE == 'false'
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
aykhans/slash
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ env.VERSION }}
|
||||
type=raw,value=stable
|
||||
flavor: |
|
||||
latest=true
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ env.VERSION }}
|
||||
|
||||
# Metadata for rc versions
|
||||
- name: Docker meta for rc
|
||||
id: meta-rc
|
||||
if: env.PRE_RELEASE == 'true'
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
aykhans/slash
|
||||
tags: |
|
||||
type=raw,value=${{ env.VERSION }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ env.VERSION }}
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta-stable.outputs.tags || steps.meta-rc.outputs.tags }}
|
||||
labels: ${{ steps.meta-stable.outputs.labels || steps.meta-rc.outputs.labels }}
|
||||
tags: yourselfhosted/slash:latest, yourselfhosted/slash:${{ env.VERSION }}
|
||||
|
10
.github/workflows/build-and-push-test-image.yml
vendored
@ -8,27 +8,27 @@ jobs:
|
||||
build-and-push-test-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: yourselfhosted
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
install: true
|
||||
version: v0.9.1
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
|
33
.github/workflows/build-artifacts.yml
vendored
@ -1,33 +0,0 @@
|
||||
name: Build artifacts
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.23
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
# either 'goreleaser' (default) or 'goreleaser-pro'
|
||||
distribution: goreleaser
|
||||
# 'latest', 'nightly', or a semver
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
20
.github/workflows/extension-test.yml
vendored
@ -14,17 +14,19 @@ jobs:
|
||||
eslint-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4.0.0
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: pnpm
|
||||
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: frontend/extension
|
||||
- run: pnpm type-gen
|
||||
working-directory: frontend/extension
|
||||
- name: Run eslint check
|
||||
run: pnpm lint
|
||||
working-directory: frontend/extension
|
||||
@ -32,17 +34,19 @@ jobs:
|
||||
extension-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4.0.0
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: pnpm
|
||||
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: frontend/extension
|
||||
- run: pnpm type-gen
|
||||
working-directory: frontend/extension
|
||||
- name: Run extension build
|
||||
run: pnpm build
|
||||
working-directory: frontend/extension
|
||||
|
23
.github/workflows/frontend-test.yml
vendored
@ -14,38 +14,39 @@ jobs:
|
||||
eslint-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4.0.0
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: pnpm
|
||||
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: frontend/web
|
||||
- run: pnpm type-gen
|
||||
working-directory: frontend/web
|
||||
- name: Run eslint check
|
||||
run: pnpm lint
|
||||
working-directory: frontend/web
|
||||
- name: Run type check
|
||||
run: pnpm type-check
|
||||
working-directory: frontend/web
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4.0.0
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: pnpm
|
||||
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: frontend/web
|
||||
- run: pnpm type-gen
|
||||
working-directory: frontend/web
|
||||
- name: Run frontend build
|
||||
run: pnpm build
|
||||
working-directory: frontend/web
|
||||
|
2
.github/workflows/proto-linter.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup buf
|
||||
|
4
.gitignore
vendored
@ -10,7 +10,3 @@ build
|
||||
.DS_Store
|
||||
|
||||
node_modules
|
||||
|
||||
.env
|
||||
|
||||
dist/
|
||||
|
@ -20,12 +20,16 @@ issues:
|
||||
# https://golangci-lint.run/usage/configuration/#command-line-options
|
||||
exclude:
|
||||
- Rollback
|
||||
- logger.Sync
|
||||
- pgInstance.Stop
|
||||
- fmt.Printf
|
||||
- Enter(.*)_(.*)
|
||||
- Exit(.*)_(.*)
|
||||
|
||||
linters-settings:
|
||||
goimports:
|
||||
# Put imports beginning with prefix after 3rd-party packages.
|
||||
local-prefixes: github.com/yourselfhosted/slash
|
||||
local-prefixes: github.com/boojack/slash
|
||||
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
|
||||
@ -61,14 +65,6 @@ linters-settings:
|
||||
disabled: true
|
||||
- name: early-return
|
||||
disabled: true
|
||||
- name: use-any
|
||||
disabled: true
|
||||
- name: var-naming
|
||||
disabled: true
|
||||
- name: unchecked-type-assertion
|
||||
disabled: true
|
||||
- name: max-control-nesting
|
||||
disabled: true
|
||||
- name: exported
|
||||
arguments:
|
||||
- "disableStutteringCheck"
|
||||
|
@ -1,38 +0,0 @@
|
||||
version: 1
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
main: ./bin/slash
|
||||
binary: slash
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
checksum:
|
||||
disable: true
|
||||
|
||||
release:
|
||||
draft: true
|
||||
replace_existing_draft: true
|
||||
make_latest: true
|
||||
mode: replace
|
||||
skip_upload: false
|
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"go.lintOnSave": "workspace",
|
||||
"go.lintTool": "golangci-lint"
|
||||
}
|
@ -6,16 +6,16 @@ COPY . .
|
||||
|
||||
WORKDIR /frontend-build/frontend/web
|
||||
|
||||
RUN corepack enable && pnpm i --frozen-lockfile
|
||||
RUN corepack enable && pnpm i --frozen-lockfile && pnpm type-gen
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# Build backend exec file.
|
||||
FROM golang:1.23-alpine AS backend
|
||||
FROM golang:1.21-alpine AS backend
|
||||
WORKDIR /backend-build
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /frontend-build/frontend/web/dist /backend-build/server/route/frontend/dist
|
||||
COPY --from=frontend /frontend-build/frontend/web/dist ./server/dist
|
||||
|
||||
RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
|
||||
|
||||
|
21
README.md
@ -1,19 +1,19 @@
|
||||
# Slash
|
||||
|
||||
**Slash** is an open source, self-hosted platform designed to help you organize, manage, and share your most important links. Easily create customizable, human-readable shortcuts to streamline your link management. Use tags to categorize your links and share them easily with your team or publicly.
|
||||
<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 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/)
|
||||
|
||||
Getting started with Slash's [Shortcuts](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/shortcuts.md) and [Collections](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/collections.md).
|
||||
|
||||
[👉 Join our Discord 💬](https://discord.gg/QZqUuUAhDV)
|
||||
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a> • <a href="https://discord.gg/QZqUuUAhDV">Discord</a>
|
||||
|
||||
<p>
|
||||
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a>
|
||||
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></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>
|
||||
|
||||

|
||||

|
||||
|
||||
## Background
|
||||
|
||||
@ -23,11 +23,10 @@ That's why we developed Slash, a solution that transforms these links into easil
|
||||
|
||||
## Features
|
||||
|
||||
- Create customizable `s/` short links for any URL.
|
||||
- Create customizable `/s/` short links for any URL.
|
||||
- Share short links public or only with your teammates.
|
||||
- View analytics on link traffic and sources.
|
||||
- Easy access to your shortcuts with browser extension.
|
||||
- Share your shortcuts with Collection to anyone, on any browser.
|
||||
- Open source self-hosted solution.
|
||||
|
||||
## Deploy with Docker in seconds
|
||||
@ -36,15 +35,15 @@ That's why we developed Slash, a solution that transforms these links into easil
|
||||
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
||||
```
|
||||
|
||||
Learn more in [Self-hosting Slash with Docker](https://github.com/yourselfhosted/slash/blob/main/docs/install.md).
|
||||
Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md).
|
||||
|
||||
## Browser Extension
|
||||
|
||||
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
|
||||
|
||||

|
||||

|
||||
|
||||
Learn more in [The Browser Extension of Slash](https://github.com/yourselfhosted/slash/blob/main/docs/install-browser-extension.md).
|
||||
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md).
|
||||
|
||||
### Chromium based browsers
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
package v1
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
const (
|
12
api/v1/activity.go
Normal file
@ -0,0 +1,12 @@
|
||||
package v1
|
||||
|
||||
type ActivityShorcutCreatePayload struct {
|
||||
ShortcutID int32 `json:"shortcutId"`
|
||||
}
|
||||
|
||||
type ActivityShorcutViewPayload struct {
|
||||
ShortcutID int32 `json:"shortcutId"`
|
||||
IP string `json:"ip"`
|
||||
Referer string `json:"referer"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
}
|
131
api/v1/analytics.go
Normal file
@ -0,0 +1,131 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mssola/useragent"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/boojack/slash/server/metric"
|
||||
"github.com/boojack/slash/store"
|
||||
)
|
||||
|
||||
type ReferenceInfo struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type DeviceInfo struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type BrowserInfo struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type AnalysisData struct {
|
||||
ReferenceData []ReferenceInfo `json:"referenceData"`
|
||||
DeviceData []DeviceInfo `json:"deviceData"`
|
||||
BrowserData []BrowserInfo `json:"browserData"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
||||
g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||
Type: store.ActivityShortcutView,
|
||||
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcutID)},
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
referenceMap := make(map[string]int)
|
||||
deviceMap := make(map[string]int)
|
||||
browserMap := make(map[string]int)
|
||||
for _, activity := range activities {
|
||||
payload := &ActivityShorcutViewPayload{}
|
||||
if err := json.Unmarshal([]byte(activity.Payload), payload); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal payload, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
if _, ok := referenceMap[payload.Referer]; !ok {
|
||||
referenceMap[payload.Referer] = 0
|
||||
}
|
||||
referenceMap[payload.Referer]++
|
||||
|
||||
ua := useragent.New(payload.UserAgent)
|
||||
deviceName := ua.OSInfo().Name
|
||||
browserName, _ := ua.Browser()
|
||||
|
||||
if _, ok := deviceMap[deviceName]; !ok {
|
||||
deviceMap[deviceName] = 0
|
||||
}
|
||||
deviceMap[deviceName]++
|
||||
|
||||
if _, ok := browserMap[browserName]; !ok {
|
||||
browserMap[browserName] = 0
|
||||
}
|
||||
browserMap[browserName]++
|
||||
}
|
||||
|
||||
metric.Enqueue("shortcut analytics")
|
||||
return c.JSON(http.StatusOK, &AnalysisData{
|
||||
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
||||
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
||||
BrowserData: mapToBrowserInfoSlice(browserMap),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
|
||||
referenceInfoSlice := make([]ReferenceInfo, 0)
|
||||
for key, value := range m {
|
||||
referenceInfoSlice = append(referenceInfoSlice, ReferenceInfo{
|
||||
Name: key,
|
||||
Count: value,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) int {
|
||||
return i.Count - j.Count
|
||||
})
|
||||
return referenceInfoSlice
|
||||
}
|
||||
|
||||
func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
|
||||
deviceInfoSlice := make([]DeviceInfo, 0)
|
||||
for key, value := range m {
|
||||
deviceInfoSlice = append(deviceInfoSlice, DeviceInfo{
|
||||
Name: key,
|
||||
Count: value,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) int {
|
||||
return i.Count - j.Count
|
||||
})
|
||||
return deviceInfoSlice
|
||||
}
|
||||
|
||||
func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
|
||||
browserInfoSlice := make([]BrowserInfo, 0)
|
||||
for key, value := range m {
|
||||
browserInfoSlice = append(browserInfoSlice, BrowserInfo{
|
||||
Name: key,
|
||||
Count: value,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) int {
|
||||
return i.Count - j.Count
|
||||
})
|
||||
return browserInfoSlice
|
||||
}
|
211
api/v1/auth.go
Normal file
@ -0,0 +1,211 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"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 {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type SignUpRequest struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
||||
g.POST("/auth/signin", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &SignInRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signin request, err: %s", err))
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Email: &signin.Email,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user by email %s", signin.Email)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("user not found with email %s", signin.Email))
|
||||
} else if user.RowStatus == store.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("user has been archived with email %s", signin.Email))
|
||||
}
|
||||
|
||||
// Compare the stored hashed password, with the hashed version of the password that was received.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
|
||||
}
|
||||
|
||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
metric.Enqueue("user sign in")
|
||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||
})
|
||||
|
||||
g.POST("/auth/signup", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
|
||||
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{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signup request, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
|
||||
create := &store.User{
|
||||
Email: signup.Email,
|
||||
Nickname: signup.Nickname,
|
||||
PasswordHash: string(passwordHash),
|
||||
}
|
||||
existingUsers, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find existing users, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
// The first user to sign up is an admin by default.
|
||||
if len(existingUsers) == 0 {
|
||||
create.Role = store.RoleAdmin
|
||||
} else {
|
||||
create.Role = store.RoleUser
|
||||
}
|
||||
|
||||
user, err := s.Store.CreateUser(ctx, create)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
metric.Enqueue("user sign up")
|
||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||
})
|
||||
|
||||
g.POST("/auth/logout", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
RemoveTokensAndCookies(c)
|
||||
accessToken := findAccessToken(c)
|
||||
userID, _ := getUserIDFromAccessToken(accessToken, secret)
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||
// Auto remove the current access token from the user access tokens.
|
||||
if err == nil && len(userAccessTokens) != 0 {
|
||||
accessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if accessToken != userAccessToken.AccessToken {
|
||||
accessTokens = append(accessTokens, userAccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: userID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: accessTokens,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
c.Response().WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get user access tokens")
|
||||
}
|
||||
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
||||
AccessToken: accessToken,
|
||||
Description: "Account sign in",
|
||||
}
|
||||
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: userAccessTokens,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTokensAndCookies removes the jwt token from the cookies.
|
||||
func RemoveTokensAndCookies(c echo.Context) {
|
||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
||||
}
|
||||
|
||||
// setTokenCookie sets the token to the cookie.
|
||||
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
||||
cookie := new(http.Cookie)
|
||||
cookie.Name = name
|
||||
cookie.Value = token
|
||||
cookie.Expires = expiration
|
||||
cookie.Path = "/"
|
||||
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
||||
cookie.HttpOnly = true
|
||||
cookie.SameSite = http.SameSiteStrictMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
15
api/v1/common.go
Normal file
@ -0,0 +1,15 @@
|
||||
package v1
|
||||
|
||||
// RowStatus is the status for a row.
|
||||
type RowStatus string
|
||||
|
||||
const (
|
||||
// Normal is the status for a normal row.
|
||||
Normal RowStatus = "NORMAL"
|
||||
// Archived is the status for an archived row.
|
||||
Archived RowStatus = "ARCHIVED"
|
||||
)
|
||||
|
||||
func (s RowStatus) String() string {
|
||||
return string(s)
|
||||
}
|
133
api/v1/jwt.go
Normal file
@ -0,0 +1,133 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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/internal/util"
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
"github.com/boojack/slash/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// The key name used to store user id in the context
|
||||
// user id is extracted from the jwt token subject field.
|
||||
userIDContextKey = "user-id"
|
||||
)
|
||||
|
||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
authHeaderParts := strings.Fields(authHeader)
|
||||
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||
return "", errors.New("Authorization header format must be Bearer {token}")
|
||||
}
|
||||
|
||||
return authHeaderParts[1], nil
|
||||
}
|
||||
|
||||
func findAccessToken(c echo.Context) string {
|
||||
// Check the HTTP request header first.
|
||||
accessToken, _ := extractTokenFromHeader(c)
|
||||
if accessToken == "" {
|
||||
// Check the cookie.
|
||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||
if cookie != nil {
|
||||
accessToken = cookie.Value
|
||||
}
|
||||
}
|
||||
return accessToken
|
||||
}
|
||||
|
||||
// JWTMiddleware validates the access token.
|
||||
func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
path := c.Request().URL.Path
|
||||
method := c.Request().Method
|
||||
|
||||
// Pass auth and profile endpoints.
|
||||
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
accessToken := findAccessToken(c)
|
||||
if accessToken == "" {
|
||||
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
||||
if util.HasPrefixes(path, "/s/", "/api/v1/user/") && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||
}
|
||||
|
||||
userID, err := getUserIDFromAccessToken(accessToken, secret)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
|
||||
}
|
||||
|
||||
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
||||
}
|
||||
if !validateAccessToken(accessToken, accessTokens) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
||||
}
|
||||
|
||||
// Even if there is no error, we still need to make sure the user still exists.
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
||||
}
|
||||
|
||||
// Stores userID into context.
|
||||
c.Set(userIDContextKey, userID)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
|
||||
claims := &auth.ClaimsMessage{}
|
||||
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(secret), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "Invalid or expired access token")
|
||||
}
|
||||
// We either have a valid access token or we will attempt to generate new access token.
|
||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "Malformed ID in the token")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if accessTokenString == userAccessToken.AccessToken {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
118
api/v1/redirector.go
Normal file
@ -0,0 +1,118 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"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) {
|
||||
g.GET("/*", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
if len(c.ParamValues()) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid shortcut name")
|
||||
}
|
||||
|
||||
shortcutName := c.ParamValues()[0]
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
Name: &shortcutName,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/404?shortcut=%s", shortcutName))
|
||||
}
|
||||
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.createShortcutViewActivity(c, shortcut); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
metric.Enqueue("shortcut redirect")
|
||||
return redirectToShortcut(c, shortcut)
|
||||
})
|
||||
}
|
||||
|
||||
func redirectToShortcut(c echo.Context, shortcut *storepb.Shortcut) error {
|
||||
isValidURL := isValidURLString(shortcut.Link)
|
||||
if shortcut.OgMetadata == nil || (shortcut.OgMetadata.Title == "" && shortcut.OgMetadata.Description == "" && shortcut.OgMetadata.Image == "") {
|
||||
if isValidURL {
|
||||
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
||||
}
|
||||
return c.String(http.StatusOK, shortcut.Link)
|
||||
}
|
||||
|
||||
htmlTemplate := `<html><head>%s</head><body>%s</body></html>`
|
||||
metadataList := []string{
|
||||
fmt.Sprintf(`<title>%s</title>`, shortcut.OgMetadata.Title),
|
||||
fmt.Sprintf(`<meta name="description" content="%s" />`, shortcut.OgMetadata.Description),
|
||||
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OgMetadata.Title),
|
||||
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OgMetadata.Description),
|
||||
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OgMetadata.Image),
|
||||
`<meta property="og:type" content="website" />`,
|
||||
// Twitter related metadata.
|
||||
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OgMetadata.Title),
|
||||
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OgMetadata.Description),
|
||||
fmt.Sprintf(`<meta name="twitter:image" content="%s" />`, shortcut.OgMetadata.Image),
|
||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||
}
|
||||
if isValidURL {
|
||||
metadataList = append(metadataList, fmt.Sprintf(`<meta property="og:url" content="%s" />`, shortcut.Link))
|
||||
}
|
||||
body := ""
|
||||
if isValidURL {
|
||||
body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link)
|
||||
} else {
|
||||
body = html.EscapeString(shortcut.Link)
|
||||
}
|
||||
htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
|
||||
return c.HTML(http.StatusOK, htmlString)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *storepb.Shortcut) error {
|
||||
payload := &ActivityShorcutViewPayload{
|
||||
ShortcutID: shortcut.Id,
|
||||
IP: c.RealIP(),
|
||||
Referer: c.Request().Referer(),
|
||||
UserAgent: c.Request().UserAgent(),
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
||||
}
|
||||
activity := &store.Activity{
|
||||
CreatorID: BotID,
|
||||
Type: store.ActivityShortcutView,
|
||||
Level: store.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
}
|
||||
_, err = s.Store.CreateActivity(c.Request().Context(), activity)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create activity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidURLString(s string) bool {
|
||||
_, err := url.ParseRequestURI(s)
|
||||
return err == nil
|
||||
}
|
33
api/v1/redirector_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package v1
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidURLString(t *testing.T) {
|
||||
tests := []struct {
|
||||
link string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
link: "https://google.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
link: "http://google.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
link: "google.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
link: "mailto:email@example.com",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if isValidURLString(test.link) != test.expected {
|
||||
t.Errorf("isValidURLString(%s) = %v, expected %v", test.link, !test.expected, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
383
api/v1/shortcut.go
Normal file
@ -0,0 +1,383 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"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.
|
||||
type Visibility string
|
||||
|
||||
const (
|
||||
// VisibilityPublic is the PUBLIC visibility.
|
||||
VisibilityPublic Visibility = "PUBLIC"
|
||||
// VisibilityWorkspace is the WORKSPACE visibility.
|
||||
VisibilityWorkspace Visibility = "WORKSPACE"
|
||||
// VisibilityPrivate is the PRIVATE visibility.
|
||||
VisibilityPrivate Visibility = "PRIVATE"
|
||||
)
|
||||
|
||||
func (v Visibility) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
type OpenGraphMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type Shortcut struct {
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int32 `json:"creatorId"`
|
||||
Creator *User `json:"creator"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
View int `json:"view"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
type CreateShortcutRequest struct {
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
type PatchShortcutRequest struct {
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
Name *string `json:"name"`
|
||||
Link *string `json:"link"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Visibility *Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
g.POST("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
create := &CreateShortcutRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut := &storepb.Shortcut{
|
||||
CreatorId: userID,
|
||||
Name: create.Name,
|
||||
Link: create.Link,
|
||||
Title: create.Title,
|
||||
Description: create.Description,
|
||||
Visibility: convertVisibilityToStorepb(create.Visibility),
|
||||
Tags: create.Tags,
|
||||
OgMetadata: &storepb.OpenGraphMetadata{},
|
||||
}
|
||||
if create.OpenGraphMetadata != nil {
|
||||
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
||||
Title: create.OpenGraphMetadata.Title,
|
||||
Description: create.OpenGraphMetadata.Description,
|
||||
Image: create.OpenGraphMetadata.Image,
|
||||
}
|
||||
}
|
||||
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
metric.Enqueue("shortcut create")
|
||||
return c.JSON(http.StatusOK, shortcutMessage)
|
||||
})
|
||||
|
||||
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &shortcutID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||
}
|
||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
||||
}
|
||||
|
||||
patch := &PatchShortcutRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(patch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode patch shortcut request, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutUpdate := &store.UpdateShortcut{
|
||||
ID: shortcutID,
|
||||
Name: patch.Name,
|
||||
Link: patch.Link,
|
||||
Title: patch.Title,
|
||||
Description: patch.Description,
|
||||
}
|
||||
if patch.RowStatus != nil {
|
||||
shortcutUpdate.RowStatus = (*store.RowStatus)(patch.RowStatus)
|
||||
}
|
||||
if patch.Visibility != nil {
|
||||
shortcutUpdate.Visibility = (*store.Visibility)(patch.Visibility)
|
||||
}
|
||||
if patch.Tags != nil {
|
||||
tag := strings.Join(patch.Tags, " ")
|
||||
shortcutUpdate.Tag = &tag
|
||||
}
|
||||
if patch.OpenGraphMetadata != nil {
|
||||
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
|
||||
Title: patch.OpenGraphMetadata.Title,
|
||||
Description: patch.OpenGraphMetadata.Description,
|
||||
Image: patch.OpenGraphMetadata.Image,
|
||||
}
|
||||
}
|
||||
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, shortcutMessage)
|
||||
})
|
||||
|
||||
g.GET("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
|
||||
find := &store.FindShortcut{}
|
||||
if tag := c.QueryParam("tag"); tag != "" {
|
||||
find.Tag = &tag
|
||||
}
|
||||
|
||||
list := []*storepb.Shortcut{}
|
||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut list, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
list = append(list, visibleShortcutList...)
|
||||
|
||||
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
|
||||
find.CreatorID = &userID
|
||||
privateShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch private shortcut list, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
list = append(list, privateShortcutList...)
|
||||
|
||||
shortcutMessageList := []*Shortcut{}
|
||||
for _, shortcut := range list {
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
shortcutMessageList = append(shortcutMessageList, shortcutMessage)
|
||||
}
|
||||
return c.JSON(http.StatusOK, shortcutMessageList)
|
||||
})
|
||||
|
||||
g.GET("/shortcut/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &shortcutID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||
}
|
||||
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, shortcutMessage)
|
||||
})
|
||||
|
||||
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &shortcutID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||
}
|
||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
||||
}
|
||||
|
||||
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
||||
if shortcut == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &shortcut.CreatorID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to get creator")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("Creator not found")
|
||||
}
|
||||
shortcut.Creator = convertUserFromStore(user)
|
||||
|
||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||
Type: store.ActivityShortcutView,
|
||||
Level: store.ActivityInfo,
|
||||
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcut.ID)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to list activities")
|
||||
}
|
||||
shortcut.View = len(activityList)
|
||||
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
|
||||
return &Shortcut{
|
||||
ID: shortcut.Id,
|
||||
CreatedTs: shortcut.CreatedTs,
|
||||
UpdatedTs: shortcut.UpdatedTs,
|
||||
CreatorID: shortcut.CreatorId,
|
||||
RowStatus: RowStatus(shortcut.RowStatus.String()),
|
||||
Name: shortcut.Name,
|
||||
Link: shortcut.Link,
|
||||
Title: shortcut.Title,
|
||||
Description: shortcut.Description,
|
||||
Visibility: Visibility(shortcut.Visibility.String()),
|
||||
Tags: shortcut.Tags,
|
||||
OpenGraphMetadata: &OpenGraphMetadata{
|
||||
Title: shortcut.OgMetadata.Title,
|
||||
Description: shortcut.OgMetadata.Description,
|
||||
Image: shortcut.OgMetadata.Image,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
|
||||
switch visibility {
|
||||
case VisibilityPublic:
|
||||
return storepb.Visibility_PUBLIC
|
||||
case VisibilityWorkspace:
|
||||
return storepb.Visibility_WORKSPACE
|
||||
case VisibilityPrivate:
|
||||
return storepb.Visibility_PRIVATE
|
||||
default:
|
||||
return storepb.Visibility_PUBLIC
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
||||
payload := &ActivityShorcutCreatePayload{
|
||||
ShortcutID: shortcut.Id,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
||||
}
|
||||
activity := &store.Activity{
|
||||
CreatorID: shortcut.CreatorId,
|
||||
Type: store.ActivityShortcutCreate,
|
||||
Level: store.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
}
|
||||
_, err = s.Store.CreateActivity(ctx, activity)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create activity")
|
||||
}
|
||||
return nil
|
||||
}
|
340
api/v1/user.go
Normal file
@ -0,0 +1,340 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"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 (
|
||||
// BotID is the id of bot.
|
||||
BotID = 0
|
||||
)
|
||||
|
||||
// Role is the type of a role.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// RoleAdmin is the ADMIN role.
|
||||
RoleAdmin Role = "ADMIN"
|
||||
// RoleUser is the USER role.
|
||||
RoleUser Role = "USER"
|
||||
)
|
||||
|
||||
func (r Role) String() string {
|
||||
switch r {
|
||||
case RoleAdmin:
|
||||
return "ADMIN"
|
||||
case RoleUser:
|
||||
return "USER"
|
||||
}
|
||||
return "USER"
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Password string `json:"password"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
func (create CreateUserRequest) Validate() error {
|
||||
if create.Email != "" && !validateEmail(create.Email) {
|
||||
return errors.New("invalid email format")
|
||||
}
|
||||
if create.Nickname != "" && len(create.Nickname) < 3 {
|
||||
return errors.New("nickname is too short, minimum length is 3")
|
||||
}
|
||||
if len(create.Password) < 3 {
|
||||
return errors.New("password is too short, minimum length is 3")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type PatchUserRequest struct {
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
Email *string `json:"email"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Password *string `json:"password"`
|
||||
Role *Role `json:"role"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
g.POST("/user", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
if currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
|
||||
}
|
||||
|
||||
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
|
||||
}
|
||||
if len(userList) >= 5 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
|
||||
}
|
||||
}
|
||||
|
||||
userCreate := &CreateUserRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
||||
}
|
||||
if err := userCreate.Validate(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
|
||||
user, err := s.Store.CreateUser(ctx, &store.User{
|
||||
Role: store.Role(userCreate.Role),
|
||||
Email: userCreate.Email,
|
||||
Nickname: userCreate.Nickname,
|
||||
PasswordHash: string(passwordHash),
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
metric.Enqueue("user create")
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
})
|
||||
|
||||
g.GET("/user", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to list users, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
userList := []*User{}
|
||||
for _, user := range list {
|
||||
userList = append(userList, convertUserFromStore(user))
|
||||
}
|
||||
return c.JSON(http.StatusOK, userList)
|
||||
})
|
||||
|
||||
// GET /api/user/me is used to check if the user is logged in.
|
||||
g.GET("/user/me", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||
})
|
||||
|
||||
g.GET("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx := c.Request().Context()
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: ¤tUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||
}
|
||||
|
||||
userPatch := &PatchUserRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
updateUser := &store.UpdateUser{
|
||||
ID: userID,
|
||||
}
|
||||
if userPatch.Email != nil {
|
||||
if !validateEmail(*userPatch.Email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
||||
}
|
||||
updateUser.Email = userPatch.Email
|
||||
}
|
||||
if userPatch.Nickname != nil {
|
||||
updateUser.Nickname = userPatch.Nickname
|
||||
}
|
||||
if userPatch.Password != nil && *userPatch.Password != "" {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to hash password, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHashStr := string(passwordHash)
|
||||
updateUser.PasswordHash = &passwordHashStr
|
||||
}
|
||||
if userPatch.RowStatus != nil {
|
||||
rowStatus := store.RowStatus(*userPatch.RowStatus)
|
||||
updateUser.RowStatus = &rowStatus
|
||||
}
|
||||
if userPatch.Role != nil {
|
||||
adminRole := store.RoleAdmin
|
||||
adminUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
|
||||
Role: &adminRole,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list admin users, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if len(adminUsers) == 1 && adminUsers[0].ID == userID && *userPatch.Role != RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "cannot remove admin role from the last admin user")
|
||||
}
|
||||
role := store.Role(*userPatch.Role)
|
||||
updateUser.Role = &role
|
||||
}
|
||||
|
||||
user, err := s.Store.UpdateUser(ctx, updateUser)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to update user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||
})
|
||||
|
||||
g.DELETE("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: ¤tUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find current session user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
||||
}
|
||||
if currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||
}
|
||||
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
if user.Role == store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||
ID: userID,
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
// validateEmail validates the email.
|
||||
func validateEmail(email string) bool {
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// convertUserFromStore converts a store user to a user.
|
||||
func convertUserFromStore(user *store.User) *User {
|
||||
return &User{
|
||||
ID: user.ID,
|
||||
CreatedTs: user.CreatedTs,
|
||||
UpdatedTs: user.UpdatedTs,
|
||||
RowStatus: RowStatus(user.RowStatus),
|
||||
Email: user.Email,
|
||||
Nickname: user.Nickname,
|
||||
Role: Role(user.Role),
|
||||
}
|
||||
}
|
67
api/v1/user_setting.go
Normal file
@ -0,0 +1,67 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type UserSettingKey string
|
||||
|
||||
const (
|
||||
// UserSettingLocaleKey is the key type for user locale.
|
||||
UserSettingLocaleKey UserSettingKey = "locale"
|
||||
)
|
||||
|
||||
// String returns the string format of UserSettingKey type.
|
||||
func (k UserSettingKey) String() string {
|
||||
return string(k)
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{"en", "zh"}
|
||||
)
|
||||
|
||||
type UserSetting struct {
|
||||
UserID int
|
||||
Key UserSettingKey `json:"key"`
|
||||
// Value is a JSON string with basic value.
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type UserSettingUpsert struct {
|
||||
UserID int
|
||||
Key UserSettingKey `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (upsert UserSettingUpsert) Validate() error {
|
||||
if upsert.Key == UserSettingLocaleKey {
|
||||
localeValue := "en"
|
||||
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
||||
if err != nil {
|
||||
return errors.New("failed to unmarshal user setting locale value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, value := range UserSettingLocaleValue {
|
||||
if localeValue == value {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
return errors.New("invalid user setting locale value")
|
||||
}
|
||||
} else {
|
||||
return errors.New("invalid user setting key")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserSettingFind struct {
|
||||
UserID int
|
||||
|
||||
Key *UserSettingKey `json:"key"`
|
||||
}
|
41
api/v1/v1.go
Normal file
@ -0,0 +1,41 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"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 {
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
LicenseService *license.LicenseService
|
||||
}
|
||||
|
||||
func NewAPIV1Service(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *APIV1Service {
|
||||
return &APIV1Service{
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
LicenseService: licenseService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
||||
apiV1Group := apiGroup.Group("/api/v1")
|
||||
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return JWTMiddleware(s, next, secret)
|
||||
})
|
||||
s.registerWorkspaceRoutes(apiV1Group)
|
||||
s.registerAuthRoutes(apiV1Group, secret)
|
||||
s.registerUserRoutes(apiV1Group)
|
||||
s.registerShortcutRoutes(apiV1Group)
|
||||
s.registerAnalyticsRoutes(apiV1Group)
|
||||
|
||||
redirectorGroup := apiGroup.Group("/s")
|
||||
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return JWTMiddleware(s, next, secret)
|
||||
})
|
||||
s.registerRedirectorRoutes(redirectorGroup)
|
||||
}
|
39
api/v1/workspace.go
Normal file
@ -0,0 +1,39 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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/store"
|
||||
)
|
||||
|
||||
type WorkspaceProfile struct {
|
||||
Profile *profile.Profile `json:"profile"`
|
||||
DisallowSignUp bool `json:"disallowSignUp"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
|
||||
g.GET("/workspace/profile", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
workspaceProfile := WorkspaceProfile{
|
||||
Profile: s.Profile,
|
||||
DisallowSignUp: false,
|
||||
}
|
||||
|
||||
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if enableSignUpSetting != nil {
|
||||
workspaceProfile.DisallowSignUp = !enableSignUpSetting.GetEnableSignup()
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, workspaceProfile)
|
||||
})
|
||||
}
|
@ -1,20 +1,21 @@
|
||||
package v1
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/yourselfhosted/slash/internal/util"
|
||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
"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.
|
||||
@ -80,7 +81,7 @@ func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken str
|
||||
if accessToken == "" {
|
||||
return 0, status.Errorf(codes.Unauthenticated, "access token not found")
|
||||
}
|
||||
claims := &ClaimsMessage{}
|
||||
claims := &auth.ClaimsMessage{}
|
||||
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
@ -95,11 +96,11 @@ func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken str
|
||||
if err != nil {
|
||||
return 0, status.Errorf(codes.Unauthenticated, "Invalid or expired access token")
|
||||
}
|
||||
if !audienceContains(claims.Audience, AccessTokenAudienceName) {
|
||||
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||
return 0, status.Errorf(codes.Unauthenticated,
|
||||
"invalid access token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||
claims.Audience,
|
||||
AccessTokenAudienceName,
|
||||
auth.AccessTokenAudienceName,
|
||||
)
|
||||
}
|
||||
|
||||
@ -116,7 +117,7 @@ func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken str
|
||||
if user == nil {
|
||||
return 0, status.Errorf(codes.Unauthenticated, "user ID %q not exists in the access token", userID)
|
||||
}
|
||||
if user.RowStatus == storepb.RowStatus_ARCHIVED {
|
||||
if user.RowStatus == store.Archived {
|
||||
return 0, status.Errorf(codes.Unauthenticated, "user ID %q has been deactivated by administrators", userID)
|
||||
}
|
||||
|
||||
@ -147,7 +148,7 @@ func getTokenFromMetadata(md metadata.MD) (string, error) {
|
||||
header := http.Header{}
|
||||
header.Add("Cookie", t)
|
||||
request := http.Request{Header: header}
|
||||
if v, _ := request.Cookie(AccessTokenCookieName); v != nil {
|
||||
if v, _ := request.Cookie(auth.AccessTokenCookieName); v != nil {
|
||||
accessToken = v.Value
|
||||
}
|
||||
}
|
||||
@ -163,7 +164,7 @@ func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.UserSetting_AccessTokensSetting_AccessToken) bool {
|
||||
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if accessTokenString == userAccessToken.AccessToken {
|
||||
return true
|
29
api/v2/acl_config.go
Normal file
@ -0,0 +1,29 @@
|
||||
package v2
|
||||
|
||||
import "strings"
|
||||
|
||||
var allowedMethodsWhenUnauthorized = map[string]bool{
|
||||
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
|
||||
"/slash.api.v2.WorkspaceService/GetWorkspaceSetting": true,
|
||||
"/slash.api.v2.ShortcutService/GetShortcut": true,
|
||||
"/slash.api.v2.CollectionService/GetCollectionByName": true,
|
||||
}
|
||||
|
||||
// isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized.
|
||||
func isUnauthorizeAllowedMethod(methodName string) bool {
|
||||
if strings.HasPrefix(methodName, "/grpc.reflection") {
|
||||
return true
|
||||
}
|
||||
return allowedMethodsWhenUnauthorized[methodName]
|
||||
}
|
||||
|
||||
var allowedMethodsOnlyForAdmin = map[string]bool{
|
||||
"/slash.api.v2.UserService/CreateUser": true,
|
||||
"/slash.api.v2.UserService/DeleteUser": true,
|
||||
"/slash.api.v2.WorkspaceService/UpdateWorkspaceSetting": true,
|
||||
}
|
||||
|
||||
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
|
||||
func isOnlyForAdminAllowedMethod(methodName string) bool {
|
||||
return allowedMethodsOnlyForAdmin[methodName]
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package v1
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -6,33 +6,35 @@ import (
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
v1pb "github.com/yourselfhosted/slash/proto/gen/api/v1"
|
||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||
"github.com/yourselfhosted/slash/server/service/license"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
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 *APIV1Service) ListCollections(ctx context.Context, _ *v1pb.ListCollectionsRequest) (*v1pb.ListCollectionsResponse, error) {
|
||||
collections, err := s.Store.ListCollections(ctx, &store.FindCollection{})
|
||||
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 := []*v1pb.Collection{}
|
||||
convertedCollections := []*apiv2pb.Collection{}
|
||||
for _, collection := range collections {
|
||||
convertedCollections = append(convertedCollections, convertCollectionFromStore(collection))
|
||||
}
|
||||
|
||||
response := &v1pb.ListCollectionsResponse{
|
||||
response := &apiv2pb.ListCollectionsResponse{
|
||||
Collections: convertedCollections,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) GetCollection(ctx context.Context, request *v1pb.GetCollectionRequest) (*v1pb.Collection, error) {
|
||||
func (s *APIV2Service) GetCollection(ctx context.Context, request *apiv2pb.GetCollectionRequest) (*apiv2pb.GetCollectionResponse, error) {
|
||||
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||
ID: &request.Id,
|
||||
})
|
||||
@ -43,17 +45,17 @@ func (s *APIV1Service) GetCollection(ctx context.Context, request *v1pb.GetColle
|
||||
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||
}
|
||||
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user == nil && collection.Visibility != storepb.Visibility_PUBLIC {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
if collection.Visibility == storepb.Visibility_PRIVATE && collection.CreatorId != userID {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
return convertCollectionFromStore(collection), nil
|
||||
response := &apiv2pb.GetCollectionResponse{
|
||||
Collection: convertCollectionFromStore(collection),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) GetCollectionByName(ctx context.Context, request *v1pb.GetCollectionByNameRequest) (*v1pb.Collection, error) {
|
||||
func (s *APIV2Service) GetCollectionByName(ctx context.Context, request *apiv2pb.GetCollectionByNameRequest) (*apiv2pb.GetCollectionByNameResponse, error) {
|
||||
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||
Name: &request.Name,
|
||||
})
|
||||
@ -64,60 +66,56 @@ func (s *APIV1Service) GetCollectionByName(ctx context.Context, request *v1pb.Ge
|
||||
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||
}
|
||||
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user == nil && collection.Visibility != storepb.Visibility_PUBLIC {
|
||||
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")
|
||||
}
|
||||
return convertCollectionFromStore(collection), nil
|
||||
} 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 *APIV1Service) CreateCollection(ctx context.Context, request *v1pb.CreateCollectionRequest) (*v1pb.Collection, error) {
|
||||
if request.Collection.Name == "" || request.Collection.Title == "" {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "name and title are required")
|
||||
}
|
||||
|
||||
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedCollections) {
|
||||
collections, err := s.Store.ListCollections(ctx, &store.FindCollection{})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err)
|
||||
}
|
||||
collectionsLimit := int(s.LicenseService.GetSubscription().CollectionsLimit)
|
||||
if len(collections) >= collectionsLimit {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Maximum number of collections %d reached", collectionsLimit)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
collectionCreate := &storepb.Collection{
|
||||
CreatorId: user.ID,
|
||||
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: convertVisibilityToStorepb(request.Collection.Visibility),
|
||||
Visibility: storepb.Visibility(request.Collection.Visibility),
|
||||
}
|
||||
collection, err := s.Store.CreateCollection(ctx, collectionCreate)
|
||||
collection, err := s.Store.CreateCollection(ctx, collection)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create collection, err: %v", err)
|
||||
}
|
||||
|
||||
return convertCollectionFromStore(collection), nil
|
||||
response := &apiv2pb.CreateCollectionResponse{
|
||||
Collection: convertCollectionFromStore(collection),
|
||||
}
|
||||
metric.Enqueue("collection create")
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) UpdateCollection(ctx context.Context, request *v1pb.UpdateCollectionRequest) (*v1pb.Collection, error) {
|
||||
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")
|
||||
}
|
||||
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
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: %v", err)
|
||||
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,
|
||||
@ -128,7 +126,7 @@ func (s *APIV1Service) UpdateCollection(ctx context.Context, request *v1pb.Updat
|
||||
if collection == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||
}
|
||||
if collection.CreatorId != user.ID && user.Role != store.RoleAdmin {
|
||||
if collection.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
|
||||
@ -146,7 +144,7 @@ func (s *APIV1Service) UpdateCollection(ctx context.Context, request *v1pb.Updat
|
||||
case "shortcut_ids":
|
||||
update.ShortcutIDs = request.Collection.ShortcutIds
|
||||
case "visibility":
|
||||
visibility := convertVisibilityToStorepb(request.Collection.Visibility)
|
||||
visibility := store.Visibility(request.Collection.Visibility.String())
|
||||
update.Visibility = &visibility
|
||||
}
|
||||
}
|
||||
@ -155,13 +153,19 @@ func (s *APIV1Service) UpdateCollection(ctx context.Context, request *v1pb.Updat
|
||||
return nil, status.Errorf(codes.Internal, "failed to update collection, err: %v", err)
|
||||
}
|
||||
|
||||
return convertCollectionFromStore(collection), nil
|
||||
response := &apiv2pb.UpdateCollectionResponse{
|
||||
Collection: convertCollectionFromStore(collection),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) DeleteCollection(ctx context.Context, request *v1pb.DeleteCollectionRequest) (*emptypb.Empty, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
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: %v", err)
|
||||
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,
|
||||
@ -172,7 +176,7 @@ func (s *APIV1Service) DeleteCollection(ctx context.Context, request *v1pb.Delet
|
||||
if collection == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||
}
|
||||
if collection.CreatorId != user.ID && user.Role != store.RoleAdmin {
|
||||
if collection.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
|
||||
@ -182,11 +186,12 @@ func (s *APIV1Service) DeleteCollection(ctx context.Context, request *v1pb.Delet
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete collection, err: %v", err)
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
response := &apiv2pb.DeleteCollectionResponse{}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func convertCollectionFromStore(collection *storepb.Collection) *v1pb.Collection {
|
||||
return &v1pb.Collection{
|
||||
func convertCollectionFromStore(collection *storepb.Collection) *apiv2pb.Collection {
|
||||
return &apiv2pb.Collection{
|
||||
Id: collection.Id,
|
||||
CreatorId: collection.CreatorId,
|
||||
CreatedTime: timestamppb.New(time.Unix(collection.CreatedTs, 0)),
|
||||
@ -195,6 +200,6 @@ func convertCollectionFromStore(collection *storepb.Collection) *v1pb.Collection
|
||||
Title: collection.Title,
|
||||
Description: collection.Description,
|
||||
ShortcutIds: collection.ShortcutIds,
|
||||
Visibility: convertVisibilityFromStorepb(collection.Visibility),
|
||||
Visibility: apiv2pb.Visibility(collection.Visibility),
|
||||
}
|
||||
}
|
17
api/v2/common.go
Normal file
@ -0,0 +1,17 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||
"github.com/boojack/slash/store"
|
||||
)
|
||||
|
||||
func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
|
||||
switch rowStatus {
|
||||
case store.Normal:
|
||||
return apiv2pb.RowStatus_NORMAL
|
||||
case store.Archived:
|
||||
return apiv2pb.RowStatus_ARCHIVED
|
||||
default:
|
||||
return apiv2pb.RowStatus_ROW_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
269
api/v2/shortcut_service.go
Normal file
@ -0,0 +1,269 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
"github.com/boojack/slash/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
find := &store.FindShortcut{}
|
||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to fetch visible shortcut list, err: %v", err)
|
||||
}
|
||||
|
||||
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
|
||||
find.CreatorID = &userID
|
||||
shortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to fetch private shortcut list, err: %v", err)
|
||||
}
|
||||
|
||||
shortcutList = append(shortcutList, visibleShortcutList...)
|
||||
shortcuts := []*apiv2pb.Shortcut{}
|
||||
for _, shortcut := range shortcutList {
|
||||
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||
}
|
||||
shortcuts = append(shortcuts, composedShortcut)
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListShortcutsResponse{
|
||||
Shortcuts: shortcuts,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) {
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||
}
|
||||
|
||||
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||
if ok {
|
||||
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
} else {
|
||||
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
}
|
||||
|
||||
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||
}
|
||||
response := &apiv2pb.GetShortcutResponse{
|
||||
Shortcut: composedShortcut,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) CreateShortcut(ctx context.Context, request *apiv2pb.CreateShortcutRequest) (*apiv2pb.CreateShortcutResponse, error) {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
shortcut := &storepb.Shortcut{
|
||||
CreatorId: userID,
|
||||
Name: request.Shortcut.Name,
|
||||
Link: request.Shortcut.Link,
|
||||
Title: request.Shortcut.Title,
|
||||
Tags: request.Shortcut.Tags,
|
||||
Description: request.Shortcut.Description,
|
||||
Visibility: storepb.Visibility(request.Shortcut.Visibility),
|
||||
OgMetadata: &storepb.OpenGraphMetadata{},
|
||||
}
|
||||
if request.Shortcut.OgMetadata != nil {
|
||||
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
||||
Title: request.Shortcut.OgMetadata.Title,
|
||||
Description: request.Shortcut.OgMetadata.Description,
|
||||
Image: request.Shortcut.OgMetadata.Image,
|
||||
}
|
||||
}
|
||||
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create shortcut, err: %v", err)
|
||||
}
|
||||
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create activity, err: %v", err)
|
||||
}
|
||||
|
||||
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||
}
|
||||
response := &apiv2pb.CreateShortcutResponse{
|
||||
Shortcut: composedShortcut,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateShortcut(ctx context.Context, request *apiv2pb.UpdateShortcutRequest) (*apiv2pb.UpdateShortcutResponse, error) {
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
||||
}
|
||||
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||
}
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &request.Shortcut.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||
}
|
||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
|
||||
update := &store.UpdateShortcut{}
|
||||
for _, path := range request.UpdateMask.Paths {
|
||||
switch path {
|
||||
case "link":
|
||||
update.Link = &request.Shortcut.Link
|
||||
case "title":
|
||||
update.Title = &request.Shortcut.Title
|
||||
case "tags":
|
||||
tag := strings.Join(request.Shortcut.Tags, " ")
|
||||
update.Tag = &tag
|
||||
case "description":
|
||||
update.Description = &request.Shortcut.Description
|
||||
case "visibility":
|
||||
visibility := store.Visibility(request.Shortcut.Visibility.String())
|
||||
update.Visibility = &visibility
|
||||
case "og_metadata":
|
||||
if request.Shortcut.OgMetadata != nil {
|
||||
update.OpenGraphMetadata = &store.OpenGraphMetadata{
|
||||
Title: request.Shortcut.OgMetadata.Title,
|
||||
Description: request.Shortcut.OgMetadata.Description,
|
||||
Image: request.Shortcut.OgMetadata.Image,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shortcut, err = s.Store.UpdateShortcut(ctx, update)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update shortcut, err: %v", err)
|
||||
}
|
||||
|
||||
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||
}
|
||||
response := &apiv2pb.UpdateShortcutResponse{
|
||||
Shortcut: composedShortcut,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) DeleteShortcut(ctx context.Context, request *apiv2pb.DeleteShortcutRequest) (*apiv2pb.DeleteShortcutResponse, error) {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||
}
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||
}
|
||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
|
||||
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
||||
ID: shortcut.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete shortcut, err: %v", err)
|
||||
}
|
||||
response := &apiv2pb.DeleteShortcutResponse{}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
||||
payload := &storepb.ActivityShorcutCreatePayload{
|
||||
ShortcutId: shortcut.Id,
|
||||
}
|
||||
payloadStr, err := protojson.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
||||
}
|
||||
activity := &store.Activity{
|
||||
CreatorID: shortcut.CreatorId,
|
||||
Type: store.ActivityShortcutCreate,
|
||||
Level: store.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
}
|
||||
_, err = s.Store.CreateActivity(ctx, activity)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create activity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) convertShortcutFromStorepb(ctx context.Context, shortcut *storepb.Shortcut) (*apiv2pb.Shortcut, error) {
|
||||
composedShortcut := &apiv2pb.Shortcut{
|
||||
Id: shortcut.Id,
|
||||
CreatorId: shortcut.CreatorId,
|
||||
CreatedTime: timestamppb.New(time.Unix(shortcut.CreatedTs, 0)),
|
||||
UpdatedTime: timestamppb.New(time.Unix(shortcut.UpdatedTs, 0)),
|
||||
RowStatus: apiv2pb.RowStatus(shortcut.RowStatus),
|
||||
Name: shortcut.Name,
|
||||
Link: shortcut.Link,
|
||||
Title: shortcut.Title,
|
||||
Tags: shortcut.Tags,
|
||||
Description: shortcut.Description,
|
||||
Visibility: apiv2pb.Visibility(shortcut.Visibility),
|
||||
OgMetadata: &apiv2pb.OpenGraphMetadata{
|
||||
Title: shortcut.OgMetadata.Title,
|
||||
Description: shortcut.OgMetadata.Description,
|
||||
Image: shortcut.OgMetadata.Image,
|
||||
},
|
||||
}
|
||||
|
||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||
Type: store.ActivityShortcutView,
|
||||
Level: store.ActivityInfo,
|
||||
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", composedShortcut.Id)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to list activities")
|
||||
}
|
||||
composedShortcut.ViewCount = int32(len(activityList))
|
||||
|
||||
return composedShortcut, nil
|
||||
}
|
30
api/v2/subscription_service.go
Normal file
@ -0,0 +1,30 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) GetSubscription(ctx context.Context, _ *apiv2pb.GetSubscriptionRequest) (*apiv2pb.GetSubscriptionResponse, error) {
|
||||
subscription, err := s.LicenseService.LoadSubscription(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
||||
}
|
||||
return &apiv2pb.GetSubscriptionResponse{
|
||||
Subscription: subscription,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateSubscription(ctx context.Context, request *apiv2pb.UpdateSubscriptionRequest) (*apiv2pb.UpdateSubscriptionResponse, error) {
|
||||
subscription, err := s.LicenseService.UpdateSubscription(ctx, request.LicenseKey)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
||||
}
|
||||
return &apiv2pb.UpdateSubscriptionResponse{
|
||||
Subscription: subscription,
|
||||
}, nil
|
||||
}
|
@ -1,40 +1,41 @@
|
||||
package v1
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/exp/slices"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
v1pb "github.com/yourselfhosted/slash/proto/gen/api/v1"
|
||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (s *APIV1Service) ListUsers(ctx context.Context, _ *v1pb.ListUsersRequest) (*v1pb.ListUsersResponse, error) {
|
||||
func (s *APIV2Service) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, error) {
|
||||
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
||||
}
|
||||
|
||||
userMessages := []*v1pb.User{}
|
||||
userMessages := []*apiv2pb.User{}
|
||||
for _, user := range users {
|
||||
userMessages = append(userMessages, convertUserFromStore(user))
|
||||
}
|
||||
response := &v1pb.ListUsersResponse{
|
||||
response := &apiv2pb.ListUsersResponse{
|
||||
Users: userMessages,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest) (*v1pb.User, error) {
|
||||
func (s *APIV2Service) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &request.Id,
|
||||
})
|
||||
@ -44,17 +45,28 @@ func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
return convertUserFromStore(user), nil
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
response := &apiv2pb.GetUserResponse{
|
||||
User: userMessage,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserRequest) (*v1pb.User, error) {
|
||||
func (s *APIV2Service) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
if err := s.checkSeatAvailability(ctx); err != nil {
|
||||
return nil, 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{
|
||||
@ -66,15 +78,15 @@ func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserR
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
|
||||
}
|
||||
return convertUserFromStore(user), nil
|
||||
response := &apiv2pb.CreateUserResponse{
|
||||
User: convertUserFromStore(user),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserRequest) (*v1pb.User, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user.ID != request.User.Id {
|
||||
func (s *APIV2Service) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
if userID != request.User.Id {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
@ -91,45 +103,45 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
|
||||
userUpdate.Nickname = &request.User.Nickname
|
||||
}
|
||||
}
|
||||
user, err = s.Store.UpdateUser(ctx, userUpdate)
|
||||
user, err := s.Store.UpdateUser(ctx, userUpdate)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
|
||||
}
|
||||
return convertUserFromStore(user), nil
|
||||
return &apiv2pb.UpdateUserResponse{
|
||||
User: convertUserFromStore(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserRequest) (*emptypb.Empty, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user.ID == request.Id {
|
||||
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")
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{ID: request.Id}); err != nil {
|
||||
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)
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
response := &apiv2pb.DeleteUserResponse{}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *v1pb.ListUserAccessTokensRequest) (*v1pb.ListUserAccessTokensResponse, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user.ID != request.Id {
|
||||
func (s *APIV2Service) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
if userID != request.Id {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
||||
}
|
||||
|
||||
accessTokens := []*v1pb.UserAccessToken{}
|
||||
accessTokens := []*apiv2pb.UserAccessToken{}
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
claims := &ClaimsMessage{}
|
||||
claims := &auth.ClaimsMessage{}
|
||||
_, err := jwt.ParseWithClaims(userAccessToken.AccessToken, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
@ -146,7 +158,7 @@ func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *v1pb.L
|
||||
continue
|
||||
}
|
||||
|
||||
userAccessToken := &v1pb.UserAccessToken{
|
||||
userAccessToken := &apiv2pb.UserAccessToken{
|
||||
AccessToken: userAccessToken.AccessToken,
|
||||
Description: userAccessToken.Description,
|
||||
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
||||
@ -158,34 +170,41 @@ func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *v1pb.L
|
||||
}
|
||||
|
||||
// Sort by issued time in descending order.
|
||||
slices.SortFunc(accessTokens, func(i, j *v1pb.UserAccessToken) int {
|
||||
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) int {
|
||||
return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
|
||||
})
|
||||
response := &v1pb.ListUserAccessTokensResponse{
|
||||
response := &apiv2pb.ListUserAccessTokensResponse{
|
||||
AccessTokens: accessTokens,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *v1pb.CreateUserAccessTokenRequest) (*v1pb.UserAccessToken, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user.ID != request.Id {
|
||||
func (s *APIV2Service) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
if userID != request.Id {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
|
||||
expiresAt := time.Time{}
|
||||
if request.ExpiresAt != nil {
|
||||
expiresAt = request.ExpiresAt.AsTime()
|
||||
}
|
||||
accessToken, err := GenerateAccessToken(user.Email, user.ID, expiresAt, []byte(s.Secret))
|
||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, expiresAt, []byte(s.Secret))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
|
||||
}
|
||||
|
||||
claims := &ClaimsMessage{}
|
||||
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)
|
||||
@ -206,7 +225,7 @@ func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *v1pb.
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
|
||||
}
|
||||
|
||||
userAccessToken := &v1pb.UserAccessToken{
|
||||
userAccessToken := &apiv2pb.UserAccessToken{
|
||||
AccessToken: accessToken,
|
||||
Description: request.Description,
|
||||
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
||||
@ -214,19 +233,23 @@ func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *v1pb.
|
||||
if claims.ExpiresAt != nil {
|
||||
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
|
||||
}
|
||||
return userAccessToken, nil
|
||||
response := &apiv2pb.CreateUserAccessTokenResponse{
|
||||
AccessToken: userAccessToken,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *v1pb.DeleteUserAccessTokenRequest) (*emptypb.Empty, error) {
|
||||
user, err := getCurrentUser(ctx, s.Store)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
func (s *APIV2Service) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
if userID != request.Id {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
||||
}
|
||||
updatedUserAccessTokens := []*storepb.UserSetting_AccessTokensSetting_AccessToken{}
|
||||
updatedUserAccessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if userAccessToken.AccessToken == request.AccessToken {
|
||||
continue
|
||||
@ -234,10 +257,10 @@ func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *v1pb.
|
||||
updatedUserAccessTokens = append(updatedUserAccessTokens, userAccessToken)
|
||||
}
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
UserId: userID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.UserSetting_AccessTokensSetting{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: updatedUserAccessTokens,
|
||||
},
|
||||
},
|
||||
@ -245,15 +268,15 @@ func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *v1pb.
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) 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)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get user access tokens")
|
||||
}
|
||||
userAccessToken := storepb.UserSetting_AccessTokensSetting_AccessToken{
|
||||
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
||||
AccessToken: accessToken,
|
||||
Description: description,
|
||||
}
|
||||
@ -262,7 +285,7 @@ func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.UserSetting_AccessTokensSetting{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: userAccessTokens,
|
||||
},
|
||||
},
|
||||
@ -272,10 +295,10 @@ func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertUserFromStore(user *store.User) *v1pb.User {
|
||||
return &v1pb.User{
|
||||
func convertUserFromStore(user *store.User) *apiv2pb.User {
|
||||
return &apiv2pb.User{
|
||||
Id: int32(user.ID),
|
||||
State: convertStateFromRowStatus(user.RowStatus),
|
||||
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
||||
CreatedTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
|
||||
UpdatedTime: timestamppb.New(time.Unix(user.UpdatedTs, 0)),
|
||||
Role: convertUserRoleFromStore(user.Role),
|
||||
@ -284,13 +307,13 @@ func convertUserFromStore(user *store.User) *v1pb.User {
|
||||
}
|
||||
}
|
||||
|
||||
func convertUserRoleFromStore(role store.Role) v1pb.Role {
|
||||
func convertUserRoleFromStore(role store.Role) apiv2pb.Role {
|
||||
switch role {
|
||||
case store.RoleAdmin:
|
||||
return v1pb.Role_ADMIN
|
||||
return apiv2pb.Role_ADMIN
|
||||
case store.RoleUser:
|
||||
return v1pb.Role_USER
|
||||
return apiv2pb.Role_USER
|
||||
default:
|
||||
return v1pb.Role_ROLE_UNSPECIFIED
|
||||
return apiv2pb.Role_ROLE_UNSPECIFIED
|
||||
}
|
||||
}
|
135
api/v2/user_setting_service.go
Normal file
@ -0,0 +1,135 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
"github.com/boojack/slash/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) GetUserSetting(ctx context.Context, request *apiv2pb.GetUserSettingRequest) (*apiv2pb.GetUserSettingResponse, error) {
|
||||
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
||||
}
|
||||
return &apiv2pb.GetUserSettingResponse{
|
||||
UserSetting: userSetting,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateUserSetting(ctx context.Context, request *apiv2pb.UpdateUserSettingRequest) (*apiv2pb.UpdateUserSettingResponse, error) {
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||
}
|
||||
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
for _, path := range request.UpdateMask.Paths {
|
||||
if path == "locale" {
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: userID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_LOCALE,
|
||||
Value: &storepb.UserSetting_Locale{
|
||||
Locale: convertUserSettingLocaleToStore(request.UserSetting.Locale),
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update user setting: %v", err)
|
||||
}
|
||||
} else if path == "color_theme" {
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: userID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_COLOR_THEME,
|
||||
Value: &storepb.UserSetting_ColorTheme{
|
||||
ColorTheme: convertUserSettingColorThemeToStore(request.UserSetting.ColorTheme),
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update user setting: %v", err)
|
||||
}
|
||||
} else {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid path: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
||||
}
|
||||
return &apiv2pb.UpdateUserSettingResponse{
|
||||
UserSetting: userSetting,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getUserSetting(ctx context.Context, s *store.Store, userID int32) (*apiv2pb.UserSetting, error) {
|
||||
userSettings, err := s.ListUserSettings(ctx, &store.FindUserSetting{
|
||||
UserID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to find user setting")
|
||||
}
|
||||
|
||||
userSetting := &apiv2pb.UserSetting{
|
||||
Id: userID,
|
||||
Locale: apiv2pb.UserSetting_LOCALE_EN,
|
||||
ColorTheme: apiv2pb.UserSetting_COLOR_THEME_SYSTEM,
|
||||
}
|
||||
for _, setting := range userSettings {
|
||||
if setting.Key == storepb.UserSettingKey_USER_SETTING_LOCALE {
|
||||
userSetting.Locale = convertUserSettingLocaleFromStore(setting.GetLocale())
|
||||
} else if setting.Key == storepb.UserSettingKey_USER_SETTING_COLOR_THEME {
|
||||
userSetting.ColorTheme = convertUserSettingColorThemeFromStore(setting.GetColorTheme())
|
||||
}
|
||||
}
|
||||
return userSetting, nil
|
||||
}
|
||||
|
||||
func convertUserSettingLocaleToStore(locale apiv2pb.UserSetting_Locale) storepb.LocaleUserSetting {
|
||||
switch locale {
|
||||
case apiv2pb.UserSetting_LOCALE_EN:
|
||||
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN
|
||||
case apiv2pb.UserSetting_LOCALE_ZH:
|
||||
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH
|
||||
default:
|
||||
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertUserSettingLocaleFromStore(locale storepb.LocaleUserSetting) apiv2pb.UserSetting_Locale {
|
||||
switch locale {
|
||||
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN:
|
||||
return apiv2pb.UserSetting_LOCALE_EN
|
||||
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH:
|
||||
return apiv2pb.UserSetting_LOCALE_ZH
|
||||
default:
|
||||
return apiv2pb.UserSetting_LOCALE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertUserSettingColorThemeToStore(colorTheme apiv2pb.UserSetting_ColorTheme) storepb.ColorThemeUserSetting {
|
||||
switch colorTheme {
|
||||
case apiv2pb.UserSetting_COLOR_THEME_SYSTEM:
|
||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM
|
||||
case apiv2pb.UserSetting_COLOR_THEME_LIGHT:
|
||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT
|
||||
case apiv2pb.UserSetting_COLOR_THEME_DARK:
|
||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK
|
||||
default:
|
||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertUserSettingColorThemeFromStore(colorTheme storepb.ColorThemeUserSetting) apiv2pb.UserSetting_ColorTheme {
|
||||
switch colorTheme {
|
||||
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM:
|
||||
return apiv2pb.UserSetting_COLOR_THEME_SYSTEM
|
||||
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT:
|
||||
return apiv2pb.UserSetting_COLOR_THEME_LIGHT
|
||||
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK:
|
||||
return apiv2pb.UserSetting_COLOR_THEME_DARK
|
||||
default:
|
||||
return apiv2pb.UserSetting_COLOR_THEME_UNSPECIFIED
|
||||
}
|
||||
}
|
113
api/v2/v2.go
Normal file
@ -0,0 +1,113 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
||||
"github.com/labstack/echo/v4"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||
"github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/server/service/license"
|
||||
"github.com/boojack/slash/store"
|
||||
)
|
||||
|
||||
type APIV2Service struct {
|
||||
apiv2pb.UnimplementedWorkspaceServiceServer
|
||||
apiv2pb.UnimplementedSubscriptionServiceServer
|
||||
apiv2pb.UnimplementedUserServiceServer
|
||||
apiv2pb.UnimplementedUserSettingServiceServer
|
||||
apiv2pb.UnimplementedShortcutServiceServer
|
||||
apiv2pb.UnimplementedCollectionServiceServer
|
||||
|
||||
Secret string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
LicenseService *license.LicenseService
|
||||
|
||||
grpcServer *grpc.Server
|
||||
grpcServerPort int
|
||||
}
|
||||
|
||||
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, licenseService *license.LicenseService, grpcServerPort int) *APIV2Service {
|
||||
authProvider := NewGRPCAuthInterceptor(store, secret)
|
||||
grpcServer := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(
|
||||
authProvider.AuthenticationInterceptor,
|
||||
),
|
||||
)
|
||||
apiV2Service := &APIV2Service{
|
||||
Secret: secret,
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
LicenseService: licenseService,
|
||||
grpcServer: grpcServer,
|
||||
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 {
|
||||
return s.grpcServer
|
||||
}
|
||||
|
||||
// RegisterGateway registers the gRPC-Gateway with the given Echo instance.
|
||||
func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error {
|
||||
// Create a client connection to the gRPC Server we just started.
|
||||
// This is where the gRPC-Gateway proxies the requests.
|
||||
conn, err := grpc.DialContext(
|
||||
ctx,
|
||||
fmt.Sprintf(":%d", s.grpcServerPort),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gwMux := 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 {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterUserSettingServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterShortcutServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apiv2pb.RegisterCollectionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
||||
|
||||
// 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
|
||||
}
|
134
api/v2/workspace_service.go
Normal file
@ -0,0 +1,134 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
"github.com/boojack/slash/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) GetWorkspaceProfile(ctx context.Context, _ *apiv2pb.GetWorkspaceProfileRequest) (*apiv2pb.GetWorkspaceProfileResponse, error) {
|
||||
profile := &apiv2pb.WorkspaceProfile{
|
||||
Mode: s.Profile.Mode,
|
||||
Plan: apiv2pb.PlanType_FREE,
|
||||
}
|
||||
|
||||
// Load subscription plan from license service.
|
||||
subscription, err := s.LicenseService.GetSubscription(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get subscription: %v", err)
|
||||
}
|
||||
profile.Plan = subscription.Plan
|
||||
|
||||
workspaceSetting, err := s.GetWorkspaceSetting(ctx, &apiv2pb.GetWorkspaceSettingRequest{})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
||||
}
|
||||
if workspaceSetting != nil {
|
||||
setting := workspaceSetting.GetSetting()
|
||||
profile.EnableSignup = setting.GetEnableSignup()
|
||||
profile.CustomStyle = setting.GetCustomStyle()
|
||||
profile.CustomScript = setting.GetCustomScript()
|
||||
}
|
||||
return &apiv2pb.GetWorkspaceProfileResponse{
|
||||
Profile: profile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetWorkspaceSetting(ctx context.Context, _ *apiv2pb.GetWorkspaceSettingRequest) (*apiv2pb.GetWorkspaceSettingResponse, error) {
|
||||
isAdmin := false
|
||||
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||
if ok {
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if user.Role == store.RoleAdmin {
|
||||
isAdmin = true
|
||||
}
|
||||
}
|
||||
workspaceSettings, err := s.Store.ListWorkspaceSettings(ctx, &store.FindWorkspaceSetting{})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list workspace settings: %v", err)
|
||||
}
|
||||
workspaceSetting := &apiv2pb.WorkspaceSetting{
|
||||
EnableSignup: true,
|
||||
}
|
||||
for _, v := range workspaceSettings {
|
||||
if v.Key == storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP {
|
||||
workspaceSetting.EnableSignup = v.GetEnableSignup()
|
||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE {
|
||||
workspaceSetting.CustomStyle = v.GetCustomStyle()
|
||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT {
|
||||
workspaceSetting.CustomScript = v.GetCustomScript()
|
||||
} else if isAdmin {
|
||||
// For some settings, only admin can get the value.
|
||||
if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY {
|
||||
workspaceSetting.LicenseKey = v.GetLicenseKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
return &apiv2pb.GetWorkspaceSettingResponse{
|
||||
Setting: workspaceSetting,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateWorkspaceSetting(ctx context.Context, request *apiv2pb.UpdateWorkspaceSettingRequest) (*apiv2pb.UpdateWorkspaceSettingResponse, error) {
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||
}
|
||||
|
||||
for _, path := range request.UpdateMask.Paths {
|
||||
if path == "license_key" {
|
||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY,
|
||||
Value: &storepb.WorkspaceSetting_LicenseKey{
|
||||
LicenseKey: request.Setting.LicenseKey,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||
}
|
||||
} else if path == "enable_signup" {
|
||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||
Value: &storepb.WorkspaceSetting_EnableSignup{
|
||||
EnableSignup: request.Setting.EnableSignup,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||
}
|
||||
} else if path == "custom_style" {
|
||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE,
|
||||
Value: &storepb.WorkspaceSetting_CustomStyle{
|
||||
CustomStyle: request.Setting.CustomStyle,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||
}
|
||||
} else if path == "custom_script" {
|
||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT,
|
||||
Value: &storepb.WorkspaceSetting_CustomScript{
|
||||
CustomScript: request.Setting.CustomScript,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||
}
|
||||
} else {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid path: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
getWorkspaceSettingResponse, err := s.GetWorkspaceSetting(ctx, &apiv2pb.GetWorkspaceSettingRequest{})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
||||
}
|
||||
return &apiv2pb.UpdateWorkspaceSettingResponse{
|
||||
Setting: getWorkspaceSettingResponse.Setting,
|
||||
}, nil
|
||||
}
|
@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@ -11,12 +10,15 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/yourselfhosted/slash/server"
|
||||
"github.com/yourselfhosted/slash/server/common"
|
||||
"github.com/yourselfhosted/slash/server/profile"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
"github.com/yourselfhosted/slash/store/db"
|
||||
"github.com/boojack/slash/internal/log"
|
||||
"github.com/boojack/slash/server"
|
||||
"github.com/boojack/slash/server/metric"
|
||||
"github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/boojack/slash/store/db"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -24,43 +26,34 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
serverProfile *profile.Profile
|
||||
mode string
|
||||
port int
|
||||
data string
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "slash",
|
||||
Short: `An open source, self-hosted platform for sharing and managing your most frequently used links.`,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
serverProfile := &profile.Profile{
|
||||
Mode: viper.GetString("mode"),
|
||||
Port: viper.GetInt("port"),
|
||||
Data: viper.GetString("data"),
|
||||
DSN: viper.GetString("dsn"),
|
||||
Driver: viper.GetString("driver"),
|
||||
Version: common.GetCurrentVersion(viper.GetString("mode")),
|
||||
}
|
||||
if err := serverProfile.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
Short: `An open source, self-hosted bookmarks and link sharing platform.`,
|
||||
Run: func(_cmd *cobra.Command, _args []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
dbDriver, err := db.NewDBDriver(serverProfile)
|
||||
if err != nil {
|
||||
db := db.NewDB(serverProfile)
|
||||
if err := db.Open(ctx); err != nil {
|
||||
cancel()
|
||||
slog.Error("failed to create db driver", "error", err)
|
||||
log.Error("failed to open database", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
storeInstance := store.New(dbDriver, serverProfile)
|
||||
if err := storeInstance.Migrate(ctx); err != nil {
|
||||
cancel()
|
||||
slog.Error("failed to migrate db", "error", err)
|
||||
return
|
||||
}
|
||||
storeInstance := store.New(db.DBInstance, serverProfile)
|
||||
s, err := server.NewServer(ctx, serverProfile, storeInstance)
|
||||
if err != nil {
|
||||
cancel()
|
||||
slog.Error("failed to create server", "error", err)
|
||||
log.Error("failed to create server", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// nolint
|
||||
metric.NewMetricClient(s.Secret, *serverProfile)
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
||||
// The default signal sent by the `kill` command is SIGTERM,
|
||||
@ -68,16 +61,16 @@ var (
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-c
|
||||
slog.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||
log.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||
s.Shutdown(ctx)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
printGreetings(serverProfile)
|
||||
printGreetings()
|
||||
|
||||
if err := s.Start(ctx); err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
slog.Error("failed to start server", "error", err)
|
||||
log.Error("failed to start server", zap.Error(err))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@ -88,39 +81,45 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func Execute() error {
|
||||
defer log.Sync()
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
||||
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8082, "port of server")
|
||||
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
||||
|
||||
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
viper.SetDefault("mode", "demo")
|
||||
viper.SetDefault("driver", "sqlite")
|
||||
viper.SetDefault("port", 8082)
|
||||
|
||||
rootCmd.PersistentFlags().String("mode", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
||||
rootCmd.PersistentFlags().String("addr", "", "address of server")
|
||||
rootCmd.PersistentFlags().Int("port", 8082, "port of server")
|
||||
rootCmd.PersistentFlags().String("data", "", "data directory")
|
||||
rootCmd.PersistentFlags().String("driver", "sqlite", "database driver")
|
||||
rootCmd.PersistentFlags().String("dsn", "", "database source name(aka. DSN)")
|
||||
|
||||
if err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("driver", rootCmd.PersistentFlags().Lookup("driver")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
viper.SetEnvPrefix("slash")
|
||||
viper.AutomaticEnv()
|
||||
}
|
||||
|
||||
func printGreetings(serverProfile *profile.Profile) {
|
||||
func initConfig() {
|
||||
viper.AutomaticEnv()
|
||||
var err error
|
||||
serverProfile, err = profile.GetProfile()
|
||||
if err != nil {
|
||||
log.Error("failed to get profile", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
println("---")
|
||||
println("Server profile")
|
||||
println("dsn:", serverProfile.DSN)
|
||||
@ -128,16 +127,20 @@ func printGreetings(serverProfile *profile.Profile) {
|
||||
println("mode:", serverProfile.Mode)
|
||||
println("version:", serverProfile.Version)
|
||||
println("---")
|
||||
}
|
||||
|
||||
func printGreetings() {
|
||||
println(greetingBanner)
|
||||
fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port)
|
||||
println("---")
|
||||
println("See more in:")
|
||||
fmt.Printf("👉GitHub: %s\n", "https://github.com/yourselfhosted/slash")
|
||||
fmt.Printf("👉GitHub: %s\n", "https://github.com/boojack/slash")
|
||||
println("---")
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
err := Execute()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 134 KiB |
Before Width: | Height: | Size: 613 KiB |
BIN
docs/assets/extension-usage/copy-access-token.png
Normal file
After Width: | Height: | Size: 155 KiB |
BIN
docs/assets/extension-usage/create-access-token.png
Normal file
After Width: | Height: | Size: 154 KiB |
BIN
docs/assets/extension-usage/extension-screenshot.png
Normal file
After Width: | Height: | Size: 77 KiB |
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 180 KiB |
Before Width: | Height: | Size: 144 KiB |
Before Width: | Height: | Size: 125 KiB |
@ -1,6 +1,6 @@
|
||||
# Slash Collections
|
||||
|
||||
**Slash Collections** introduces a feature to help you better organize and manage related Shortcuts.
|
||||
**Slash Collections** introduces a feature to help you better organize and manage related Shortcuts within the Slash Shortcuts platform.
|
||||
|
||||
## What is a Collection?
|
||||
|
||||
|
@ -1,37 +0,0 @@
|
||||
# Single Sign-On(SSO)
|
||||
|
||||
> **Note**: This feature is only available in the **Enterprise** plan.
|
||||
|
||||
**Single Sign-On (SSO)** is an authentication method that enables users to securely authenticate with multiple applications and websites by using just one set of credentials.
|
||||
|
||||
Slash supports SSO integration with **OAuth 2.0** standard.
|
||||
|
||||
## Create a new SSO provider
|
||||
|
||||
As an Admin user, you can create a new SSO provider in Setting > Workspace settings > SSO.
|
||||
|
||||

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

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

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

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

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

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

|
||||
|
||||
### Use your shortcuts in the search bar
|
||||
|
||||
You can use your shortcuts in the search bar of your browser. For example, if you have a shortcut named `gh` for [GitHub](https://github.com), you can type `s/gh` in the search bar and press `Enter` to go to [GitHub](https://github.com).
|
||||
|
@ -44,38 +44,16 @@ Assume that docker compose is deployed in the `/opt/slash` directory.
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/slash && cd /opt/slash
|
||||
curl -#LO https://github.com/yourselfhosted/slash/raw/main/docker-compose.yml
|
||||
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.
|
||||
|
||||
## Use PostgreSQL as Database
|
||||
### Upgrade
|
||||
|
||||
Slash supports the following database types:
|
||||
|
||||
- SQLite (default)
|
||||
- PostgreSQL
|
||||
|
||||
### Using PostgreSQL
|
||||
|
||||
To switch to PostgreSQL, you can use the following steps:
|
||||
|
||||
- **--driver** _postgres_ : This argument specifies that Slash should use the `postgres` driver instead of the default `sqlite`.
|
||||
|
||||
- **--dsn** _postgresql://postgres:PASSWORD@localhost:5432/slash_ : Provides the connection details for your PostgreSQL server.
|
||||
|
||||
You can start Slash with Docker using the following command:
|
||||
|
||||
```shell
|
||||
docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash yourselfhosted/slash:latest --driver postgres --dsn 'postgresql://postgres:PASSWORD@localhost:5432/slash'
|
||||
```bash
|
||||
cd /opt/slash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Additionally, you can set these configurations via environment variables:
|
||||
|
||||
```shell
|
||||
SLASH_DRIVER=postgres
|
||||
SLASH_DSN=postgresql://root:password@localhost:5432/slash
|
||||
```
|
||||
|
||||
Note that if the PostgreSQL server is not configured to support SSL connections you will need to add `?sslmode=disable` to the DSN.
|
||||
|
@ -1,7 +0,0 @@
|
||||
# Privacy Policy
|
||||
|
||||
Slash does not collect, store, or share any data from you.
|
||||
|
||||
You can use our application freely and without concern about your personal data being tracked or stored.
|
||||
|
||||
Our primary goal is to provide you with a secure and private experience.
|
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 yourselfhosted
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 257 KiB |
@ -1,46 +1,57 @@
|
||||
{
|
||||
"name": "slash-extension",
|
||||
"displayName": "Slash",
|
||||
"version": "1.0.11",
|
||||
"description": "An open source, self-hosted platform for sharing and managing your most frequently used links. Save and share your links very easily.",
|
||||
"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"
|
||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
||||
"type-gen": "cd ../../proto && buf generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/joy": "5.0.0-beta.48",
|
||||
"@plasmohq/storage": "^1.12.0",
|
||||
"classnames": "^2.5.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"plasmo": "^0.89.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1"
|
||||
"@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": {
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/chrome": "^0.0.280",
|
||||
"@types/node": "^22.8.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3"
|
||||
"@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",
|
||||
|
10758
frontend/extension/pnpm-lock.yaml
generated
@ -1,4 +1,5 @@
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
|
||||
const storage = new Storage();
|
||||
const urlRegex = /https?:\/\/s\/(.+)/;
|
||||
@ -12,15 +13,33 @@ chrome.webRequest.onBeforeRequest.addListener(
|
||||
|
||||
const shortcutName = getShortcutNameFromUrl(param.url);
|
||||
if (shortcutName) {
|
||||
const instanceUrl = (await storage.getItem<string>("instance_url")) || "";
|
||||
const url = new URL(`/s/${shortcutName}`, instanceUrl);
|
||||
return chrome.tabs.update({ url: url.toString() });
|
||||
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
||||
const shortcut = shortcuts.find((shortcut) => shortcut.name === shortcutName);
|
||||
if (!shortcut) {
|
||||
return;
|
||||
}
|
||||
return chrome.tabs.update({ url: shortcut.link });
|
||||
}
|
||||
})();
|
||||
},
|
||||
{ urls: ["*://s/*", "*://*/search*"] },
|
||||
{ urls: ["*://s/*", "*://*/search*"] }
|
||||
);
|
||||
|
||||
chrome.omnibox.onInputEntered.addListener(async (text, disposition) => {
|
||||
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
||||
const shortcut = shortcuts.find((shortcut) => shortcut.name === text);
|
||||
if (!shortcut) {
|
||||
return;
|
||||
}
|
||||
if (disposition === "currentTab") {
|
||||
chrome.tabs.update({ url: shortcut.link });
|
||||
} else if (disposition === "newForegroundTab") {
|
||||
chrome.tabs.create({ url: shortcut.link });
|
||||
} else if (disposition === "newBackgroundTab") {
|
||||
chrome.tabs.create({ url: shortcut.link, active: false });
|
||||
}
|
||||
});
|
||||
|
||||
const getShortcutNameFromUrl = (urlString: string) => {
|
||||
const matchResult = urlRegex.exec(urlString);
|
||||
if (matchResult === null) {
|
||||
@ -31,19 +50,19 @@ const getShortcutNameFromUrl = (urlString: string) => {
|
||||
|
||||
const getShortcutNameFromSearchUrl = (urlString: string) => {
|
||||
const url = new URL(urlString);
|
||||
if ((url.hostname.endsWith("google.com") || url.hostname.endsWith("bing.com")) && url.pathname === "/search") {
|
||||
if ((url.hostname === "www.google.com" || url.hostname === "www.bing.com") && url.pathname === "/search") {
|
||||
const params = new URLSearchParams(url.search);
|
||||
const shortcutName = params.get("q");
|
||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||
return shortcutName.slice(2);
|
||||
}
|
||||
} else if (url.hostname.endsWith("baidu.com") && url.pathname === "/s") {
|
||||
} else if (url.hostname === "www.baidu.com" && url.pathname === "/s") {
|
||||
const params = new URLSearchParams(url.search);
|
||||
const shortcutName = params.get("wd");
|
||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||
return shortcutName.slice(2);
|
||||
}
|
||||
} else if (url.hostname.endsWith("duckduckgo.com") && url.pathname === "/") {
|
||||
} else if (url.hostname === "duckduckgo.com" && url.pathname === "/") {
|
||||
const params = new URLSearchParams(url.search);
|
||||
const shortcutName = params.get("q");
|
||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||
|
174
frontend/extension/src/components/CreateShortcutsButton.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Visibility } from "@/types/proto/api/v2/common";
|
||||
import { CreateShortcutResponse, OpenGraphMetadata } from "@/types/proto/api/v2/shortcut_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const generateTempName = (length = 6) => {
|
||||
let result = "";
|
||||
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const charactersLength = characters.length;
|
||||
let counter = 0;
|
||||
while (counter < length) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
counter += 1;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
interface State {
|
||||
name: string;
|
||||
title: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const CreateShortcutsButton = () => {
|
||||
const [domain] = useStorage("domain");
|
||||
const [accessToken] = useStorage("access_token");
|
||||
const [shortcuts, setShortcuts] = useStorage("shortcuts");
|
||||
const [state, setState] = useState<State>({
|
||||
name: "",
|
||||
title: "",
|
||||
link: "",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
document.body.style.height = "384px";
|
||||
} else {
|
||||
document.body.style.height = "auto";
|
||||
}
|
||||
}, [showModal]);
|
||||
|
||||
const handleCreateShortcutButtonClick = async () => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
||||
if (tabs.length === 0) {
|
||||
toast.error("No active tab found");
|
||||
return;
|
||||
}
|
||||
const tab = tabs[0];
|
||||
setState((state) => ({
|
||||
...state,
|
||||
name: generateTempName() + "-temp",
|
||||
title: tab.title || "",
|
||||
link: tab.url || "",
|
||||
}));
|
||||
setShowModal(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
name: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
title: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLinkInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
link: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!state.name) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const {
|
||||
data: { shortcut },
|
||||
} = await axios.post<CreateShortcutResponse>(
|
||||
`${domain}/api/v2/shortcuts`,
|
||||
{
|
||||
name: state.name,
|
||||
title: state.title,
|
||||
link: state.link,
|
||||
visibility: Visibility.PRIVATE,
|
||||
ogMetadata: OpenGraphMetadata.fromPartial({}),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setShortcuts([shortcut, ...shortcuts]);
|
||||
toast.success("Shortcut created successfully");
|
||||
setShowModal(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton color="primary" variant="solid" size="sm" onClick={() => handleCreateShortcutButtonClick()}>
|
||||
<Icon.Plus className="w-5 h-auto" />
|
||||
</IconButton>
|
||||
|
||||
<Modal container={() => document.body} open={showModal} onClose={() => setShowModal(false)}>
|
||||
<ModalDialog className="w-3/4">
|
||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
||||
<span className="text-base font-medium">Create Shortcut</span>
|
||||
<Button size="sm" variant="plain" onClick={() => setShowModal(false)}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-hidden w-full flex flex-col justify-start items-center">
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<span className="block w-12 mr-2 shrink-0">Name</span>
|
||||
<Input className="grow" type="text" placeholder="Unique shortcut name" value={state.name} onChange={handleNameInputChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<span className="block w-12 mr-2 shrink-0">Title</span>
|
||||
<Input className="grow" type="text" placeholder="Shortcut title" value={state.title} onChange={handleTitleInputChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<span className="block w-12 mr-2 shrink-0">Link</span>
|
||||
<Input
|
||||
className="grow"
|
||||
type="text"
|
||||
placeholder="https://github.com/boojack/slash"
|
||||
value={state.link}
|
||||
onChange={handleLinkInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
|
||||
<Button color="neutral" variant="plain" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" disabled={isLoading} loading={isLoading} onClick={handleSaveBtnClick}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateShortcutsButton;
|
@ -1,12 +1,12 @@
|
||||
import classNames from "classnames";
|
||||
import Icon from "./Icon";
|
||||
import LogoBase64 from "data-base64:../..//assets/icon.png";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Logo = ({ className }: Props) => {
|
||||
return <Icon.CircleSlash className={classNames("dark:text-gray-500", className)} strokeWidth={1.5} />;
|
||||
return <img className={classNames(className)} src={LogoBase64} alt="" />;
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
|
45
frontend/extension/src/components/PullShortcutsButton.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { IconButton } from "@mui/joy";
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import axios from "axios";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { ListShortcutsResponse } from "@/types/proto/api/v2/shortcut_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const PullShortcutsButton = () => {
|
||||
const [domain] = useStorage("domain");
|
||||
const [accessToken] = useStorage("access_token");
|
||||
const [, setShortcuts] = useStorage("shortcuts");
|
||||
|
||||
useEffect(() => {
|
||||
if (domain && accessToken) {
|
||||
handlePullShortcuts(true);
|
||||
}
|
||||
}, [domain, accessToken]);
|
||||
|
||||
const handlePullShortcuts = async (silence = false) => {
|
||||
try {
|
||||
const {
|
||||
data: { shortcuts },
|
||||
} = await axios.get<ListShortcutsResponse>(`${domain}/api/v2/shortcuts`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
setShortcuts(shortcuts);
|
||||
if (!silence) {
|
||||
toast.success("Shortcuts pulled");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to pull shortcuts, error: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton color="neutral" variant="plain" size="sm" onClick={() => handlePullShortcuts()}>
|
||||
<Icon.RefreshCcw className="w-4 h-auto" />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default PullShortcutsButton;
|
67
frontend/extension/src/components/ShortcutView.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import classNames from "classnames";
|
||||
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
|
||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
shortcut: Shortcut;
|
||||
}
|
||||
|
||||
const ShortcutView = (props: Props) => {
|
||||
const { shortcut } = props;
|
||||
const [domain] = useStorage<string>("domain", "");
|
||||
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||
|
||||
const handleShortcutLinkClick = () => {
|
||||
const shortcutLink = `${domain}/s/${shortcut.name}`;
|
||||
chrome.tabs.create({ url: shortcutLink });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800"
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto rounded-lg" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||
)}
|
||||
</span>
|
||||
<div className="ml-1 w-[calc(100%-20px)] flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<button
|
||||
className={classNames(
|
||||
"max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
|
||||
)}
|
||||
onClick={handleShortcutLinkClick}
|
||||
>
|
||||
<div className="truncate">
|
||||
<span className="dark:text-gray-400">{shortcut.title}</span>
|
||||
{shortcut.title ? (
|
||||
<span className="text-gray-500">(s/{shortcut.name})</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
||||
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
||||
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutView;
|
18
frontend/extension/src/components/ShortcutsContainer.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import classNames from "classnames";
|
||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import ShortcutView from "./ShortcutView";
|
||||
|
||||
const ShortcutsContainer = () => {
|
||||
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", (v) => (v ? v : []));
|
||||
|
||||
return (
|
||||
<div className={classNames("w-full grid grid-cols-2 gap-2")}>
|
||||
{shortcuts.map((shortcut) => {
|
||||
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutsContainer;
|
@ -1,18 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface Context {
|
||||
instanceUrl?: string;
|
||||
setInstanceUrl: (instanceUrl: string) => void;
|
||||
}
|
||||
|
||||
export const StorageContext = createContext<Context>({
|
||||
instanceUrl: undefined,
|
||||
setInstanceUrl: () => {},
|
||||
});
|
||||
|
||||
const useStorageContext = () => {
|
||||
const context = useContext(StorageContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export default useStorageContext;
|
@ -1,4 +0,0 @@
|
||||
import useStorageContext from "./context";
|
||||
import StorageContextProvider from "./provider";
|
||||
|
||||
export { useStorageContext, StorageContextProvider };
|
@ -1,41 +0,0 @@
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StorageContext } from "./context";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const StorageContextProvider = ({ children }: Props) => {
|
||||
const storage = new Storage();
|
||||
const [instanceUrl, setInstanceUrl] = useState<string | undefined>(undefined);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const instanceUrl = await storage.get("instance_url");
|
||||
|
||||
setInstanceUrl(instanceUrl);
|
||||
setIsInitialized(true);
|
||||
})();
|
||||
|
||||
storage.watch({
|
||||
instance_url: (c) => {
|
||||
setInstanceUrl(c.newValue);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StorageContext.Provider
|
||||
value={{
|
||||
instanceUrl,
|
||||
setInstanceUrl: (instanceUrl: string) => storage.set("instance_url", instanceUrl),
|
||||
}}
|
||||
>
|
||||
{isInitialized && children}
|
||||
</StorageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageContextProvider;
|
14
frontend/extension/src/helpers/utils.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { isNull, isUndefined } from "lodash-es";
|
||||
|
||||
export const isNullorUndefined = (value: any) => {
|
||||
return isNull(value) || isUndefined(value);
|
||||
};
|
||||
|
||||
export const getFaviconWithGoogleS2 = (url: string) => {
|
||||
try {
|
||||
const urlObject = new URL(url);
|
||||
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
43
frontend/extension/src/hooks/useColorTheme.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { useColorScheme } from "@mui/joy";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const useColorTheme = () => {
|
||||
const { mode: colorTheme, setMode: setColorTheme } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (colorTheme === "light") {
|
||||
root.classList.remove("dark");
|
||||
} else if (colorTheme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
if (darkMediaQuery.matches) {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
|
||||
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
|
||||
if (e.matches) {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
};
|
||||
try {
|
||||
darkMediaQuery.addEventListener("change", handleColorSchemeChange);
|
||||
} catch (error) {
|
||||
console.error("failed to initial color scheme listener", error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
darkMediaQuery.removeEventListener("change", handleColorSchemeChange);
|
||||
};
|
||||
}
|
||||
}, [colorTheme]);
|
||||
|
||||
return { colorTheme, setColorTheme };
|
||||
};
|
||||
|
||||
export default useColorTheme;
|
@ -1,26 +1,52 @@
|
||||
import { Button, CssVarsProvider, Input } from "@mui/joy";
|
||||
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 { StorageContextProvider, useStorageContext } from "./context";
|
||||
import PullShortcutsButton from "./components/PullShortcutsButton";
|
||||
import ShortcutsContainer from "./components/ShortcutsContainer";
|
||||
import useColorTheme from "./hooks/useColorTheme";
|
||||
import "./style.css";
|
||||
|
||||
interface SettingState {
|
||||
instanceUrl: string;
|
||||
domain: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
const colorThemeOptions = [
|
||||
{
|
||||
value: "system",
|
||||
label: "System",
|
||||
},
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
},
|
||||
];
|
||||
|
||||
const IndexOptions = () => {
|
||||
const context = useStorageContext();
|
||||
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>({
|
||||
instanceUrl: context.instanceUrl || "",
|
||||
domain,
|
||||
accessToken,
|
||||
});
|
||||
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
|
||||
const isInitialized = domain && accessToken;
|
||||
|
||||
useEffect(() => {
|
||||
setSettingState({
|
||||
instanceUrl: context.instanceUrl || "",
|
||||
domain,
|
||||
accessToken,
|
||||
});
|
||||
}, [context]);
|
||||
}, [domain, accessToken]);
|
||||
|
||||
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
|
||||
setSettingState((prevState) => ({
|
||||
@ -30,16 +56,21 @@ const IndexOptions = () => {
|
||||
};
|
||||
|
||||
const handleSaveSetting = () => {
|
||||
context.setInstanceUrl(settingState.instanceUrl);
|
||||
setDomain(settingState.domain);
|
||||
setAccessToken(settingState.accessToken);
|
||||
toast.success("Setting saved");
|
||||
};
|
||||
|
||||
const handleSelectColorTheme = async (colorTheme: string) => {
|
||||
setColorTheme(colorTheme as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full px-4">
|
||||
<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/yourselfhosted/slash#browser-extension"
|
||||
href="https://github.com/boojack/slash#browser-extension"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon.HelpCircle className="w-4 h-auto" />
|
||||
@ -48,7 +79,7 @@ const IndexOptions = () => {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start py-12">
|
||||
<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>
|
||||
@ -59,11 +90,11 @@ const IndexOptions = () => {
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-4">
|
||||
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
||||
<span className="dark:text-gray-400">Instance URL</span>
|
||||
{context.instanceUrl !== "" && (
|
||||
<span className="dark:text-gray-400">Domain</span>
|
||||
{domain !== "" && (
|
||||
<a
|
||||
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
||||
href={context.instanceUrl}
|
||||
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>
|
||||
@ -75,9 +106,22 @@ const IndexOptions = () => {
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="The url of your Slash instance. e.g., https://slash.example.com"
|
||||
value={settingState.instanceUrl}
|
||||
onChange={(e) => setPartialSettingState({ instanceUrl: e.target.value })}
|
||||
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>
|
||||
@ -85,7 +129,39 @@ const IndexOptions = () => {
|
||||
<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>
|
||||
);
|
||||
@ -93,12 +169,10 @@ const IndexOptions = () => {
|
||||
|
||||
const Options = () => {
|
||||
return (
|
||||
<StorageContextProvider>
|
||||
<CssVarsProvider>
|
||||
<IndexOptions />
|
||||
<Toaster position="top-center" />
|
||||
<Toaster position="top-right" />
|
||||
</CssVarsProvider>
|
||||
</StorageContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,13 +1,21 @@
|
||||
import { Button, CssVarsProvider } from "@mui/joy";
|
||||
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 { StorageContextProvider, useStorageContext } from "./context";
|
||||
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 = () => {
|
||||
const context = useStorageContext();
|
||||
const isInitialized = context.instanceUrl;
|
||||
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();
|
||||
@ -22,52 +30,61 @@ const IndexPopup = () => {
|
||||
<div className="w-full min-w-[512px] px-4 pt-4">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center dark:text-gray-400">
|
||||
<Logo className="w-6 h-auto mr-1" />
|
||||
<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 ? (
|
||||
<>
|
||||
<p className="w-full mb-2">
|
||||
<span>Your instance URL is </span>
|
||||
{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="inline-flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
||||
href={context.instanceUrl}
|
||||
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">{context.instanceUrl}</span>
|
||||
<span className="mr-1">Go to my Slash</span>
|
||||
<Icon.ExternalLink className="w-4 h-auto" />
|
||||
</a>
|
||||
</p>
|
||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
||||
<div className="flex flex-row justify-start items-center gap-2">
|
||||
<Button size="sm" variant="outlined" color="neutral" onClick={handleSettingButtonClick}>
|
||||
<Icon.Settings className="w-5 h-auto text-gray-500 dark:text-gray-400 mr-1" />
|
||||
Setting
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
component="a"
|
||||
href="https://github.com/yourselfhosted/slash"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400 mr-1" />
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full flex flex-col justify-start items-center">
|
||||
<Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" />
|
||||
<p className="dark:text-gray-400">Please set your instance URL first.</p>
|
||||
<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" /> Go to Setting
|
||||
<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}>
|
||||
@ -83,12 +100,10 @@ const IndexPopup = () => {
|
||||
|
||||
const Popup = () => {
|
||||
return (
|
||||
<StorageContextProvider>
|
||||
<CssVarsProvider>
|
||||
<IndexPopup />
|
||||
<Toaster position="top-center" />
|
||||
<Toaster position="top-right" />
|
||||
</CssVarsProvider>
|
||||
</StorageContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,10 +1,19 @@
|
||||
{
|
||||
"extends": "plasmo/templates/tsconfig.base",
|
||||
"exclude": ["node_modules"],
|
||||
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
".plasmo/index.d.ts",
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx",
|
||||
"../types"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
},
|
||||
"baseUrl": "."
|
||||
}
|
||||
|
@ -12,16 +12,13 @@
|
||||
"search": "Search",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"account": "Account",
|
||||
"or": "Or"
|
||||
"account": "Account"
|
||||
},
|
||||
"auth": {
|
||||
"sign-in": "Sign in",
|
||||
"sign-up": "Sign up",
|
||||
"sign-out": "Sign out",
|
||||
"create-your-account": "Create your account",
|
||||
"host-tip": "You are registering as Admin.",
|
||||
"sign-in-with": "Sign in with {{provider}}"
|
||||
"create-your-account": "Create your account"
|
||||
},
|
||||
"analytics": {
|
||||
"self": "Analytics",
|
||||
@ -36,19 +33,23 @@
|
||||
"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": "Public on the internet"
|
||||
"description": "Visible to everyone on the internet"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"personal": "Personal",
|
||||
"mine": "Mine",
|
||||
"compact-mode": "Compact mode",
|
||||
"order-by": "Order by",
|
||||
"direction": "Direction"
|
||||
@ -58,7 +59,10 @@
|
||||
"nickname": "Nickname",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"profile": "Profile"
|
||||
"profile": "Profile",
|
||||
"action": {
|
||||
"add-user": "Add user"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"self": "Setting",
|
||||
@ -69,13 +73,9 @@
|
||||
"workspace": {
|
||||
"self": "Workspace settings",
|
||||
"custom-style": "Custom style",
|
||||
"disallow-user-registration": {
|
||||
"self": "Disallow user registration"
|
||||
},
|
||||
"default-visibility": "Default visibility",
|
||||
"member": {
|
||||
"self": "Member",
|
||||
"add": "Add member"
|
||||
"enable-user-signup": {
|
||||
"self": "Enable user signup",
|
||||
"description": "Once enabled, other users can signup."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,79 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"about": "À propos",
|
||||
"loading": "Chargement",
|
||||
"cancel": "Annuler",
|
||||
"save": "Sauver",
|
||||
"create": "Créer",
|
||||
"download": "Télécharger",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"language": "Langue",
|
||||
"search": "Recherche",
|
||||
"email": "E-mail",
|
||||
"password": "Mot de passe",
|
||||
"account": "Compte"
|
||||
},
|
||||
"auth": {
|
||||
"sign-in": "Se connecter",
|
||||
"sign-up": "S'inscrire",
|
||||
"sign-out": "Se déconnecter",
|
||||
"create-your-account": "Créez votre compte"
|
||||
},
|
||||
"analytics": {
|
||||
"self": "Analyse",
|
||||
"top-sources": "Principales sources",
|
||||
"source": "Source",
|
||||
"visitors": "Visiteurs",
|
||||
"devices": "Dispositifs",
|
||||
"browser": "Navigateur",
|
||||
"browsers": "Navigateurs",
|
||||
"operating-system": "Systèmes d'exploitation"
|
||||
},
|
||||
"shortcut": {
|
||||
"visits": "{{count}} visites",
|
||||
"visibility": {
|
||||
"workspace": {
|
||||
"self": "Espace de travail",
|
||||
"description": "Les membres de l'espace de travail ont accès"
|
||||
},
|
||||
"public": {
|
||||
"self": "Public",
|
||||
"description": "Visible par tous sur Internet"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"all": "Tout",
|
||||
"mine": "Le mien",
|
||||
"compact-mode": "Mode compact",
|
||||
"order-by": "Commandé par",
|
||||
"direction": "Direction"
|
||||
},
|
||||
"user": {
|
||||
"self": "Utilisateur",
|
||||
"nickname": "Surnom",
|
||||
"email": "E-mail",
|
||||
"role": "Rôle",
|
||||
"profile": "Profil",
|
||||
"action": {
|
||||
"add-user": "Ajouter utilisateur"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"self": "Paramètres",
|
||||
"preference": {
|
||||
"self": "Préférence",
|
||||
"color-theme": "Thème de couleur"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "Paramètres de l'espace de travail",
|
||||
"custom-style": "Style personnalisé",
|
||||
"enable-user-signup": {
|
||||
"self": "Activer l'inscription des utilisateurs",
|
||||
"description": "Une fois activé, d'autres utilisateurs peuvent s'inscrire."
|
||||
},
|
||||
"default-visibility": "Visibilité par défaut"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"about": "Névjegy",
|
||||
"loading": "Betöltés",
|
||||
"cancel": "Mégse",
|
||||
"save": "Mentés",
|
||||
"create": "Létrehozás",
|
||||
"download": "Letöltés",
|
||||
"edit": "Szerkesztés",
|
||||
"delete": "Törlés",
|
||||
"language": "Nyelv",
|
||||
"search": "Keresés",
|
||||
"email": "Email",
|
||||
"password": "Jelszó",
|
||||
"account": "Fiók"
|
||||
},
|
||||
"auth": {
|
||||
"sign-in": "Bejelentkezés",
|
||||
"sign-up": "Regisztráció",
|
||||
"sign-out": "Kijelentkezés",
|
||||
"create-your-account": "Fiók létrehozás",
|
||||
"host-tip": "Adminisztrátorként regisztrál."
|
||||
},
|
||||
"analytics": {
|
||||
"self": "Analitika",
|
||||
"top-sources": "Legfontosabb források",
|
||||
"source": "Forrás",
|
||||
"visitors": "Látogatók",
|
||||
"devices": "Eszközök",
|
||||
"browser": "Böngésző",
|
||||
"browsers": "Böngészők",
|
||||
"operating-system": "Operációs rendszer"
|
||||
},
|
||||
"shortcut": {
|
||||
"visits": "{{count}} látogatás",
|
||||
"visibility": {
|
||||
"workspace": {
|
||||
"self": "Munkaterület",
|
||||
"description": "A munkaterület tagjai hozzáférhetnek"
|
||||
},
|
||||
"public": {
|
||||
"self": "Nyilvános",
|
||||
"description": "Mindenki számára látható az interneten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"all": "Összes",
|
||||
"mine": "Saját",
|
||||
"compact-mode": "Kompakt mód",
|
||||
"order-by": "Rendezés",
|
||||
"direction": "Irány"
|
||||
},
|
||||
"user": {
|
||||
"self": "Felhasználó",
|
||||
"nickname": "Becenév",
|
||||
"email": "Email",
|
||||
"role": "Szerep",
|
||||
"profile": "Profil",
|
||||
"action": {
|
||||
"add-user": "Felhasználó hozzáadása"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"self": "Beállítás",
|
||||
"preference": {
|
||||
"self": "Preferencia",
|
||||
"color-theme": "Színtéma"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "Munkaterület beállítások",
|
||||
"custom-style": "Egyéni stílus",
|
||||
"enable-user-signup": {
|
||||
"self": "Felhasználói regisztráció engedélyezése",
|
||||
"description": "Ha engedélyezve van, más felhasználók is regisztrálhatnak."
|
||||
},
|
||||
"default-visibility": "Alapértelmezett láthatóság"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"about": "About",
|
||||
"loading": "読込中",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"create": "作成",
|
||||
"download": "ダウンロード",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"language": "言語",
|
||||
"search": "検索",
|
||||
"email": "Eメール",
|
||||
"password": "パスワード",
|
||||
"account": "アカウント"
|
||||
},
|
||||
"auth": {
|
||||
"sign-in": "サインイン",
|
||||
"sign-up": "登録",
|
||||
"sign-out": "サインアウト",
|
||||
"create-your-account": "アカウントを作成してください",
|
||||
"host-tip": "管理者として登録されています。"
|
||||
},
|
||||
"analytics": {
|
||||
"self": "分析",
|
||||
"top-sources": "トップソース",
|
||||
"source": "ソース",
|
||||
"visitors": "訪問者",
|
||||
"devices": "デバイス",
|
||||
"browser": "ブラウザ",
|
||||
"browsers": "ブラウザ",
|
||||
"operating-system": "オペレーティングシステム"
|
||||
},
|
||||
"shortcut": {
|
||||
"visits": "{{count}} 回訪問",
|
||||
"visibility": {
|
||||
"workspace": {
|
||||
"self": "ワークスペース",
|
||||
"description": "ワークスペースメンバーがアクセスできます"
|
||||
},
|
||||
"public": {
|
||||
"self": "公開",
|
||||
"description": "誰でもアクセスできます"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"all": "全て",
|
||||
"mine": "自分",
|
||||
"compact-mode": "コンパクトモード",
|
||||
"order-by": "順序",
|
||||
"direction": "方向"
|
||||
},
|
||||
"user": {
|
||||
"self": "ユーザー",
|
||||
"nickname": "ニックネーム",
|
||||
"email": "Eメール",
|
||||
"role": "役割",
|
||||
"profile": "プロフィール",
|
||||
"action": {
|
||||
"add-user": "ユーザーの追加"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"self": "設定",
|
||||
"preference": {
|
||||
"self": "プリファレンス",
|
||||
"color-theme": "カラーテーマ"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "ワークスペースの設定",
|
||||
"custom-style": "カスタムスタイル",
|
||||
"enable-user-signup": {
|
||||
"self": "ユーザーの登録を有効にする",
|
||||
"description": "有効にすると他のユーザーが登録できるようになります。"
|
||||
},
|
||||
"default-visibility": "デフォルトの表示"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"about": "Информация",
|
||||
"loading": "Загружается",
|
||||
"cancel": "Отменить",
|
||||
"save": "Сохранить",
|
||||
"create": "Создать",
|
||||
"download": "Загрузить",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить",
|
||||
"language": "Язык",
|
||||
"search": "Поиск",
|
||||
"email": "Email",
|
||||
"password": "Пароль",
|
||||
"account": "Аккаунт"
|
||||
},
|
||||
"auth": {
|
||||
"sign-in": "Войти",
|
||||
"sign-up": "Регистрация",
|
||||
"sign-out": "Выйти",
|
||||
"create-your-account": "Создать аккаунт",
|
||||
"host-tip": "Вы зарегистрированы как Admin."
|
||||
},
|
||||
"analytics": {
|
||||
"self": "Аналитика",
|
||||
"top-sources": "Лучшие источники",
|
||||
"source": "Источник",
|
||||
"visitors": "Посетители",
|
||||
"devices": "Устройства",
|
||||
"browser": "Браузер",
|
||||
"browsers": "Браузеры",
|
||||
"operating-system": "Операционная система"
|
||||
},
|
||||
"shortcut": {
|
||||
"visits": "{{count}} перехода",
|
||||
"visibility": {
|
||||
"workspace": {
|
||||
"self": "Команда",
|
||||
"description": "Члены команды имеют доступ"
|
||||
},
|
||||
"public": {
|
||||
"self": "Публичная",
|
||||
"description": "Видимая для всех из интернета"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
"mine": "Мои",
|
||||
"compact-mode": "Компактный режим",
|
||||
"order-by": "Создана",
|
||||
"direction": "Путь"
|
||||
},
|
||||
"user": {
|
||||
"self": "Пользователь",
|
||||
"nickname": "Имя пользователя",
|
||||
"email": "Email",
|
||||
"role": "Роль",
|
||||
"profile": "Профиль",
|
||||
"action": {
|
||||
"add-user": "Добавить пользователя"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"self": "Настройки",
|
||||
"preference": {
|
||||
"self": "Внешний вид",
|
||||
"color-theme": "Цветовая схема"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "Настройки команды",
|
||||
"custom-style": "Пользовательский стиль",
|
||||
"enable-user-signup": {
|
||||
"self": "Разрешить регистрацию пользователей",
|
||||
"description": "После включения, другие пользователи смогут зарегистрироваться."
|
||||
},
|
||||
"default-visibility": "Отображение по умолчанию"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,79 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"about": "Hakkında",
|
||||
"loading": "Yükleniyor",
|
||||
"cancel": "İptal",
|
||||
"save": "Kaydet",
|
||||
"create": "Oluştur",
|
||||
"download": "İndir",
|
||||
"edit": "Düzenle",
|
||||
"delete": "Sil",
|
||||
"language": "Dil",
|
||||
"search": "Ara",
|
||||
"email": "E-posta",
|
||||
"password": "Şifre",
|
||||
"account": "Hesap"
|
||||
},
|
||||
"auth": {
|
||||
"sign-in": "Giriş yap",
|
||||
"sign-up": "Kaydol",
|
||||
"sign-out": "Çıkış yap",
|
||||
"create-your-account": "Hesabınızı oluşturun"
|
||||
},
|
||||
"analytics": {
|
||||
"self": "Analizler",
|
||||
"top-sources": "En İyi Kaynaklar",
|
||||
"source": "Kaynak",
|
||||
"visitors": "Ziyâretçiler",
|
||||
"devices": "Cihazlar",
|
||||
"browser": "Tarayıcı",
|
||||
"browsers": "Tarayıcılar",
|
||||
"operating-system": "İşletim Sistemi"
|
||||
},
|
||||
"shortcut": {
|
||||
"visits": "{{count}} ziyaret",
|
||||
"visibility": {
|
||||
"workspace": {
|
||||
"self": "Çalışma Alanı",
|
||||
"description": "Çalışma alanı üyeleri erişebilir"
|
||||
},
|
||||
"public": {
|
||||
"self": "Herkese açık",
|
||||
"description": "İnternette herkese görünür"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"all": "Hepsi",
|
||||
"mine": "Benim",
|
||||
"compact-mode": "Kompakt mod",
|
||||
"order-by": "Sırala",
|
||||
"direction": "Yön"
|
||||
},
|
||||
"user": {
|
||||
"self": "Kullanıcı",
|
||||
"nickname": "Takma ad",
|
||||
"email": "E-posta",
|
||||
"role": "Rol",
|
||||
"profile": "Profil",
|
||||
"action": {
|
||||
"add-user": "Kullanıcı ekle"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"self": "Ayarlar",
|
||||
"preference": {
|
||||
"self": "Tercihler",
|
||||
"color-theme": "Renk teması"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "Çalışma alanı ayarları",
|
||||
"custom-style": "Özel stil",
|
||||
"enable-user-signup": {
|
||||
"self": "Kullanıcı kaydını etkinleştir",
|
||||
"description": "Etkinleştirildiğinde, diğer kullanıcılar kaydolabilir."
|
||||
},
|
||||
"default-visibility": "Varsayılan görünürlük"
|
||||
}
|
||||
}
|
||||
}
|
@ -33,19 +33,23 @@
|
||||
"shortcut": {
|
||||
"visits": "{{count}} 次访问",
|
||||
"visibility": {
|
||||
"private": {
|
||||
"self": "私有的",
|
||||
"description": "仅您可以访问"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "工作区",
|
||||
"description": "工作区成员可以访问"
|
||||
},
|
||||
"public": {
|
||||
"self": "公开的",
|
||||
"description": "公开至互联网"
|
||||
"description": "对任何人可见"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"all": "所有",
|
||||
"personal": "我的",
|
||||
"mine": "我的",
|
||||
"compact-mode": "紧凑模式",
|
||||
"order-by": "排序方式",
|
||||
"direction": "方向"
|
||||
@ -72,8 +76,7 @@
|
||||
"enable-user-signup": {
|
||||
"self": "启用用户注册",
|
||||
"description": "允许其他用户注册新账号"
|
||||
},
|
||||
"default-visibility": "默认可见性"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
6
frontend/web/.vscode/setting.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
}
|
@ -2,10 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/logo.svg" type="image/*" />
|
||||
<link rel="icon" href="/logo.png" type="image/*" />
|
||||
<meta name="theme-color" content="#FFFFFF" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<!-- slash.metadata -->
|
||||
<title>Slash</title>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -2,60 +2,55 @@
|
||||
"name": "slash",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "tsc && vite build",
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
||||
"type-check": "tsc --noEmit --skipLibCheck",
|
||||
"postinstall": "cd ../../proto && buf generate"
|
||||
"type-gen": "cd ../../proto && buf generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/joy": "5.0.0-beta.48",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"classnames": "^2.5.1",
|
||||
"@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.13",
|
||||
"i18next": "^23.16.4",
|
||||
"dayjs": "^1.11.10",
|
||||
"i18next": "^23.6.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.446.0",
|
||||
"nice-grpc-web": "^3.3.5",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"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": "^15.1.0",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-use": "^17.5.1",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"uuid": "^10.0.0",
|
||||
"zustand": "^5.0.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.46.0",
|
||||
"@bufbuild/protobuf": "^2.2.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"@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.47",
|
||||
"prettier": "^3.3.3",
|
||||
"protobufjs": "^7.4.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
},
|
||||
"resolutions": {
|
||||
"csstype": "3.1.2"
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "2.6.2",
|
||||
"protobufjs": "^7.2.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.5.0"
|
||||
}
|
||||
}
|
||||
|
6227
frontend/web/pnpm-lock.yaml
generated
BIN
frontend/web/public/logo.png
Normal file
After Width: | Height: | Size: 257 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-slash"><line x1="9" x2="15" y1="15" y2="9"/><circle cx="12" cy="12" r="10"/></svg>
|
Before Width: | Height: | Size: 291 B |
@ -1,24 +1,26 @@
|
||||
import { useColorScheme } from "@mui/joy";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import DemoBanner from "@/components/DemoBanner";
|
||||
import { useWorkspaceStore } from "@/stores";
|
||||
import useNavigateTo from "./hooks/useNavigateTo";
|
||||
import { FeatureType } from "./stores/workspace";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import useUserStore from "./stores/v1/user";
|
||||
import useWorkspaceStore from "./stores/v1/workspace";
|
||||
|
||||
function App() {
|
||||
const navigateTo = useNavigateTo();
|
||||
const { mode: colorScheme } = useColorScheme();
|
||||
const userStore = useUserStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Redirect to sign up page if no instance owner.
|
||||
useEffect(() => {
|
||||
if (!workspaceStore.profile.owner) {
|
||||
navigateTo("/auth/signup", {
|
||||
replace: true,
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
await Promise.all([workspaceStore.fetchWorkspaceProfile(), workspaceStore.fetchWorkspaceSetting(), userStore.fetchCurrentUser()]);
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
}, [workspaceStore.profile]);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const styleEl = document.createElement("style");
|
||||
@ -27,16 +29,6 @@ function App() {
|
||||
document.body.insertAdjacentElement("beforeend", styleEl);
|
||||
}, [workspaceStore.setting.customStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasCustomBranding = workspaceStore.checkFeatureAvailable(FeatureType.CustomeBranding);
|
||||
if (!hasCustomBranding || !workspaceStore.setting.branding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const favicon = document.querySelector("link[rel='icon']") as HTMLLinkElement;
|
||||
favicon.href = new TextDecoder().decode(workspaceStore.setting.branding);
|
||||
}, [workspaceStore.setting.branding]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (colorScheme === "light") {
|
||||
@ -70,11 +62,13 @@ function App() {
|
||||
}
|
||||
}, [colorScheme]);
|
||||
|
||||
return (
|
||||
return !loading ? (
|
||||
<>
|
||||
<DemoBanner />
|
||||
<Outlet />
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -21,12 +21,11 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
<div className="max-w-full w-80 sm:w-96">
|
||||
<p>
|
||||
<span className="font-medium">Slash</span> is an open source, self-hosted platform for sharing and managing your most frequently
|
||||
used links.
|
||||
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform.
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<span className="mr-2">Source code:</span>
|
||||
<Link variant="plain" href="https://github.com/yourselfhosted/slash" target="_blank">
|
||||
<span className="mr-2">See more in</span>
|
||||
<Link variant="plain" href="https://github.com/boojack/slash" target="_blank">
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -45,7 +45,7 @@ const Alert: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80">
|
||||
<div className="flex flex-row justify-between items-center w-80 mb-4">
|
||||
<span className="text-lg font-medium">{title}</span>
|
||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
|
@ -1,29 +1,28 @@
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { shortcutServiceClient } from "@/grpcweb";
|
||||
import { GetShortcutAnalyticsResponse } from "@/types/proto/api/v1/shortcut_service";
|
||||
import * as api from "../helpers/api";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
shortcutId: number;
|
||||
shortcutId: ShortcutId;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||
const { shortcutId, className } = props;
|
||||
const { t } = useTranslation();
|
||||
const [analytics, setAnalytics] = useState<GetShortcutAnalyticsResponse | null>(null);
|
||||
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
||||
|
||||
useEffect(() => {
|
||||
shortcutServiceClient.getShortcutAnalytics({ id: shortcutId }).then((response) => {
|
||||
setAnalytics(response);
|
||||
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
||||
setAnalytics(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classNames("relative w-full", className)}>
|
||||
<div className={classNames("w-full", className)}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<div className="w-full">
|
||||
@ -35,13 +34,13 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||
<span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">{t("analytics.visitors")}</span>
|
||||
</div>
|
||||
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
{analytics.references.length === 0 && (
|
||||
{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.references.map((reference) => (
|
||||
{analytics.referenceData.map((reference) => (
|
||||
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900 dark:text-gray-500">
|
||||
{reference.name ? (
|
||||
@ -96,13 +95,13 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||
</div>
|
||||
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
{analytics.browsers.length === 0 && (
|
||||
{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.browsers.map((reference) => (
|
||||
{analytics.browserData.map((reference) => (
|
||||
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate dark:text-gray-500">
|
||||
{reference.name || "Unknown"}
|
||||
@ -119,13 +118,13 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||
</div>
|
||||
<div className="w-full divide-y divide-gray-200">
|
||||
{analytics.devices.length === 0 && (
|
||||
{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.devices.map((device) => (
|
||||
{analytics.deviceData.map((device) => (
|
||||
<div key={device.name} className="w-full flex flex-row justify-between items-center">
|
||||
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span>
|
||||
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span>
|
||||
@ -138,7 +137,7 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute 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" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
|
@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useUserStore } from "@/stores";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -46,18 +46,15 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
requestState.setLoading();
|
||||
try {
|
||||
userStore.patchUser(
|
||||
{
|
||||
userStore.patchUser({
|
||||
id: userStore.getCurrentUser().id,
|
||||
password: newPassword,
|
||||
},
|
||||
["password"],
|
||||
);
|
||||
});
|
||||
onClose();
|
||||
toast("Password changed");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
requestState.setFinish();
|
||||
};
|
||||
@ -65,7 +62,7 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80">
|
||||
<div className="flex flex-row justify-between items-center w-80 mb-4">
|
||||
<span className="text-lg font-medium">Change Password</span>
|
||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { Tooltip } from "@mui/joy";
|
||||
import classNames from "classnames";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useState } from "react";
|
||||
@ -8,11 +7,11 @@ import { Link } from "react-router-dom";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import { useCollectionStore, useShortcutStore, useUserStore } from "@/stores";
|
||||
import { Collection } from "@/types/proto/api/v1/collection_service";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
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 "./CreateCollectionDrawer";
|
||||
import CreateCollectionDialog from "./CreateCollectionDialog";
|
||||
import Icon from "./Icon";
|
||||
import ShortcutView from "./ShortcutView";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
@ -26,15 +25,12 @@ const CollectionView = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { sm } = useResponsiveWidth();
|
||||
const navigateTo = useNavigateTo();
|
||||
const userStore = useUserStore();
|
||||
const currentUser = userStore.getCurrentUser();
|
||||
const collectionStore = useCollectionStore();
|
||||
const shortcutList = useShortcutStore().getShortcutList();
|
||||
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 showAdminActions = currentUser.id === collection.creatorId;
|
||||
|
||||
const handleCopyCollectionLink = () => {
|
||||
copy(absolutifyLink(`/c/${collection.name}`));
|
||||
@ -56,57 +52,33 @@ const CollectionView = (props: Props) => {
|
||||
navigateTo(`/shortcut/${shortcut.id}`);
|
||||
};
|
||||
|
||||
const handleOpenAllShortcutsButtonClick = () => {
|
||||
shortcuts.forEach((shortcut: Shortcut) => window.open(`/s/${shortcut.name}`));
|
||||
};
|
||||
|
||||
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">
|
||||
<Link className="leading-6 font-medium dark:text-gray-400" to={`/c/${collection.name}`} viewTransition>
|
||||
{collection.title}
|
||||
</Link>
|
||||
<span className="ml-1 leading-6 text-gray-500 dark:text-gray-400" onClick={handleCopyCollectionLink}>
|
||||
(c/{collection.name})
|
||||
</span>
|
||||
<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 gap-2">
|
||||
<Tooltip title="Share" placement="top" arrow>
|
||||
<Link className="w-auto text-gray-400 cursor-pointer hover:text-gray-500" to={`/c/${collection.name}`} target="_blank">
|
||||
<Icon.Share className="w-4 h-auto" />
|
||||
<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>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open all" placement="top" arrow>
|
||||
<button
|
||||
className="w-auto text-gray-400 cursor-pointer hover:text-gray-500"
|
||||
onClick={() => handleOpenAllShortcutsButtonClick()}
|
||||
>
|
||||
<Icon.ArrowUpRight className="w-5 h-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showAdminActions && (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex flex-row justify-center items-center rounded text-gray-400 cursor-pointer hover:text-gray-500">
|
||||
<Icon.MoreVertical className="w-4 h-auto" />
|
||||
</button>
|
||||
}
|
||||
actionsClassName="!w-28 text-sm"
|
||||
actionsClassName="!w-28 dark:text-gray-500"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
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 text-red-600 dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
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();
|
||||
}}
|
||||
@ -116,7 +88,6 @@ const CollectionView = (props: Props) => {
|
||||
</>
|
||||
}
|
||||
></Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full p-3 flex flex-row justify-start items-start flex-wrap gap-3">
|
||||
|
@ -3,8 +3,8 @@ import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { userServiceClient } from "@/grpcweb";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useUserStore } from "@/stores";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -80,14 +80,14 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80">
|
||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||
<span className="text-lg font-medium">Create Access Token</span>
|
||||
<Button variant="plain" onClick={onClose}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { Button, Checkbox, DialogActions, DialogContent, DialogTitle, Divider, Drawer, Input, ModalClose } from "@mui/joy";
|
||||
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
||||
import { isUndefined } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useCollectionStore, useShortcutStore, useWorkspaceStore } from "@/stores";
|
||||
import { Collection } from "@/types/proto/api/v1/collection_service";
|
||||
import { Visibility } from "@/types/proto/api/v1/common";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
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";
|
||||
|
||||
@ -21,50 +22,23 @@ interface State {
|
||||
collectionCreate: Collection;
|
||||
}
|
||||
|
||||
const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
const CreateCollectionDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose, onConfirm, collectionId } = props;
|
||||
const { t } = useTranslation();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const collectionStore = useCollectionStore();
|
||||
const shortcutList = useShortcutStore().getShortcutList();
|
||||
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||
const [state, setState] = useState<State>({
|
||||
collectionCreate: Collection.fromPartial({
|
||||
visibility: Visibility.WORKSPACE,
|
||||
visibility: Visibility.PRIVATE,
|
||||
}),
|
||||
});
|
||||
const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]);
|
||||
const isCreating = isUndefined(collectionId);
|
||||
const loadingState = useLoading(!isCreating);
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = isUndefined(collectionId);
|
||||
const unselectedShortcuts = shortcutList
|
||||
.filter((shortcut) => {
|
||||
if (state.collectionCreate.visibility === Visibility.PUBLIC) {
|
||||
return shortcut.visibility === Visibility.PUBLIC;
|
||||
} else if (state.collectionCreate.visibility === Visibility.WORKSPACE) {
|
||||
return shortcut.visibility === Visibility.PUBLIC || shortcut.visibility === Visibility.WORKSPACE;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.filter((shortcut) => (state.collectionCreate.visibility === Visibility.PUBLIC ? shortcut.visibility === "PUBLIC" : true))
|
||||
.filter((shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id));
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
|
||||
setPartialState({
|
||||
collectionCreate: Object.assign(state.collectionCreate, {
|
||||
visibility: workspaceStore.setting.defaultVisibility,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (collectionId) {
|
||||
@ -79,17 +53,19 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
setSelectedShortcuts(
|
||||
collection.shortcutIds
|
||||
.map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId))
|
||||
.filter(Boolean) as Shortcut[],
|
||||
.filter(Boolean) as Shortcut[]
|
||||
);
|
||||
loadingState.setFinish();
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [collectionId]);
|
||||
|
||||
if (loadingState.isLoading) {
|
||||
return null;
|
||||
}
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
@ -107,6 +83,14 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
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, {
|
||||
@ -136,7 +120,7 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
visibility: state.collectionCreate.visibility,
|
||||
shortcutIds: selectedShortcuts.map((shortcut) => shortcut.id),
|
||||
},
|
||||
["name", "title", "description", "visibility", "shortcut_ids"],
|
||||
["name", "title", "description", "visibility", "shortcut_ids"]
|
||||
);
|
||||
} else {
|
||||
await collectionStore.createCollection({
|
||||
@ -157,24 +141,29 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={true} onClose={onClose}>
|
||||
<DialogTitle>{isCreating ? "Create Collection" : "Edit Collection"}</DialogTitle>
|
||||
<ModalClose />
|
||||
<DialogContent className="w-full max-w-full">
|
||||
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
||||
<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"
|
||||
startDecorator="c/"
|
||||
placeholder="An easy name to remember"
|
||||
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>
|
||||
@ -183,7 +172,7 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="A short title of your collection"
|
||||
placeholder="A short title to describe your collection"
|
||||
value={state.collectionCreate.title}
|
||||
onChange={handleTitleInputChange}
|
||||
/>
|
||||
@ -202,25 +191,22 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<Checkbox
|
||||
className="w-full dark:text-gray-400"
|
||||
checked={state.collectionCreate.visibility === Visibility.PUBLIC}
|
||||
label={t(`shortcut.visibility.public.description`)}
|
||||
onChange={(e) =>
|
||||
setPartialState({
|
||||
collectionCreate: Object.assign(state.collectionCreate, {
|
||||
visibility: e.target.checked ? Visibility.PUBLIC : Visibility.WORKSPACE,
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<Divider className="text-gray-500" />
|
||||
<div className="w-full flex flex-col justify-start items-start mt-3 mb-3">
|
||||
<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>}
|
||||
{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) => {
|
||||
@ -255,10 +241,8 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<div className="w-full flex flex-row justify-end items-center px-3 py-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}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@ -266,9 +250,10 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogActions>
|
||||
</Drawer>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCollectionDrawer;
|
||||
export default CreateCollectionDialog;
|
@ -1,291 +0,0 @@
|
||||
import { Button, DialogActions, DialogContent, DialogTitle, Divider, Drawer, Input, ModalClose } from "@mui/joy";
|
||||
import { isUndefined } from "lodash-es";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { workspaceServiceClient } from "@/grpcweb";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useWorkspaceStore } from "@/stores";
|
||||
import { IdentityProvider, IdentityProvider_Type, IdentityProviderConfig_OAuth2Config } from "@/types/proto/api/v1/workspace_service";
|
||||
|
||||
interface Props {
|
||||
identityProvider?: IdentityProvider;
|
||||
onClose: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
identityProviderCreate: IdentityProvider;
|
||||
}
|
||||
|
||||
const CreateIdentityProviderDrawer: React.FC<Props> = (props: Props) => {
|
||||
const { onClose, onConfirm, identityProvider } = props;
|
||||
const { t } = useTranslation();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const [state, setState] = useState<State>({
|
||||
identityProviderCreate: IdentityProvider.fromPartial(
|
||||
identityProvider || {
|
||||
id: uuidv4(),
|
||||
type: IdentityProvider_Type.OAUTH2,
|
||||
config: {
|
||||
oauth2: IdentityProviderConfig_OAuth2Config.fromPartial({
|
||||
scopes: [],
|
||||
fieldMapping: {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
),
|
||||
});
|
||||
const isCreating = isUndefined(identityProvider);
|
||||
const requestState = useLoading(false);
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
identityProviderCreate: Object.assign(state.identityProviderCreate, {
|
||||
title: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOAuth2ConfigChange = (e: React.ChangeEvent<HTMLInputElement>, field: string) => {
|
||||
if (!state.identityProviderCreate.config || !state.identityProviderCreate.config.oauth2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = field === "scopes" ? e.target.value.split(" ") : e.target.value;
|
||||
setPartialState({
|
||||
identityProviderCreate: Object.assign(state.identityProviderCreate, {
|
||||
config: Object.assign(state.identityProviderCreate.config, {
|
||||
oauth2: Object.assign(state.identityProviderCreate.config.oauth2, {
|
||||
[field]: value,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleFieldMappingChange = (e: React.ChangeEvent<HTMLInputElement>, field: string) => {
|
||||
if (
|
||||
!state.identityProviderCreate.config ||
|
||||
!state.identityProviderCreate.config.oauth2 ||
|
||||
!state.identityProviderCreate.config.oauth2.fieldMapping
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPartialState({
|
||||
identityProviderCreate: Object.assign(state.identityProviderCreate, {
|
||||
config: Object.assign(state.identityProviderCreate.config, {
|
||||
oauth2: Object.assign(state.identityProviderCreate.config.oauth2, {
|
||||
fieldMapping: Object.assign(state.identityProviderCreate.config.oauth2.fieldMapping, {
|
||||
[field]: e.target.value,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
if (!state.identityProviderCreate.id || !state.identityProviderCreate.title) {
|
||||
toast.error("Please fill in required fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCreating) {
|
||||
await workspaceServiceClient.updateWorkspaceSetting({
|
||||
setting: {
|
||||
identityProviders: workspaceStore.setting.identityProviders.map((idp) =>
|
||||
idp.id === state.identityProviderCreate.id ? state.identityProviderCreate : idp,
|
||||
),
|
||||
},
|
||||
updateMask: ["identity_providers"],
|
||||
});
|
||||
} else {
|
||||
await workspaceServiceClient.updateWorkspaceSetting({
|
||||
setting: {
|
||||
identityProviders: [...workspaceStore.setting.identityProviders, state.identityProviderCreate],
|
||||
},
|
||||
updateMask: ["identity_providers"],
|
||||
});
|
||||
}
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={true} onClose={onClose}>
|
||||
<DialogTitle>{isCreating ? "Create Identity Provider" : "Edit Identity Provider"}</DialogTitle>
|
||||
<ModalClose />
|
||||
<DialogContent className="w-full max-w-full">
|
||||
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
||||
<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 will be displayed in the UI"
|
||||
value={state.identityProviderCreate.title}
|
||||
onChange={handleTitleInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="!mb-3" />
|
||||
<p className="font-medium mb-2">Identity provider information</p>
|
||||
{isCreating && (
|
||||
<p className="shadow-sm rounded-md py-1 px-2 bg-zinc-100 dark:bg-zinc-900 text-sm w-full mb-2 break-all">
|
||||
<span className="opacity-60">Redirect URL</span>
|
||||
<br />
|
||||
<code>{absolutifyLink("/auth/callback")}</code>
|
||||
</p>
|
||||
)}
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Client ID <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Client ID of the OAuth2 provider"
|
||||
value={state.identityProviderCreate.config?.oauth2?.clientId}
|
||||
onChange={(e) => handleOAuth2ConfigChange(e, "clientId")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Client Secret <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Client Secret of the OAuth2 provider"
|
||||
value={state.identityProviderCreate.config?.oauth2?.clientSecret}
|
||||
onChange={(e) => handleOAuth2ConfigChange(e, "clientSecret")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Authorization endpoint <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Authorization endpoint of the OAuth2 provider"
|
||||
value={state.identityProviderCreate.config?.oauth2?.authUrl}
|
||||
onChange={(e) => handleOAuth2ConfigChange(e, "authUrl")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Token endpoint <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Token endpoint of the OAuth2 provider"
|
||||
value={state.identityProviderCreate.config?.oauth2?.tokenUrl}
|
||||
onChange={(e) => handleOAuth2ConfigChange(e, "tokenUrl")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
User endpoint <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="User endpoint of the OAuth2 provider"
|
||||
value={state.identityProviderCreate.config?.oauth2?.userInfoUrl}
|
||||
onChange={(e) => handleOAuth2ConfigChange(e, "userInfoUrl")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Scopes <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Scopes of the OAuth2 provider, separated by space"
|
||||
value={state.identityProviderCreate.config?.oauth2?.scopes.join(" ")}
|
||||
onChange={(e) => handleOAuth2ConfigChange(e, "scopes")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="!mb-3" />
|
||||
<p className="font-medium mb-2">Field mapping</p>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Identifier <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="The field in the user info response to identify the user"
|
||||
value={state.identityProviderCreate.config?.oauth2?.fieldMapping?.identifier}
|
||||
onChange={(e) => handleFieldMappingChange(e, "identifier")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<span className="mb-2">Display name</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="The field in the user info response to display the user"
|
||||
value={state.identityProviderCreate.config?.oauth2?.fieldMapping?.displayName}
|
||||
onChange={(e) => handleFieldMappingChange(e, "displayName")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<div className="w-full flex flex-row justify-end items-center px-3 py-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={onSave}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogActions>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateIdentityProviderDrawer;
|
@ -1,71 +1,57 @@
|
||||
import { Button, Checkbox, DialogActions, DialogContent, DialogTitle, Divider, Drawer, Input, ModalClose, Textarea } from "@mui/joy";
|
||||
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
||||
import classnames from "classnames";
|
||||
import { isUndefined, uniq } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useShortcutStore, useWorkspaceStore } from "@/stores";
|
||||
import { getShortcutUpdateMask } from "@/stores/shortcut";
|
||||
import { Visibility } from "@/types/proto/api/v1/common";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { useAppSelector } from "@/stores";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { shortcutService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
shortcutId?: number;
|
||||
shortcutId?: ShortcutId;
|
||||
initialShortcut?: Partial<Shortcut>;
|
||||
onClose: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
shortcutCreate: Shortcut;
|
||||
shortcutCreate: ShortcutCreate;
|
||||
}
|
||||
|
||||
const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
const visibilities: Visibility[] = ["PRIVATE", "WORKSPACE", "PUBLIC"];
|
||||
|
||||
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose, onConfirm, shortcutId, initialShortcut } = props;
|
||||
const { t } = useTranslation();
|
||||
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||
const [state, setState] = useState<State>({
|
||||
shortcutCreate: Shortcut.fromPartial({
|
||||
visibility: Visibility.WORKSPACE,
|
||||
ogMetadata: {
|
||||
shortcutCreate: {
|
||||
name: "",
|
||||
link: "",
|
||||
title: "",
|
||||
description: "",
|
||||
visibility: "PRIVATE",
|
||||
tags: [],
|
||||
openGraphMetadata: {
|
||||
title: "",
|
||||
description: "",
|
||||
image: "",
|
||||
},
|
||||
...initialShortcut,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const shortcutStore = useShortcutStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
|
||||
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||
const shortcutList = shortcutStore.getShortcutList();
|
||||
const [tag, setTag] = useState<string>("");
|
||||
const tagSuggestions = uniq(shortcutList.map((shortcut) => shortcut.tags).flat());
|
||||
const isCreating = isUndefined(shortcutId);
|
||||
const loadingState = useLoading(!isCreating);
|
||||
const requestState = useLoading(false);
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
visibility: workspaceStore.setting.defaultVisibility,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const isCreating = isUndefined(shortcutId);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutId) {
|
||||
const shortcut = shortcutStore.getShortcutById(shortcutId);
|
||||
const shortcut = shortcutService.getShortcutById(shortcutId);
|
||||
if (shortcut) {
|
||||
setState({
|
||||
...state,
|
||||
@ -75,18 +61,20 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
title: shortcut.title,
|
||||
description: shortcut.description,
|
||||
visibility: shortcut.visibility,
|
||||
ogMetadata: shortcut.ogMetadata,
|
||||
openGraphMetadata: shortcut.openGraphMetadata,
|
||||
}),
|
||||
});
|
||||
setTag(shortcut.tags.join(" "));
|
||||
loadingState.setFinish();
|
||||
}
|
||||
}
|
||||
}, [shortcutId]);
|
||||
|
||||
if (loadingState.isLoading) {
|
||||
return null;
|
||||
}
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
@ -112,6 +100,14 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
visibility: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
@ -128,8 +124,8 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
ogMetadata: {
|
||||
...state.shortcutCreate.ogMetadata,
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
image: e.target.value,
|
||||
},
|
||||
}),
|
||||
@ -139,8 +135,8 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
ogMetadata: {
|
||||
...state.shortcutCreate.ogMetadata,
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
title: e.target.value,
|
||||
},
|
||||
}),
|
||||
@ -150,8 +146,8 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
ogMetadata: {
|
||||
...state.shortcutCreate.ogMetadata,
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
description: e.target.value,
|
||||
},
|
||||
}),
|
||||
@ -173,19 +169,21 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const tags = tag.split(" ").filter(Boolean);
|
||||
if (shortcutId) {
|
||||
const originShortcut = shortcutStore.getShortcutById(shortcutId);
|
||||
const updatingShortcut = {
|
||||
...state.shortcutCreate,
|
||||
await shortcutService.patchShortcut({
|
||||
id: shortcutId,
|
||||
tags,
|
||||
};
|
||||
await shortcutStore.updateShortcut(updatingShortcut, getShortcutUpdateMask(originShortcut, updatingShortcut));
|
||||
name: state.shortcutCreate.name,
|
||||
link: state.shortcutCreate.link,
|
||||
title: state.shortcutCreate.title,
|
||||
description: state.shortcutCreate.description,
|
||||
visibility: state.shortcutCreate.visibility,
|
||||
tags: tag.split(" ").filter(Boolean),
|
||||
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||
});
|
||||
} else {
|
||||
await shortcutStore.createShortcut({
|
||||
await shortcutService.createShortcut({
|
||||
...state.shortcutCreate,
|
||||
tags,
|
||||
tags: tag.split(" ").filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
@ -196,64 +194,49 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={true} onClose={onClose}>
|
||||
<DialogTitle>{isCreating ? "Create Shortcut" : "Edit Shortcut"}</DialogTitle>
|
||||
<ModalClose />
|
||||
<DialogContent className="w-full max-w-full">
|
||||
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96">
|
||||
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
|
||||
<Button variant="plain" onClick={onClose}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
startDecorator="s/"
|
||||
placeholder="An easy name to remember"
|
||||
placeholder="Should be an unique name and will be put in url"
|
||||
value={state.shortcutCreate.name}
|
||||
onChange={handleNameInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Link <span className="text-red-600">*</span>
|
||||
Destination URL <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="The destination link of the shortcut"
|
||||
placeholder="https://github.com/boojack/slash"
|
||||
value={state.shortcutCreate.link}
|
||||
onChange={handleLinkInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">Title</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="The title of the shortcut"
|
||||
value={state.shortcutCreate.title}
|
||||
onChange={handleTitleInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">Description</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="A short description of the shortcut"
|
||||
value={state.shortcutCreate.description}
|
||||
onChange={handleDescriptionInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">Tags</span>
|
||||
<Input className="w-full" type="text" placeholder="The tags of shortcut" value={tag} onChange={handleTagsInputChange} />
|
||||
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} />
|
||||
{tagSuggestions.length > 0 && (
|
||||
<div className="w-full flex flex-row justify-start items-start mt-2">
|
||||
<Icon.Asterisk className="w-4 h-auto shrink-0 mx-1 text-gray-400 dark:text-gray-500" />
|
||||
@ -272,25 +255,64 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<Checkbox
|
||||
className="w-full dark:text-gray-400"
|
||||
checked={state.shortcutCreate.visibility === Visibility.PUBLIC}
|
||||
label={t(`shortcut.visibility.public.description`)}
|
||||
onChange={(e) =>
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
visibility: e.target.checked ? Visibility.PUBLIC : Visibility.WORKSPACE,
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="mb-2">Visibility</span>
|
||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
||||
{visibilities.map((visibility) => (
|
||||
<Radio key={visibility} value={visibility} label={t(`shortcut.visibility.${visibility.toLowerCase()}.self`)} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md">
|
||||
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
<Divider className="text-gray-500">More</Divider>
|
||||
<div className="w-full flex flex-col justify-start items-start border rounded-md mt-3 overflow-hidden dark:border-zinc-800">
|
||||
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3 dark:border-zinc-800">
|
||||
<div
|
||||
className={classnames(
|
||||
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
|
||||
showOpenGraphMetadata ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : "",
|
||||
showAdditionalFields ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : ""
|
||||
)}
|
||||
onClick={() => setShowAdditionalFields(!showAdditionalFields)}
|
||||
>
|
||||
<span className="text-sm">Additional fields</span>
|
||||
<button className="w-7 h-7 p-1 rounded-md">
|
||||
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showAdditionalFields ? "transform rotate-180" : "")} />
|
||||
</button>
|
||||
</div>
|
||||
{showAdditionalFields && (
|
||||
<div className="w-full px-2 py-1">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2 text-sm">Title</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
size="sm"
|
||||
value={state.shortcutCreate.title}
|
||||
onChange={handleTitleInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2 text-sm">Description</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Github repo for slash"
|
||||
size="sm"
|
||||
value={state.shortcutCreate.description}
|
||||
onChange={handleDescriptionInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden dark:border-zinc-800">
|
||||
<div
|
||||
className={classnames(
|
||||
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
|
||||
showOpenGraphMetadata ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : ""
|
||||
)}
|
||||
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
|
||||
>
|
||||
@ -311,7 +333,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
type="text"
|
||||
placeholder="https://the.link.to/the/image.png"
|
||||
size="sm"
|
||||
value={state.shortcutCreate.ogMetadata?.image}
|
||||
value={state.shortcutCreate.openGraphMetadata.image}
|
||||
onChange={handleOpenGraphMetadataImageChange}
|
||||
/>
|
||||
</div>
|
||||
@ -320,9 +342,9 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Slash - An open source, self-hosted platform for sharing and managing your most frequently used links"
|
||||
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
||||
size="sm"
|
||||
value={state.shortcutCreate.ogMetadata?.title}
|
||||
value={state.shortcutCreate.openGraphMetadata.title}
|
||||
onChange={handleOpenGraphMetadataTitleChange}
|
||||
/>
|
||||
</div>
|
||||
@ -330,20 +352,18 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
<span className="mb-2 text-sm">Description</span>
|
||||
<Textarea
|
||||
className="w-full"
|
||||
placeholder="An open source, self-hosted platform for sharing and managing your most frequently used links."
|
||||
placeholder="An open source, self-hosted bookmarks and link sharing platform."
|
||||
size="sm"
|
||||
maxRows={3}
|
||||
value={state.shortcutCreate.ogMetadata?.description}
|
||||
value={state.shortcutCreate.openGraphMetadata.description}
|
||||
onChange={handleOpenGraphMetadataDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<div className="w-full flex flex-row justify-end items-center px-3 py-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}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@ -351,9 +371,10 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogActions>
|
||||
</Drawer>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateShortcutDrawer;
|
||||
export default CreateShortcutDialog;
|
@ -3,9 +3,8 @@ import { isUndefined } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { Role, User } from "@/types/proto/api/v1/user_service";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -15,9 +14,11 @@ interface Props {
|
||||
}
|
||||
|
||||
interface State {
|
||||
userCreate: Pick<User, "email" | "nickname" | "password" | "role">;
|
||||
userCreate: UserCreate;
|
||||
}
|
||||
|
||||
const roles: Role[] = ["USER", "ADMIN"];
|
||||
|
||||
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose, onConfirm, user } = props;
|
||||
const { t } = useTranslation();
|
||||
@ -27,7 +28,7 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||
email: "",
|
||||
nickname: "",
|
||||
password: "",
|
||||
role: Role.USER,
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const requestState = useLoading(false);
|
||||
@ -94,23 +95,19 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
try {
|
||||
if (user) {
|
||||
const userPatch: Partial<User> = {
|
||||
const userPatch: UserPatch = {
|
||||
id: user.id,
|
||||
};
|
||||
const updateMask: string[] = [];
|
||||
if (user.email !== state.userCreate.email) {
|
||||
userPatch.email = state.userCreate.email;
|
||||
updateMask.push("email");
|
||||
}
|
||||
if (user.nickname !== state.userCreate.nickname) {
|
||||
userPatch.nickname = state.userCreate.nickname;
|
||||
updateMask.push("nickname");
|
||||
}
|
||||
if (user.role !== state.userCreate.role) {
|
||||
userPatch.role = state.userCreate.role;
|
||||
updateMask.push("role");
|
||||
}
|
||||
await userStore.patchUser(userPatch, updateMask);
|
||||
await userStore.patchUser(userPatch);
|
||||
} else {
|
||||
await userStore.createUser(state.userCreate);
|
||||
}
|
||||
@ -122,14 +119,14 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96">
|
||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||
<span className="text-lg font-medium">{isCreating ? "Create User" : "Edit User"}</span>
|
||||
<Button variant="plain" onClick={onClose}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
@ -182,8 +179,9 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||
</span>
|
||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
|
||||
<Radio value={Role.USER} label={"User"} />
|
||||
<Radio value={Role.ADMIN} label={"Admin"} />
|
||||
{roles.map((role) => (
|
||||
<Radio key={role} value={role} label={role} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useWorkspaceStore } from "@/stores";
|
||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const DemoBanner: React.FC = () => {
|
||||
@ -10,10 +10,10 @@ const DemoBanner: React.FC = () => {
|
||||
return (
|
||||
<div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
|
||||
<div className="w-full max-w-8xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
|
||||
<span>✨🔗 Slash - An open source, self-hosted platform for sharing and managing your most frequently used links.</span>
|
||||
<span>✨🔗 Slash - An open source, self-hosted bookmarks and link sharing platform</span>
|
||||
<a
|
||||
className="shadow flex flex-row justify-center items-center px-2 py-1 rounded-md text-sm sm:text-base text-white bg-blue-600 hover:bg-blue-700"
|
||||
href="https://github.com/yourselfhosted/slash#deploy-with-docker-in-seconds"
|
||||
href="https://github.com/boojack/slash#deploy-with-docker-in-seconds"
|
||||
target="_blank"
|
||||
>
|
||||
Install
|
||||
|
@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useUserStore } from "@/stores";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -41,19 +41,16 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
requestState.setLoading();
|
||||
try {
|
||||
await userStore.patchUser(
|
||||
{
|
||||
await userStore.patchUser({
|
||||
id: currentUser.id,
|
||||
email,
|
||||
nickname,
|
||||
},
|
||||
["email", "nickname"],
|
||||
);
|
||||
});
|
||||
onClose();
|
||||
toast("User information updated");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
requestState.setFinish();
|
||||
};
|
||||
@ -61,7 +58,7 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80">
|
||||
<div className="flex flex-row justify-between items-center w-80 mb-4">
|
||||
<span className="text-lg font-medium">Edit Userinfo</span>
|
||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
|
@ -1,25 +0,0 @@
|
||||
import { Tooltip } from "@mui/joy";
|
||||
import { useWorkspaceStore } from "@/stores";
|
||||
import { FeatureType } from "@/stores/workspace";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
feature: FeatureType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FeatureBadge = ({ feature, className }: Props) => {
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const isFeatureEnabled = workspaceStore.checkFeatureAvailable(feature);
|
||||
|
||||
if (isFeatureEnabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip title="This feature is not available on your plan." className={className} placement="top" arrow>
|
||||
<Icon.Sparkles />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureBadge;
|
@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewStore } from "@/stores";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import Icon from "./Icon";
|
||||
import VisibilityIcon from "./VisibilityIcon";
|
||||
|
||||
|
@ -3,8 +3,7 @@ import { QRCodeCanvas } from "qrcode.react";
|
||||
import { useRef } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -25,12 +24,12 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||
const handleDownloadQRCodeClick = () => {
|
||||
const canvas = containerRef.current?.querySelector("canvas");
|
||||
if (!canvas) {
|
||||
toast.error("Failed to get QR code canvas");
|
||||
toast.error("Failed to get qr code canvas");
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.download = `${shortcut.title || shortcut.name}-qrcode.png`;
|
||||
link.download = "filename.png";
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
handleCloseBtnClick();
|
||||
@ -39,7 +38,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-64">
|
||||
<div className="flex flex-row justify-between items-center w-64 mb-4">
|
||||
<span className="text-lg font-medium">QR Code</span>
|
||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
@ -47,7 +46,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
<div>
|
||||
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
|
||||
<QRCodeCanvas value={shortcutLink} size={180} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
||||
<QRCodeCanvas value={shortcutLink} size={128} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-center items-center px-4">
|
||||
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
||||
|