mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-06 13:12:36 +00:00
Compare commits
125 Commits
Author | SHA1 | Date | |
---|---|---|---|
9a491e2a82 | |||
e798e5e82b | |||
87841828ff | |||
f28d23eae7 | |||
606652f7a2 | |||
6395b698b9 | |||
f83c21cc93 | |||
b365355610 | |||
98d4bb40b2 | |||
fcf5981b97 | |||
977ac76928 | |||
66f9c2b568 | |||
e3ce79917d | |||
61cec67ec0 | |||
d6dccb1f95 | |||
c26834e9cd | |||
59a75c89eb | |||
dfe47b9b7e | |||
759ca1c6fd | |||
74200f468c | |||
23d84299e4 | |||
47e0fcd43c | |||
0c4ed55a76 | |||
db842a2c78 | |||
e6ece43231 | |||
714889433f | |||
80c6464208 | |||
1f9c87b81b | |||
4cc2de8e82 | |||
fab3d0033c | |||
6f9df9dfd7 | |||
f5463af7db | |||
a44b6494bf | |||
1ce4b91433 | |||
4139520181 | |||
890bc27982 | |||
a379614cd9 | |||
c18bbfd0bb | |||
d798b2c5fb | |||
4e3ca8ceb4 | |||
96a68ab117 | |||
0eea0a92db | |||
4a47010608 | |||
fa504a88e5 | |||
de51e1a8d3 | |||
49cc1e9755 | |||
ce5c4b65d3 | |||
cee6c7c401 | |||
b6839d2b7d | |||
0ebf03eb9b | |||
21eab35e45 | |||
fd1168e1dc | |||
5ee32d2e78 | |||
2db9c1e850 | |||
953ec3dbc0 | |||
fc28473aee | |||
c42c543618 | |||
72106d13de | |||
6bbf2df8e0 | |||
d42d3fbe10 | |||
6dfccb9509 | |||
66876452e1 | |||
6b107924aa | |||
b84620c057 | |||
c30b6adb8e | |||
c8fea442d6 | |||
a36a99e53d | |||
86078b097d | |||
11205566ac | |||
709118464b | |||
792b60c480 | |||
1418fc2209 | |||
53c1d8fa91 | |||
b32fdbfc0a | |||
db2aebcf57 | |||
b4e23fc8a0 | |||
7ab66113ac | |||
2909676ed3 | |||
5af9236c19 | |||
04c0f47559 | |||
a91997683b | |||
014dd7d660 | |||
a1b633e4db | |||
57496c9b46 | |||
c4f38f1de6 | |||
e7cf0c2f79 | |||
15ffd0738c | |||
21ff8ba797 | |||
b2ce071ef0 | |||
65545c78c6 | |||
4279151238 | |||
3d3f55a931 | |||
85569c032a | |||
bd9daddaef | |||
af31875e6a | |||
a0766159f2 | |||
316617c396 | |||
402b766872 | |||
8fade614d2 | |||
d8c980f56f | |||
b36572c5be | |||
fcd72e1f98 | |||
1cbab78989 | |||
28df6e35fb | |||
12172f11c0 | |||
00c7abc38d | |||
0cceed51f8 | |||
d866d5b53b | |||
05bc21b660 | |||
9455824a2d | |||
0b659ba124 | |||
d82d3701dd | |||
5db3506cba | |||
c00f7d0852 | |||
d900ca060a | |||
b179f7b441 | |||
506e740438 | |||
731ad57fd2 | |||
9fd7d6bd34 | |||
7ca5c92769 | |||
96d44bd651 | |||
ee9e092129 | |||
f0334d5755 | |||
1084381bbf | |||
7d90b47875 |
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -5,7 +5,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to suggest an idea for Shortify!
|
Thanks for taking the time to suggest an idea for Slash!
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Is your feature request related to a problem?
|
label: Is your feature request related to a problem?
|
||||||
|
@ -28,43 +28,6 @@ jobs:
|
|||||||
args: -v
|
args: -v
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|
||||||
eslint-checks:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: "18"
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
|
||||||
- run: pnpm install
|
|
||||||
working-directory: web
|
|
||||||
- name: Run eslint check
|
|
||||||
run: pnpm lint
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
frontend-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: "18"
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
|
||||||
- run: pnpm install
|
|
||||||
working-directory: web
|
|
||||||
- name: Run frontend build
|
|
||||||
run: pnpm build
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
go-tests:
|
go-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
@ -24,7 +24,7 @@ jobs:
|
|||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: stevenlgtm
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -41,4 +41,4 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: stevenlgtm/shortify:latest, stevenlgtm/shortify:${{ env.VERSION }}
|
tags: yourselfhosted/slash:latest, yourselfhosted/slash:${{ env.VERSION }}
|
||||||
|
@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: stevenlgtm
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -34,4 +34,4 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: stevenlgtm/shortify:test
|
tags: yourselfhosted/slash:test
|
||||||
|
49
.github/workflows/frontend-test.yml
vendored
Normal file
49
.github/workflows/frontend-test.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release/v*.*.*"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "web/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
eslint-checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: "web/pnpm-lock.yaml"
|
||||||
|
- run: pnpm install
|
||||||
|
working-directory: web
|
||||||
|
- name: Run eslint check
|
||||||
|
run: pnpm lint
|
||||||
|
working-directory: web
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: "web/pnpm-lock.yaml"
|
||||||
|
- run: pnpm install
|
||||||
|
working-directory: web
|
||||||
|
- name: Run frontend build
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: web
|
34
.github/workflows/proto-linter.yml
vendored
Normal file
34
.github/workflows/proto-linter.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
name: Proto linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release/v*.*.*"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release/*.*.*"
|
||||||
|
paths:
|
||||||
|
- "proto/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-protos:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup buf
|
||||||
|
uses: bufbuild/buf-setup-action@v1
|
||||||
|
- name: buf lint
|
||||||
|
uses: bufbuild/buf-lint-action@v1
|
||||||
|
with:
|
||||||
|
input: "proto"
|
||||||
|
- name: buf format
|
||||||
|
run: |
|
||||||
|
if [[ $(buf format -d) ]]; then
|
||||||
|
echo "Run 'buf format -w'"
|
||||||
|
exit 1
|
||||||
|
fi
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -11,5 +11,3 @@ web/dist
|
|||||||
build
|
build
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
extension
|
|
||||||
|
16
Dockerfile
16
Dockerfile
@ -17,18 +17,24 @@ WORKDIR /backend-build
|
|||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=frontend /frontend-build/dist ./server/dist
|
COPY --from=frontend /frontend-build/dist ./server/dist
|
||||||
|
|
||||||
RUN go build -o shortify ./cmd/shortify/main.go
|
RUN CGO_ENABLED=0 go build -o slash ./cmd/slash/main.go
|
||||||
|
|
||||||
# Make workspace with above generated files.
|
# Make workspace with above generated files.
|
||||||
FROM alpine:3.16 AS monolithic
|
FROM alpine:3.16 AS monolithic
|
||||||
WORKDIR /usr/local/shortify
|
WORKDIR /usr/local/slash
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata
|
||||||
ENV TZ="UTC"
|
ENV TZ="UTC"
|
||||||
|
|
||||||
COPY --from=backend /backend-build/shortify /usr/local/shortify/
|
COPY --from=backend /backend-build/slash /usr/local/slash/
|
||||||
|
|
||||||
|
EXPOSE 5231
|
||||||
|
|
||||||
# Directory to store the data, which can be referenced as the mounting point.
|
# Directory to store the data, which can be referenced as the mounting point.
|
||||||
RUN mkdir -p /var/opt/shortify
|
RUN mkdir -p /var/opt/slash
|
||||||
|
VOLUME /var/opt/slash
|
||||||
|
|
||||||
ENTRYPOINT ["./shortify", "--mode", "prod", "--port", "5231"]
|
ENV SLASH_MODE="prod"
|
||||||
|
ENV SLASH_PORT="5231"
|
||||||
|
|
||||||
|
ENTRYPOINT ["./slash"]
|
||||||
|
25
README.md
25
README.md
@ -1,17 +1,28 @@
|
|||||||
# Shortify
|
# Slash
|
||||||
|
|
||||||
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
||||||
|
|
||||||
**Shortify** is a bookmarking and short link service that allows you to save and share links easily. It lets you store and categorize links, generate short URLs for easy sharing, search and filter your saved links, and access them from any device. It simplifies link organization, management, and collaboration, making it effortless to navigate and share web resources.
|
**Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them using custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
|
||||||
|
|
||||||
Let's Simplify, Share, and Save your links with **Shortify**.
|
<p>
|
||||||
|
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
||||||
|
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg" /></a>
|
||||||
|
<a href="https://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|

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

|
|
||||||
|
24
api/auth/auth.go
Normal file
24
api/auth/auth.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// issuer is the issuer of the jwt token.
|
||||||
|
Issuer = "slash"
|
||||||
|
// Signing key section. For now, this is only used for signing, not for verifying since we only
|
||||||
|
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
|
||||||
|
KeyID = "v1"
|
||||||
|
// AccessTokenAudienceName is the audience name of the access token.
|
||||||
|
AccessTokenAudienceName = "user.access-token"
|
||||||
|
AccessTokenDuration = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
||||||
|
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
||||||
|
// Suppose we have a valid refresh token, we will refresh the token in cases:
|
||||||
|
// 1. The access token has already expired, we refresh the token so that the ongoing request can pass through.
|
||||||
|
CookieExpDuration = AccessTokenDuration - 1*time.Minute
|
||||||
|
// AccessTokenCookieName is the cookie name of access token.
|
||||||
|
AccessTokenCookieName = "slash.access-token"
|
||||||
|
)
|
@ -1,11 +1,11 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
type ActivityShorcutCreatePayload struct {
|
type ActivityShorcutCreatePayload struct {
|
||||||
ShortcutID int `json:"shortcutId"`
|
ShortcutID int32 `json:"shortcutId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActivityShorcutViewPayload struct {
|
type ActivityShorcutViewPayload struct {
|
||||||
ShortcutID int `json:"shortcutId"`
|
ShortcutID int32 `json:"shortcutId"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Referer string `json:"referer"`
|
Referer string `json:"referer"`
|
||||||
UserAgent string `json:"userAgent"`
|
UserAgent string `json:"userAgent"`
|
||||||
|
128
api/v1/analytics.go
Normal file
128
api/v1/analytics.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mssola/useragent"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReferenceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalysisData struct {
|
||||||
|
ReferenceData []ReferenceInfo `json:"referenceData"`
|
||||||
|
DeviceData []DeviceInfo `json:"deviceData"`
|
||||||
|
BrowserData []BrowserInfo `json:"browserData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
||||||
|
g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
|
Type: store.ActivityShortcutView,
|
||||||
|
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcutID)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
referenceMap := make(map[string]int)
|
||||||
|
deviceMap := make(map[string]int)
|
||||||
|
browserMap := make(map[string]int)
|
||||||
|
for _, activity := range activities {
|
||||||
|
payload := &ActivityShorcutViewPayload{}
|
||||||
|
if err := json.Unmarshal([]byte(activity.Payload), payload); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal payload, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := referenceMap[payload.Referer]; !ok {
|
||||||
|
referenceMap[payload.Referer] = 0
|
||||||
|
}
|
||||||
|
referenceMap[payload.Referer]++
|
||||||
|
|
||||||
|
ua := useragent.New(payload.UserAgent)
|
||||||
|
deviceName := ua.OSInfo().Name
|
||||||
|
browserName, _ := ua.Browser()
|
||||||
|
|
||||||
|
if _, ok := deviceMap[deviceName]; !ok {
|
||||||
|
deviceMap[deviceName] = 0
|
||||||
|
}
|
||||||
|
deviceMap[deviceName]++
|
||||||
|
|
||||||
|
if _, ok := browserMap[browserName]; !ok {
|
||||||
|
browserMap[browserName] = 0
|
||||||
|
}
|
||||||
|
browserMap[browserName]++
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, &AnalysisData{
|
||||||
|
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
||||||
|
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
||||||
|
BrowserData: mapToBrowserInfoSlice(browserMap),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
|
||||||
|
referenceInfoSlice := make([]ReferenceInfo, 0)
|
||||||
|
for key, value := range m {
|
||||||
|
referenceInfoSlice = append(referenceInfoSlice, ReferenceInfo{
|
||||||
|
Name: key,
|
||||||
|
Count: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) bool {
|
||||||
|
return i.Count > j.Count
|
||||||
|
})
|
||||||
|
return referenceInfoSlice
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
|
||||||
|
deviceInfoSlice := make([]DeviceInfo, 0)
|
||||||
|
for key, value := range m {
|
||||||
|
deviceInfoSlice = append(deviceInfoSlice, DeviceInfo{
|
||||||
|
Name: key,
|
||||||
|
Count: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) bool {
|
||||||
|
return i.Count > j.Count
|
||||||
|
})
|
||||||
|
return deviceInfoSlice
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
|
||||||
|
browserInfoSlice := make([]BrowserInfo, 0)
|
||||||
|
for key, value := range m {
|
||||||
|
browserInfoSlice = append(browserInfoSlice, BrowserInfo{
|
||||||
|
Name: key,
|
||||||
|
Count: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) bool {
|
||||||
|
return i.Count > j.Count
|
||||||
|
})
|
||||||
|
return browserInfoSlice
|
||||||
|
}
|
@ -5,9 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/auth"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@ -48,7 +46,7 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|||||||
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
|
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
if err := GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||||
@ -97,7 +95,7 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
if err := GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,7 +103,7 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
g.POST("/auth/logout", func(c echo.Context) error {
|
g.POST("/auth/logout", func(c echo.Context) error {
|
||||||
auth.RemoveTokensAndCookies(c)
|
RemoveTokensAndCookies(c)
|
||||||
c.Response().WriteHeader(http.StatusOK)
|
c.Response().WriteHeader(http.StatusOK)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
178
api/v1/jwt.go
178
api/v1/jwt.go
@ -3,36 +3,93 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/boojack/shortify/internal/util"
|
"github.com/boojack/slash/api/auth"
|
||||||
"github.com/boojack/shortify/server/auth"
|
"github.com/boojack/slash/internal/util"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Context section
|
|
||||||
// The key name used to store user id in the context
|
// The key name used to store user id in the context
|
||||||
// user id is extracted from the jwt token subject field.
|
// user id is extracted from the jwt token subject field.
|
||||||
userIDContextKey = "user-id"
|
UserIDContextKey = "user-id"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getUserIDContextKey() string {
|
type claimsMessage struct {
|
||||||
return userIDContextKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claims creates a struct that will be encoded to a JWT.
|
|
||||||
// We add jwt.RegisteredClaims as an embedded type, to provide fields such as name.
|
|
||||||
type Claims struct {
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateAccessToken generates an access token for web.
|
||||||
|
func GenerateAccessToken(username string, userID int32, secret string) (string, error) {
|
||||||
|
expirationTime := time.Now().Add(auth.AccessTokenDuration)
|
||||||
|
return generateToken(username, userID, auth.AccessTokenAudienceName, expirationTime, []byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
|
||||||
|
func GenerateTokensAndSetCookies(c echo.Context, user *store.User, secret string) error {
|
||||||
|
accessToken, err := GenerateAccessToken(user.Email, user.ID, secret)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to generate access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||||
|
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTokensAndCookies removes the jwt token and refresh token from the cookies.
|
||||||
|
func RemoveTokensAndCookies(c echo.Context) {
|
||||||
|
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||||
|
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTokenCookie sets the token to the cookie.
|
||||||
|
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
||||||
|
cookie := new(http.Cookie)
|
||||||
|
cookie.Name = name
|
||||||
|
cookie.Value = token
|
||||||
|
cookie.Expires = expiration
|
||||||
|
cookie.Path = "/"
|
||||||
|
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
||||||
|
cookie.HttpOnly = true
|
||||||
|
cookie.SameSite = http.SameSiteStrictMode
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken generates a jwt token.
|
||||||
|
func generateToken(username string, userID int32, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
||||||
|
// Create the JWT claims, which includes the username and expiry time.
|
||||||
|
claims := &claimsMessage{
|
||||||
|
Name: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Audience: jwt.ClaimStrings{aud},
|
||||||
|
// In JWT, the expiry time is expressed as unix milliseconds.
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: auth.Issuer,
|
||||||
|
Subject: fmt.Sprint(userID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
token.Header["kid"] = auth.KeyID
|
||||||
|
|
||||||
|
// Create the JWT string.
|
||||||
|
tokenString, err := token.SignedString(secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||||
authHeader := c.Request().Header.Get("Authorization")
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
@ -74,24 +131,26 @@ func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
|||||||
// will try to generate new access token and refresh token.
|
// will try to generate new access token and refresh token.
|
||||||
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
path := c.Path()
|
ctx := c.Request().Context()
|
||||||
|
path := c.Request().URL.Path
|
||||||
method := c.Request().Method
|
method := c.Request().Method
|
||||||
|
|
||||||
if defaultAuthSkipper(c) {
|
// Pass auth and profile endpoints.
|
||||||
|
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
token := findAccessToken(c)
|
token := findAccessToken(c)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
||||||
if util.HasPrefixes(path, "/api/v1/workspace/profile", "/s/*") && method == http.MethodGet {
|
if util.HasPrefixes(path, "/s/*") && method == http.MethodGet {
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := &Claims{}
|
claims := &claimsMessage{}
|
||||||
accessToken, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
_, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
}
|
}
|
||||||
@ -102,28 +161,19 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
}
|
}
|
||||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
RemoveTokensAndCookies(c)
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
||||||
|
}
|
||||||
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
|
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
|
||||||
}
|
}
|
||||||
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
|
||||||
if err != nil {
|
|
||||||
var ve *jwt.ValidationError
|
|
||||||
if errors.As(err, &ve) {
|
|
||||||
// If expiration error is the only error, we will clear the err
|
|
||||||
// and generate new access token and refresh token
|
|
||||||
if ve.Errors == jwt.ValidationErrorExpired {
|
|
||||||
generateToken = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
||||||
ctx := c.Request().Context()
|
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||||
userID, err := strconv.Atoi(claims.Subject)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.").WithInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Even if there is no error, we still need to make sure the user still exists.
|
// Even if there is no error, we still need to make sure the user still exists.
|
||||||
@ -137,66 +187,8 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
||||||
}
|
}
|
||||||
|
|
||||||
if generateToken {
|
|
||||||
generateTokenFunc := func() error {
|
|
||||||
rc, err := c.Cookie(auth.RefreshTokenCookieName)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Missing refresh token.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parses token and checks if it's valid.
|
|
||||||
refreshTokenClaims := &Claims{}
|
|
||||||
refreshToken, err := jwt.ParseWithClaims(rc.Value, refreshTokenClaims, func(t *jwt.Token) (any, error) {
|
|
||||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
|
||||||
return nil, errors.Errorf("unexpected refresh token signing method=%v, expected %v", t.Header["alg"], jwt.SigningMethodHS256)
|
|
||||||
}
|
|
||||||
|
|
||||||
if kid, ok := t.Header["kid"].(string); ok {
|
|
||||||
if kid == "v1" {
|
|
||||||
return []byte(secret), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.Errorf("unexpected refresh token kid=%v", t.Header["kid"])
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if err == jwt.ErrSignatureInvalid {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Invalid refresh token signature.")
|
|
||||||
}
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !audienceContains(refreshTokenClaims.Audience, auth.RefreshTokenAudienceName) {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
|
||||||
fmt.Sprintf("Invalid refresh token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
|
||||||
refreshTokenClaims.Audience,
|
|
||||||
auth.RefreshTokenAudienceName,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a valid refresh token, we will generate new access token and refresh token
|
|
||||||
if refreshToken != nil && refreshToken.Valid {
|
|
||||||
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// It may happen that we still have a valid access token, but we encounter issue when trying to generate new token
|
|
||||||
// In such case, we won't return the error.
|
|
||||||
if err := generateTokenFunc(); err != nil && !accessToken.Valid {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stores userID into context.
|
// Stores userID into context.
|
||||||
c.Set(getUserIDContextKey(), userID)
|
c.Set(UserIDContextKey, userID)
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultAuthSkipper(c echo.Context) bool {
|
|
||||||
path := c.Path()
|
|
||||||
return util.HasPrefixes(path, "/api/v1/auth")
|
|
||||||
}
|
|
||||||
|
@ -3,10 +3,13 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
@ -28,12 +31,12 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
|||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with name: %s", shortcutName))
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with name: %s", shortcutName))
|
||||||
}
|
}
|
||||||
if shortcut.Visibility != store.VisibilityPublic {
|
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
}
|
}
|
||||||
if shortcut.Visibility == store.VisibilityPrivate && shortcut.CreatorID != userID {
|
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -42,16 +45,49 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isValidURLString(shortcut.Link) {
|
return redirectToShortcut(c, shortcut)
|
||||||
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
|
||||||
}
|
|
||||||
return c.String(http.StatusOK, shortcut.Link)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *store.Shortcut) error {
|
func redirectToShortcut(c echo.Context, shortcut *storepb.Shortcut) error {
|
||||||
|
isValidURL := isValidURLString(shortcut.Link)
|
||||||
|
if shortcut.OgMetadata == nil || (shortcut.OgMetadata.Title == "" && shortcut.OgMetadata.Description == "" && shortcut.OgMetadata.Image == "") {
|
||||||
|
if isValidURL {
|
||||||
|
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
||||||
|
}
|
||||||
|
return c.String(http.StatusOK, shortcut.Link)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlTemplate := `<html><head>%s</head><body>%s</body></html>`
|
||||||
|
metadataList := []string{
|
||||||
|
fmt.Sprintf(`<title>%s</title>`, shortcut.OgMetadata.Title),
|
||||||
|
fmt.Sprintf(`<meta name="description" content="%s" />`, shortcut.OgMetadata.Description),
|
||||||
|
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OgMetadata.Title),
|
||||||
|
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OgMetadata.Description),
|
||||||
|
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OgMetadata.Image),
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
// Twitter related metadata.
|
||||||
|
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OgMetadata.Title),
|
||||||
|
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OgMetadata.Description),
|
||||||
|
fmt.Sprintf(`<meta name="twitter:image" content="%s" />`, shortcut.OgMetadata.Image),
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
}
|
||||||
|
if isValidURL {
|
||||||
|
metadataList = append(metadataList, fmt.Sprintf(`<meta property="og:url" content="%s" />`, shortcut.Link))
|
||||||
|
}
|
||||||
|
body := ""
|
||||||
|
if isValidURL {
|
||||||
|
body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link)
|
||||||
|
} else {
|
||||||
|
body = html.EscapeString(shortcut.Link)
|
||||||
|
}
|
||||||
|
htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
|
||||||
|
return c.HTML(http.StatusOK, htmlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *storepb.Shortcut) error {
|
||||||
payload := &ActivityShorcutViewPayload{
|
payload := &ActivityShorcutViewPayload{
|
||||||
ShortcutID: shortcut.ID,
|
ShortcutID: shortcut.Id,
|
||||||
IP: c.RealIP(),
|
IP: c.RealIP(),
|
||||||
Referer: c.Request().Referer(),
|
Referer: c.Request().Referer(),
|
||||||
UserAgent: c.Request().UserAgent(),
|
UserAgent: c.Request().UserAgent(),
|
||||||
|
@ -5,13 +5,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/internal/util"
|
||||||
"github.com/pkg/errors"
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Visibility is the type of a shortcut visibility.
|
// Visibility is the type of a shortcut visibility.
|
||||||
@ -30,11 +30,17 @@ func (v Visibility) String() string {
|
|||||||
return string(v)
|
return string(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenGraphMetadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
type Shortcut struct {
|
type Shortcut struct {
|
||||||
ID int `json:"id"`
|
ID int32 `json:"id"`
|
||||||
|
|
||||||
// Standard fields
|
// Standard fields
|
||||||
CreatorID int `json:"creatorId"`
|
CreatorID int32 `json:"creatorId"`
|
||||||
Creator *User `json:"creator"`
|
Creator *User `json:"creator"`
|
||||||
CreatedTs int64 `json:"createdTs"`
|
CreatedTs int64 `json:"createdTs"`
|
||||||
UpdatedTs int64 `json:"updatedTs"`
|
UpdatedTs int64 `json:"updatedTs"`
|
||||||
@ -43,33 +49,39 @@ type Shortcut struct {
|
|||||||
// Domain specific fields
|
// Domain specific fields
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Visibility Visibility `json:"visibility"`
|
Visibility Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
View int `json:"view"`
|
View int `json:"view"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateShortcutRequest struct {
|
type CreateShortcutRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Visibility Visibility `json:"visibility"`
|
Visibility Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PatchShortcutRequest struct {
|
type PatchShortcutRequest struct {
|
||||||
RowStatus *RowStatus `json:"rowStatus"`
|
RowStatus *RowStatus `json:"rowStatus"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Link *string `json:"link"`
|
Link *string `json:"link"`
|
||||||
|
Title *string `json:"title"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Visibility *Visibility `json:"visibility"`
|
Visibility *Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||||
g.POST("/shortcut", func(c echo.Context) error {
|
g.POST("/shortcut", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
@ -78,13 +90,19 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
shortcut, err := s.Store.CreateShortcut(ctx, &store.Shortcut{
|
shortcut, err := s.Store.CreateShortcut(ctx, &storepb.Shortcut{
|
||||||
CreatorID: userID,
|
CreatorId: userID,
|
||||||
Name: strings.ToLower(create.Name),
|
Name: strings.ToLower(create.Name),
|
||||||
Link: create.Link,
|
Link: create.Link,
|
||||||
|
Title: create.Title,
|
||||||
Description: create.Description,
|
Description: create.Description,
|
||||||
Visibility: convertVisibilityToStore(create.Visibility),
|
Visibility: convertVisibilityToStorepb(create.Visibility),
|
||||||
Tag: strings.Join(create.Tags, " "),
|
Tags: create.Tags,
|
||||||
|
OgMetadata: &storepb.OpenGraphMetadata{
|
||||||
|
Title: create.OpenGraphMetadata.Title,
|
||||||
|
Description: create.OpenGraphMetadata.Description,
|
||||||
|
Image: create.OpenGraphMetadata.Image,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
||||||
@ -94,7 +112,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -103,11 +121,11 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
@ -127,7 +145,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||||
}
|
}
|
||||||
if shortcut.CreatorID != userID && currentUser.Role != store.RoleAdmin {
|
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,6 +162,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
ID: shortcutID,
|
ID: shortcutID,
|
||||||
Name: patch.Name,
|
Name: patch.Name,
|
||||||
Link: patch.Link,
|
Link: patch.Link,
|
||||||
|
Title: patch.Title,
|
||||||
Description: patch.Description,
|
Description: patch.Description,
|
||||||
}
|
}
|
||||||
if patch.RowStatus != nil {
|
if patch.RowStatus != nil {
|
||||||
@ -156,12 +175,19 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
tag := strings.Join(patch.Tags, " ")
|
tag := strings.Join(patch.Tags, " ")
|
||||||
shortcutUpdate.Tag = &tag
|
shortcutUpdate.Tag = &tag
|
||||||
}
|
}
|
||||||
|
if patch.OpenGraphMetadata != nil {
|
||||||
|
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
|
||||||
|
Title: patch.OpenGraphMetadata.Title,
|
||||||
|
Description: patch.OpenGraphMetadata.Description,
|
||||||
|
Image: patch.OpenGraphMetadata.Image,
|
||||||
|
}
|
||||||
|
}
|
||||||
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -170,24 +196,17 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.GET("/shortcut", func(c echo.Context) error {
|
g.GET("/shortcut", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
|
|
||||||
find := &store.FindShortcut{}
|
find := &store.FindShortcut{}
|
||||||
if creatorIDStr := c.QueryParam("creatorId"); creatorIDStr != "" {
|
|
||||||
creatorID, err := strconv.Atoi(creatorIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("unwanted creator id string: %s", creatorIDStr))
|
|
||||||
}
|
|
||||||
find.CreatorID = &creatorID
|
|
||||||
}
|
|
||||||
if tag := c.QueryParam("tag"); tag != "" {
|
if tag := c.QueryParam("tag"); tag != "" {
|
||||||
find.Tag = &tag
|
find.Tag = &tag
|
||||||
}
|
}
|
||||||
|
|
||||||
list := []*store.Shortcut{}
|
list := []*storepb.Shortcut{}
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -205,7 +224,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
shortcutMessageList := []*Shortcut{}
|
shortcutMessageList := []*Shortcut{}
|
||||||
for _, shortcut := range list {
|
for _, shortcut := range list {
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -216,7 +235,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.GET("/shortcut/:id", func(c echo.Context) error {
|
g.GET("/shortcut/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
shortcutID, err := strconv.Atoi(c.Param("id"))
|
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -231,7 +250,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||||
}
|
}
|
||||||
|
|
||||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -240,11 +259,11 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
shortcutID, err := strconv.Atoi(c.Param("id"))
|
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
@ -264,41 +283,18 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||||
}
|
}
|
||||||
if shortcut.CreatorID != userID && currentUser.Role != store.RoleAdmin {
|
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
||||||
ID: shortcutID,
|
if err != nil {
|
||||||
}); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, true)
|
return c.JSON(http.StatusOK, true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *store.Shortcut) error {
|
|
||||||
payload := &ActivityShorcutCreatePayload{
|
|
||||||
ShortcutID: shortcut.ID,
|
|
||||||
}
|
|
||||||
payloadStr, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
|
||||||
}
|
|
||||||
activity := &store.Activity{
|
|
||||||
CreatorID: shortcut.CreatorID,
|
|
||||||
Type: store.ActivityShortcutCreate,
|
|
||||||
Level: store.ActivityInfo,
|
|
||||||
Payload: string(payloadStr),
|
|
||||||
}
|
|
||||||
_, err = s.Store.CreateActivity(ctx, activity)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to create activity")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -310,6 +306,9 @@ func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "Failed to get creator")
|
return nil, errors.Wrap(err, "Failed to get creator")
|
||||||
}
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("Creator not found")
|
||||||
|
}
|
||||||
shortcut.Creator = convertUserFromStore(user)
|
shortcut.Creator = convertUserFromStore(user)
|
||||||
|
|
||||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
@ -325,35 +324,55 @@ func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut)
|
|||||||
return shortcut, nil
|
return shortcut, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertVisibilityToStore(visibility Visibility) store.Visibility {
|
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
|
||||||
switch visibility {
|
|
||||||
case VisibilityPrivate:
|
|
||||||
return store.VisibilityPrivate
|
|
||||||
case VisibilityWorkspace:
|
|
||||||
return store.VisibilityWorkspace
|
|
||||||
case VisibilityPublic:
|
|
||||||
return store.VisibilityPublic
|
|
||||||
default:
|
|
||||||
return store.VisibilityPrivate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
|
|
||||||
tags := []string{}
|
|
||||||
if shortcut.Tag != "" {
|
|
||||||
tags = append(tags, strings.Split(shortcut.Tag, " ")...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Shortcut{
|
return &Shortcut{
|
||||||
ID: shortcut.ID,
|
ID: shortcut.Id,
|
||||||
CreatedTs: shortcut.CreatedTs,
|
CreatedTs: shortcut.CreatedTs,
|
||||||
UpdatedTs: shortcut.UpdatedTs,
|
UpdatedTs: shortcut.UpdatedTs,
|
||||||
CreatorID: shortcut.CreatorID,
|
CreatorID: shortcut.CreatorId,
|
||||||
|
RowStatus: RowStatus(shortcut.RowStatus.String()),
|
||||||
Name: shortcut.Name,
|
Name: shortcut.Name,
|
||||||
Link: shortcut.Link,
|
Link: shortcut.Link,
|
||||||
|
Title: shortcut.Title,
|
||||||
Description: shortcut.Description,
|
Description: shortcut.Description,
|
||||||
Visibility: Visibility(shortcut.Visibility),
|
Visibility: Visibility(shortcut.Visibility.String()),
|
||||||
RowStatus: RowStatus(shortcut.RowStatus),
|
Tags: shortcut.Tags,
|
||||||
Tags: tags,
|
OpenGraphMetadata: &OpenGraphMetadata{
|
||||||
|
Title: shortcut.OgMetadata.Title,
|
||||||
|
Description: shortcut.OgMetadata.Description,
|
||||||
|
Image: shortcut.OgMetadata.Image,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
|
||||||
|
switch visibility {
|
||||||
|
case VisibilityPublic:
|
||||||
|
return storepb.Visibility_PUBLIC
|
||||||
|
case VisibilityPrivate:
|
||||||
|
return storepb.Visibility_PRIVATE
|
||||||
|
default:
|
||||||
|
return storepb.Visibility_PUBLIC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
||||||
|
payload := &ActivityShorcutCreatePayload{
|
||||||
|
ShortcutID: shortcut.Id,
|
||||||
|
}
|
||||||
|
payloadStr, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity := &store.Activity{
|
||||||
|
CreatorID: shortcut.CreatorId,
|
||||||
|
Type: store.ActivityShortcutCreate,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
Payload: string(payloadStr),
|
||||||
|
}
|
||||||
|
_, err = s.Store.CreateActivity(ctx, activity)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to create activity")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
|
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
|
||||||
|
// GET /url/favicon?url=...
|
||||||
g.GET("/url/favicon", func(c echo.Context) error {
|
g.GET("/url/favicon", func(c echo.Context) error {
|
||||||
url := c.QueryParam("url")
|
url := c.QueryParam("url")
|
||||||
icons, err := favicon.Find(url)
|
icons, err := favicon.Find(url)
|
||||||
|
116
api/v1/user.go
116
api/v1/user.go
@ -5,10 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
|
|
||||||
|
"github.com/boojack/slash/internal/util"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@ -39,7 +38,7 @@ func (r Role) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `json:"id"`
|
ID int32 `json:"id"`
|
||||||
|
|
||||||
// Standard fields
|
// Standard fields
|
||||||
CreatedTs int64 `json:"createdTs"`
|
CreatedTs int64 `json:"createdTs"`
|
||||||
@ -56,7 +55,7 @@ type CreateUserRequest struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Role Role `json:"-"`
|
Role Role `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (create CreateUserRequest) Validate() error {
|
func (create CreateUserRequest) Validate() error {
|
||||||
@ -78,13 +77,56 @@ type PatchUserRequest struct {
|
|||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
Nickname *string `json:"nickname"`
|
Nickname *string `json:"nickname"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
}
|
Role *Role `json:"role"`
|
||||||
|
|
||||||
type UserDelete struct {
|
|
||||||
ID int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||||
|
g.POST("/user", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||||
|
}
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||||
|
}
|
||||||
|
if currentUser.Role != store.RoleAdmin {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
|
||||||
|
}
|
||||||
|
|
||||||
|
userCreate := &CreateUserRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := userCreate.Validate(); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.CreateUser(ctx, &store.User{
|
||||||
|
Role: store.Role(userCreate.Role),
|
||||||
|
Email: userCreate.Email,
|
||||||
|
Nickname: userCreate.Nickname,
|
||||||
|
PasswordHash: string(passwordHash),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userMessage := convertUserFromStore(user)
|
||||||
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
|
})
|
||||||
|
|
||||||
g.GET("/user", func(c echo.Context) error {
|
g.GET("/user", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
@ -102,7 +144,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
// GET /api/user/me is used to check if the user is logged in.
|
// GET /api/user/me is used to check if the user is logged in.
|
||||||
g.GET("/user/me", func(c echo.Context) error {
|
g.GET("/user/me", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
||||||
}
|
}
|
||||||
@ -119,7 +161,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.GET("/user/:id", func(c echo.Context) error {
|
g.GET("/user/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, err := strconv.Atoi(c.Param("id"))
|
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
@ -136,15 +178,24 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.PATCH("/user/:id", func(c echo.Context) error {
|
g.PATCH("/user/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, err := strconv.Atoi(c.Param("id"))
|
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
currentUserID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
if currentUserID != userID {
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: ¤tUserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
|
}
|
||||||
|
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,13 +205,12 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateUser := &store.UpdateUser{
|
updateUser := &store.UpdateUser{
|
||||||
ID: currentUserID,
|
ID: userID,
|
||||||
}
|
}
|
||||||
if userPatch.Email != nil {
|
if userPatch.Email != nil {
|
||||||
if !validateEmail(*userPatch.Email) {
|
if !validateEmail(*userPatch.Email) {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUser.Email = userPatch.Email
|
updateUser.Email = userPatch.Email
|
||||||
}
|
}
|
||||||
if userPatch.Nickname != nil {
|
if userPatch.Nickname != nil {
|
||||||
@ -175,6 +225,24 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
passwordHashStr := string(passwordHash)
|
passwordHashStr := string(passwordHash)
|
||||||
updateUser.PasswordHash = &passwordHashStr
|
updateUser.PasswordHash = &passwordHashStr
|
||||||
}
|
}
|
||||||
|
if userPatch.RowStatus != nil {
|
||||||
|
rowStatus := store.RowStatus(*userPatch.RowStatus)
|
||||||
|
updateUser.RowStatus = &rowStatus
|
||||||
|
}
|
||||||
|
if userPatch.Role != nil {
|
||||||
|
adminRole := store.RoleAdmin
|
||||||
|
adminUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
|
||||||
|
Role: &adminRole,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list admin users, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if len(adminUsers) == 1 && adminUsers[0].ID == userID && *userPatch.Role != RoleAdmin {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "cannot remove admin role from the last admin user")
|
||||||
|
}
|
||||||
|
role := store.Role(*userPatch.Role)
|
||||||
|
updateUser.Role = &role
|
||||||
|
}
|
||||||
|
|
||||||
user, err := s.Store.UpdateUser(ctx, updateUser)
|
user, err := s.Store.UpdateUser(ctx, updateUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -186,7 +254,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.DELETE("/user/:id", func(c echo.Context) error {
|
g.DELETE("/user/:id", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
currentUserID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
@ -203,10 +271,22 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := strconv.Atoi(c.Param("id"))
|
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if user.Role == store.RoleAdmin {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
|
@ -13,11 +13,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// String returns the string format of UserSettingKey type.
|
// String returns the string format of UserSettingKey type.
|
||||||
func (key UserSettingKey) String() string {
|
func (k UserSettingKey) String() string {
|
||||||
if key == UserSettingLocaleKey {
|
return string(k)
|
||||||
return "locale"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -27,7 +24,7 @@ var (
|
|||||||
type UserSetting struct {
|
type UserSetting struct {
|
||||||
UserID int
|
UserID int
|
||||||
Key UserSettingKey `json:"key"`
|
Key UserSettingKey `json:"key"`
|
||||||
// Value is a JSON string with basic value
|
// Value is a JSON string with basic value.
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -29,6 +28,7 @@ func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
|||||||
s.registerAuthRoutes(apiV1Group, secret)
|
s.registerAuthRoutes(apiV1Group, secret)
|
||||||
s.registerUserRoutes(apiV1Group)
|
s.registerUserRoutes(apiV1Group)
|
||||||
s.registerShortcutRoutes(apiV1Group)
|
s.registerShortcutRoutes(apiV1Group)
|
||||||
|
s.registerAnalyticsRoutes(apiV1Group)
|
||||||
|
|
||||||
redirectorGroup := apiGroup.Group("/s")
|
redirectorGroup := apiGroup.Group("/s")
|
||||||
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
@ -5,8 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.POST("/workspace/setting", func(c echo.Context) error {
|
g.POST("/workspace/setting", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
@ -97,7 +97,7 @@ func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
|
|||||||
|
|
||||||
g.GET("/workspace/setting", func(c echo.Context) error {
|
g.GET("/workspace/setting", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
|
17
api/v2/common.go
Normal file
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
|
||||||
|
}
|
||||||
|
}
|
194
api/v2/jwt.go
Normal file
194
api/v2/jwt.go
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/api/auth"
|
||||||
|
"github.com/boojack/slash/internal/util"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
var authenticationAllowlistMethods = map[string]bool{
|
||||||
|
"/memos.api.v2.UserService/GetUser": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAuthenticationAllowed returns whether the method is exempted from authentication.
|
||||||
|
func IsAuthenticationAllowed(fullMethodName string) bool {
|
||||||
|
if strings.HasPrefix(fullMethodName, "/grpc.reflection") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return authenticationAllowlistMethods[fullMethodName]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextKey is the key type of context value.
|
||||||
|
type ContextKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The key name used to store user id in the context
|
||||||
|
// user id is extracted from the jwt token subject field.
|
||||||
|
UserIDContextKey ContextKey = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
// GRPCAuthInterceptor is the auth interceptor for gRPC server.
|
||||||
|
type GRPCAuthInterceptor struct {
|
||||||
|
store *store.Store
|
||||||
|
secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGRPCAuthInterceptor returns a new API auth interceptor.
|
||||||
|
func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthInterceptor {
|
||||||
|
return &GRPCAuthInterceptor{
|
||||||
|
store: store,
|
||||||
|
secret: secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticationInterceptor is the unary interceptor for gRPC API.
|
||||||
|
func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, request any, serverInfo *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context")
|
||||||
|
}
|
||||||
|
accessTokenStr, err := getTokenFromMetadata(md)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := in.authenticate(ctx, accessTokenStr)
|
||||||
|
if err != nil {
|
||||||
|
if IsAuthenticationAllowed(serverInfo.FullMethod) {
|
||||||
|
return handler(ctx, request)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores userID into context.
|
||||||
|
childCtx := context.WithValue(ctx, UserIDContextKey, userID)
|
||||||
|
return handler(childCtx, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr string) (int32, error) {
|
||||||
|
if accessTokenStr == "" {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "access token not found")
|
||||||
|
}
|
||||||
|
claims := &claimsMessage{}
|
||||||
|
_, err := jwt.ParseWithClaims(accessTokenStr, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
|
}
|
||||||
|
if kid, ok := t.Header["kid"].(string); ok {
|
||||||
|
if kid == "v1" {
|
||||||
|
return []byte(in.secret), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "unexpected access token kid=%v", t.Header["kid"])
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "Invalid or expired access token")
|
||||||
|
}
|
||||||
|
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated,
|
||||||
|
"invalid access token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||||
|
claims.Audience,
|
||||||
|
auth.AccessTokenAudienceName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "malformed ID %q in the access token", claims.Subject)
|
||||||
|
}
|
||||||
|
user, err := in.store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "failed to find user ID %q in the access token", userID)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "user ID %q not exists in the access token", userID)
|
||||||
|
}
|
||||||
|
if user.RowStatus == store.Archived {
|
||||||
|
return 0, status.Errorf(codes.Unauthenticated, "user ID %q has been deactivated by administrators", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTokenFromMetadata(md metadata.MD) (string, error) {
|
||||||
|
authorizationHeaders := md.Get("Authorization")
|
||||||
|
if len(md.Get("Authorization")) > 0 {
|
||||||
|
authHeaderParts := strings.Fields(authorizationHeaders[0])
|
||||||
|
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||||
|
return "", errors.Errorf("authorization header format must be Bearer {token}")
|
||||||
|
}
|
||||||
|
return authHeaderParts[1], nil
|
||||||
|
}
|
||||||
|
// check the HTTP cookie
|
||||||
|
var accessToken string
|
||||||
|
for _, t := range append(md.Get("grpcgateway-cookie"), md.Get("cookie")...) {
|
||||||
|
header := http.Header{}
|
||||||
|
header.Add("Cookie", t)
|
||||||
|
request := http.Request{Header: header}
|
||||||
|
if v, _ := request.Cookie(auth.AccessTokenCookieName); v != nil {
|
||||||
|
accessToken = v.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
||||||
|
for _, v := range audience {
|
||||||
|
if v == token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type claimsMessage struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAccessToken generates an access token for web.
|
||||||
|
func GenerateAccessToken(username string, userID int, secret string) (string, error) {
|
||||||
|
expirationTime := time.Now().Add(auth.AccessTokenDuration)
|
||||||
|
return generateToken(username, userID, auth.AccessTokenAudienceName, expirationTime, []byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken(username string, userID int, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
||||||
|
// Create the JWT claims, which includes the username and expiry time.
|
||||||
|
claims := &claimsMessage{
|
||||||
|
Name: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Audience: jwt.ClaimStrings{aud},
|
||||||
|
// In JWT, the expiry time is expressed as unix milliseconds.
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: auth.Issuer,
|
||||||
|
Subject: strconv.Itoa(userID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
token.Header["kid"] = auth.KeyID
|
||||||
|
|
||||||
|
// Create the JWT string.
|
||||||
|
tokenString, err := token.SignedString(secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
64
api/v2/user_service.go
Normal file
64
api/v2/user_service.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserService struct {
|
||||||
|
apiv2pb.UnimplementedUserServiceServer
|
||||||
|
|
||||||
|
Store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserService creates a new UserService.
|
||||||
|
func NewUserService(store *store.Store) *UserService {
|
||||||
|
return &UserService{
|
||||||
|
Store: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to list tags: %v", err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userMessage := convertUserFromStore(user)
|
||||||
|
response := &apiv2pb.GetUserResponse{
|
||||||
|
User: userMessage,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertUserFromStore(user *store.User) *apiv2pb.User {
|
||||||
|
return &apiv2pb.User{
|
||||||
|
Id: int32(user.ID),
|
||||||
|
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
||||||
|
CreatedTs: user.CreatedTs,
|
||||||
|
UpdatedTs: user.UpdatedTs,
|
||||||
|
Role: convertUserRoleFromStore(user.Role),
|
||||||
|
Email: user.Email,
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertUserRoleFromStore(role store.Role) apiv2pb.Role {
|
||||||
|
switch role {
|
||||||
|
case store.RoleAdmin:
|
||||||
|
return apiv2pb.Role_ADMIN
|
||||||
|
case store.RoleUser:
|
||||||
|
return apiv2pb.Role_USER
|
||||||
|
default:
|
||||||
|
return apiv2pb.Role_ROLE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
67
api/v2/v2.go
Normal file
67
api/v2/v2.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIV2Service struct {
|
||||||
|
Secret string
|
||||||
|
Profile *profile.Profile
|
||||||
|
Store *store.Store
|
||||||
|
|
||||||
|
grpcServer *grpc.Server
|
||||||
|
grpcServerPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, grpcServerPort int) *APIV2Service {
|
||||||
|
authProvider := NewGRPCAuthInterceptor(store, secret)
|
||||||
|
grpcServer := grpc.NewServer(
|
||||||
|
grpc.ChainUnaryInterceptor(
|
||||||
|
authProvider.AuthenticationInterceptor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
apiv2pb.RegisterUserServiceServer(grpcServer, NewUserService(store))
|
||||||
|
|
||||||
|
return &APIV2Service{
|
||||||
|
Secret: secret,
|
||||||
|
Profile: profile,
|
||||||
|
Store: store,
|
||||||
|
grpcServer: grpcServer,
|
||||||
|
grpcServerPort: grpcServerPort,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
||||||
|
return s.grpcServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterGateway registers the gRPC-Gateway with the given Echo instance.
|
||||||
|
func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error {
|
||||||
|
// Create a client connection to the gRPC Server we just started.
|
||||||
|
// This is where the gRPC-Gateway proxies the requests.
|
||||||
|
conn, err := grpc.DialContext(
|
||||||
|
ctx,
|
||||||
|
fmt.Sprintf(":%d", s.grpcServerPort),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
gwMux := grpcRuntime.NewServeMux()
|
||||||
|
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -12,21 +12,14 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server"
|
"github.com/boojack/slash/server"
|
||||||
_profile "github.com/boojack/shortify/server/profile"
|
_profile "github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/boojack/shortify/store/db"
|
"github.com/boojack/slash/store/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
greetingBanner = `
|
greetingBanner = `Welcome to Slash!`
|
||||||
███████╗██╗ ██╗ ██████╗ ██████╗ ████████╗██╗███████╗██╗ ██╗
|
|
||||||
██╔════╝██║ ██║██╔═══██╗██╔══██╗╚══██╔══╝██║██╔════╝╚██╗ ██╔╝
|
|
||||||
███████╗███████║██║ ██║██████╔╝ ██║ ██║█████╗ ╚████╔╝
|
|
||||||
╚════██║██╔══██║██║ ██║██╔══██╗ ██║ ██║██╔══╝ ╚██╔╝
|
|
||||||
███████║██║ ██║╚██████╔╝██║ ██║ ██║ ██║██║ ██║
|
|
||||||
╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝
|
|
||||||
`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -36,8 +29,8 @@ var (
|
|||||||
data string
|
data string
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "shortify",
|
Use: "slash",
|
||||||
Short: "",
|
Short: `An open source, self-hosted bookmarks and link sharing platform.`,
|
||||||
Run: func(_cmd *cobra.Command, _args []string) {
|
Run: func(_cmd *cobra.Command, _args []string) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
db := db.NewDB(profile)
|
db := db.NewDB(profile)
|
||||||
@ -89,7 +82,7 @@ func Execute() error {
|
|||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "dev", `mode of server, can be "prod" or "dev"`)
|
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().IntVarP(&port, "port", "p", 8082, "port of server")
|
||||||
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
||||||
|
|
||||||
@ -106,9 +99,9 @@ func init() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
viper.SetDefault("mode", "dev")
|
viper.SetDefault("mode", "demo")
|
||||||
viper.SetDefault("port", 8082)
|
viper.SetDefault("port", 8082)
|
||||||
viper.SetEnvPrefix("shortify")
|
viper.SetEnvPrefix("slash")
|
||||||
}
|
}
|
||||||
|
|
||||||
func initConfig() {
|
func initConfig() {
|
39
docs/install.md
Normal file
39
docs/install.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Self-hosting Slash with Docker
|
||||||
|
|
||||||
|
Slash is designed for self-hosting through Docker. No Docker expertise is required to launch your own instance. Just basic understanding of command line and networking.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
The only requirement is a server with Docker installed.
|
||||||
|
|
||||||
|
## Docker Run
|
||||||
|
|
||||||
|
To deploy Slash using docker run, just one command is needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
To upgrade Slash to latest version, stop and remove the old container first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop slash && docker rm slash
|
||||||
|
```
|
||||||
|
|
||||||
|
It's recommended but optional to backup database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r ~/.slash/slash_prod.db ~/.slash/slash_prod.db.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pull the latest image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull yourselfhosted/slash:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, restart Slash by following the steps in [Docker Run](#docker-run).
|
23
go.mod
23
go.mod
@ -1,4 +1,4 @@
|
|||||||
module github.com/boojack/shortify
|
module github.com/boojack/slash
|
||||||
|
|
||||||
go 1.19
|
go 1.19
|
||||||
|
|
||||||
@ -10,10 +10,10 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
golang.org/x/crypto v0.9.0
|
golang.org/x/crypto v0.11.0
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/net v0.12.0 // indirect
|
||||||
golang.org/x/sys v0.8.0 // indirect
|
golang.org/x/sys v0.10.0 // indirect
|
||||||
golang.org/x/text v0.9.0 // indirect
|
golang.org/x/text v0.11.0 // indirect
|
||||||
golang.org/x/time v0.3.0 // indirect
|
golang.org/x/time v0.3.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -35,15 +35,16 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/friendsofgo/errors v0.9.2 // indirect
|
github.com/friendsofgo/errors v0.9.2 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.3 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 // indirect
|
||||||
github.com/spf13/afero v1.9.3 // indirect
|
github.com/spf13/afero v1.9.3 // indirect
|
||||||
github.com/spf13/cast v1.5.0 // indirect
|
github.com/spf13/cast v1.5.0 // indirect
|
||||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||||
@ -51,6 +52,8 @@ require (
|
|||||||
github.com/subosito/gotenv v1.4.2 // indirect
|
github.com/subosito/gotenv v1.4.2 // indirect
|
||||||
golang.org/x/tools v0.6.0 // indirect
|
golang.org/x/tools v0.6.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||||
|
google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230726155614-23370e0ffb3e // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
lukechampine.com/uint128 v1.2.0 // indirect
|
lukechampine.com/uint128 v1.2.0 // indirect
|
||||||
@ -66,8 +69,14 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2
|
||||||
|
github.com/mssola/useragent v1.0.0
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
go.deanishe.net/favicon v0.1.0
|
go.deanishe.net/favicon v0.1.0
|
||||||
golang.org/x/mod v0.8.0
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df
|
||||||
|
golang.org/x/mod v0.11.0
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e
|
||||||
|
google.golang.org/grpc v1.57.0
|
||||||
|
google.golang.org/protobuf v1.31.0
|
||||||
modernc.org/sqlite v1.23.1
|
modernc.org/sqlite v1.23.1
|
||||||
)
|
)
|
||||||
|
49
go.sum
49
go.sum
@ -53,7 +53,6 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
|
|||||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@ -106,6 +105,7 @@ github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzq
|
|||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE=
|
||||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
@ -130,6 +130,9 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
|||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||||
|
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
@ -142,6 +145,7 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
@ -165,6 +169,8 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
|||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2 h1:dygLcbEBA+t/P7ck6a8AkXv6juQ4cK0RHBoh32jxhHM=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2/go.mod h1:Ap9RLCIJVtgQg1/BBgVEfypOAySvvlcpcVQkSzJCH4Y=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
@ -191,11 +197,9 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxv
|
|||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/labstack/echo/v4 v4.10.1 h1:rB+D8In9PWjsp1OpHaqK+t04nQv/SBD1IoIcXCg0lpY=
|
github.com/labstack/echo/v4 v4.10.1 h1:rB+D8In9PWjsp1OpHaqK+t04nQv/SBD1IoIcXCg0lpY=
|
||||||
github.com/labstack/echo/v4 v4.10.1/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
github.com/labstack/echo/v4 v4.10.1/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
||||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||||
@ -217,10 +221,11 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/mssola/useragent v1.0.0 h1:WRlDpXyxHDNfvZaPEut5Biveq86Ze4o4EMffyMxmH5o=
|
||||||
|
github.com/mssola/useragent v1.0.0/go.mod h1:hz9Cqz4RXusgg1EdI4Al0INR62kP7aPSRNHnpU+b85Y=
|
||||||
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
|
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
|
||||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@ -304,8 +309,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@ -316,6 +321,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
|||||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
|
||||||
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
@ -339,8 +346,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@ -373,8 +380,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
|
|||||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
@ -441,8 +448,8 @@ golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
@ -450,8 +457,8 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
|
|||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
@ -578,6 +585,12 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D
|
|||||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130 h1:Au6te5hbKUV8pIYWHqOUZ1pva5qK/rwbIhoXEUB9Lu8=
|
||||||
|
google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:O9kGHb51iE/nOGvQaDUuadVYqovW56s5emA88lQnj6Y=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e h1:z3vDksarJxsAKM5dmEGv0GHwE2hKJ096wZra71Vs4sw=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230726155614-23370e0ffb3e h1:S83+ibolgyZ0bqz7KEsUOPErxcv4VzlszxY+31OfB/E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
@ -594,6 +607,8 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
|
|||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||||
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
|
||||||
|
google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@ -604,9 +619,13 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
|||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||||
|
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
@ -1,6 +1,18 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConvertStringToInt32 converts a string to int32.
|
||||||
|
func ConvertStringToInt32(src string) (int32, error) {
|
||||||
|
i, err := strconv.Atoi(src)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int32(i), nil
|
||||||
|
}
|
||||||
|
|
||||||
// HasPrefixes returns true if the string s has any of the given prefixes.
|
// HasPrefixes returns true if the string s has any of the given prefixes.
|
||||||
func HasPrefixes(src string, prefixes ...string) bool {
|
func HasPrefixes(src string, prefixes ...string) bool {
|
||||||
|
13
proto/api/v2/common.proto
Normal file
13
proto/api/v2/common.proto
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package slash.api.v2;
|
||||||
|
|
||||||
|
option go_package = "gen/api/v2";
|
||||||
|
|
||||||
|
enum RowStatus {
|
||||||
|
ROW_STATUS_UNSPECIFIED = 0;
|
||||||
|
|
||||||
|
NORMAL = 1;
|
||||||
|
|
||||||
|
ARCHIVED = 2;
|
||||||
|
}
|
48
proto/api/v2/user_service.proto
Normal file
48
proto/api/v2/user_service.proto
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package slash.api.v2;
|
||||||
|
|
||||||
|
import "api/v2/common.proto";
|
||||||
|
import "google/api/annotations.proto";
|
||||||
|
import "google/api/client.proto";
|
||||||
|
|
||||||
|
option go_package = "gen/api/v2";
|
||||||
|
|
||||||
|
service UserService {
|
||||||
|
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
|
||||||
|
option (google.api.http) = {get: "/api/v2/users/{id}"};
|
||||||
|
option (google.api.method_signature) = "id";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message User {
|
||||||
|
int32 id = 1;
|
||||||
|
|
||||||
|
RowStatus row_status = 2;
|
||||||
|
|
||||||
|
int64 created_ts = 3;
|
||||||
|
|
||||||
|
int64 updated_ts = 4;
|
||||||
|
|
||||||
|
Role role = 6;
|
||||||
|
|
||||||
|
string email = 7;
|
||||||
|
|
||||||
|
string nickname = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ROLE_UNSPECIFIED = 0;
|
||||||
|
|
||||||
|
ADMIN = 1;
|
||||||
|
|
||||||
|
USER = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetUserRequest {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetUserResponse {
|
||||||
|
User user = 1;
|
||||||
|
}
|
24
proto/buf.gen.yaml
Normal file
24
proto/buf.gen.yaml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
version: v1
|
||||||
|
managed:
|
||||||
|
enabled: true
|
||||||
|
go_package_prefix:
|
||||||
|
default: github.com/boojack/slash/proto/gen
|
||||||
|
except:
|
||||||
|
- buf.build/googleapis/googleapis
|
||||||
|
plugins:
|
||||||
|
- plugin: buf.build/protocolbuffers/go:v1.31.0
|
||||||
|
out: gen
|
||||||
|
opt:
|
||||||
|
- paths=source_relative
|
||||||
|
- plugin: buf.build/grpc/go:v1.3.0
|
||||||
|
out: gen
|
||||||
|
opt:
|
||||||
|
- paths=source_relative
|
||||||
|
- plugin: buf.build/grpc-ecosystem/gateway:v2.16.1
|
||||||
|
out: gen
|
||||||
|
opt:
|
||||||
|
- paths=source_relative
|
||||||
|
- plugin: buf.build/community/pseudomuto-doc:v1.5.1
|
||||||
|
out: gen
|
||||||
|
opt:
|
||||||
|
- markdown,README.md,source_relative
|
13
proto/buf.lock
Normal file
13
proto/buf.lock
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Generated by buf. DO NOT EDIT.
|
||||||
|
version: v1
|
||||||
|
deps:
|
||||||
|
- remote: buf.build
|
||||||
|
owner: googleapis
|
||||||
|
repository: googleapis
|
||||||
|
commit: 711e289f6a384c4caeebaff7c6931ade
|
||||||
|
digest: shake256:e08fb55dad7469f69df00304eed31427d2d1576e9aab31e6bf86642688e04caaf0372f15fe6974cf79432779a635b3ea401ca69c943976dc42749524e4c25d94
|
||||||
|
- remote: buf.build
|
||||||
|
owner: grpc-ecosystem
|
||||||
|
repository: grpc-gateway
|
||||||
|
commit: fed2dcdcfd694403ad51cd3c94957830
|
||||||
|
digest: shake256:ed076a21e3d772892fc465ced0e4dd50f9dba86fdd4473920eaa25efa4807644e8e021be423dcfcee74bf4242e7e57422393f9b1abb10acb18ea1a5df509bb19
|
15
proto/buf.yaml
Normal file
15
proto/buf.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
version: v1
|
||||||
|
name: buf.build/yourselfhosted/slash
|
||||||
|
breaking:
|
||||||
|
use:
|
||||||
|
- FILE
|
||||||
|
lint:
|
||||||
|
use:
|
||||||
|
- DEFAULT
|
||||||
|
except:
|
||||||
|
- ENUM_VALUE_PREFIX
|
||||||
|
- PACKAGE_DIRECTORY_MATCH
|
||||||
|
- PACKAGE_VERSION_SUFFIX
|
||||||
|
deps:
|
||||||
|
- buf.build/googleapis/googleapis
|
||||||
|
- buf.build/grpc-ecosystem/grpc-gateway
|
160
proto/gen/api/v2/README.md
Normal file
160
proto/gen/api/v2/README.md
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
# Protocol Documentation
|
||||||
|
<a name="top"></a>
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [api/v2/common.proto](#api_v2_common-proto)
|
||||||
|
- [RowStatus](#slash-api-v2-RowStatus)
|
||||||
|
|
||||||
|
- [api/v2/user_service.proto](#api_v2_user_service-proto)
|
||||||
|
- [GetUserRequest](#slash-api-v2-GetUserRequest)
|
||||||
|
- [GetUserResponse](#slash-api-v2-GetUserResponse)
|
||||||
|
- [User](#slash-api-v2-User)
|
||||||
|
|
||||||
|
- [Role](#slash-api-v2-Role)
|
||||||
|
|
||||||
|
- [UserService](#slash-api-v2-UserService)
|
||||||
|
|
||||||
|
- [Scalar Value Types](#scalar-value-types)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="api_v2_common-proto"></a>
|
||||||
|
<p align="right"><a href="#top">Top</a></p>
|
||||||
|
|
||||||
|
## api/v2/common.proto
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-api-v2-RowStatus"></a>
|
||||||
|
|
||||||
|
### RowStatus
|
||||||
|
|
||||||
|
|
||||||
|
| Name | Number | Description |
|
||||||
|
| ---- | ------ | ----------- |
|
||||||
|
| ROW_STATUS_UNSPECIFIED | 0 | |
|
||||||
|
| NORMAL | 1 | |
|
||||||
|
| ARCHIVED | 2 | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="api_v2_user_service-proto"></a>
|
||||||
|
<p align="right"><a href="#top">Top</a></p>
|
||||||
|
|
||||||
|
## api/v2/user_service.proto
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-api-v2-GetUserRequest"></a>
|
||||||
|
|
||||||
|
### GetUserRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Field | Type | Label | Description |
|
||||||
|
| ----- | ---- | ----- | ----------- |
|
||||||
|
| id | [int32](#int32) | | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-api-v2-GetUserResponse"></a>
|
||||||
|
|
||||||
|
### GetUserResponse
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Field | Type | Label | Description |
|
||||||
|
| ----- | ---- | ----- | ----------- |
|
||||||
|
| user | [User](#slash-api-v2-User) | | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-api-v2-User"></a>
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Field | Type | Label | Description |
|
||||||
|
| ----- | ---- | ----- | ----------- |
|
||||||
|
| id | [int32](#int32) | | |
|
||||||
|
| row_status | [RowStatus](#slash-api-v2-RowStatus) | | |
|
||||||
|
| created_ts | [int64](#int64) | | |
|
||||||
|
| updated_ts | [int64](#int64) | | |
|
||||||
|
| role | [Role](#slash-api-v2-Role) | | |
|
||||||
|
| email | [string](#string) | | |
|
||||||
|
| nickname | [string](#string) | | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-api-v2-Role"></a>
|
||||||
|
|
||||||
|
### Role
|
||||||
|
|
||||||
|
|
||||||
|
| Name | Number | Description |
|
||||||
|
| ---- | ------ | ----------- |
|
||||||
|
| ROLE_UNSPECIFIED | 0 | |
|
||||||
|
| ADMIN | 1 | |
|
||||||
|
| USER | 2 | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-api-v2-UserService"></a>
|
||||||
|
|
||||||
|
### UserService
|
||||||
|
|
||||||
|
|
||||||
|
| Method Name | Request Type | Response Type | Description |
|
||||||
|
| ----------- | ------------ | ------------- | ------------|
|
||||||
|
| GetUser | [GetUserRequest](#slash-api-v2-GetUserRequest) | [GetUserResponse](#slash-api-v2-GetUserResponse) | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Scalar Value Types
|
||||||
|
|
||||||
|
| .proto Type | Notes | C++ | Java | Python | Go | C# | PHP | Ruby |
|
||||||
|
| ----------- | ----- | --- | ---- | ------ | -- | -- | --- | ---- |
|
||||||
|
| <a name="double" /> double | | double | double | float | float64 | double | float | Float |
|
||||||
|
| <a name="float" /> float | | float | float | float | float32 | float | float | Float |
|
||||||
|
| <a name="int32" /> int32 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint32 instead. | int32 | int | int | int32 | int | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="int64" /> int64 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint64 instead. | int64 | long | int/long | int64 | long | integer/string | Bignum |
|
||||||
|
| <a name="uint32" /> uint32 | Uses variable-length encoding. | uint32 | int | int/long | uint32 | uint | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="uint64" /> uint64 | Uses variable-length encoding. | uint64 | long | int/long | uint64 | ulong | integer/string | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="sint32" /> sint32 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int32s. | int32 | int | int | int32 | int | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="sint64" /> sint64 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int64s. | int64 | long | int/long | int64 | long | integer/string | Bignum |
|
||||||
|
| <a name="fixed32" /> fixed32 | Always four bytes. More efficient than uint32 if values are often greater than 2^28. | uint32 | int | int | uint32 | uint | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="fixed64" /> fixed64 | Always eight bytes. More efficient than uint64 if values are often greater than 2^56. | uint64 | long | int/long | uint64 | ulong | integer/string | Bignum |
|
||||||
|
| <a name="sfixed32" /> sfixed32 | Always four bytes. | int32 | int | int | int32 | int | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="sfixed64" /> sfixed64 | Always eight bytes. | int64 | long | int/long | int64 | long | integer/string | Bignum |
|
||||||
|
| <a name="bool" /> bool | | bool | boolean | boolean | bool | bool | boolean | TrueClass/FalseClass |
|
||||||
|
| <a name="string" /> string | A string must always contain UTF-8 encoded or 7-bit ASCII text. | string | String | str/unicode | string | string | string | String (UTF-8) |
|
||||||
|
| <a name="bytes" /> bytes | May contain any arbitrary sequence of bytes. | string | ByteString | str | []byte | ByteString | string | String (ASCII-8BIT) |
|
||||||
|
|
142
proto/gen/api/v2/common.pb.go
Normal file
142
proto/gen/api/v2/common.pb.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.31.0
|
||||||
|
// protoc (unknown)
|
||||||
|
// source: api/v2/common.proto
|
||||||
|
|
||||||
|
package apiv2
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type RowStatus int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
RowStatus_ROW_STATUS_UNSPECIFIED RowStatus = 0
|
||||||
|
RowStatus_NORMAL RowStatus = 1
|
||||||
|
RowStatus_ARCHIVED RowStatus = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum value maps for RowStatus.
|
||||||
|
var (
|
||||||
|
RowStatus_name = map[int32]string{
|
||||||
|
0: "ROW_STATUS_UNSPECIFIED",
|
||||||
|
1: "NORMAL",
|
||||||
|
2: "ARCHIVED",
|
||||||
|
}
|
||||||
|
RowStatus_value = map[string]int32{
|
||||||
|
"ROW_STATUS_UNSPECIFIED": 0,
|
||||||
|
"NORMAL": 1,
|
||||||
|
"ARCHIVED": 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (x RowStatus) Enum() *RowStatus {
|
||||||
|
p := new(RowStatus)
|
||||||
|
*p = x
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x RowStatus) String() string {
|
||||||
|
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RowStatus) Descriptor() protoreflect.EnumDescriptor {
|
||||||
|
return file_api_v2_common_proto_enumTypes[0].Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RowStatus) Type() protoreflect.EnumType {
|
||||||
|
return &file_api_v2_common_proto_enumTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x RowStatus) Number() protoreflect.EnumNumber {
|
||||||
|
return protoreflect.EnumNumber(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use RowStatus.Descriptor instead.
|
||||||
|
func (RowStatus) EnumDescriptor() ([]byte, []int) {
|
||||||
|
return file_api_v2_common_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_api_v2_common_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_api_v2_common_proto_rawDesc = []byte{
|
||||||
|
0x0a, 0x13, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e,
|
||||||
|
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0c, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x61, 0x70, 0x69,
|
||||||
|
0x2e, 0x76, 0x32, 0x2a, 0x41, 0x0a, 0x09, 0x52, 0x6f, 0x77, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
|
||||||
|
0x12, 0x1a, 0x0a, 0x16, 0x52, 0x4f, 0x57, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55,
|
||||||
|
0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06,
|
||||||
|
0x4e, 0x4f, 0x52, 0x4d, 0x41, 0x4c, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x41, 0x52, 0x43, 0x48,
|
||||||
|
0x49, 0x56, 0x45, 0x44, 0x10, 0x02, 0x42, 0xa2, 0x01, 0x0a, 0x10, 0x63, 0x6f, 0x6d, 0x2e, 0x73,
|
||||||
|
0x6c, 0x61, 0x73, 0x68, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x32, 0x42, 0x0b, 0x43, 0x6f, 0x6d,
|
||||||
|
0x6d, 0x6f, 0x6e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x2f, 0x67, 0x69, 0x74, 0x68,
|
||||||
|
0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x6f, 0x6f, 0x6a, 0x61, 0x63, 0x6b, 0x2f, 0x73,
|
||||||
|
0x6c, 0x61, 0x73, 0x68, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x61,
|
||||||
|
0x70, 0x69, 0x2f, 0x76, 0x32, 0x3b, 0x61, 0x70, 0x69, 0x76, 0x32, 0xa2, 0x02, 0x03, 0x53, 0x41,
|
||||||
|
0x58, 0xaa, 0x02, 0x0c, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x41, 0x70, 0x69, 0x2e, 0x56, 0x32,
|
||||||
|
0xca, 0x02, 0x0c, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x56, 0x32, 0xe2,
|
||||||
|
0x02, 0x18, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x56, 0x32, 0x5c, 0x47,
|
||||||
|
0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0e, 0x53, 0x6c, 0x61,
|
||||||
|
0x73, 0x68, 0x3a, 0x3a, 0x41, 0x70, 0x69, 0x3a, 0x3a, 0x56, 0x32, 0x62, 0x06, 0x70, 0x72, 0x6f,
|
||||||
|
0x74, 0x6f, 0x33,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_api_v2_common_proto_rawDescOnce sync.Once
|
||||||
|
file_api_v2_common_proto_rawDescData = file_api_v2_common_proto_rawDesc
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_api_v2_common_proto_rawDescGZIP() []byte {
|
||||||
|
file_api_v2_common_proto_rawDescOnce.Do(func() {
|
||||||
|
file_api_v2_common_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_v2_common_proto_rawDescData)
|
||||||
|
})
|
||||||
|
return file_api_v2_common_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_api_v2_common_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||||
|
var file_api_v2_common_proto_goTypes = []interface{}{
|
||||||
|
(RowStatus)(0), // 0: slash.api.v2.RowStatus
|
||||||
|
}
|
||||||
|
var file_api_v2_common_proto_depIdxs = []int32{
|
||||||
|
0, // [0:0] is the sub-list for method output_type
|
||||||
|
0, // [0:0] is the sub-list for method input_type
|
||||||
|
0, // [0:0] is the sub-list for extension type_name
|
||||||
|
0, // [0:0] is the sub-list for extension extendee
|
||||||
|
0, // [0:0] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_api_v2_common_proto_init() }
|
||||||
|
func file_api_v2_common_proto_init() {
|
||||||
|
if File_api_v2_common_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: file_api_v2_common_proto_rawDesc,
|
||||||
|
NumEnums: 1,
|
||||||
|
NumMessages: 0,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 0,
|
||||||
|
},
|
||||||
|
GoTypes: file_api_v2_common_proto_goTypes,
|
||||||
|
DependencyIndexes: file_api_v2_common_proto_depIdxs,
|
||||||
|
EnumInfos: file_api_v2_common_proto_enumTypes,
|
||||||
|
}.Build()
|
||||||
|
File_api_v2_common_proto = out.File
|
||||||
|
file_api_v2_common_proto_rawDesc = nil
|
||||||
|
file_api_v2_common_proto_goTypes = nil
|
||||||
|
file_api_v2_common_proto_depIdxs = nil
|
||||||
|
}
|
414
proto/gen/api/v2/user_service.pb.go
Normal file
414
proto/gen/api/v2/user_service.pb.go
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.31.0
|
||||||
|
// protoc (unknown)
|
||||||
|
// source: api/v2/user_service.proto
|
||||||
|
|
||||||
|
package apiv2
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "google.golang.org/genproto/googleapis/api/annotations"
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
Role_ROLE_UNSPECIFIED Role = 0
|
||||||
|
Role_ADMIN Role = 1
|
||||||
|
Role_USER Role = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum value maps for Role.
|
||||||
|
var (
|
||||||
|
Role_name = map[int32]string{
|
||||||
|
0: "ROLE_UNSPECIFIED",
|
||||||
|
1: "ADMIN",
|
||||||
|
2: "USER",
|
||||||
|
}
|
||||||
|
Role_value = map[string]int32{
|
||||||
|
"ROLE_UNSPECIFIED": 0,
|
||||||
|
"ADMIN": 1,
|
||||||
|
"USER": 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (x Role) Enum() *Role {
|
||||||
|
p := new(Role)
|
||||||
|
*p = x
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x Role) String() string {
|
||||||
|
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Role) Descriptor() protoreflect.EnumDescriptor {
|
||||||
|
return file_api_v2_user_service_proto_enumTypes[0].Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Role) Type() protoreflect.EnumType {
|
||||||
|
return &file_api_v2_user_service_proto_enumTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x Role) Number() protoreflect.EnumNumber {
|
||||||
|
return protoreflect.EnumNumber(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Role.Descriptor instead.
|
||||||
|
func (Role) EnumDescriptor() ([]byte, []int) {
|
||||||
|
return file_api_v2_user_service_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
RowStatus RowStatus `protobuf:"varint,2,opt,name=row_status,json=rowStatus,proto3,enum=slash.api.v2.RowStatus" json:"row_status,omitempty"`
|
||||||
|
CreatedTs int64 `protobuf:"varint,3,opt,name=created_ts,json=createdTs,proto3" json:"created_ts,omitempty"`
|
||||||
|
UpdatedTs int64 `protobuf:"varint,4,opt,name=updated_ts,json=updatedTs,proto3" json:"updated_ts,omitempty"`
|
||||||
|
Role Role `protobuf:"varint,6,opt,name=role,proto3,enum=slash.api.v2.Role" json:"role,omitempty"`
|
||||||
|
Email string `protobuf:"bytes,7,opt,name=email,proto3" json:"email,omitempty"`
|
||||||
|
Nickname string `protobuf:"bytes,8,opt,name=nickname,proto3" json:"nickname,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *User) Reset() {
|
||||||
|
*x = User{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_api_v2_user_service_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *User) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*User) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *User) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_api_v2_user_service_proto_msgTypes[0]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use User.ProtoReflect.Descriptor instead.
|
||||||
|
func (*User) Descriptor() ([]byte, []int) {
|
||||||
|
return file_api_v2_user_service_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *User) GetId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *User) GetRowStatus() RowStatus {
|
||||||
|
if x != nil {
|
||||||
|
return x.RowStatus
|
||||||
|
}
|
||||||
|
return RowStatus_ROW_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *User) GetCreatedTs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CreatedTs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *User) GetUpdatedTs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.UpdatedTs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *User) GetRole() Role {
|
||||||
|
if x != nil {
|
||||||
|
return x.Role
|
||||||
|
}
|
||||||
|
return Role_ROLE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *User) GetEmail() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Email
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *User) GetNickname() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Nickname
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUserRequest struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetUserRequest) Reset() {
|
||||||
|
*x = GetUserRequest{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_api_v2_user_service_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetUserRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetUserRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetUserRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_api_v2_user_service_proto_msgTypes[1]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetUserRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetUserRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_api_v2_user_service_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetUserRequest) GetId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUserResponse struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetUserResponse) Reset() {
|
||||||
|
*x = GetUserResponse{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_api_v2_user_service_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetUserResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetUserResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetUserResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_api_v2_user_service_proto_msgTypes[2]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetUserResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetUserResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_api_v2_user_service_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetUserResponse) GetUser() *User {
|
||||||
|
if x != nil {
|
||||||
|
return x.User
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_api_v2_user_service_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_api_v2_user_service_proto_rawDesc = []byte{
|
||||||
|
0x0a, 0x19, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x73, 0x65,
|
||||||
|
0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0c, 0x73, 0x6c, 0x61,
|
||||||
|
0x73, 0x68, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x32, 0x1a, 0x13, 0x61, 0x70, 0x69, 0x2f, 0x76,
|
||||||
|
0x32, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1c,
|
||||||
|
0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74,
|
||||||
|
0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x17, 0x67, 0x6f,
|
||||||
|
0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e,
|
||||||
|
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe6, 0x01, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0e,
|
||||||
|
0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x02, 0x69, 0x64, 0x12, 0x36,
|
||||||
|
0x0a, 0x0a, 0x72, 0x6f, 0x77, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01,
|
||||||
|
0x28, 0x0e, 0x32, 0x17, 0x2e, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76,
|
||||||
|
0x32, 0x2e, 0x52, 0x6f, 0x77, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x09, 0x72, 0x6f, 0x77,
|
||||||
|
0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65,
|
||||||
|
0x64, 0x5f, 0x74, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61,
|
||||||
|
0x74, 0x65, 0x64, 0x54, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64,
|
||||||
|
0x5f, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74,
|
||||||
|
0x65, 0x64, 0x54, 0x73, 0x12, 0x26, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x06, 0x20, 0x01,
|
||||||
|
0x28, 0x0e, 0x32, 0x12, 0x2e, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76,
|
||||||
|
0x32, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x12, 0x14, 0x0a, 0x05,
|
||||||
|
0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61,
|
||||||
|
0x69, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x08,
|
||||||
|
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x20,
|
||||||
|
0x0a, 0x0e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||||
|
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x02, 0x69, 0x64,
|
||||||
|
0x22, 0x39, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||||
|
0x6e, 0x73, 0x65, 0x12, 0x26, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
|
0x0b, 0x32, 0x12, 0x2e, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x32,
|
||||||
|
0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x2a, 0x31, 0x0a, 0x04, 0x52,
|
||||||
|
0x6f, 0x6c, 0x65, 0x12, 0x14, 0x0a, 0x10, 0x52, 0x4f, 0x4c, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50,
|
||||||
|
0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x44, 0x4d,
|
||||||
|
0x49, 0x4e, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x55, 0x53, 0x45, 0x52, 0x10, 0x02, 0x32, 0x76,
|
||||||
|
0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x67, 0x0a,
|
||||||
|
0x07, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x73, 0x6c, 0x61, 0x73, 0x68,
|
||||||
|
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52,
|
||||||
|
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x61,
|
||||||
|
0x70, 0x69, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73,
|
||||||
|
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1f, 0xda, 0x41, 0x02, 0x69, 0x64, 0x82, 0xd3, 0xe4, 0x93,
|
||||||
|
0x02, 0x14, 0x12, 0x12, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x2f, 0x75, 0x73, 0x65, 0x72,
|
||||||
|
0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x42, 0xa7, 0x01, 0x0a, 0x10, 0x63, 0x6f, 0x6d, 0x2e, 0x73,
|
||||||
|
0x6c, 0x61, 0x73, 0x68, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x32, 0x42, 0x10, 0x55, 0x73, 0x65,
|
||||||
|
0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a,
|
||||||
|
0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x6f, 0x6f, 0x6a,
|
||||||
|
0x61, 0x63, 0x6b, 0x2f, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
|
||||||
|
0x67, 0x65, 0x6e, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x3b, 0x61, 0x70, 0x69, 0x76, 0x32,
|
||||||
|
0xa2, 0x02, 0x03, 0x53, 0x41, 0x58, 0xaa, 0x02, 0x0c, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x41,
|
||||||
|
0x70, 0x69, 0x2e, 0x56, 0x32, 0xca, 0x02, 0x0c, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x5c, 0x41, 0x70,
|
||||||
|
0x69, 0x5c, 0x56, 0x32, 0xe2, 0x02, 0x18, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x5c, 0x41, 0x70, 0x69,
|
||||||
|
0x5c, 0x56, 0x32, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea,
|
||||||
|
0x02, 0x0e, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x3a, 0x3a, 0x41, 0x70, 0x69, 0x3a, 0x3a, 0x56, 0x32,
|
||||||
|
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_api_v2_user_service_proto_rawDescOnce sync.Once
|
||||||
|
file_api_v2_user_service_proto_rawDescData = file_api_v2_user_service_proto_rawDesc
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_api_v2_user_service_proto_rawDescGZIP() []byte {
|
||||||
|
file_api_v2_user_service_proto_rawDescOnce.Do(func() {
|
||||||
|
file_api_v2_user_service_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_v2_user_service_proto_rawDescData)
|
||||||
|
})
|
||||||
|
return file_api_v2_user_service_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_api_v2_user_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||||
|
var file_api_v2_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||||
|
var file_api_v2_user_service_proto_goTypes = []interface{}{
|
||||||
|
(Role)(0), // 0: slash.api.v2.Role
|
||||||
|
(*User)(nil), // 1: slash.api.v2.User
|
||||||
|
(*GetUserRequest)(nil), // 2: slash.api.v2.GetUserRequest
|
||||||
|
(*GetUserResponse)(nil), // 3: slash.api.v2.GetUserResponse
|
||||||
|
(RowStatus)(0), // 4: slash.api.v2.RowStatus
|
||||||
|
}
|
||||||
|
var file_api_v2_user_service_proto_depIdxs = []int32{
|
||||||
|
4, // 0: slash.api.v2.User.row_status:type_name -> slash.api.v2.RowStatus
|
||||||
|
0, // 1: slash.api.v2.User.role:type_name -> slash.api.v2.Role
|
||||||
|
1, // 2: slash.api.v2.GetUserResponse.user:type_name -> slash.api.v2.User
|
||||||
|
2, // 3: slash.api.v2.UserService.GetUser:input_type -> slash.api.v2.GetUserRequest
|
||||||
|
3, // 4: slash.api.v2.UserService.GetUser:output_type -> slash.api.v2.GetUserResponse
|
||||||
|
4, // [4:5] is the sub-list for method output_type
|
||||||
|
3, // [3:4] is the sub-list for method input_type
|
||||||
|
3, // [3:3] is the sub-list for extension type_name
|
||||||
|
3, // [3:3] is the sub-list for extension extendee
|
||||||
|
0, // [0:3] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_api_v2_user_service_proto_init() }
|
||||||
|
func file_api_v2_user_service_proto_init() {
|
||||||
|
if File_api_v2_user_service_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file_api_v2_common_proto_init()
|
||||||
|
if !protoimpl.UnsafeEnabled {
|
||||||
|
file_api_v2_user_service_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||||
|
switch v := v.(*User); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_api_v2_user_service_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||||
|
switch v := v.(*GetUserRequest); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_api_v2_user_service_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
|
||||||
|
switch v := v.(*GetUserResponse); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: file_api_v2_user_service_proto_rawDesc,
|
||||||
|
NumEnums: 1,
|
||||||
|
NumMessages: 3,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_api_v2_user_service_proto_goTypes,
|
||||||
|
DependencyIndexes: file_api_v2_user_service_proto_depIdxs,
|
||||||
|
EnumInfos: file_api_v2_user_service_proto_enumTypes,
|
||||||
|
MessageInfos: file_api_v2_user_service_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_api_v2_user_service_proto = out.File
|
||||||
|
file_api_v2_user_service_proto_rawDesc = nil
|
||||||
|
file_api_v2_user_service_proto_goTypes = nil
|
||||||
|
file_api_v2_user_service_proto_depIdxs = nil
|
||||||
|
}
|
189
proto/gen/api/v2/user_service.pb.gw.go
Normal file
189
proto/gen/api/v2/user_service.pb.gw.go
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT.
|
||||||
|
// source: api/v2/user_service.proto
|
||||||
|
|
||||||
|
/*
|
||||||
|
Package apiv2 is a reverse proxy.
|
||||||
|
|
||||||
|
It translates gRPC into RESTful JSON APIs.
|
||||||
|
*/
|
||||||
|
package apiv2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
|
"github.com/grpc-ecosystem/grpc-gateway/v2/utilities"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/grpclog"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Suppress "imported and not used" errors
|
||||||
|
var _ codes.Code
|
||||||
|
var _ io.Reader
|
||||||
|
var _ status.Status
|
||||||
|
var _ = runtime.String
|
||||||
|
var _ = utilities.NewDoubleArray
|
||||||
|
var _ = metadata.Join
|
||||||
|
|
||||||
|
func request_UserService_GetUser_0(ctx context.Context, marshaler runtime.Marshaler, client UserServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||||
|
var protoReq GetUserRequest
|
||||||
|
var metadata runtime.ServerMetadata
|
||||||
|
|
||||||
|
var (
|
||||||
|
val string
|
||||||
|
ok bool
|
||||||
|
err error
|
||||||
|
_ = err
|
||||||
|
)
|
||||||
|
|
||||||
|
val, ok = pathParams["id"]
|
||||||
|
if !ok {
|
||||||
|
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||||
|
}
|
||||||
|
|
||||||
|
protoReq.Id, err = runtime.Int32(val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := client.GetUser(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||||
|
return msg, metadata, err
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func local_request_UserService_GetUser_0(ctx context.Context, marshaler runtime.Marshaler, server UserServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||||
|
var protoReq GetUserRequest
|
||||||
|
var metadata runtime.ServerMetadata
|
||||||
|
|
||||||
|
var (
|
||||||
|
val string
|
||||||
|
ok bool
|
||||||
|
err error
|
||||||
|
_ = err
|
||||||
|
)
|
||||||
|
|
||||||
|
val, ok = pathParams["id"]
|
||||||
|
if !ok {
|
||||||
|
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||||
|
}
|
||||||
|
|
||||||
|
protoReq.Id, err = runtime.Int32(val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := server.GetUser(ctx, &protoReq)
|
||||||
|
return msg, metadata, err
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterUserServiceHandlerServer registers the http handlers for service UserService to "mux".
|
||||||
|
// UnaryRPC :call UserServiceServer directly.
|
||||||
|
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
|
||||||
|
// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterUserServiceHandlerFromEndpoint instead.
|
||||||
|
func RegisterUserServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server UserServiceServer) error {
|
||||||
|
|
||||||
|
mux.Handle("GET", pattern_UserService_GetUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||||
|
ctx, cancel := context.WithCancel(req.Context())
|
||||||
|
defer cancel()
|
||||||
|
var stream runtime.ServerTransportStream
|
||||||
|
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||||
|
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||||
|
var err error
|
||||||
|
var annotatedContext context.Context
|
||||||
|
annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/slash.api.v2.UserService/GetUser", runtime.WithHTTPPathPattern("/api/v2/users/{id}"))
|
||||||
|
if err != nil {
|
||||||
|
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, md, err := local_request_UserService_GetUser_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||||
|
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||||
|
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||||
|
if err != nil {
|
||||||
|
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
forward_UserService_GetUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterUserServiceHandlerFromEndpoint is same as RegisterUserServiceHandler but
|
||||||
|
// automatically dials to "endpoint" and closes the connection when "ctx" gets done.
|
||||||
|
func RegisterUserServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) {
|
||||||
|
conn, err := grpc.DialContext(ctx, endpoint, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return RegisterUserServiceHandler(ctx, mux, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterUserServiceHandler registers the http handlers for service UserService to "mux".
|
||||||
|
// The handlers forward requests to the grpc endpoint over "conn".
|
||||||
|
func RegisterUserServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error {
|
||||||
|
return RegisterUserServiceHandlerClient(ctx, mux, NewUserServiceClient(conn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterUserServiceHandlerClient registers the http handlers for service UserService
|
||||||
|
// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "UserServiceClient".
|
||||||
|
// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "UserServiceClient"
|
||||||
|
// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in
|
||||||
|
// "UserServiceClient" to call the correct interceptors.
|
||||||
|
func RegisterUserServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client UserServiceClient) error {
|
||||||
|
|
||||||
|
mux.Handle("GET", pattern_UserService_GetUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||||
|
ctx, cancel := context.WithCancel(req.Context())
|
||||||
|
defer cancel()
|
||||||
|
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||||
|
var err error
|
||||||
|
var annotatedContext context.Context
|
||||||
|
annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/slash.api.v2.UserService/GetUser", runtime.WithHTTPPathPattern("/api/v2/users/{id}"))
|
||||||
|
if err != nil {
|
||||||
|
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, md, err := request_UserService_GetUser_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||||
|
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||||
|
if err != nil {
|
||||||
|
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
forward_UserService_GetUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pattern_UserService_GetUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v2", "users", "id"}, ""))
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
forward_UserService_GetUser_0 = runtime.ForwardResponseMessage
|
||||||
|
)
|
109
proto/gen/api/v2/user_service_grpc.pb.go
Normal file
109
proto/gen/api/v2/user_service_grpc.pb.go
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.3.0
|
||||||
|
// - protoc (unknown)
|
||||||
|
// source: api/v2/user_service.proto
|
||||||
|
|
||||||
|
package apiv2
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.32.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion7
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserService_GetUser_FullMethodName = "/slash.api.v2.UserService/GetUser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserServiceClient is the client API for UserService service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
type UserServiceClient interface {
|
||||||
|
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*GetUserResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type userServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserServiceClient(cc grpc.ClientConnInterface) UserServiceClient {
|
||||||
|
return &userServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *userServiceClient) GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*GetUserResponse, error) {
|
||||||
|
out := new(GetUserResponse)
|
||||||
|
err := c.cc.Invoke(ctx, UserService_GetUser_FullMethodName, in, out, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserServiceServer is the server API for UserService service.
|
||||||
|
// All implementations must embed UnimplementedUserServiceServer
|
||||||
|
// for forward compatibility
|
||||||
|
type UserServiceServer interface {
|
||||||
|
GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error)
|
||||||
|
mustEmbedUnimplementedUserServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedUserServiceServer must be embedded to have forward compatible implementations.
|
||||||
|
type UnimplementedUserServiceServer struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedUserServiceServer) GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetUser not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedUserServiceServer) mustEmbedUnimplementedUserServiceServer() {}
|
||||||
|
|
||||||
|
// UnsafeUserServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to UserServiceServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeUserServiceServer interface {
|
||||||
|
mustEmbedUnimplementedUserServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterUserServiceServer(s grpc.ServiceRegistrar, srv UserServiceServer) {
|
||||||
|
s.RegisterService(&UserService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _UserService_GetUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetUserRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(UserServiceServer).GetUser(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: UserService_GetUser_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(UserServiceServer).GetUser(ctx, req.(*GetUserRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserService_ServiceDesc is the grpc.ServiceDesc for UserService service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var UserService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "slash.api.v2.UserService",
|
||||||
|
HandlerType: (*UserServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "GetUser",
|
||||||
|
Handler: _UserService_GetUser_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "api/v2/user_service.proto",
|
||||||
|
}
|
140
proto/gen/store/README.md
Normal file
140
proto/gen/store/README.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# Protocol Documentation
|
||||||
|
<a name="top"></a>
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [store/common.proto](#store_common-proto)
|
||||||
|
- [RowStatus](#slash-store-RowStatus)
|
||||||
|
|
||||||
|
- [store/shortcut.proto](#store_shortcut-proto)
|
||||||
|
- [OpenGraphMetadata](#slash-store-OpenGraphMetadata)
|
||||||
|
- [Shortcut](#slash-store-Shortcut)
|
||||||
|
|
||||||
|
- [Visibility](#slash-store-Visibility)
|
||||||
|
|
||||||
|
- [Scalar Value Types](#scalar-value-types)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="store_common-proto"></a>
|
||||||
|
<p align="right"><a href="#top">Top</a></p>
|
||||||
|
|
||||||
|
## store/common.proto
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-store-RowStatus"></a>
|
||||||
|
|
||||||
|
### RowStatus
|
||||||
|
|
||||||
|
|
||||||
|
| Name | Number | Description |
|
||||||
|
| ---- | ------ | ----------- |
|
||||||
|
| ROW_STATUS_UNSPECIFIED | 0 | |
|
||||||
|
| NORMAL | 1 | |
|
||||||
|
| ARCHIVED | 2 | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="store_shortcut-proto"></a>
|
||||||
|
<p align="right"><a href="#top">Top</a></p>
|
||||||
|
|
||||||
|
## store/shortcut.proto
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-store-OpenGraphMetadata"></a>
|
||||||
|
|
||||||
|
### OpenGraphMetadata
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Field | Type | Label | Description |
|
||||||
|
| ----- | ---- | ----- | ----------- |
|
||||||
|
| title | [string](#string) | | |
|
||||||
|
| description | [string](#string) | | |
|
||||||
|
| image | [string](#string) | | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-store-Shortcut"></a>
|
||||||
|
|
||||||
|
### Shortcut
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
| Field | Type | Label | Description |
|
||||||
|
| ----- | ---- | ----- | ----------- |
|
||||||
|
| id | [int32](#int32) | | |
|
||||||
|
| creator_id | [int32](#int32) | | |
|
||||||
|
| created_ts | [int64](#int64) | | |
|
||||||
|
| updated_ts | [int64](#int64) | | |
|
||||||
|
| row_status | [RowStatus](#slash-store-RowStatus) | | |
|
||||||
|
| name | [string](#string) | | |
|
||||||
|
| link | [string](#string) | | |
|
||||||
|
| title | [string](#string) | | |
|
||||||
|
| tags | [string](#string) | repeated | |
|
||||||
|
| description | [string](#string) | | |
|
||||||
|
| visibility | [Visibility](#slash-store-Visibility) | | |
|
||||||
|
| og_metadata | [OpenGraphMetadata](#slash-store-OpenGraphMetadata) | | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a name="slash-store-Visibility"></a>
|
||||||
|
|
||||||
|
### Visibility
|
||||||
|
|
||||||
|
|
||||||
|
| Name | Number | Description |
|
||||||
|
| ---- | ------ | ----------- |
|
||||||
|
| VISIBILITY_UNSPECIFIED | 0 | |
|
||||||
|
| PRIVATE | 1 | |
|
||||||
|
| WORKSPACE | 2 | |
|
||||||
|
| PUBLIC | 3 | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Scalar Value Types
|
||||||
|
|
||||||
|
| .proto Type | Notes | C++ | Java | Python | Go | C# | PHP | Ruby |
|
||||||
|
| ----------- | ----- | --- | ---- | ------ | -- | -- | --- | ---- |
|
||||||
|
| <a name="double" /> double | | double | double | float | float64 | double | float | Float |
|
||||||
|
| <a name="float" /> float | | float | float | float | float32 | float | float | Float |
|
||||||
|
| <a name="int32" /> int32 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint32 instead. | int32 | int | int | int32 | int | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="int64" /> int64 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint64 instead. | int64 | long | int/long | int64 | long | integer/string | Bignum |
|
||||||
|
| <a name="uint32" /> uint32 | Uses variable-length encoding. | uint32 | int | int/long | uint32 | uint | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="uint64" /> uint64 | Uses variable-length encoding. | uint64 | long | int/long | uint64 | ulong | integer/string | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="sint32" /> sint32 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int32s. | int32 | int | int | int32 | int | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="sint64" /> sint64 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int64s. | int64 | long | int/long | int64 | long | integer/string | Bignum |
|
||||||
|
| <a name="fixed32" /> fixed32 | Always four bytes. More efficient than uint32 if values are often greater than 2^28. | uint32 | int | int | uint32 | uint | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="fixed64" /> fixed64 | Always eight bytes. More efficient than uint64 if values are often greater than 2^56. | uint64 | long | int/long | uint64 | ulong | integer/string | Bignum |
|
||||||
|
| <a name="sfixed32" /> sfixed32 | Always four bytes. | int32 | int | int | int32 | int | integer | Bignum or Fixnum (as required) |
|
||||||
|
| <a name="sfixed64" /> sfixed64 | Always eight bytes. | int64 | long | int/long | int64 | long | integer/string | Bignum |
|
||||||
|
| <a name="bool" /> bool | | bool | boolean | boolean | bool | bool | boolean | TrueClass/FalseClass |
|
||||||
|
| <a name="string" /> string | A string must always contain UTF-8 encoded or 7-bit ASCII text. | string | String | str/unicode | string | string | string | String (UTF-8) |
|
||||||
|
| <a name="bytes" /> bytes | May contain any arbitrary sequence of bytes. | string | ByteString | str | []byte | ByteString | string | String (ASCII-8BIT) |
|
||||||
|
|
141
proto/gen/store/common.pb.go
Normal file
141
proto/gen/store/common.pb.go
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.31.0
|
||||||
|
// protoc (unknown)
|
||||||
|
// source: store/common.proto
|
||||||
|
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type RowStatus int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
RowStatus_ROW_STATUS_UNSPECIFIED RowStatus = 0
|
||||||
|
RowStatus_NORMAL RowStatus = 1
|
||||||
|
RowStatus_ARCHIVED RowStatus = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum value maps for RowStatus.
|
||||||
|
var (
|
||||||
|
RowStatus_name = map[int32]string{
|
||||||
|
0: "ROW_STATUS_UNSPECIFIED",
|
||||||
|
1: "NORMAL",
|
||||||
|
2: "ARCHIVED",
|
||||||
|
}
|
||||||
|
RowStatus_value = map[string]int32{
|
||||||
|
"ROW_STATUS_UNSPECIFIED": 0,
|
||||||
|
"NORMAL": 1,
|
||||||
|
"ARCHIVED": 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (x RowStatus) Enum() *RowStatus {
|
||||||
|
p := new(RowStatus)
|
||||||
|
*p = x
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x RowStatus) String() string {
|
||||||
|
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RowStatus) Descriptor() protoreflect.EnumDescriptor {
|
||||||
|
return file_store_common_proto_enumTypes[0].Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RowStatus) Type() protoreflect.EnumType {
|
||||||
|
return &file_store_common_proto_enumTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x RowStatus) Number() protoreflect.EnumNumber {
|
||||||
|
return protoreflect.EnumNumber(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use RowStatus.Descriptor instead.
|
||||||
|
func (RowStatus) EnumDescriptor() ([]byte, []int) {
|
||||||
|
return file_store_common_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_store_common_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_store_common_proto_rawDesc = []byte{
|
||||||
|
0x0a, 0x12, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70,
|
||||||
|
0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x73, 0x74, 0x6f, 0x72,
|
||||||
|
0x65, 0x2a, 0x41, 0x0a, 0x09, 0x52, 0x6f, 0x77, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1a,
|
||||||
|
0x0a, 0x16, 0x52, 0x4f, 0x57, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53,
|
||||||
|
0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x4e, 0x4f,
|
||||||
|
0x52, 0x4d, 0x41, 0x4c, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x41, 0x52, 0x43, 0x48, 0x49, 0x56,
|
||||||
|
0x45, 0x44, 0x10, 0x02, 0x42, 0x95, 0x01, 0x0a, 0x0f, 0x63, 0x6f, 0x6d, 0x2e, 0x73, 0x6c, 0x61,
|
||||||
|
0x73, 0x68, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x42, 0x0b, 0x43, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e,
|
||||||
|
0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e,
|
||||||
|
0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x6f, 0x6f, 0x6a, 0x61, 0x63, 0x6b, 0x2f, 0x73, 0x6c, 0x61, 0x73,
|
||||||
|
0x68, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x73, 0x74, 0x6f, 0x72,
|
||||||
|
0x65, 0xa2, 0x02, 0x03, 0x53, 0x53, 0x58, 0xaa, 0x02, 0x0b, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x2e,
|
||||||
|
0x53, 0x74, 0x6f, 0x72, 0x65, 0xca, 0x02, 0x0b, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x5c, 0x53, 0x74,
|
||||||
|
0x6f, 0x72, 0x65, 0xe2, 0x02, 0x17, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x5c, 0x53, 0x74, 0x6f, 0x72,
|
||||||
|
0x65, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0c,
|
||||||
|
0x53, 0x6c, 0x61, 0x73, 0x68, 0x3a, 0x3a, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72,
|
||||||
|
0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_store_common_proto_rawDescOnce sync.Once
|
||||||
|
file_store_common_proto_rawDescData = file_store_common_proto_rawDesc
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_store_common_proto_rawDescGZIP() []byte {
|
||||||
|
file_store_common_proto_rawDescOnce.Do(func() {
|
||||||
|
file_store_common_proto_rawDescData = protoimpl.X.CompressGZIP(file_store_common_proto_rawDescData)
|
||||||
|
})
|
||||||
|
return file_store_common_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_store_common_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||||
|
var file_store_common_proto_goTypes = []interface{}{
|
||||||
|
(RowStatus)(0), // 0: slash.store.RowStatus
|
||||||
|
}
|
||||||
|
var file_store_common_proto_depIdxs = []int32{
|
||||||
|
0, // [0:0] is the sub-list for method output_type
|
||||||
|
0, // [0:0] is the sub-list for method input_type
|
||||||
|
0, // [0:0] is the sub-list for extension type_name
|
||||||
|
0, // [0:0] is the sub-list for extension extendee
|
||||||
|
0, // [0:0] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_store_common_proto_init() }
|
||||||
|
func file_store_common_proto_init() {
|
||||||
|
if File_store_common_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: file_store_common_proto_rawDesc,
|
||||||
|
NumEnums: 1,
|
||||||
|
NumMessages: 0,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 0,
|
||||||
|
},
|
||||||
|
GoTypes: file_store_common_proto_goTypes,
|
||||||
|
DependencyIndexes: file_store_common_proto_depIdxs,
|
||||||
|
EnumInfos: file_store_common_proto_enumTypes,
|
||||||
|
}.Build()
|
||||||
|
File_store_common_proto = out.File
|
||||||
|
file_store_common_proto_rawDesc = nil
|
||||||
|
file_store_common_proto_goTypes = nil
|
||||||
|
file_store_common_proto_depIdxs = nil
|
||||||
|
}
|
411
proto/gen/store/shortcut.pb.go
Normal file
411
proto/gen/store/shortcut.pb.go
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.31.0
|
||||||
|
// protoc (unknown)
|
||||||
|
// source: store/shortcut.proto
|
||||||
|
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Visibility int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
Visibility_VISIBILITY_UNSPECIFIED Visibility = 0
|
||||||
|
Visibility_PRIVATE Visibility = 1
|
||||||
|
Visibility_WORKSPACE Visibility = 2
|
||||||
|
Visibility_PUBLIC Visibility = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum value maps for Visibility.
|
||||||
|
var (
|
||||||
|
Visibility_name = map[int32]string{
|
||||||
|
0: "VISIBILITY_UNSPECIFIED",
|
||||||
|
1: "PRIVATE",
|
||||||
|
2: "WORKSPACE",
|
||||||
|
3: "PUBLIC",
|
||||||
|
}
|
||||||
|
Visibility_value = map[string]int32{
|
||||||
|
"VISIBILITY_UNSPECIFIED": 0,
|
||||||
|
"PRIVATE": 1,
|
||||||
|
"WORKSPACE": 2,
|
||||||
|
"PUBLIC": 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (x Visibility) Enum() *Visibility {
|
||||||
|
p := new(Visibility)
|
||||||
|
*p = x
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x Visibility) String() string {
|
||||||
|
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Visibility) Descriptor() protoreflect.EnumDescriptor {
|
||||||
|
return file_store_shortcut_proto_enumTypes[0].Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Visibility) Type() protoreflect.EnumType {
|
||||||
|
return &file_store_shortcut_proto_enumTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x Visibility) Number() protoreflect.EnumNumber {
|
||||||
|
return protoreflect.EnumNumber(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Visibility.Descriptor instead.
|
||||||
|
func (Visibility) EnumDescriptor() ([]byte, []int) {
|
||||||
|
return file_store_shortcut_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shortcut struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
CreatorId int32 `protobuf:"varint,2,opt,name=creator_id,json=creatorId,proto3" json:"creator_id,omitempty"`
|
||||||
|
CreatedTs int64 `protobuf:"varint,3,opt,name=created_ts,json=createdTs,proto3" json:"created_ts,omitempty"`
|
||||||
|
UpdatedTs int64 `protobuf:"varint,4,opt,name=updated_ts,json=updatedTs,proto3" json:"updated_ts,omitempty"`
|
||||||
|
RowStatus RowStatus `protobuf:"varint,5,opt,name=row_status,json=rowStatus,proto3,enum=slash.store.RowStatus" json:"row_status,omitempty"`
|
||||||
|
Name string `protobuf:"bytes,6,opt,name=name,proto3" json:"name,omitempty"`
|
||||||
|
Link string `protobuf:"bytes,7,opt,name=link,proto3" json:"link,omitempty"`
|
||||||
|
Title string `protobuf:"bytes,8,opt,name=title,proto3" json:"title,omitempty"`
|
||||||
|
Tags []string `protobuf:"bytes,9,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||||
|
Description string `protobuf:"bytes,10,opt,name=description,proto3" json:"description,omitempty"`
|
||||||
|
Visibility Visibility `protobuf:"varint,11,opt,name=visibility,proto3,enum=slash.store.Visibility" json:"visibility,omitempty"`
|
||||||
|
OgMetadata *OpenGraphMetadata `protobuf:"bytes,12,opt,name=og_metadata,json=ogMetadata,proto3" json:"og_metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) Reset() {
|
||||||
|
*x = Shortcut{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_store_shortcut_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Shortcut) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Shortcut) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_store_shortcut_proto_msgTypes[0]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Shortcut.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Shortcut) Descriptor() ([]byte, []int) {
|
||||||
|
return file_store_shortcut_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetCreatorId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CreatorId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetCreatedTs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CreatedTs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetUpdatedTs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.UpdatedTs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetRowStatus() RowStatus {
|
||||||
|
if x != nil {
|
||||||
|
return x.RowStatus
|
||||||
|
}
|
||||||
|
return RowStatus_ROW_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetLink() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Link
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetTitle() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Title
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetTags() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Tags
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetDescription() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Description
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetVisibility() Visibility {
|
||||||
|
if x != nil {
|
||||||
|
return x.Visibility
|
||||||
|
}
|
||||||
|
return Visibility_VISIBILITY_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Shortcut) GetOgMetadata() *OpenGraphMetadata {
|
||||||
|
if x != nil {
|
||||||
|
return x.OgMetadata
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenGraphMetadata struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
|
||||||
|
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
|
||||||
|
Image string `protobuf:"bytes,3,opt,name=image,proto3" json:"image,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OpenGraphMetadata) Reset() {
|
||||||
|
*x = OpenGraphMetadata{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_store_shortcut_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OpenGraphMetadata) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*OpenGraphMetadata) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *OpenGraphMetadata) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_store_shortcut_proto_msgTypes[1]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use OpenGraphMetadata.ProtoReflect.Descriptor instead.
|
||||||
|
func (*OpenGraphMetadata) Descriptor() ([]byte, []int) {
|
||||||
|
return file_store_shortcut_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OpenGraphMetadata) GetTitle() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Title
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OpenGraphMetadata) GetDescription() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Description
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OpenGraphMetadata) GetImage() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Image
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_store_shortcut_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_store_shortcut_proto_rawDesc = []byte{
|
||||||
|
0x0a, 0x14, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x63, 0x75, 0x74,
|
||||||
|
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x73, 0x74,
|
||||||
|
0x6f, 0x72, 0x65, 0x1a, 0x12, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f,
|
||||||
|
0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9c, 0x03, 0x0a, 0x08, 0x53, 0x68, 0x6f, 0x72,
|
||||||
|
0x74, 0x63, 0x75, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05,
|
||||||
|
0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x6f, 0x72, 0x5f,
|
||||||
|
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x6f,
|
||||||
|
0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x74,
|
||||||
|
0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
|
||||||
|
0x54, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x73,
|
||||||
|
0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x54,
|
||||||
|
0x73, 0x12, 0x35, 0x0a, 0x0a, 0x72, 0x6f, 0x77, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
|
||||||
|
0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x73, 0x74,
|
||||||
|
0x6f, 0x72, 0x65, 0x2e, 0x52, 0x6f, 0x77, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x09, 0x72,
|
||||||
|
0x6f, 0x77, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65,
|
||||||
|
0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04,
|
||||||
|
0x6c, 0x69, 0x6e, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, 0x6e, 0x6b,
|
||||||
|
0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
|
0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x09,
|
||||||
|
0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65,
|
||||||
|
0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
|
0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x37, 0x0a, 0x0a,
|
||||||
|
0x76, 0x69, 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0e,
|
||||||
|
0x32, 0x17, 0x2e, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2e, 0x56,
|
||||||
|
0x69, 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x0a, 0x76, 0x69, 0x73, 0x69, 0x62,
|
||||||
|
0x69, 0x6c, 0x69, 0x74, 0x79, 0x12, 0x3f, 0x0a, 0x0b, 0x6f, 0x67, 0x5f, 0x6d, 0x65, 0x74, 0x61,
|
||||||
|
0x64, 0x61, 0x74, 0x61, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x73, 0x6c, 0x61,
|
||||||
|
0x73, 0x68, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x47, 0x72, 0x61,
|
||||||
|
0x70, 0x68, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x0a, 0x6f, 0x67, 0x4d, 0x65,
|
||||||
|
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x61, 0x0a, 0x11, 0x4f, 0x70, 0x65, 0x6e, 0x47, 0x72,
|
||||||
|
0x61, 0x70, 0x68, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, 0x05, 0x74,
|
||||||
|
0x69, 0x74, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c,
|
||||||
|
0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||||
|
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74,
|
||||||
|
0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01,
|
||||||
|
0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2a, 0x50, 0x0a, 0x0a, 0x56, 0x69, 0x73,
|
||||||
|
0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x12, 0x1a, 0x0a, 0x16, 0x56, 0x49, 0x53, 0x49, 0x42,
|
||||||
|
0x49, 0x4c, 0x49, 0x54, 0x59, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45,
|
||||||
|
0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x52, 0x49, 0x56, 0x41, 0x54, 0x45, 0x10, 0x01,
|
||||||
|
0x12, 0x0d, 0x0a, 0x09, 0x57, 0x4f, 0x52, 0x4b, 0x53, 0x50, 0x41, 0x43, 0x45, 0x10, 0x02, 0x12,
|
||||||
|
0x0a, 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x03, 0x42, 0x97, 0x01, 0x0a, 0x0f,
|
||||||
|
0x63, 0x6f, 0x6d, 0x2e, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x42,
|
||||||
|
0x0d, 0x53, 0x68, 0x6f, 0x72, 0x74, 0x63, 0x75, 0x74, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01,
|
||||||
|
0x5a, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x6f, 0x6f,
|
||||||
|
0x6a, 0x61, 0x63, 0x6b, 0x2f, 0x73, 0x6c, 0x61, 0x73, 0x68, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||||
|
0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0xa2, 0x02, 0x03, 0x53, 0x53, 0x58,
|
||||||
|
0xaa, 0x02, 0x0b, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x2e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0xca, 0x02,
|
||||||
|
0x0b, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x5c, 0x53, 0x74, 0x6f, 0x72, 0x65, 0xe2, 0x02, 0x17, 0x53,
|
||||||
|
0x6c, 0x61, 0x73, 0x68, 0x5c, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65,
|
||||||
|
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0c, 0x53, 0x6c, 0x61, 0x73, 0x68, 0x3a, 0x3a,
|
||||||
|
0x53, 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_store_shortcut_proto_rawDescOnce sync.Once
|
||||||
|
file_store_shortcut_proto_rawDescData = file_store_shortcut_proto_rawDesc
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_store_shortcut_proto_rawDescGZIP() []byte {
|
||||||
|
file_store_shortcut_proto_rawDescOnce.Do(func() {
|
||||||
|
file_store_shortcut_proto_rawDescData = protoimpl.X.CompressGZIP(file_store_shortcut_proto_rawDescData)
|
||||||
|
})
|
||||||
|
return file_store_shortcut_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_store_shortcut_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||||
|
var file_store_shortcut_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
|
||||||
|
var file_store_shortcut_proto_goTypes = []interface{}{
|
||||||
|
(Visibility)(0), // 0: slash.store.Visibility
|
||||||
|
(*Shortcut)(nil), // 1: slash.store.Shortcut
|
||||||
|
(*OpenGraphMetadata)(nil), // 2: slash.store.OpenGraphMetadata
|
||||||
|
(RowStatus)(0), // 3: slash.store.RowStatus
|
||||||
|
}
|
||||||
|
var file_store_shortcut_proto_depIdxs = []int32{
|
||||||
|
3, // 0: slash.store.Shortcut.row_status:type_name -> slash.store.RowStatus
|
||||||
|
0, // 1: slash.store.Shortcut.visibility:type_name -> slash.store.Visibility
|
||||||
|
2, // 2: slash.store.Shortcut.og_metadata:type_name -> slash.store.OpenGraphMetadata
|
||||||
|
3, // [3:3] is the sub-list for method output_type
|
||||||
|
3, // [3:3] is the sub-list for method input_type
|
||||||
|
3, // [3:3] is the sub-list for extension type_name
|
||||||
|
3, // [3:3] is the sub-list for extension extendee
|
||||||
|
0, // [0:3] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_store_shortcut_proto_init() }
|
||||||
|
func file_store_shortcut_proto_init() {
|
||||||
|
if File_store_shortcut_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file_store_common_proto_init()
|
||||||
|
if !protoimpl.UnsafeEnabled {
|
||||||
|
file_store_shortcut_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||||
|
switch v := v.(*Shortcut); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_store_shortcut_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||||
|
switch v := v.(*OpenGraphMetadata); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: file_store_shortcut_proto_rawDesc,
|
||||||
|
NumEnums: 1,
|
||||||
|
NumMessages: 2,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 0,
|
||||||
|
},
|
||||||
|
GoTypes: file_store_shortcut_proto_goTypes,
|
||||||
|
DependencyIndexes: file_store_shortcut_proto_depIdxs,
|
||||||
|
EnumInfos: file_store_shortcut_proto_enumTypes,
|
||||||
|
MessageInfos: file_store_shortcut_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_store_shortcut_proto = out.File
|
||||||
|
file_store_shortcut_proto_rawDesc = nil
|
||||||
|
file_store_shortcut_proto_goTypes = nil
|
||||||
|
file_store_shortcut_proto_depIdxs = nil
|
||||||
|
}
|
13
proto/store/common.proto
Normal file
13
proto/store/common.proto
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package slash.store;
|
||||||
|
|
||||||
|
option go_package = "gen/store";
|
||||||
|
|
||||||
|
enum RowStatus {
|
||||||
|
ROW_STATUS_UNSPECIFIED = 0;
|
||||||
|
|
||||||
|
NORMAL = 1;
|
||||||
|
|
||||||
|
ARCHIVED = 2;
|
||||||
|
}
|
51
proto/store/shortcut.proto
Normal file
51
proto/store/shortcut.proto
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package slash.store;
|
||||||
|
|
||||||
|
import "store/common.proto";
|
||||||
|
|
||||||
|
option go_package = "gen/store";
|
||||||
|
|
||||||
|
message Shortcut {
|
||||||
|
int32 id = 1;
|
||||||
|
|
||||||
|
int32 creator_id = 2;
|
||||||
|
|
||||||
|
int64 created_ts = 3;
|
||||||
|
|
||||||
|
int64 updated_ts = 4;
|
||||||
|
|
||||||
|
RowStatus row_status = 5;
|
||||||
|
|
||||||
|
string name = 6;
|
||||||
|
|
||||||
|
string link = 7;
|
||||||
|
|
||||||
|
string title = 8;
|
||||||
|
|
||||||
|
repeated string tags = 9;
|
||||||
|
|
||||||
|
string description = 10;
|
||||||
|
|
||||||
|
Visibility visibility = 11;
|
||||||
|
|
||||||
|
OpenGraphMetadata og_metadata = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenGraphMetadata {
|
||||||
|
string title = 1;
|
||||||
|
|
||||||
|
string description = 2;
|
||||||
|
|
||||||
|
string image = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Visibility {
|
||||||
|
VISIBILITY_UNSPECIFIED = 0;
|
||||||
|
|
||||||
|
PRIVATE = 1;
|
||||||
|
|
||||||
|
WORKSPACE = 2;
|
||||||
|
|
||||||
|
PUBLIC = 3;
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.1 MiB |
BIN
resources/demo.png
Normal file
BIN
resources/demo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 969 KiB |
@ -2,8 +2,8 @@ root = "."
|
|||||||
tmp_dir = ".air"
|
tmp_dir = ".air"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
bin = "./.air/shortify"
|
bin = "./.air/slash --mode dev"
|
||||||
cmd = "go build -o ./.air/shortify ./cmd/shortify/main.go"
|
cmd = "go build -o ./.air/slash ./cmd/slash/main.go"
|
||||||
delay = 1000
|
delay = 1000
|
||||||
exclude_dir = [".air", "web", "build"]
|
exclude_dir = [".air", "web", "build"]
|
||||||
exclude_file = []
|
exclude_file = []
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Usage: ./scripts/build.sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/../"
|
|
||||||
|
|
||||||
echo "Start building backend..."
|
|
||||||
|
|
||||||
go build -o ./build/shortify ./cmd/shortify/main.go
|
|
||||||
|
|
||||||
echo "Backend built!"
|
|
@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Usage: ./scripts/start.sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/../"
|
|
||||||
|
|
||||||
air -c ./scripts/.air.toml
|
|
@ -1,133 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
issuer = "shortify"
|
|
||||||
// Signing key section. For now, this is only used for signing, not for verifying since we only
|
|
||||||
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
|
|
||||||
keyID = "v1"
|
|
||||||
// AccessTokenAudienceName is the audience name of the access token.
|
|
||||||
AccessTokenAudienceName = "user.access-token"
|
|
||||||
// RefreshTokenAudienceName is the audience name of the refresh token.
|
|
||||||
RefreshTokenAudienceName = "user.refresh-token"
|
|
||||||
apiTokenDuration = 2 * time.Hour
|
|
||||||
accessTokenDuration = 24 * time.Hour
|
|
||||||
refreshTokenDuration = 7 * 24 * time.Hour
|
|
||||||
// RefreshThresholdDuration is the threshold duration for refreshing token.
|
|
||||||
RefreshThresholdDuration = 1 * time.Hour
|
|
||||||
|
|
||||||
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
|
||||||
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
|
||||||
// Suppose we have a valid refresh token, we will refresh the token in 2 cases:
|
|
||||||
// 1. The access token is about to expire in <<refreshThresholdDuration>>
|
|
||||||
// 2. The access token has already expired, we refresh the token so that the ongoing request can pass through.
|
|
||||||
CookieExpDuration = refreshTokenDuration - 1*time.Minute
|
|
||||||
// AccessTokenCookieName is the cookie name of access token.
|
|
||||||
AccessTokenCookieName = "access-token"
|
|
||||||
// RefreshTokenCookieName is the cookie name of refresh token.
|
|
||||||
RefreshTokenCookieName = "refresh-token"
|
|
||||||
// UserIDCookieName is the cookie name of user ID.
|
|
||||||
UserIDCookieName = "user"
|
|
||||||
)
|
|
||||||
|
|
||||||
type claimsMessage struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAPIToken generates an API token.
|
|
||||||
func GenerateAPIToken(username string, userID int, secret string) (string, error) {
|
|
||||||
expirationTime := time.Now().Add(apiTokenDuration)
|
|
||||||
return generateToken(username, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAccessToken generates an access token for web.
|
|
||||||
func GenerateAccessToken(username string, userID int, secret string) (string, error) {
|
|
||||||
expirationTime := time.Now().Add(accessTokenDuration)
|
|
||||||
return generateToken(username, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateRefreshToken generates a refresh token for web.
|
|
||||||
func GenerateRefreshToken(username string, userID int, secret string) (string, error) {
|
|
||||||
expirationTime := time.Now().Add(refreshTokenDuration)
|
|
||||||
return generateToken(username, userID, RefreshTokenAudienceName, expirationTime, []byte(secret))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
|
|
||||||
func GenerateTokensAndSetCookies(c echo.Context, user *store.User, secret string) error {
|
|
||||||
accessToken, err := GenerateAccessToken(user.Email, user.ID, secret)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "failed to generate access token")
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieExp := time.Now().Add(CookieExpDuration)
|
|
||||||
setTokenCookie(c, AccessTokenCookieName, accessToken, cookieExp)
|
|
||||||
|
|
||||||
// We generate here a new refresh token and saving it to the cookie.
|
|
||||||
refreshToken, err := GenerateRefreshToken(user.Email, user.ID, secret)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "failed to generate refresh token")
|
|
||||||
}
|
|
||||||
setTokenCookie(c, RefreshTokenCookieName, refreshToken, cookieExp)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveTokensAndCookies removes the jwt token and refresh token from the cookies.
|
|
||||||
func RemoveTokensAndCookies(c echo.Context) {
|
|
||||||
// We set the expiration time to the past, so that the cookie will be removed.
|
|
||||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
|
||||||
setTokenCookie(c, AccessTokenCookieName, "", cookieExp)
|
|
||||||
setTokenCookie(c, RefreshTokenCookieName, "", cookieExp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setTokenCookie sets the token to the cookie.
|
|
||||||
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
|
||||||
cookie := new(http.Cookie)
|
|
||||||
cookie.Name = name
|
|
||||||
cookie.Value = token
|
|
||||||
cookie.Expires = expiration
|
|
||||||
cookie.Path = "/"
|
|
||||||
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
|
||||||
cookie.HttpOnly = true
|
|
||||||
cookie.SameSite = http.SameSiteStrictMode
|
|
||||||
c.SetCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateToken generates a jwt token.
|
|
||||||
func generateToken(username string, userID int, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
|
||||||
// Create the JWT claims, which includes the username and expiry time.
|
|
||||||
claims := &claimsMessage{
|
|
||||||
Name: username,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Audience: jwt.ClaimStrings{aud},
|
|
||||||
// In JWT, the expiry time is expressed as unix milliseconds.
|
|
||||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
Issuer: issuer,
|
|
||||||
Subject: strconv.Itoa(userID),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
token.Header["kid"] = keyID
|
|
||||||
|
|
||||||
// Create the JWT string.
|
|
||||||
tokenString, err := token.SignedString(secret)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokenString, nil
|
|
||||||
}
|
|
@ -5,7 +5,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/boojack/shortify/internal/util"
|
"github.com/boojack/slash/internal/util"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
)
|
)
|
||||||
@ -22,16 +22,16 @@ func getFileSystem(path string) http.FileSystem {
|
|||||||
return http.FS(fs)
|
return http.FS(fs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultAPIRequestSkipper(c echo.Context) bool {
|
func defaultRequestSkipper(c echo.Context) bool {
|
||||||
path := c.Path()
|
path := c.Path()
|
||||||
return util.HasPrefixes(path, "/api")
|
return util.HasPrefixes(path, "/api/", "/s/*")
|
||||||
}
|
}
|
||||||
|
|
||||||
func embedFrontend(e *echo.Echo) {
|
func embedFrontend(e *echo.Echo) {
|
||||||
// Use echo static middleware to serve the built dist folder
|
// Use echo static middleware to serve the built dist folder
|
||||||
// refer: https://github.com/labstack/echo/blob/master/middleware/static.go
|
// refer: https://github.com/labstack/echo/blob/master/middleware/static.go
|
||||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||||
Skipper: defaultAPIRequestSkipper,
|
Skipper: defaultRequestSkipper,
|
||||||
HTML5: true,
|
HTML5: true,
|
||||||
Filesystem: getFileSystem("dist"),
|
Filesystem: getFileSystem("dist"),
|
||||||
}))
|
}))
|
||||||
@ -44,7 +44,7 @@ func embedFrontend(e *echo.Echo) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
assetsGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
assetsGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||||
Skipper: defaultAPIRequestSkipper,
|
Skipper: defaultRequestSkipper,
|
||||||
HTML5: true,
|
HTML5: true,
|
||||||
Filesystem: getFileSystem("dist/assets"),
|
Filesystem: getFileSystem("dist/assets"),
|
||||||
}))
|
}))
|
||||||
|
@ -4,38 +4,44 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/version"
|
"github.com/boojack/slash/server/version"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Profile is the configuration to start main server.
|
// Profile is the configuration to start main server.
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
// Data is the data directory
|
|
||||||
Data string `json:"-"`
|
|
||||||
// DSN points to where Shortify stores its own data
|
|
||||||
DSN string `json:"-"`
|
|
||||||
// Mode can be "prod" or "dev"
|
// Mode can be "prod" or "dev"
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
// Port is the binding port for server
|
// Port is the binding port for server
|
||||||
Port int `json:"port"`
|
Port int `json:"-"`
|
||||||
|
// Data is the data directory
|
||||||
|
Data string `json:"-"`
|
||||||
|
// DSN points to where slash stores its own data
|
||||||
|
DSN string `json:"-"`
|
||||||
// Version is the current version of server
|
// Version is the current version of server
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Profile) IsDev() bool {
|
||||||
|
return p.Mode != "prod"
|
||||||
|
}
|
||||||
|
|
||||||
func checkDSN(dataDir string) (string, error) {
|
func checkDSN(dataDir string) (string, error) {
|
||||||
// Convert to absolute path if relative path is supplied.
|
// Convert to absolute path if relative path is supplied.
|
||||||
if !filepath.IsAbs(dataDir) {
|
if !filepath.IsAbs(dataDir) {
|
||||||
absDir, err := filepath.Abs(filepath.Dir(os.Args[0]) + "/" + dataDir)
|
relativeDir := filepath.Join(filepath.Dir(os.Args[0]), dataDir)
|
||||||
|
absDir, err := filepath.Abs(relativeDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
dataDir = absDir
|
dataDir = absDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim trailing / in case user supplies
|
// Trim trailing \ or / in case user supplies
|
||||||
dataDir = strings.TrimRight(dataDir, "/")
|
dataDir = strings.TrimRight(dataDir, "\\/")
|
||||||
|
|
||||||
if _, err := os.Stat(dataDir); err != nil {
|
if _, err := os.Stat(dataDir); err != nil {
|
||||||
return "", fmt.Errorf("unable to access data folder %s, err %w", dataDir, err)
|
return "", fmt.Errorf("unable to access data folder %s, err %w", dataDir, err)
|
||||||
@ -44,7 +50,7 @@ func checkDSN(dataDir string) (string, error) {
|
|||||||
return dataDir, nil
|
return dataDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDevProfile will return a profile for dev or prod.
|
// GetProfile will return a profile for dev or prod.
|
||||||
func GetProfile() (*Profile, error) {
|
func GetProfile() (*Profile, error) {
|
||||||
profile := Profile{}
|
profile := Profile{}
|
||||||
err := viper.Unmarshal(&profile)
|
err := viper.Unmarshal(&profile)
|
||||||
@ -52,12 +58,23 @@ func GetProfile() (*Profile, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if profile.Mode != "dev" && profile.Mode != "prod" {
|
if profile.Mode != "demo" && profile.Mode != "dev" && profile.Mode != "prod" {
|
||||||
profile.Mode = "dev"
|
profile.Mode = "demo"
|
||||||
}
|
}
|
||||||
|
|
||||||
if profile.Mode == "prod" && profile.Data == "" {
|
if profile.Mode == "prod" && profile.Data == "" {
|
||||||
profile.Data = "/var/opt/shortify"
|
if runtime.GOOS == "windows" {
|
||||||
|
profile.Data = filepath.Join(os.Getenv("ProgramData"), "slash")
|
||||||
|
|
||||||
|
if _, err := os.Stat(profile.Data); os.IsNotExist(err) {
|
||||||
|
if err := os.MkdirAll(profile.Data, 0770); err != nil {
|
||||||
|
fmt.Printf("Failed to create data directory: %s, err: %+v\n", profile.Data, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
profile.Data = "/var/opt/slash"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dataDir, err := checkDSN(profile.Data)
|
dataDir, err := checkDSN(profile.Data)
|
||||||
@ -67,7 +84,9 @@ func GetProfile() (*Profile, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
profile.Data = dataDir
|
profile.Data = dataDir
|
||||||
profile.DSN = fmt.Sprintf("%s/shortify_%s.db", dataDir, profile.Mode)
|
dbFile := fmt.Sprintf("slash_%s.db", profile.Mode)
|
||||||
|
profile.DSN = filepath.Join(dataDir, dbFile)
|
||||||
profile.Version = version.GetCurrentVersion(profile.Mode)
|
profile.Version = version.GetCurrentVersion(profile.Mode)
|
||||||
|
|
||||||
return &profile, nil
|
return &profile, nil
|
||||||
}
|
}
|
||||||
|
@ -3,13 +3,14 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
apiv1 "github.com/boojack/shortify/api/v1"
|
apiv1 "github.com/boojack/slash/api/v1"
|
||||||
"github.com/boojack/shortify/server/profile"
|
apiv2 "github.com/boojack/slash/api/v2"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
)
|
)
|
||||||
@ -19,6 +20,9 @@ type Server struct {
|
|||||||
|
|
||||||
Profile *profile.Profile
|
Profile *profile.Profile
|
||||||
Store *store.Store
|
Store *store.Store
|
||||||
|
|
||||||
|
// API services.
|
||||||
|
apiV2Service *apiv2.APIV2Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store) (*Server, error) {
|
func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store) (*Server, error) {
|
||||||
@ -52,7 +56,7 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
|||||||
embedFrontend(e)
|
embedFrontend(e)
|
||||||
|
|
||||||
// In dev mode, we'd like to set the const secret key to make signin session persistence.
|
// In dev mode, we'd like to set the const secret key to make signin session persistence.
|
||||||
secret := "shortify"
|
secret := "slash"
|
||||||
if profile.Mode == "prod" {
|
if profile.Mode == "prod" {
|
||||||
var err error
|
var err error
|
||||||
secret, err = s.getSystemSecretSessionName(ctx)
|
secret, err = s.getSystemSecretSessionName(ctx)
|
||||||
@ -61,15 +65,32 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apiGroup := e.Group("")
|
rootGroup := e.Group("")
|
||||||
// Register API v1 routes.
|
// Register API v1 routes.
|
||||||
apiV1Service := apiv1.NewAPIV1Service(profile, store)
|
apiV1Service := apiv1.NewAPIV1Service(profile, store)
|
||||||
apiV1Service.Start(apiGroup, secret)
|
apiV1Service.Start(rootGroup, secret)
|
||||||
|
|
||||||
|
s.apiV2Service = apiv2.NewAPIV2Service(secret, profile, store, s.Profile.Port+1)
|
||||||
|
// Register gRPC gateway as api v2.
|
||||||
|
if err := s.apiV2Service.RegisterGateway(ctx, e); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to register gRPC gateway: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Start(_ context.Context) error {
|
func (s *Server) Start(_ context.Context) error {
|
||||||
|
// Start gRPC server.
|
||||||
|
listen, err := net.Listen("tcp", fmt.Sprintf(":%d", s.Profile.Port+1))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := s.apiV2Service.GetGRPCServer().Serve(listen); err != nil {
|
||||||
|
println("grpc server listen error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return s.e.Start(fmt.Sprintf(":%d", s.Profile.Port))
|
return s.e.Start(fmt.Sprintf(":%d", s.Profile.Port))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,13 +9,13 @@ import (
|
|||||||
|
|
||||||
// Version is the service current released version.
|
// Version is the service current released version.
|
||||||
// Semantic versioning: https://semver.org/
|
// Semantic versioning: https://semver.org/
|
||||||
var Version = "0.1.0"
|
var Version = "0.4.0"
|
||||||
|
|
||||||
// DevVersion is the service current development version.
|
// DevVersion is the service current development version.
|
||||||
var DevVersion = "0.1.0"
|
var DevVersion = "0.4.0"
|
||||||
|
|
||||||
func GetCurrentVersion(mode string) string {
|
func GetCurrentVersion(mode string) string {
|
||||||
if mode == "dev" {
|
if mode == "dev" || mode == "demo" {
|
||||||
return DevVersion
|
return DevVersion
|
||||||
}
|
}
|
||||||
return Version
|
return Version
|
||||||
|
@ -2,7 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -49,8 +48,8 @@ func (l ActivityLevel) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Activity struct {
|
type Activity struct {
|
||||||
ID int
|
ID int32
|
||||||
CreatorID int
|
CreatorID int32
|
||||||
CreatedTs int64
|
CreatedTs int64
|
||||||
Type ActivityType
|
Type ActivityType
|
||||||
Level ActivityLevel
|
Level ActivityLevel
|
||||||
@ -64,13 +63,7 @@ type FindActivity struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
|
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO activity (
|
INSERT INTO activity (
|
||||||
creator_id,
|
creator_id,
|
||||||
type,
|
type,
|
||||||
@ -80,7 +73,7 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
|||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
RETURNING id, created_ts
|
RETURNING id, created_ts
|
||||||
`
|
`
|
||||||
if err := tx.QueryRowContext(ctx, query,
|
if err := s.db.QueryRowContext(ctx, stmt,
|
||||||
create.CreatorID,
|
create.CreatorID,
|
||||||
create.Type.String(),
|
create.Type.String(),
|
||||||
create.Level.String(),
|
create.Level.String(),
|
||||||
@ -92,50 +85,11 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
activity := create
|
activity := create
|
||||||
return activity, nil
|
return activity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error) {
|
func (s *Store) ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listActivities(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetActivity(ctx context.Context, find *FindActivity) (*Activity, error) {
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listActivities(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
activity := list[0]
|
|
||||||
return activity, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Activity, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
if find.Type != "" {
|
if find.Type != "" {
|
||||||
where, args = append(where, "type = ?"), append(args, find.Type.String())
|
where, args = append(where, "type = ?"), append(args, find.Type.String())
|
||||||
@ -157,11 +111,10 @@ func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Act
|
|||||||
payload
|
payload
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
WHERE ` + strings.Join(where, " AND ")
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
list := []*Activity{}
|
list := []*Activity{}
|
||||||
@ -187,3 +140,17 @@ func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Act
|
|||||||
|
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetActivity(ctx context.Context, find *FindActivity) (*Activity, error) {
|
||||||
|
list, err := s.ListActivities(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
activity := list[0]
|
||||||
|
return activity, nil
|
||||||
|
}
|
||||||
|
@ -2,6 +2,6 @@ package store
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
func getUserSettingCacheKey(userID int, key string) string {
|
func getUserSettingCacheKey(userID int32, key string) string {
|
||||||
return fmt.Sprintf("%d-%s", userID, key)
|
return fmt.Sprintf("%d-%s", userID, key)
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
)
|
||||||
|
|
||||||
// RowStatus is the status for a row.
|
// RowStatus is the status for a row.
|
||||||
type RowStatus string
|
type RowStatus string
|
||||||
|
|
||||||
@ -19,3 +23,13 @@ func (e RowStatus) String() string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func convertRowStatusStringToStorepb(status string) storepb.RowStatus {
|
||||||
|
switch status {
|
||||||
|
case "NORMAL":
|
||||||
|
return storepb.RowStatus_NORMAL
|
||||||
|
case "ARCHIVED":
|
||||||
|
return storepb.RowStatus_ARCHIVED
|
||||||
|
}
|
||||||
|
return storepb.RowStatus_ROW_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
@ -12,20 +12,23 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/server/version"
|
"github.com/boojack/slash/server/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migration
|
//go:embed migration
|
||||||
var migrationFS embed.FS
|
var migrationFS embed.FS
|
||||||
|
|
||||||
|
//go:embed seed
|
||||||
|
var seedFS embed.FS
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
profile *profile.Profile
|
|
||||||
// sqlite db connection instance
|
// sqlite db connection instance
|
||||||
DBInstance *sql.DB
|
DBInstance *sql.DB
|
||||||
|
profile *profile.Profile
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDB returns a new instance of DB.
|
// NewDB returns a new instance of DB associated with the given datasource name.
|
||||||
func NewDB(profile *profile.Profile) *DB {
|
func NewDB(profile *profile.Profile) *DB {
|
||||||
db := &DB{
|
db := &DB{
|
||||||
profile: profile,
|
profile: profile,
|
||||||
@ -39,8 +42,21 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
|||||||
return fmt.Errorf("dsn required")
|
return fmt.Errorf("dsn required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to the database without foreign_key.
|
// Connect to the database with some sane settings:
|
||||||
sqliteDB, err := sql.Open("sqlite", db.profile.DSN+"?cache=shared&_foreign_keys=0&_journal_mode=WAL")
|
// - No shared-cache: it's obsolete; WAL journal mode is a better solution.
|
||||||
|
// - No foreign key constraints: it's currently disabled by default, but it's a
|
||||||
|
// good practice to be explicit and prevent future surprises on SQLite upgrades.
|
||||||
|
// - Journal mode set to WAL: it's the recommended journal mode for most applications
|
||||||
|
// as it prevents locking issues.
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - When using the `modernc.org/sqlite` driver, each pragma must be prefixed with `_pragma=`.
|
||||||
|
//
|
||||||
|
// References:
|
||||||
|
// - https://pkg.go.dev/modernc.org/sqlite#Driver.Open
|
||||||
|
// - https://www.sqlite.org/sharedcache.html
|
||||||
|
// - https://www.sqlite.org/pragma.html
|
||||||
|
sqliteDB, err := sql.Open("sqlite", db.profile.DSN+"?_pragma=foreign_keys(0)&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
|
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
|
||||||
}
|
}
|
||||||
@ -49,16 +65,16 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
|||||||
if db.profile.Mode == "prod" {
|
if db.profile.Mode == "prod" {
|
||||||
_, err := os.Stat(db.profile.DSN)
|
_, err := os.Stat(db.profile.DSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If db file not exists, we should apply the latest schema.
|
// If db file not exists, we should create a new one with latest schema.
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
if err := db.applyLatestSchema(ctx); err != nil {
|
if err := db.applyLatestSchema(ctx); err != nil {
|
||||||
return fmt.Errorf("failed to apply latest schema: %w", err)
|
return fmt.Errorf("failed to apply latest schema, err: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("failed to check database file: %w", err)
|
return fmt.Errorf("failed to get db file stat, err: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If db file exists, we should check the migration history and apply the migration if needed.
|
// If db file exists, we should check if we need to migrate the database.
|
||||||
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
||||||
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -89,7 +105,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read raw database file, err: %w", err)
|
return fmt.Errorf("failed to read raw database file, err: %w", err)
|
||||||
}
|
}
|
||||||
backupDBFilePath := fmt.Sprintf("%s/shortify_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
|
backupDBFilePath := fmt.Sprintf("%s/slash_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
|
||||||
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
|
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write raw database file, err: %w", err)
|
return fmt.Errorf("failed to write raw database file, err: %w", err)
|
||||||
}
|
}
|
||||||
@ -119,6 +135,12 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
|||||||
if err := db.applyLatestSchema(ctx); err != nil {
|
if err := db.applyLatestSchema(ctx); err != nil {
|
||||||
return fmt.Errorf("failed to apply latest schema: %w", err)
|
return fmt.Errorf("failed to apply latest schema: %w", err)
|
||||||
}
|
}
|
||||||
|
// In demo mode, we should seed the database.
|
||||||
|
if db.profile.Mode == "demo" {
|
||||||
|
if err := db.seed(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to seed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,36 +190,46 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := db.DBInstance.Begin()
|
// Upsert the newest version to migration_history.
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// upsert the newest version to migration_history
|
|
||||||
version := minorVersion + ".0"
|
version := minorVersion + ".0"
|
||||||
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
|
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||||
Version: version,
|
Version: version,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("failed to upsert migration history with version: %s, err: %w", version, err)
|
return fmt.Errorf("failed to upsert migration history with version: %s, err: %w", version, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) seed(ctx context.Context) error {
|
||||||
|
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read seed files, err: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(filenames)
|
||||||
|
|
||||||
|
// Loop over all seed files and execute them in order.
|
||||||
|
for _, filename := range filenames {
|
||||||
|
buf, err := seedFS.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read seed file, filename=%s err=%w", filename, err)
|
||||||
|
}
|
||||||
|
stmt := string(buf)
|
||||||
|
if err := db.execute(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("seed error: statement:%s err=%w", stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// execute runs a single SQL statement within a transaction.
|
// execute runs a single SQL statement within a transaction.
|
||||||
func (db *DB) execute(ctx context.Context, stmt string) error {
|
func (db *DB) execute(ctx context.Context, stmt string) error {
|
||||||
tx, err := db.DBInstance.Begin()
|
if _, err := db.DBInstance.ExecContext(ctx, stmt); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
|
||||||
return fmt.Errorf("failed to execute statement, err: %w", err)
|
return fmt.Errorf("failed to execute statement, err: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// minorDirRegexp is a regular expression for minor version directory.
|
// minorDirRegexp is a regular expression for minor version directory.
|
||||||
|
@ -22,6 +22,8 @@ CREATE TABLE user (
|
|||||||
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
|
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_email ON user(email);
|
||||||
|
|
||||||
-- user_setting
|
-- user_setting
|
||||||
CREATE TABLE user_setting (
|
CREATE TABLE user_setting (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
@ -39,11 +41,15 @@ CREATE TABLE shortcut (
|
|||||||
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
|
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
link TEXT NOT NULL,
|
link TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
||||||
tag TEXT NOT NULL DEFAULT ''
|
tag TEXT NOT NULL DEFAULT '',
|
||||||
|
og_metadata TEXT NOT NULL DEFAULT '{}'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shortcut_name ON shortcut(name);
|
||||||
|
|
||||||
-- activity
|
-- activity
|
||||||
CREATE TABLE activity (
|
CREATE TABLE activity (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
2
store/db/migration/prod/0.2/00__create_index.sql
Normal file
2
store/db/migration/prod/0.2/00__create_index.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS idx_user_email ON user(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shortcut_name ON shortcut(name);
|
1
store/db/migration/prod/0.3/00__add_og_metadata.sql
Normal file
1
store/db/migration/prod/0.3/00__add_og_metadata.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shortcut ADD COLUMN og_metadata TEXT NOT NULL DEFAULT '{}';
|
1
store/db/migration/prod/0.4/00__add_shortcut_title.sql
Normal file
1
store/db/migration/prod/0.4/00__add_shortcut_title.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shortcut ADD COLUMN title TEXT NOT NULL DEFAULT '';
|
@ -22,6 +22,8 @@ CREATE TABLE user (
|
|||||||
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
|
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_email ON user(email);
|
||||||
|
|
||||||
-- user_setting
|
-- user_setting
|
||||||
CREATE TABLE user_setting (
|
CREATE TABLE user_setting (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
@ -39,11 +41,15 @@ CREATE TABLE shortcut (
|
|||||||
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
|
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
link TEXT NOT NULL,
|
link TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
||||||
tag TEXT NOT NULL DEFAULT ''
|
tag TEXT NOT NULL DEFAULT '',
|
||||||
|
og_metadata TEXT NOT NULL DEFAULT '{}'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shortcut_name ON shortcut(name);
|
||||||
|
|
||||||
-- activity
|
-- activity
|
||||||
CREATE TABLE activity (
|
CREATE TABLE activity (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
@ -2,7 +2,6 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -20,47 +19,13 @@ type MigrationHistoryFind struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) FindMigrationHistoryList(ctx context.Context, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
func (db *DB) FindMigrationHistoryList(ctx context.Context, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
||||||
tx, err := db.DBInstance.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := findMigrationHistoryList(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
|
|
||||||
tx, err := db.DBInstance.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
migrationHistory, err := upsertMigrationHistory(ctx, tx, upsert)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return migrationHistory, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.Version; v != nil {
|
if v := find.Version; v != nil {
|
||||||
where, args = append(where, "version = ?"), append(args, *v)
|
where, args = append(where, "version = ?"), append(args, *v)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
SELECT
|
SELECT
|
||||||
version,
|
version,
|
||||||
created_ts
|
created_ts
|
||||||
@ -69,7 +34,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
|||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
ORDER BY created_ts DESC
|
ORDER BY created_ts DESC
|
||||||
`
|
`
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := db.DBInstance.QueryContext(ctx, stmt, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -84,7 +49,6 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
|
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +59,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
|||||||
return migrationHistoryList, nil
|
return migrationHistoryList, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
|
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO migration_history (
|
INSERT INTO migration_history (
|
||||||
version
|
version
|
||||||
@ -107,7 +71,7 @@ func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHi
|
|||||||
RETURNING version, created_ts
|
RETURNING version, created_ts
|
||||||
`
|
`
|
||||||
migrationHistory := &MigrationHistory{}
|
migrationHistory := &MigrationHistory{}
|
||||||
if err := tx.QueryRowContext(ctx, query, upsert.Version).Scan(
|
if err := db.DBInstance.QueryRowContext(ctx, query, upsert.Version).Scan(
|
||||||
&migrationHistory.Version,
|
&migrationHistory.Version,
|
||||||
&migrationHistory.CreatedTs,
|
&migrationHistory.CreatedTs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
9
store/db/seed/10000__reset.sql
Normal file
9
store/db/seed/10000__reset.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
DELETE FROM activity;
|
||||||
|
|
||||||
|
DELETE FROM shortcut;
|
||||||
|
|
||||||
|
DELETE FROM user_setting;
|
||||||
|
|
||||||
|
DELETE FROM user;
|
||||||
|
|
||||||
|
DELETE FROM workspace_setting;
|
34
store/db/seed/10001__user.sql
Normal file
34
store/db/seed/10001__user.sql
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
INSERT INTO
|
||||||
|
user (
|
||||||
|
`id`,
|
||||||
|
`role`,
|
||||||
|
`email`,
|
||||||
|
`nickname`,
|
||||||
|
`password_hash`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
101,
|
||||||
|
'ADMIN',
|
||||||
|
'slash@yourselfhosted.com',
|
||||||
|
'Slasher',
|
||||||
|
'$2a$10$H8HBWGcG/hoePhFy5SiNKOHxMD6omIpyEEWbl/fIorFC814bXW.Ua'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
user (
|
||||||
|
`id`,
|
||||||
|
`role`,
|
||||||
|
`email`,
|
||||||
|
`nickname`,
|
||||||
|
`password_hash`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
102,
|
||||||
|
'USER',
|
||||||
|
'steven@usememos.com',
|
||||||
|
'Steven',
|
||||||
|
-- raw password: secret
|
||||||
|
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
|
||||||
|
);
|
94
store/db/seed/10002__shortcut.sql
Normal file
94
store/db/seed/10002__shortcut.sql
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`visibility`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
101,
|
||||||
|
'discord',
|
||||||
|
'https://discord.gg/QZqUuUAhDV',
|
||||||
|
'PUBLIC'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`visibility`,
|
||||||
|
`tag`,
|
||||||
|
`og_metadata`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
101,
|
||||||
|
'ai-infra',
|
||||||
|
'https://star-history.com/blog/open-source-ai-infra-projects',
|
||||||
|
'PUBLIC',
|
||||||
|
'star-history ai',
|
||||||
|
'{"title":"Open Source AI Infra for Your Next Project","description":"Some open-source infra projects that can be directly used for your next project. 💡","image":"https://star-history.com/blog/assets/open-source-ai-infra-projects/banner.webp"}'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`visibility`,
|
||||||
|
`tag`,
|
||||||
|
`og_metadata`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
3,
|
||||||
|
101,
|
||||||
|
'schema-change',
|
||||||
|
'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change',
|
||||||
|
'PUBLIC',
|
||||||
|
'database article👍',
|
||||||
|
'{"title":"How to Handle Database Migration / Schema Change?","description":"A database schema is the structure of a database, which describes the relationships between the different tables and fields in the database. A database schema change, also known as schema migration, or simply migration refers to any alteration to this structure, such as adding a new table, modifying the data type of a field, or changing the relationships between tables.","image":"https://www.bytebase.com/_next/image/?url=%2Fcontent%2Fblog%2Fhow-to-handle-database-schema-change%2Fchange.webp\u0026w=2048\u0026q=75"}'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`tag`,
|
||||||
|
`visibility`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
101,
|
||||||
|
'sqlchat',
|
||||||
|
'https://www.sqlchat.ai',
|
||||||
|
'ai chatbot sql',
|
||||||
|
'WORKSPACE'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`visibility`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
5,
|
||||||
|
102,
|
||||||
|
'stevenlgtm',
|
||||||
|
'https://github.com/boojack',
|
||||||
|
'PUBLIC'
|
||||||
|
);
|
@ -3,8 +3,12 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Visibility is the type of a visibility.
|
// Visibility is the type of a visibility.
|
||||||
@ -31,37 +35,28 @@ func (e Visibility) String() string {
|
|||||||
return "PRIVATE"
|
return "PRIVATE"
|
||||||
}
|
}
|
||||||
|
|
||||||
type Shortcut struct {
|
type OpenGraphMetadata struct {
|
||||||
ID int
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
// Standard fields
|
Image string `json:"image"`
|
||||||
CreatorID int
|
|
||||||
CreatedTs int64
|
|
||||||
UpdatedTs int64
|
|
||||||
RowStatus RowStatus
|
|
||||||
|
|
||||||
// Domain specific fields
|
|
||||||
Name string
|
|
||||||
Link string
|
|
||||||
Description string
|
|
||||||
Visibility Visibility
|
|
||||||
Tag string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateShortcut struct {
|
type UpdateShortcut struct {
|
||||||
ID int
|
ID int32
|
||||||
|
|
||||||
RowStatus *RowStatus
|
RowStatus *RowStatus
|
||||||
Name *string
|
Name *string
|
||||||
Link *string
|
Link *string
|
||||||
|
Title *string
|
||||||
Description *string
|
Description *string
|
||||||
Visibility *Visibility
|
Visibility *Visibility
|
||||||
Tag *string
|
Tag *string
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
type FindShortcut struct {
|
type FindShortcut struct {
|
||||||
ID *int
|
ID *int32
|
||||||
CreatorID *int
|
CreatorID *int32
|
||||||
RowStatus *RowStatus
|
RowStatus *RowStatus
|
||||||
Name *string
|
Name *string
|
||||||
VisibilityList []Visibility
|
VisibilityList []Visibility
|
||||||
@ -69,50 +64,46 @@ type FindShortcut struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DeleteShortcut struct {
|
type DeleteShortcut struct {
|
||||||
ID int
|
ID int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut, error) {
|
func (s *Store) CreateShortcut(ctx context.Context, create *storepb.Shortcut) (*storepb.Shortcut, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
set := []string{"creator_id", "name", "link", "title", "description", "visibility", "tag"}
|
||||||
|
args := []any{create.CreatorId, create.Name, create.Link, create.Title, create.Description, create.Visibility.String(), strings.Join(create.Tags, " ")}
|
||||||
|
placeholder := []string{"?", "?", "?", "?", "?", "?", "?"}
|
||||||
|
if create.OgMetadata != nil {
|
||||||
|
set = append(set, "og_metadata")
|
||||||
|
openGraphMetadataBytes, err := protojson.Marshal(create.OgMetadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
args = append(args, string(openGraphMetadataBytes))
|
||||||
|
placeholder = append(placeholder, "?")
|
||||||
|
}
|
||||||
|
|
||||||
set := []string{"creator_id", "name", "link", "description", "visibility", "tag"}
|
stmt := `
|
||||||
args := []any{create.CreatorID, create.Name, create.Link, create.Description, create.Visibility, create.Tag}
|
|
||||||
placeholder := []string{"?", "?", "?", "?", "?", "?"}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO shortcut (
|
INSERT INTO shortcut (
|
||||||
` + strings.Join(set, ", ") + `
|
` + strings.Join(set, ", ") + `
|
||||||
)
|
)
|
||||||
VALUES (` + strings.Join(placeholder, ",") + `)
|
VALUES (` + strings.Join(placeholder, ",") + `)
|
||||||
RETURNING id, created_ts, updated_ts, row_status
|
RETURNING id, created_ts, updated_ts, row_status
|
||||||
`
|
`
|
||||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
var rowStatus string
|
||||||
&create.ID,
|
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||||
|
&create.Id,
|
||||||
&create.CreatedTs,
|
&create.CreatedTs,
|
||||||
&create.UpdatedTs,
|
&create.UpdatedTs,
|
||||||
&create.RowStatus,
|
&rowStatus,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
create.RowStatus = convertRowStatusStringToStorepb(rowStatus)
|
||||||
if err := tx.Commit(); err != nil {
|
shortcut := create
|
||||||
return nil, err
|
s.shortcutCache.Store(shortcut.Id, shortcut)
|
||||||
|
return shortcut, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return create, nil
|
func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*storepb.Shortcut, error) {
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Shortcut, error) {
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
set, args := []string{}, []any{}
|
set, args := []string{}, []any{}
|
||||||
if update.RowStatus != nil {
|
if update.RowStatus != nil {
|
||||||
set, args = append(set, "row_status = ?"), append(args, update.RowStatus.String())
|
set, args = append(set, "row_status = ?"), append(args, update.RowStatus.String())
|
||||||
@ -123,6 +114,9 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
|
|||||||
if update.Link != nil {
|
if update.Link != nil {
|
||||||
set, args = append(set, "link = ?"), append(args, *update.Link)
|
set, args = append(set, "link = ?"), append(args, *update.Link)
|
||||||
}
|
}
|
||||||
|
if update.Title != nil {
|
||||||
|
set, args = append(set, "title = ?"), append(args, *update.Title)
|
||||||
|
}
|
||||||
if update.Description != nil {
|
if update.Description != nil {
|
||||||
set, args = append(set, "description = ?"), append(args, *update.Description)
|
set, args = append(set, "description = ?"), append(args, *update.Description)
|
||||||
}
|
}
|
||||||
@ -132,111 +126,58 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
|
|||||||
if update.Tag != nil {
|
if update.Tag != nil {
|
||||||
set, args = append(set, "tag = ?"), append(args, *update.Tag)
|
set, args = append(set, "tag = ?"), append(args, *update.Tag)
|
||||||
}
|
}
|
||||||
|
if update.OpenGraphMetadata != nil {
|
||||||
|
openGraphMetadataBytes, err := json.Marshal(update.OpenGraphMetadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set, args = append(set, "og_metadata = ?"), append(args, string(openGraphMetadataBytes))
|
||||||
|
}
|
||||||
if len(set) == 0 {
|
if len(set) == 0 {
|
||||||
return nil, fmt.Errorf("no update specified")
|
return nil, fmt.Errorf("no update specified")
|
||||||
}
|
}
|
||||||
args = append(args, update.ID)
|
args = append(args, update.ID)
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
UPDATE shortcut
|
UPDATE shortcut
|
||||||
SET
|
SET
|
||||||
` + strings.Join(set, ", ") + `
|
` + strings.Join(set, ", ") + `
|
||||||
WHERE
|
WHERE
|
||||||
id = ?
|
id = ?
|
||||||
RETURNING id, creator_id, created_ts, updated_ts, row_status, name, link, description, visibility, tag
|
RETURNING id, creator_id, created_ts, updated_ts, row_status, name, link, title, description, visibility, tag, og_metadata
|
||||||
`
|
`
|
||||||
shortcut := &Shortcut{}
|
shortcut := &storepb.Shortcut{}
|
||||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
var rowStatus, visibility, tags, openGraphMetadataString string
|
||||||
&shortcut.ID,
|
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||||
&shortcut.CreatorID,
|
&shortcut.Id,
|
||||||
|
&shortcut.CreatorId,
|
||||||
&shortcut.CreatedTs,
|
&shortcut.CreatedTs,
|
||||||
&shortcut.UpdatedTs,
|
&shortcut.UpdatedTs,
|
||||||
&shortcut.RowStatus,
|
&rowStatus,
|
||||||
&shortcut.Name,
|
&shortcut.Name,
|
||||||
&shortcut.Link,
|
&shortcut.Link,
|
||||||
|
&shortcut.Title,
|
||||||
&shortcut.Description,
|
&shortcut.Description,
|
||||||
&shortcut.Visibility,
|
&visibility,
|
||||||
&shortcut.Tag,
|
&tags,
|
||||||
|
&openGraphMetadataString,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
shortcut.RowStatus = convertRowStatusStringToStorepb(rowStatus)
|
||||||
if err := tx.Commit(); err != nil {
|
shortcut.Visibility = convertVisibilityStringToStorepb(visibility)
|
||||||
|
shortcut.Tags = filterTags(strings.Split(tags, " "))
|
||||||
|
var ogMetadata storepb.OpenGraphMetadata
|
||||||
|
if err := protojson.Unmarshal([]byte(openGraphMetadataString), &ogMetadata); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
shortcut.OgMetadata = &ogMetadata
|
||||||
s.shortcutCache.Store(shortcut.ID, shortcut)
|
s.shortcutCache.Store(shortcut.Id, shortcut)
|
||||||
return shortcut, nil
|
return shortcut, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListShortcuts(ctx context.Context, find *FindShortcut) ([]*Shortcut, error) {
|
func (s *Store) ListShortcuts(ctx context.Context, find *FindShortcut) ([]*storepb.Shortcut, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listShortcuts(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, shortcut := range list {
|
|
||||||
s.shortcutCache.Store(shortcut.ID, shortcut)
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetShortcut(ctx context.Context, find *FindShortcut) (*Shortcut, error) {
|
|
||||||
if find.ID != nil {
|
|
||||||
if cache, ok := s.shortcutCache.Load(*find.ID); ok {
|
|
||||||
return cache.(*Shortcut), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
shortcuts, err := listShortcuts(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(shortcuts) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut := shortcuts[0]
|
|
||||||
s.shortcutCache.Store(shortcut.ID, shortcut)
|
|
||||||
return shortcut, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeleteShortcut(ctx context.Context, delete *DeleteShortcut) error {
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM shortcut WHERE id = ?`, delete.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
// do nothing here to prevent linter warning.
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.shortcutCache.Delete(delete.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shortcut, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.ID; v != nil {
|
if v := find.ID; v != nil {
|
||||||
where, args = append(where, "id = ?"), append(args, *v)
|
where, args = append(where, "id = ?"), append(args, *v)
|
||||||
}
|
}
|
||||||
@ -261,7 +202,7 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
where, args = append(where, "tag LIKE ?"), append(args, "%"+*v+"%")
|
where, args = append(where, "tag LIKE ?"), append(args, "%"+*v+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := tx.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
creator_id,
|
creator_id,
|
||||||
@ -270,9 +211,11 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
row_status,
|
row_status,
|
||||||
name,
|
name,
|
||||||
link,
|
link,
|
||||||
|
title,
|
||||||
description,
|
description,
|
||||||
visibility,
|
visibility,
|
||||||
tag
|
tag,
|
||||||
|
og_metadata
|
||||||
FROM shortcut
|
FROM shortcut
|
||||||
WHERE `+strings.Join(where, " AND ")+`
|
WHERE `+strings.Join(where, " AND ")+`
|
||||||
ORDER BY created_ts DESC`,
|
ORDER BY created_ts DESC`,
|
||||||
@ -283,29 +226,106 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
list := make([]*Shortcut, 0)
|
list := make([]*storepb.Shortcut, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
shortcut := &Shortcut{}
|
shortcut := &storepb.Shortcut{}
|
||||||
|
var rowStatus, visibility, tags, openGraphMetadataString string
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&shortcut.ID,
|
&shortcut.Id,
|
||||||
&shortcut.CreatorID,
|
&shortcut.CreatorId,
|
||||||
&shortcut.CreatedTs,
|
&shortcut.CreatedTs,
|
||||||
&shortcut.UpdatedTs,
|
&shortcut.UpdatedTs,
|
||||||
&shortcut.RowStatus,
|
&rowStatus,
|
||||||
&shortcut.Name,
|
&shortcut.Name,
|
||||||
&shortcut.Link,
|
&shortcut.Link,
|
||||||
|
&shortcut.Title,
|
||||||
&shortcut.Description,
|
&shortcut.Description,
|
||||||
&shortcut.Visibility,
|
&visibility,
|
||||||
&shortcut.Tag,
|
&tags,
|
||||||
|
&openGraphMetadataString,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
shortcut.RowStatus = convertRowStatusStringToStorepb(rowStatus)
|
||||||
|
shortcut.Visibility = storepb.Visibility(storepb.Visibility_value[visibility])
|
||||||
|
shortcut.Tags = filterTags(strings.Split(tags, " "))
|
||||||
|
var ogMetadata storepb.OpenGraphMetadata
|
||||||
|
if err := protojson.Unmarshal([]byte(openGraphMetadataString), &ogMetadata); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
shortcut.OgMetadata = &ogMetadata
|
||||||
list = append(list, shortcut)
|
list = append(list, shortcut)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
for _, shortcut := range list {
|
||||||
|
s.shortcutCache.Store(shortcut.Id, shortcut)
|
||||||
|
}
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetShortcut(ctx context.Context, find *FindShortcut) (*storepb.Shortcut, error) {
|
||||||
|
if find.ID != nil {
|
||||||
|
if cache, ok := s.shortcutCache.Load(*find.ID); ok {
|
||||||
|
return cache.(*storepb.Shortcut), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcuts, err := s.ListShortcuts(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(shortcuts) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut := shortcuts[0]
|
||||||
|
s.shortcutCache.Store(shortcut.Id, shortcut)
|
||||||
|
return shortcut, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteShortcut(ctx context.Context, delete *DeleteShortcut) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx, `DELETE FROM shortcut WHERE id = ?`, delete.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.shortcutCache.Delete(delete.ID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vacuumShortcut(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
stmt := `
|
||||||
|
DELETE FROM
|
||||||
|
shortcut
|
||||||
|
WHERE
|
||||||
|
creator_id NOT IN (
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
user
|
||||||
|
)`
|
||||||
|
_, err := tx.ExecContext(ctx, stmt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterTags(tags []string) []string {
|
||||||
|
result := []string{}
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag != "" {
|
||||||
|
result = append(result, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertVisibilityStringToStorepb(visibility string) storepb.Visibility {
|
||||||
|
return storepb.Visibility(storepb.Visibility_value[visibility])
|
||||||
|
}
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store provides database access to all raw objects.
|
// Store provides database access to all raw objects.
|
||||||
|
159
store/user.go
159
store/user.go
@ -2,7 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -18,7 +17,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int
|
ID int32
|
||||||
|
|
||||||
// Standard fields
|
// Standard fields
|
||||||
CreatedTs int64
|
CreatedTs int64
|
||||||
@ -33,7 +32,7 @@ type User struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUser struct {
|
type UpdateUser struct {
|
||||||
ID int
|
ID int32
|
||||||
|
|
||||||
RowStatus *RowStatus
|
RowStatus *RowStatus
|
||||||
Email *string
|
Email *string
|
||||||
@ -43,7 +42,7 @@ type UpdateUser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FindUser struct {
|
type FindUser struct {
|
||||||
ID *int
|
ID *int32
|
||||||
RowStatus *RowStatus
|
RowStatus *RowStatus
|
||||||
Email *string
|
Email *string
|
||||||
Nickname *string
|
Nickname *string
|
||||||
@ -51,17 +50,11 @@ type FindUser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DeleteUser struct {
|
type DeleteUser struct {
|
||||||
ID int
|
ID int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO user (
|
INSERT INTO user (
|
||||||
email,
|
email,
|
||||||
nickname,
|
nickname,
|
||||||
@ -71,7 +64,7 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
|||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
RETURNING id, created_ts, updated_ts, row_status
|
RETURNING id, created_ts, updated_ts, row_status
|
||||||
`
|
`
|
||||||
if err := tx.QueryRowContext(ctx, query,
|
if err := s.db.QueryRowContext(ctx, stmt,
|
||||||
create.Email,
|
create.Email,
|
||||||
create.Nickname,
|
create.Nickname,
|
||||||
create.PasswordHash,
|
create.PasswordHash,
|
||||||
@ -85,22 +78,12 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user := create
|
user := create
|
||||||
s.userCache.Store(user.ID, user)
|
s.userCache.Store(user.ID, user)
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, error) {
|
func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
set, args := []string{}, []any{}
|
set, args := []string{}, []any{}
|
||||||
if v := update.RowStatus; v != nil {
|
if v := update.RowStatus; v != nil {
|
||||||
set, args = append(set, "row_status = ?"), append(args, *v)
|
set, args = append(set, "row_status = ?"), append(args, *v)
|
||||||
@ -122,7 +105,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
|||||||
return nil, fmt.Errorf("no fields to update")
|
return nil, fmt.Errorf("no fields to update")
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
UPDATE user
|
UPDATE user
|
||||||
SET ` + strings.Join(set, ", ") + `
|
SET ` + strings.Join(set, ", ") + `
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@ -130,7 +113,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
|||||||
`
|
`
|
||||||
args = append(args, update.ID)
|
args = append(args, update.ID)
|
||||||
user := &User{}
|
user := &User{}
|
||||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||||
&user.ID,
|
&user.ID,
|
||||||
&user.CreatedTs,
|
&user.CreatedTs,
|
||||||
&user.UpdatedTs,
|
&user.UpdatedTs,
|
||||||
@ -143,82 +126,11 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.userCache.Store(user.ID, user)
|
s.userCache.Store(user.ID, user)
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error) {
|
func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listUsers(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, user := range list {
|
|
||||||
s.userCache.Store(user.ID, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
|
|
||||||
if find.ID != nil {
|
|
||||||
if cache, ok := s.userCache.Load(*find.ID); ok {
|
|
||||||
return cache.(*User), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listUsers(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return list[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
|
||||||
DELETE FROM user WHERE id = ?
|
|
||||||
`, delete.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
// do nothing here to prevent linter warning.
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.userCache.Delete(delete.ID)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.ID; v != nil {
|
if v := find.ID; v != nil {
|
||||||
@ -251,7 +163,7 @@ func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, error)
|
|||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
ORDER BY updated_ts DESC, created_ts DESC
|
ORDER BY updated_ts DESC, created_ts DESC
|
||||||
`
|
`
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -279,5 +191,58 @@ func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, user := range list {
|
||||||
|
s.userCache.Store(user.ID, user)
|
||||||
|
}
|
||||||
|
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
|
||||||
|
if find.ID != nil {
|
||||||
|
if cache, ok := s.userCache.Load(*find.ID); ok {
|
||||||
|
return cache.(*User), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListUsers(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return list[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
DELETE FROM user WHERE id = ?
|
||||||
|
`, delete.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := vacuumUserSetting(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := vacuumShortcut(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.userCache.Delete(delete.ID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -7,24 +7,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserSetting struct {
|
type UserSetting struct {
|
||||||
UserID int
|
UserID int32
|
||||||
Key string
|
Key string
|
||||||
Value string
|
Value string
|
||||||
}
|
}
|
||||||
|
|
||||||
type FindUserSetting struct {
|
type FindUserSetting struct {
|
||||||
UserID *int
|
UserID *int32
|
||||||
Key string
|
Key string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*UserSetting, error) {
|
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*UserSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO user_setting (
|
INSERT INTO user_setting (
|
||||||
user_id, key, value
|
user_id, key, value
|
||||||
)
|
)
|
||||||
@ -32,11 +26,7 @@ func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*Us
|
|||||||
ON CONFLICT(user_id, key) DO UPDATE
|
ON CONFLICT(user_id, key) DO UPDATE
|
||||||
SET value = EXCLUDED.value
|
SET value = EXCLUDED.value
|
||||||
`
|
`
|
||||||
if _, err := tx.ExecContext(ctx, query, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
if _, err := s.db.ExecContext(ctx, stmt, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,51 +36,6 @@ func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListUserSettings(ctx context.Context, find *FindUserSetting) ([]*UserSetting, error) {
|
func (s *Store) ListUserSettings(ctx context.Context, find *FindUserSetting) ([]*UserSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
userSettingList, err := listUserSettings(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, userSetting := range userSettingList {
|
|
||||||
s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserID, userSetting.Key), userSetting)
|
|
||||||
}
|
|
||||||
return userSettingList, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetUserSetting(ctx context.Context, find *FindUserSetting) (*UserSetting, error) {
|
|
||||||
if find.UserID != nil && find.Key != "" {
|
|
||||||
if cache, ok := s.userSettingCache.Load(getUserSettingCacheKey(*find.UserID, find.Key)); ok {
|
|
||||||
return cache.(*UserSetting), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listUserSettings(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
userSettingMessage := list[0]
|
|
||||||
s.userSettingCache.Store(getUserSettingCacheKey(userSettingMessage.UserID, userSettingMessage.Key), userSettingMessage)
|
|
||||||
return userSettingMessage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([]*UserSetting, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.Key; v != "" {
|
if v := find.Key; v != "" {
|
||||||
@ -107,28 +52,71 @@ func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([
|
|||||||
value
|
value
|
||||||
FROM user_setting
|
FROM user_setting
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
WHERE ` + strings.Join(where, " AND ")
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
userSettingMessageList := make([]*UserSetting, 0)
|
userSettingList := make([]*UserSetting, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
userSettingMessage := &UserSetting{}
|
userSetting := &UserSetting{}
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&userSettingMessage.UserID,
|
&userSetting.UserID,
|
||||||
&userSettingMessage.Key,
|
&userSetting.Key,
|
||||||
&userSettingMessage.Value,
|
&userSetting.Value,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
userSettingMessageList = append(userSettingMessageList, userSettingMessage)
|
userSettingList = append(userSettingList, userSetting)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return userSettingMessageList, nil
|
for _, userSetting := range userSettingList {
|
||||||
|
s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserID, userSetting.Key), userSetting)
|
||||||
|
}
|
||||||
|
return userSettingList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUserSetting(ctx context.Context, find *FindUserSetting) (*UserSetting, error) {
|
||||||
|
if find.UserID != nil && find.Key != "" {
|
||||||
|
if cache, ok := s.userSettingCache.Load(getUserSettingCacheKey(*find.UserID, find.Key)); ok {
|
||||||
|
return cache.(*UserSetting), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListUserSettings(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userSettingMessage := list[0]
|
||||||
|
s.userSettingCache.Store(getUserSettingCacheKey(userSettingMessage.UserID, userSettingMessage.Key), userSettingMessage)
|
||||||
|
return userSettingMessage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vacuumUserSetting(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
stmt := `
|
||||||
|
DELETE FROM
|
||||||
|
user_setting
|
||||||
|
WHERE
|
||||||
|
user_id NOT IN (
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
user
|
||||||
|
)`
|
||||||
|
_, err := tx.ExecContext(ctx, stmt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -17,13 +16,7 @@ const (
|
|||||||
|
|
||||||
// String returns the string format of WorkspaceSettingKey type.
|
// String returns the string format of WorkspaceSettingKey type.
|
||||||
func (key WorkspaceSettingKey) String() string {
|
func (key WorkspaceSettingKey) String() string {
|
||||||
switch key {
|
return string(key)
|
||||||
case WorkspaceDisallowSignUp:
|
|
||||||
return "disallow-signup"
|
|
||||||
case WorkspaceSecretSessionName:
|
|
||||||
return "secret-session-name"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceSetting struct {
|
type WorkspaceSetting struct {
|
||||||
@ -36,13 +29,7 @@ type FindWorkspaceSetting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSetting) (*WorkspaceSetting, error) {
|
func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSetting) (*WorkspaceSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO workspace_setting (
|
INSERT INTO workspace_setting (
|
||||||
key,
|
key,
|
||||||
value
|
value
|
||||||
@ -51,11 +38,7 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSet
|
|||||||
ON CONFLICT(key) DO UPDATE
|
ON CONFLICT(key) DO UPDATE
|
||||||
SET value = EXCLUDED.value
|
SET value = EXCLUDED.value
|
||||||
`
|
`
|
||||||
if _, err := tx.ExecContext(ctx, query, upsert.Key, upsert.Value); err != nil {
|
if _, err := s.db.ExecContext(ctx, stmt, upsert.Key, upsert.Value); err != nil {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,53 +48,8 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSet
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListWorkspaceSettings(ctx context.Context, find *FindWorkspaceSetting) ([]*WorkspaceSetting, error) {
|
func (s *Store) ListWorkspaceSettings(ctx context.Context, find *FindWorkspaceSetting) ([]*WorkspaceSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listWorkspaceSettings(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, workspaceSetting := range list {
|
|
||||||
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetWorkspaceSetting(ctx context.Context, find *FindWorkspaceSetting) (*WorkspaceSetting, error) {
|
|
||||||
if find.Key != "" {
|
|
||||||
if cache, ok := s.workspaceSettingCache.Load(find.Key); ok {
|
|
||||||
return cache.(*WorkspaceSetting), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listWorkspaceSettings(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceSetting := list[0]
|
|
||||||
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
|
|
||||||
return workspaceSetting, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceSetting) ([]*WorkspaceSetting, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if find.Key != "" {
|
if find.Key != "" {
|
||||||
where, args = append(where, "key = ?"), append(args, find.Key)
|
where, args = append(where, "key = ?"), append(args, find.Key)
|
||||||
}
|
}
|
||||||
@ -122,7 +60,7 @@ func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceS
|
|||||||
value
|
value
|
||||||
FROM workspace_setting
|
FROM workspace_setting
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
WHERE ` + strings.Join(where, " AND ")
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -146,5 +84,30 @@ func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceS
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, workspaceSetting := range list {
|
||||||
|
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
|
||||||
|
}
|
||||||
|
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetWorkspaceSetting(ctx context.Context, find *FindWorkspaceSetting) (*WorkspaceSetting, error) {
|
||||||
|
if find.Key != "" {
|
||||||
|
if cache, ok := s.workspaceSettingCache.Load(find.Key); ok {
|
||||||
|
return cache.(*WorkspaceSetting), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListWorkspaceSettings(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceSetting := list[0]
|
||||||
|
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
|
||||||
|
return workspaceSetting, nil
|
||||||
|
}
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -4,7 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -13,13 +14,14 @@ func TestShortcutStore(t *testing.T) {
|
|||||||
ts := NewTestingStore(ctx, t)
|
ts := NewTestingStore(ctx, t)
|
||||||
user, err := createTestingAdminUser(ctx, ts)
|
user, err := createTestingAdminUser(ctx, ts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
shortcut, err := ts.CreateShortcut(ctx, &store.Shortcut{
|
shortcut, err := ts.CreateShortcut(ctx, &storepb.Shortcut{
|
||||||
CreatorID: user.ID,
|
CreatorId: user.ID,
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Link: "https://test.link",
|
Link: "https://test.link",
|
||||||
Description: "A test shortcut",
|
Description: "A test shortcut",
|
||||||
Visibility: store.VisibilityPrivate,
|
Visibility: storepb.Visibility_PRIVATE,
|
||||||
Tag: "test link",
|
Tags: []string{"test", "shortcut"},
|
||||||
|
OgMetadata: &storepb.OpenGraphMetadata{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{
|
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{
|
||||||
@ -30,7 +32,7 @@ func TestShortcutStore(t *testing.T) {
|
|||||||
require.Equal(t, shortcut, shortcuts[0])
|
require.Equal(t, shortcut, shortcuts[0])
|
||||||
newLink := "https://new.link"
|
newLink := "https://new.link"
|
||||||
updatedShortcut, err := ts.UpdateShortcut(ctx, &store.UpdateShortcut{
|
updatedShortcut, err := ts.UpdateShortcut(ctx, &store.UpdateShortcut{
|
||||||
ID: shortcut.ID,
|
ID: shortcut.Id,
|
||||||
Link: &newLink,
|
Link: &newLink,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@ -40,9 +42,8 @@ func TestShortcutStore(t *testing.T) {
|
|||||||
Tag: &tag,
|
Tag: &tag,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, updatedShortcut, shortcut)
|
|
||||||
err = ts.DeleteShortcut(ctx, &store.DeleteShortcut{
|
err = ts.DeleteShortcut(ctx, &store.DeleteShortcut{
|
||||||
ID: shortcut.ID,
|
ID: shortcut.Id,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
shortcuts, err = ts.ListShortcuts(ctx, &store.FindShortcut{
|
shortcuts, err = ts.ListShortcuts(ctx, &store.FindShortcut{
|
||||||
|
@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/boojack/shortify/store/db"
|
"github.com/boojack/slash/store/db"
|
||||||
test "github.com/boojack/shortify/test"
|
test "github.com/boojack/slash/test"
|
||||||
|
|
||||||
// sqlite driver.
|
// sqlite driver.
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@ -26,6 +26,13 @@ func TestUserStore(t *testing.T) {
|
|||||||
Nickname: &userPatchNickname,
|
Nickname: &userPatchNickname,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
_, err = ts.CreateShortcut(ctx, &storepb.Shortcut{
|
||||||
|
CreatorId: user.ID,
|
||||||
|
Name: "test_shortcut",
|
||||||
|
Link: "https://www.google.com",
|
||||||
|
Visibility: storepb.Visibility_PUBLIC,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
require.Equal(t, userPatchNickname, user.Nickname)
|
require.Equal(t, userPatchNickname, user.Nickname)
|
||||||
err = ts.DeleteUser(ctx, &store.DeleteUser{
|
err = ts.DeleteUser(ctx, &store.DeleteUser{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
@ -34,6 +41,9 @@ func TestUserStore(t *testing.T) {
|
|||||||
users, err = ts.ListUsers(ctx, &store.FindUser{})
|
users, err = ts.ListUsers(ctx, &store.FindUser{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 0, len(users))
|
require.Equal(t, 0, len(users))
|
||||||
|
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, len(shortcuts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// createTestingAdminUser creates a testing admin user.
|
// createTestingAdminUser creates a testing admin user.
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
@ -5,8 +5,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/server/version"
|
"github.com/boojack/slash/server/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getUnusedPort() int {
|
func getUnusedPort() int {
|
||||||
@ -31,7 +31,7 @@ func GetTestingProfile(t *testing.T) *profile.Profile {
|
|||||||
Mode: mode,
|
Mode: mode,
|
||||||
Port: port,
|
Port: port,
|
||||||
Data: dir,
|
Data: dir,
|
||||||
DSN: fmt.Sprintf("%s/shortify_%s.db", dir, mode),
|
DSN: fmt.Sprintf("%s/slash_%s.db", dir, mode),
|
||||||
Version: version.GetCurrentVersion(mode),
|
Version: version.GetCurrentVersion(mode),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,5 +2,7 @@
|
|||||||
"printWidth": 140,
|
"printWidth": 140,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": false
|
"singleQuote": false,
|
||||||
|
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||||
|
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]", ".less$"]
|
||||||
}
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
# Shortify
|
# Slash
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" href="/logo.png" type="image/*" />
|
<link rel="icon" href="/logo.png" type="image/*" />
|
||||||
<meta name="theme-color" content="#FFFFFF" />
|
<meta name="theme-color" content="#FFFFFF" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||||
<title>Shortify</title>
|
<title>Slash</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
@ -1,47 +1,51 @@
|
|||||||
{
|
{
|
||||||
"name": "shortify",
|
"name": "slash",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"lint": "eslint --ext .js,.ts,.tsx, src"
|
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||||
|
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/joy": "5.0.0-alpha.84",
|
"@mui/joy": "5.0.0-beta.0",
|
||||||
"@reduxjs/toolkit": "^1.8.1",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
"copy-to-clipboard": "^3.3.2",
|
"copy-to-clipboard": "^3.3.2",
|
||||||
"dayjs": "^1.11.3",
|
"dayjs": "^1.11.3",
|
||||||
"i18next": "^23.2.3",
|
"i18next": "^23.2.3",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.252.0",
|
"lucide-react": "^0.263.1",
|
||||||
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.4.0",
|
"react-hot-toast": "^2.4.0",
|
||||||
"react-i18next": "^13.0.1",
|
"react-i18next": "^13.0.1",
|
||||||
"react-redux": "^8.0.1",
|
"react-redux": "^8.0.2",
|
||||||
"react-router-dom": "^6.13.0",
|
"react-router-dom": "^6.13.0",
|
||||||
"tailwindcss": "^3.3.2",
|
"tailwindcss": "^3.3.2",
|
||||||
"zustand": "^4.3.8"
|
"zustand": "^4.3.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@ianvs/prettier-plugin-sort-imports": "^4.1.0",
|
||||||
"@types/lodash-es": "^4.17.5",
|
"@types/lodash-es": "^4.17.5",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.18",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||||
"@typescript-eslint/parser": "^5.6.0",
|
"@typescript-eslint/parser": "^6.2.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
"eslint": "^8.4.1",
|
"eslint": "^8.46.0",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.3.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
"eslint-plugin-react": "^7.27.1",
|
"eslint-plugin-react": "^7.27.1",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"prettier": "2.5.1",
|
"prettier": "2.5.1",
|
||||||
"typescript": "^5.0.4",
|
"typescript": "^5.0.4",
|
||||||
"vite": "^4.0.0"
|
"vite": "^4.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1395
web/pnpm-lock.yaml
generated
1395
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,40 @@
|
|||||||
import { CssVarsProvider } from "@mui/joy/styles";
|
import { useEffect, useState } from "react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Outlet } from "react-router-dom";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import DemoBanner from "./components/DemoBanner";
|
||||||
import router from "./routers";
|
import { globalService } from "./services";
|
||||||
|
import useUserStore from "./stores/v1/user";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
const userStore = useUserStore();
|
||||||
<CssVarsProvider>
|
const [loading, setLoading] = useState(true);
|
||||||
<RouterProvider router={router} />
|
|
||||||
<Toaster position="top-center" />
|
useEffect(() => {
|
||||||
</CssVarsProvider>
|
const initialState = async () => {
|
||||||
|
try {
|
||||||
|
await globalService.initialState();
|
||||||
|
} catch (error) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userStore.fetchCurrentUser();
|
||||||
|
} catch (error) {
|
||||||
|
// do nothing.
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initialState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !loading ? (
|
||||||
|
<>
|
||||||
|
<DemoBanner />
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,12 +19,11 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-w-full w-80 sm:w-96">
|
<div className="max-w-full w-80 sm:w-96">
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Shortify</span> is a bookmarking and short link service that allows you to save and share links
|
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform.
|
||||||
easily.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span className="mr-2">See more in:</span>
|
<span className="mr-2">See more in</span>
|
||||||
<Link variant="plain" href="https://github.com/boojack/shortify">
|
<Link variant="plain" href="https://github.com/boojack/slash" target="_blank">
|
||||||
GitHub
|
GitHub
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
30
web/src/components/AnalyticsDialog.tsx
Normal file
30
web/src/components/AnalyticsDialog.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Button, Modal, ModalDialog } from "@mui/joy";
|
||||||
|
import AnalyticsView from "./AnalyticsView";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcutId: ShortcutId;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { shortcutId, onClose } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="text-lg font-medium">Analytics</span>
|
||||||
|
<Button variant="plain" onClick={onClose}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-full w-80 sm:w-96">
|
||||||
|
<AnalyticsView className="grid grid-cols-1 gap-2" shortcutId={shortcutId} />
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnalyticsDialog;
|
127
web/src/components/AnalyticsView.tsx
Normal file
127
web/src/components/AnalyticsView.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import * as api from "../helpers/api";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcutId: ShortcutId;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||||
|
const { shortcutId, className } = props;
|
||||||
|
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||||
|
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
||||||
|
setAnalytics(data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames("w-full", className)}>
|
||||||
|
{analytics ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="w-full h-8 px-2">Top Sources</p>
|
||||||
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||||
|
<div className="w-full divide-y divide-gray-300">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="py-2 px-2 text-left font-semibold text-sm text-gray-500">Source</span>
|
||||||
|
<span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">Visitors</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full divide-y divide-gray-200">
|
||||||
|
{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">
|
||||||
|
{reference.name ? (
|
||||||
|
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
||||||
|
{reference.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"Direct"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="w-full h-8 px-2 flex flex-row justify-between items-center">
|
||||||
|
<span>Devices</span>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
|
selectedDeviceTab === "browser"
|
||||||
|
? "border-blue-600 text-blue-600"
|
||||||
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedDeviceTab("browser")}
|
||||||
|
>
|
||||||
|
Browser
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-200 font-mono mx-1">/</span>
|
||||||
|
<button
|
||||||
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
|
selectedDeviceTab === "os"
|
||||||
|
? "border-blue-600 text-blue-600"
|
||||||
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedDeviceTab("os")}
|
||||||
|
>
|
||||||
|
OS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||||
|
{selectedDeviceTab === "browser" ? (
|
||||||
|
<div className="w-full divide-y divide-gray-300">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">Browsers</span>
|
||||||
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">Visitors</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full divide-y divide-gray-200">
|
||||||
|
{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">{reference.name || "Unknown"}</span>
|
||||||
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full divide-y divide-gray-300">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">Operating system</span>
|
||||||
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">Visitors</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full divide-y divide-gray-200">
|
||||||
|
{analytics.deviceData.map((device) => (
|
||||||
|
<div key={device.name} className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span>
|
||||||
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
||||||
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
|
loading
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnalyticsView;
|
@ -2,7 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { userService } from "../services";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -11,6 +11,7 @@ interface Props {
|
|||||||
|
|
||||||
const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
|
const userStore = useUserStore();
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
@ -43,9 +44,8 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
requestState.setLoading();
|
requestState.setLoading();
|
||||||
try {
|
try {
|
||||||
const user = userService.getState().user as User;
|
userStore.patchUser({
|
||||||
await userService.patchUser({
|
id: userStore.getCurrentUser().id,
|
||||||
id: user.id,
|
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
||||||
|
import classnames from "classnames";
|
||||||
|
import { isUndefined } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { shortcutService } from "../services";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { showCommonDialog } from "./Alert";
|
import { shortcutService } from "../services";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -26,29 +27,39 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
shortcutCreate: {
|
shortcutCreate: {
|
||||||
name: "",
|
name: "",
|
||||||
link: "",
|
link: "",
|
||||||
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
visibility: "PRIVATE",
|
visibility: "PRIVATE",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
openGraphMetadata: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
image: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
|
||||||
|
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||||
const [tag, setTag] = useState<string>("");
|
const [tag, setTag] = useState<string>("");
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
const isEditing = !!shortcutId;
|
const isCreating = isUndefined(shortcutId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shortcutId) {
|
if (shortcutId) {
|
||||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
const shortcut = shortcutService.getShortcutById(shortcutId);
|
||||||
if (shortcutTemp) {
|
if (shortcut) {
|
||||||
setState({
|
setState({
|
||||||
...state,
|
...state,
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
name: shortcutTemp.name,
|
name: shortcut.name,
|
||||||
link: shortcutTemp.link,
|
link: shortcut.link,
|
||||||
description: shortcutTemp.description,
|
title: shortcut.title,
|
||||||
visibility: shortcutTemp.visibility,
|
description: shortcut.description,
|
||||||
|
visibility: shortcut.visibility,
|
||||||
|
openGraphMetadata: shortcut.openGraphMetadata,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setTag(shortcutTemp.tags.join(" "));
|
setTag(shortcut.tags.join(" "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [shortcutId]);
|
}, [shortcutId]);
|
||||||
@ -76,6 +87,22 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
title: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
visibility: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
@ -89,27 +116,36 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
setTag(text);
|
setTag(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
visibility: e.target.value,
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
|
image: e.target.value,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteShortcutButtonClick = () => {
|
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!shortcutId) {
|
setPartialState({
|
||||||
return;
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
}
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
showCommonDialog({
|
title: e.target.value,
|
||||||
title: "Delete Shortcut",
|
|
||||||
content: `Are you sure to delete shortcut \`${state.shortcutCreate.name}\`? You can not undo this action.`,
|
|
||||||
style: "danger",
|
|
||||||
onConfirm: async () => {
|
|
||||||
await shortcutService.deleteShortcutById(shortcutId);
|
|
||||||
onClose();
|
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
|
description: e.target.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -125,9 +161,11 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
id: shortcutId,
|
id: shortcutId,
|
||||||
name: state.shortcutCreate.name,
|
name: state.shortcutCreate.name,
|
||||||
link: state.shortcutCreate.link,
|
link: state.shortcutCreate.link,
|
||||||
|
title: state.shortcutCreate.title,
|
||||||
description: state.shortcutCreate.description,
|
description: state.shortcutCreate.description,
|
||||||
visibility: state.shortcutCreate.visibility,
|
visibility: state.shortcutCreate.visibility,
|
||||||
tags: tag.split(" "),
|
tags: tag.split(" "),
|
||||||
|
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await shortcutService.createShortcut({
|
await shortcutService.createShortcut({
|
||||||
@ -151,16 +189,14 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||||
<span className="text-lg font-medium">{isEditing ? "Edit Shortcut" : "Create Shortcut"}</span>
|
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
|
||||||
<Button variant="plain" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="overflow-y-auto overflow-x-hidden">
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">Name</span>
|
||||||
Name <span className="text-red-600">*</span>
|
|
||||||
</span>
|
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -172,35 +208,21 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">Destination URL</span>
|
||||||
Link <span className="text-red-600">*</span>
|
|
||||||
</span>
|
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="The full URL of the page you want to get to"
|
placeholder="https://github.com/boojack/slash"
|
||||||
value={state.shortcutCreate.link}
|
value={state.shortcutCreate.link}
|
||||||
onChange={handleLinkInputChange}
|
onChange={handleLinkInputChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="Something to describe the link"
|
|
||||||
value={state.shortcutCreate.description}
|
|
||||||
onChange={handleDescriptionInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Tags</span>
|
<span className="mb-2">Tags</span>
|
||||||
<Input className="w-full" type="text" placeholder="Separated by spaces" value={tag} onChange={handleTagsInputChange} />
|
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">Visibility</span>
|
||||||
Visibility <span className="text-red-600">*</span>
|
|
||||||
</span>
|
|
||||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
||||||
{visibilities.map((visibility) => (
|
{visibilities.map((visibility) => (
|
||||||
@ -208,16 +230,106 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 px-2 py-1 rounded-md">
|
||||||
|
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Divider className="text-gray-500">Optional</Divider>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3">
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100",
|
||||||
|
showAdditionalFields ? "bg-gray-100 border-b" : ""
|
||||||
|
)}
|
||||||
|
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-row justify-between items-center mt-8 space-x-2">
|
|
||||||
<div>
|
|
||||||
{isEditing && (
|
|
||||||
<Button color="danger" variant="plain" onClick={handleDeleteShortcutButtonClick}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-x-2">
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${
|
||||||
|
showOpenGraphMetadata ? "bg-gray-100 border-b" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
|
||||||
|
>
|
||||||
|
<span className="text-sm flex flex-row justify-start items-center">
|
||||||
|
Social media metadata
|
||||||
|
<Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" />
|
||||||
|
</span>
|
||||||
|
<button className="w-7 h-7 p-1 rounded-md">
|
||||||
|
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showOpenGraphMetadata && (
|
||||||
|
<div className="w-full px-2 py-1">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Image URL</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://the.link.to/the/image.png"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.openGraphMetadata.image}
|
||||||
|
onChange={handleOpenGraphMetadataImageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Title</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.openGraphMetadata.title}
|
||||||
|
onChange={handleOpenGraphMetadataTitleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Description</span>
|
||||||
|
<Textarea
|
||||||
|
className="w-full"
|
||||||
|
placeholder="An open source, self-hosted bookmarks and link sharing platform."
|
||||||
|
size="sm"
|
||||||
|
maxRows={3}
|
||||||
|
value={state.shortcutCreate.openGraphMetadata.description}
|
||||||
|
onChange={handleOpenGraphMetadataDescriptionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@ -226,7 +338,6 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
200
web/src/components/CreateUserDialog.tsx
Normal file
200
web/src/components/CreateUserDialog.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
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 useLoading from "../hooks/useLoading";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user?: User;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
userCreate: UserCreate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles: Role[] = ["USER", "ADMIN"];
|
||||||
|
|
||||||
|
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { onClose, onConfirm, user } = props;
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
userCreate: {
|
||||||
|
email: "",
|
||||||
|
nickname: "",
|
||||||
|
password: "",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const requestState = useLoading(false);
|
||||||
|
const isCreating = isUndefined(user);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname,
|
||||||
|
password: "",
|
||||||
|
role: user.role,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
email: e.target.value.toLowerCase(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNicknameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
nickname: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
password: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
role: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBtnClick = async () => {
|
||||||
|
if (isCreating && (!state.userCreate.email || !state.userCreate.nickname || !state.userCreate.password)) {
|
||||||
|
toast.error("Please fill all inputs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (user) {
|
||||||
|
const userPatch: UserPatch = {
|
||||||
|
id: user.id,
|
||||||
|
};
|
||||||
|
if (user.email !== state.userCreate.email) {
|
||||||
|
userPatch.email = state.userCreate.email;
|
||||||
|
}
|
||||||
|
if (user.nickname !== state.userCreate.nickname) {
|
||||||
|
userPatch.nickname = state.userCreate.nickname;
|
||||||
|
}
|
||||||
|
if (user.role !== state.userCreate.role) {
|
||||||
|
userPatch.role = state.userCreate.role;
|
||||||
|
}
|
||||||
|
await userStore.patchUser(userPatch);
|
||||||
|
} else {
|
||||||
|
await userStore.createUser(state.userCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.response.data.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||||
|
<span className="text-lg font-medium">{isCreating ? "Create User" : "Edit User"}</span>
|
||||||
|
<Button variant="plain" onClick={onClose}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Email <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="email"
|
||||||
|
placeholder="Unique user email"
|
||||||
|
value={state.userCreate.email}
|
||||||
|
onChange={handleEmailInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Nickname <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nickname"
|
||||||
|
value={state.userCreate.nickname}
|
||||||
|
onChange={handleNicknameInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isCreating && (
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Password <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="password"
|
||||||
|
placeholder=""
|
||||||
|
value={state.userCreate.password}
|
||||||
|
onChange={handlePasswordInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Role <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
|
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<Radio key={role} value={role} label={role} />
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateUserDialog;
|
31
web/src/components/DemoBanner.tsx
Normal file
31
web/src/components/DemoBanner.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { globalService } from "../services";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const DemoBanner: React.FC = () => {
|
||||||
|
const {
|
||||||
|
workspaceProfile: {
|
||||||
|
profile: { mode },
|
||||||
|
},
|
||||||
|
} = globalService.getState();
|
||||||
|
const shouldShow = mode === "demo";
|
||||||
|
|
||||||
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
|
||||||
|
<div className="w-full max-w-6xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
|
||||||
|
<span>✨Slash - An open source, self-hosted bookmarks and link sharing platform</span>
|
||||||
|
<a
|
||||||
|
className="shadow flex flex-row justify-center items-center px-2 py-1 rounded-md text-sm sm:text-base text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
href="https://github.com/boojack/slash#deploy-with-docker-in-seconds"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
<Icon.ExternalLink className="w-4 h-auto ml-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DemoBanner;
|
@ -2,8 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { userService } from "../services";
|
import useUserStore from "../stores/v1/user";
|
||||||
import { useAppSelector } from "../stores";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -12,9 +11,10 @@ interface Props {
|
|||||||
|
|
||||||
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
const user = useAppSelector((state) => state.user.user as User);
|
const userStore = useUserStore();
|
||||||
const [email, setEmail] = useState(user.email);
|
const currentUser = userStore.getCurrentUser();
|
||||||
const [nickname, setNickname] = useState(user.nickname);
|
const [email, setEmail] = useState(currentUser.email);
|
||||||
|
const [nickname, setNickname] = useState(currentUser.nickname);
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
|
|
||||||
const handleCloseBtnClick = () => {
|
const handleCloseBtnClick = () => {
|
||||||
@ -39,14 +39,13 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
requestState.setLoading();
|
requestState.setLoading();
|
||||||
try {
|
try {
|
||||||
const user = userService.getState().user as User;
|
await userStore.patchUser({
|
||||||
await userService.patchUser({
|
id: currentUser.id,
|
||||||
id: user.id,
|
|
||||||
email,
|
email,
|
||||||
nickname,
|
nickname,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
toast("Password changed");
|
toast("User information updated");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.response.data.message);
|
toast.error(error.response.data.message);
|
||||||
|
43
web/src/components/FilterView.tsx
Normal file
43
web/src/components/FilterView.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
|
||||||
|
const FilterView = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const filter = viewStore.filter;
|
||||||
|
const shouldShowFilters = filter.tag !== undefined || filter.visibility !== undefined;
|
||||||
|
|
||||||
|
if (!shouldShowFilters) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row justify-start items-center mb-4 pl-2">
|
||||||
|
<span className="text-gray-400">Filters:</span>
|
||||||
|
{filter.tag && (
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
|
||||||
|
onClick={() => viewStore.setFilter({ tag: undefined })}
|
||||||
|
>
|
||||||
|
<Icon.Tag className="w-4 h-auto mr-1" />
|
||||||
|
<span className="max-w-[8rem] truncate">#{filter.tag}</span>
|
||||||
|
<Icon.X className="w-4 h-auto ml-1" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filter.visibility && (
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
|
||||||
|
onClick={() => viewStore.setFilter({ visibility: undefined })}
|
||||||
|
>
|
||||||
|
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
|
||||||
|
{t(`shortcut.visibility.${filter.visibility.toLowerCase()}.self`)}
|
||||||
|
<Icon.X className="w-4 h-auto ml-1" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterView;
|
61
web/src/components/GenerateQRCodeDialog.tsx
Normal file
61
web/src/components/GenerateQRCodeDialog.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { Button, Modal, ModalDialog } from "@mui/joy";
|
||||||
|
import { QRCodeCanvas } from "qrcode.react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { shortcut, onClose } = props;
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
|
||||||
|
const handleCloseBtnClick = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadQRCodeClick = () => {
|
||||||
|
const canvas = containerRef.current?.querySelector("canvas");
|
||||||
|
if (!canvas) {
|
||||||
|
toast.error("Failed to get qr code canvas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = "filename.png";
|
||||||
|
link.href = canvas.toDataURL();
|
||||||
|
link.click();
|
||||||
|
handleCloseBtnClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="flex flex-row justify-between items-center w-64 mb-4">
|
||||||
|
<span className="text-lg font-medium">QR Code</span>
|
||||||
|
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
|
||||||
|
<QRCodeCanvas value={shortcutLink} size={128} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-center items-center px-4">
|
||||||
|
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
||||||
|
<Icon.Download className="w-4 h-auto mr-1" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenerateQRCodeDialog;
|
@ -1,28 +1,29 @@
|
|||||||
import { Avatar } from "@mui/joy";
|
import { Avatar } from "@mui/joy";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAppSelector } from "../stores";
|
import * as api from "../helpers/api";
|
||||||
import Icon from "./Icon";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Dropdown from "./common/Dropdown";
|
|
||||||
import AboutDialog from "./AboutDialog";
|
import AboutDialog from "./AboutDialog";
|
||||||
|
import Dropdown from "./common/Dropdown";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const user = useAppSelector((state) => state.user).user as User;
|
|
||||||
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleSignOutButtonClick = async () => {
|
const handleSignOutButtonClick = async () => {
|
||||||
navigate("/auth");
|
await api.signout();
|
||||||
|
window.location.href = "/auth";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full bg-amber-50">
|
<div className="w-full bg-gray-50 border-b border-b-gray-200">
|
||||||
<div className="w-full max-w-4xl mx-auto px-3 py-5 flex flex-row justify-between items-center">
|
<div className="w-full max-w-6xl mx-auto px-3 md:px-12 py-5 flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row justify-start items-center shrink mr-2">
|
<div className="flex flex-row justify-start items-center shrink mr-2">
|
||||||
<Link to="/" className="text-base font-mono font-medium cursor-pointer flex flex-row justify-start items-center">
|
<Link to="/" className="text-base font-mono font-medium cursor-pointer flex flex-row justify-start items-center">
|
||||||
<img src="/logo.png" className="w-8 h-auto mr-2" alt="" />
|
<img src="/logo.png" className="w-8 h-auto mr-2" alt="" />
|
||||||
Shortify
|
Slash
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
@ -30,7 +31,7 @@ const Header: React.FC = () => {
|
|||||||
trigger={
|
trigger={
|
||||||
<button className="flex flex-row justify-end items-center cursor-pointer">
|
<button className="flex flex-row justify-end items-center cursor-pointer">
|
||||||
<Avatar size="sm" variant="plain" />
|
<Avatar size="sm" variant="plain" />
|
||||||
<span>{user.nickname}</span>
|
<span>{currentUser.nickname}</span>
|
||||||
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600" />
|
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@ -39,18 +40,18 @@ const Header: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/setting"
|
to="/setting"
|
||||||
className="w-full flex flex-row justify-start items-center px-3 leading-10 text-left cursor-pointer rounded whitespace-nowrap hover:bg-gray-100"
|
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"
|
||||||
>
|
>
|
||||||
<Icon.Settings className="w-4 h-auto mr-2" /> Setting
|
<Icon.Settings className="w-4 h-auto mr-2" /> Setting
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="w-full flex flex-row justify-start items-center px-3 leading-10 text-left cursor-pointer rounded whitespace-nowrap hover:bg-gray-100"
|
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"
|
||||||
onClick={() => setShowAboutDialog(true)}
|
onClick={() => setShowAboutDialog(true)}
|
||||||
>
|
>
|
||||||
<Icon.Info className="w-4 h-auto mr-2" /> About
|
<Icon.Info className="w-4 h-auto mr-2" /> About
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full flex flex-row justify-start items-center px-3 leading-10 text-left cursor-pointer rounded whitespace-nowrap hover:bg-gray-100"
|
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"
|
||||||
onClick={() => handleSignOutButtonClick()}
|
onClick={() => handleSignOutButtonClick()}
|
||||||
>
|
>
|
||||||
<Icon.LogOut className="w-4 h-auto mr-2" /> Sign out
|
<Icon.LogOut className="w-4 h-auto mr-2" /> Sign out
|
||||||
|
62
web/src/components/Navigator.tsx
Normal file
62
web/src/components/Navigator.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { useAppSelector } from "../stores";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const Navigator = () => {
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
|
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
|
||||||
|
const currentTab = viewStore.filter.tab || `tab:all`;
|
||||||
|
const sortedTagMap = sortTags(tags);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
||||||
|
currentTab === "tab:all" ? "!bg-gray-600 text-white shadow" : ""
|
||||||
|
)}
|
||||||
|
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
|
||||||
|
>
|
||||||
|
<Icon.CircleSlash className="w-4 h-auto mr-1" />
|
||||||
|
<span className="font-normal">All</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
||||||
|
currentTab === "tab:mine" ? "!bg-gray-600 text-white shadow" : ""
|
||||||
|
)}
|
||||||
|
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
|
||||||
|
>
|
||||||
|
<Icon.User className="w-4 h-auto mr-1" />
|
||||||
|
<span className="font-normal">Mine</span>
|
||||||
|
</button>
|
||||||
|
{Array.from(sortedTagMap.keys()).map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
||||||
|
currentTab === `tag:${tag}` ? "!bg-gray-600 text-white shadow" : ""
|
||||||
|
)}
|
||||||
|
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
|
||||||
|
>
|
||||||
|
<Icon.Hash className="w-4 h-auto mr-0.5" />
|
||||||
|
<span className="max-w-[8rem] truncate font-normal">{tag}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortTags = (tags: string[]): Map<string, number> => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
const count = map.get(tag) || 0;
|
||||||
|
map.set(tag, count + 1);
|
||||||
|
}
|
||||||
|
const sortedMap = new Map([...map.entries()].sort((a, b) => b[1] - a[1]));
|
||||||
|
return sortedMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navigator;
|
89
web/src/components/ShortcutActionsDropdown.tsx
Normal file
89
web/src/components/ShortcutActionsDropdown.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { shortcutService } from "../services";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
import { showCommonDialog } from "./Alert";
|
||||||
|
import AnalyticsDialog from "./AnalyticsDialog";
|
||||||
|
import Dropdown from "./common/Dropdown";
|
||||||
|
import CreateShortcutDialog from "./CreateShortcutDialog";
|
||||||
|
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutActionsDropdown = (props: Props) => {
|
||||||
|
const { shortcut } = props;
|
||||||
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
|
||||||
|
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||||
|
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
|
||||||
|
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||||
|
|
||||||
|
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
||||||
|
showCommonDialog({
|
||||||
|
title: "Delete Shortcut",
|
||||||
|
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
|
||||||
|
style: "danger",
|
||||||
|
onConfirm: async () => {
|
||||||
|
await shortcutService.deleteShortcutById(shortcut.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
actionsClassName="!w-32"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{havePermission && (
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
onClick={() => setShowEditDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.Edit className="w-4 h-auto mr-2" /> Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
onClick={() => setShowQRCodeDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.QrCode className="w-4 h-auto mr-2" /> QR Code
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
onClick={() => setShowAnalyticsDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.BarChart2 className="w-4 h-auto mr-2" /> Analytics
|
||||||
|
</button>
|
||||||
|
{havePermission && (
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteShortcutButtonClick(shortcut);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon.Trash className="w-4 h-auto mr-2" /> Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
></Dropdown>
|
||||||
|
|
||||||
|
{showEditDialog && (
|
||||||
|
<CreateShortcutDialog
|
||||||
|
shortcutId={shortcut.id}
|
||||||
|
onClose={() => setShowEditDialog(false)}
|
||||||
|
onConfirm={() => setShowEditDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||||
|
|
||||||
|
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutActionsDropdown;
|
143
web/src/components/ShortcutCard.tsx
Normal file
143
web/src/components/ShortcutCard.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { Tooltip } from "@mui/joy";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
|
import useFaviconStore from "../stores/v1/favicon";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import AnalyticsDialog from "./AnalyticsDialog";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||||
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutView = (props: Props) => {
|
||||||
|
const { shortcut } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const faviconStore = useFaviconStore();
|
||||||
|
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||||
|
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
|
||||||
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
||||||
|
if (url) {
|
||||||
|
setFavicon(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [shortcut.link]);
|
||||||
|
|
||||||
|
const handleCopyButtonClick = () => {
|
||||||
|
copy(shortcutLink);
|
||||||
|
toast.success("Shortcut link copied to clipboard.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classNames("group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow")}>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
|
||||||
|
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-8 h-8 flex justify-center items-center overflow-clip shrink-0")}>
|
||||||
|
{favicon ? (
|
||||||
|
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<div className="ml-1 w-[calc(100%-24px)] flex flex-col justify-start items-start">
|
||||||
|
<div className="w-full flex flex-row justify-start items-center">
|
||||||
|
<a
|
||||||
|
className={classNames(
|
||||||
|
"max-w-[calc(100%-24px) flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow"
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
href={shortcutLink}
|
||||||
|
>
|
||||||
|
<div className="truncate">
|
||||||
|
<span>{shortcut.title}</span>
|
||||||
|
{shortcut.title ? (
|
||||||
|
<span className="text-gray-400">(s/{shortcut.name})</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-400">s/</span>
|
||||||
|
<span className="truncate">{shortcut.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
||||||
|
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||||
|
<button
|
||||||
|
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow"
|
||||||
|
onClick={() => handleCopyButtonClick()}
|
||||||
|
>
|
||||||
|
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<a className="ml-1 w-full text-sm truncate text-gray-400 hover:underline" href={shortcut.link} target="_blank">
|
||||||
|
{shortcut.link}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-full pt-2 flex flex-row justify-end items-start">
|
||||||
|
<ShortcutActionsDropdown shortcut={shortcut} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 w-full flex flex-row justify-start items-start gap-2 truncate">
|
||||||
|
{shortcut.tags.map((tag) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="max-w-[8rem] truncate text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600"
|
||||||
|
onClick={() => viewStore.setFilter({ tag: tag })}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm font-mono leading-4 italic">No tags</span>}
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex mt-2 gap-2">
|
||||||
|
<Tooltip title="Creator" variant="solid" placement="top" arrow>
|
||||||
|
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
||||||
|
<Icon.User className="w-4 h-auto mr-1" />
|
||||||
|
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
||||||
|
<div
|
||||||
|
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm"
|
||||||
|
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
|
||||||
|
>
|
||||||
|
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
|
||||||
|
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
||||||
|
<div
|
||||||
|
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm"
|
||||||
|
onClick={() => setShowAnalyticsDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.BarChart2 className="w-4 h-auto mr-1" />
|
||||||
|
{shortcut.view} visits
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutView;
|
@ -1,32 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import CreateShortcutDialog from "./CreateShortcutDialog";
|
|
||||||
import ShortcutView from "./ShortcutView";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
shortcutList: Shortcut[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShortcutListView: React.FC<Props> = (props: Props) => {
|
|
||||||
const { shortcutList } = props;
|
|
||||||
const [editingShortcutId, setEditingShortcutId] = useState<ShortcutId | undefined>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
|
||||||
{shortcutList.map((shortcut) => {
|
|
||||||
return <ShortcutView key={shortcut.id} shortcut={shortcut} handleEdit={() => setEditingShortcutId(shortcut.id)} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editingShortcutId && (
|
|
||||||
<CreateShortcutDialog
|
|
||||||
shortcutId={editingShortcutId}
|
|
||||||
onClose={() => setEditingShortcutId(undefined)}
|
|
||||||
onConfirm={() => setEditingShortcutId(undefined)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortcutListView;
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user