mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-06 21:22:36 +00:00
Compare commits
88 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 |
@ -28,43 +28,6 @@ jobs:
|
||||
args: -v
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
@ -24,7 +24,7 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: stevenlgtm
|
||||
username: yourselfhosted
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@ -41,4 +41,4 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: stevenlgtm/slash:latest, stevenlgtm/slash:${{ env.VERSION }}
|
||||
tags: yourselfhosted/slash:latest, yourselfhosted/slash:${{ env.VERSION }}
|
||||
|
@ -16,7 +16,7 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: stevenlgtm
|
||||
username: yourselfhosted
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@ -34,4 +34,4 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: stevenlgtm/slash: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
|
20
README.md
20
README.md
@ -2,21 +2,27 @@
|
||||
|
||||
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
||||
|
||||
**Slash** 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.
|
||||
**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.
|
||||
|
||||
Try it out on <a href="https://slash.stevenlgtm.com">Live Demo</a>.
|
||||
<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 others.
|
||||
- View analytics on short link traffic and sources.
|
||||
- Share short links privately or with teammates.
|
||||
- View analytics on link traffic and sources.
|
||||
- Open source self-hosted solution.
|
||||
|
||||
## Deploy with Docker in seconds
|
||||
|
||||
> This project is under active development.
|
||||
|
||||
```bash
|
||||
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash stevenlgtm/slash:latest
|
||||
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
||||
```
|
||||
|
||||
Learn more in [Self-hosting Slash with Docker](https://github.com/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
|
||||
|
||||
type ActivityShorcutCreatePayload struct {
|
||||
ShortcutID int `json:"shortcutId"`
|
||||
ShortcutID int32 `json:"shortcutId"`
|
||||
}
|
||||
|
||||
type ActivityShorcutViewPayload struct {
|
||||
ShortcutID int `json:"shortcutId"`
|
||||
ShortcutID int32 `json:"shortcutId"`
|
||||
IP string `json:"ip"`
|
||||
Referer string `json:"referer"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
|
@ -5,9 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/boojack/slash/api/v1/auth"
|
||||
"github.com/boojack/slash/store"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"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")
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -105,7 +103,7 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
||||
})
|
||||
|
||||
g.POST("/auth/logout", func(c echo.Context) error {
|
||||
auth.RemoveTokensAndCookies(c)
|
||||
RemoveTokensAndCookies(c)
|
||||
c.Response().WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
})
|
||||
|
@ -1,131 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
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"
|
||||
// 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 = "slash.access-token"
|
||||
// RefreshTokenCookieName is the cookie name of refresh token.
|
||||
RefreshTokenCookieName = "slash.refresh-token"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
164
api/v1/jwt.go
164
api/v1/jwt.go
@ -3,11 +3,10 @@ package v1
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boojack/slash/api/v1/auth"
|
||||
"github.com/boojack/slash/api/auth"
|
||||
"github.com/boojack/slash/internal/util"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
@ -16,23 +15,81 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Context section
|
||||
// The key name used to store user id in the context
|
||||
// user id is extracted from the jwt token subject field.
|
||||
userIDContextKey = "user-id"
|
||||
UserIDContextKey = "user-id"
|
||||
)
|
||||
|
||||
func getUserIDContextKey() string {
|
||||
return userIDContextKey
|
||||
}
|
||||
|
||||
// Claims creates a struct that will be encoded to a JWT.
|
||||
// We add jwt.RegisteredClaims as an embedded type, to provide fields such as name.
|
||||
type Claims struct {
|
||||
type claimsMessage struct {
|
||||
Name string `json:"name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateAccessToken generates an access token for web.
|
||||
func GenerateAccessToken(username string, userID int32, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(auth.AccessTokenDuration)
|
||||
return generateToken(username, userID, auth.AccessTokenAudienceName, expirationTime, []byte(secret))
|
||||
}
|
||||
|
||||
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
|
||||
func GenerateTokensAndSetCookies(c echo.Context, user *store.User, secret string) error {
|
||||
accessToken, err := GenerateAccessToken(user.Email, user.ID, secret)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to generate access token")
|
||||
}
|
||||
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTokensAndCookies removes the jwt token and refresh token from the cookies.
|
||||
func RemoveTokensAndCookies(c echo.Context) {
|
||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
||||
}
|
||||
|
||||
// setTokenCookie sets the token to the cookie.
|
||||
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
||||
cookie := new(http.Cookie)
|
||||
cookie.Name = name
|
||||
cookie.Value = token
|
||||
cookie.Expires = expiration
|
||||
cookie.Path = "/"
|
||||
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
||||
cookie.HttpOnly = true
|
||||
cookie.SameSite = http.SameSiteStrictMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
||||
// generateToken generates a jwt token.
|
||||
func generateToken(username string, userID int32, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
||||
// Create the JWT claims, which includes the username and expiry time.
|
||||
claims := &claimsMessage{
|
||||
Name: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{aud},
|
||||
// In JWT, the expiry time is expressed as unix milliseconds.
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: auth.Issuer,
|
||||
Subject: fmt.Sprint(userID),
|
||||
},
|
||||
}
|
||||
|
||||
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
token.Header["kid"] = auth.KeyID
|
||||
|
||||
// Create the JWT string.
|
||||
tokenString, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
@ -74,7 +131,8 @@ func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
||||
// will try to generate new access token and refresh token.
|
||||
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
path := c.Path()
|
||||
ctx := c.Request().Context()
|
||||
path := c.Request().URL.Path
|
||||
method := c.Request().Method
|
||||
|
||||
// Pass auth and profile endpoints.
|
||||
@ -91,8 +149,8 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
accessToken, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
claims := &claimsMessage{}
|
||||
_, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
@ -103,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"])
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
RemoveTokensAndCookies(c)
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
||||
}
|
||||
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
|
||||
}
|
||||
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
|
||||
ctx := c.Request().Context()
|
||||
userID, err := strconv.Atoi(claims.Subject)
|
||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||
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.
|
||||
@ -138,61 +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))
|
||||
}
|
||||
|
||||
if generateToken {
|
||||
generateTokenFunc := func() error {
|
||||
rc, err := c.Cookie(auth.RefreshTokenCookieName)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Missing refresh token.")
|
||||
}
|
||||
|
||||
// Parses token and checks if it's valid.
|
||||
refreshTokenClaims := &Claims{}
|
||||
refreshToken, err := jwt.ParseWithClaims(rc.Value, refreshTokenClaims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected refresh token signing method=%v, expected %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(secret), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Errorf("unexpected refresh token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
if err == jwt.ErrSignatureInvalid {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Invalid refresh token signature.")
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||
}
|
||||
|
||||
if !audienceContains(refreshTokenClaims.Audience, auth.RefreshTokenAudienceName) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
||||
fmt.Sprintf("Invalid refresh token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||
refreshTokenClaims.Audience,
|
||||
auth.RefreshTokenAudienceName,
|
||||
))
|
||||
}
|
||||
|
||||
// If we have a valid refresh token, we will generate new access token and refresh token
|
||||
if refreshToken != nil && refreshToken.Valid {
|
||||
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// It may happen that we still have a valid access token, but we encounter issue when trying to generate new token
|
||||
// In such case, we won't return the error.
|
||||
if err := generateTokenFunc(); err != nil && !accessToken.Valid {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Stores userID into context.
|
||||
c.Set(getUserIDContextKey(), userID)
|
||||
c.Set(UserIDContextKey, userID)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
@ -3,9 +3,12 @@ package v1
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
@ -28,12 +31,12 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with name: %s", shortcutName))
|
||||
}
|
||||
if shortcut.Visibility != store.VisibilityPublic {
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
if isValidURLString(shortcut.Link) {
|
||||
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
||||
}
|
||||
return c.String(http.StatusOK, shortcut.Link)
|
||||
return redirectToShortcut(c, shortcut)
|
||||
})
|
||||
}
|
||||
|
||||
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{
|
||||
ShortcutID: shortcut.ID,
|
||||
ShortcutID: shortcut.Id,
|
||||
IP: c.RealIP(),
|
||||
Referer: c.Request().Referer(),
|
||||
UserAgent: c.Request().UserAgent(),
|
||||
|
@ -5,13 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/boojack/slash/internal/util"
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Visibility is the type of a shortcut visibility.
|
||||
@ -30,46 +30,58 @@ func (v Visibility) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
type OpenGraphMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type Shortcut struct {
|
||||
ID int `json:"id"`
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int `json:"creatorId"`
|
||||
CreatorID int32 `json:"creatorId"`
|
||||
Creator *User `json:"creator"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Description string `json:"description"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
View int `json:"view"`
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
View int `json:"view"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
type CreateShortcutRequest struct {
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Description string `json:"description"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
type PatchShortcutRequest struct {
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
Name *string `json:"name"`
|
||||
Link *string `json:"link"`
|
||||
Description *string `json:"description"`
|
||||
Visibility *Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
Name *string `json:"name"`
|
||||
Link *string `json:"link"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Visibility *Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
g.POST("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.CreateShortcut(ctx, &store.Shortcut{
|
||||
CreatorID: userID,
|
||||
shortcut, err := s.Store.CreateShortcut(ctx, &storepb.Shortcut{
|
||||
CreatorId: userID,
|
||||
Name: strings.ToLower(create.Name),
|
||||
Link: create.Link,
|
||||
Title: create.Title,
|
||||
Description: create.Description,
|
||||
Visibility: store.Visibility(create.Visibility.String()),
|
||||
Tag: strings.Join(create.Tags, " "),
|
||||
Visibility: convertVisibilityToStorepb(create.Visibility),
|
||||
Tags: create.Tags,
|
||||
OgMetadata: &storepb.OpenGraphMetadata{
|
||||
Title: create.OpenGraphMetadata.Title,
|
||||
Description: create.OpenGraphMetadata.Description,
|
||||
Image: create.OpenGraphMetadata.Image,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
@ -103,11 +121,11 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||
shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||
}
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
@ -127,7 +145,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||
}
|
||||
if shortcut.CreatorID != userID && currentUser.Role != store.RoleAdmin {
|
||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
||||
}
|
||||
|
||||
@ -144,6 +162,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
ID: shortcutID,
|
||||
Name: patch.Name,
|
||||
Link: patch.Link,
|
||||
Title: patch.Title,
|
||||
Description: patch.Description,
|
||||
}
|
||||
if patch.RowStatus != nil {
|
||||
@ -156,12 +175,19 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
tag := strings.Join(patch.Tags, " ")
|
||||
shortcutUpdate.Tag = &tag
|
||||
}
|
||||
if patch.OpenGraphMetadata != nil {
|
||||
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
|
||||
Title: patch.OpenGraphMetadata.Title,
|
||||
Description: patch.OpenGraphMetadata.Description,
|
||||
Image: patch.OpenGraphMetadata.Image,
|
||||
}
|
||||
}
|
||||
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
@ -170,7 +196,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.GET("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
@ -180,7 +206,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
find.Tag = &tag
|
||||
}
|
||||
|
||||
list := []*store.Shortcut{}
|
||||
list := []*storepb.Shortcut{}
|
||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||
if err != nil {
|
||||
@ -198,7 +224,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
shortcutMessageList := []*Shortcut{}
|
||||
for _, shortcut := range list {
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStore(shortcut))
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
@ -209,7 +235,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.GET("/shortcut/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := strconv.Atoi(c.Param("id"))
|
||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
@ -224,7 +250,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
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 {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
@ -233,11 +259,11 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
|
||||
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := strconv.Atoi(c.Param("id"))
|
||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
@ -257,41 +283,18 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||
}
|
||||
if shortcut.CreatorID != userID && currentUser.Role != store.RoleAdmin {
|
||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
||||
ID: shortcutID,
|
||||
}); err != nil {
|
||||
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIV1Service) 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) {
|
||||
if shortcut == nil {
|
||||
return nil, nil
|
||||
@ -321,22 +324,55 @@ func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut)
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
|
||||
tags := []string{}
|
||||
if shortcut.Tag != "" {
|
||||
tags = append(tags, strings.Split(shortcut.Tag, " ")...)
|
||||
}
|
||||
|
||||
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
|
||||
return &Shortcut{
|
||||
ID: shortcut.ID,
|
||||
ID: shortcut.Id,
|
||||
CreatedTs: shortcut.CreatedTs,
|
||||
UpdatedTs: shortcut.UpdatedTs,
|
||||
CreatorID: shortcut.CreatorID,
|
||||
CreatorID: shortcut.CreatorId,
|
||||
RowStatus: RowStatus(shortcut.RowStatus.String()),
|
||||
Name: shortcut.Name,
|
||||
Link: shortcut.Link,
|
||||
Title: shortcut.Title,
|
||||
Description: shortcut.Description,
|
||||
Visibility: Visibility(shortcut.Visibility),
|
||||
RowStatus: RowStatus(shortcut.RowStatus),
|
||||
Tags: tags,
|
||||
Visibility: Visibility(shortcut.Visibility.String()),
|
||||
Tags: shortcut.Tags,
|
||||
OpenGraphMetadata: &OpenGraphMetadata{
|
||||
Title: shortcut.OgMetadata.Title,
|
||||
Description: shortcut.OgMetadata.Description,
|
||||
Image: shortcut.OgMetadata.Image,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
|
||||
switch visibility {
|
||||
case VisibilityPublic:
|
||||
return storepb.Visibility_PUBLIC
|
||||
case 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
|
||||
}
|
||||
|
@ -5,10 +5,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
|
||||
"github.com/boojack/slash/internal/util"
|
||||
"github.com/boojack/slash/store"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@ -39,7 +38,7 @@ func (r Role) String() string {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
@ -84,7 +83,7 @@ type PatchUserRequest struct {
|
||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
g.POST("/user", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||
}
|
||||
@ -145,7 +144,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
// GET /api/user/me is used to check if the user is logged in.
|
||||
g.GET("/user/me", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
||||
}
|
||||
@ -162,7 +161,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
|
||||
g.GET("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, err := strconv.Atoi(c.Param("id"))
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
@ -179,11 +178,11 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
|
||||
g.PATCH("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, err := strconv.Atoi(c.Param("id"))
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
currentUserID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
@ -231,6 +230,16 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
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
|
||||
}
|
||||
@ -245,7 +254,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
|
||||
g.DELETE("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
currentUserID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
@ -262,7 +271,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
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 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ package v1
|
||||
import (
|
||||
"github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/store"
|
||||
|
||||
"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 {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
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 {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
userID, ok := c.Get(UserIDContextKey).(int32)
|
||||
if !ok {
|
||||
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
|
||||
}
|
@ -30,7 +30,7 @@ var (
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "slash",
|
||||
Short: `A bookmarking and url shortener, save and share your links very easily.`,
|
||||
Short: `An open source, self-hosted bookmarks and link sharing platform.`,
|
||||
Run: func(_cmd *cobra.Command, _args []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
db := db.NewDB(profile)
|
||||
|
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).
|
@ -1,21 +0,0 @@
|
||||
import { getSlashData } from "./common.js";
|
||||
|
||||
const urlRegex = /https?:\/\/s\/(.+)/;
|
||||
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
|
||||
if (typeof tab.url === "string") {
|
||||
const matchResult = urlRegex.exec(tab.url);
|
||||
if (matchResult) {
|
||||
const slashData = await getSlashData();
|
||||
const name = matchResult[1];
|
||||
const url = `${slashData.domain}/s/${name}`;
|
||||
return chrome.tabs.update(tab.id, { url });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chrome.omnibox.onInputEntered.addListener(async (text) => {
|
||||
const slashData = await getSlashData();
|
||||
const url = `${slashData.domain}/s/${text}`;
|
||||
return chrome.tabs.update({ url });
|
||||
});
|
@ -1,11 +0,0 @@
|
||||
export const getSlashData = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.local.get(["slash"], (data) => {
|
||||
if (data?.slash) {
|
||||
resolve(data.slash);
|
||||
} else {
|
||||
reject("slash data not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "Slash",
|
||||
"description": "",
|
||||
"version": "0.1.0",
|
||||
"manifest_version": 3,
|
||||
"omnibox": {
|
||||
"keyword": "s/"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"permissions": ["tabs", "activeTab", "storage"],
|
||||
"host_permissions": ["*://s/*"]
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h2>Slash extension</h2>
|
||||
<div>
|
||||
<span>Domain</span>
|
||||
<input id="domain-input" type="text" />
|
||||
</div>
|
||||
<div>
|
||||
<button id="save-button">Save</button>
|
||||
</div>
|
||||
<script type="module" src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,23 +0,0 @@
|
||||
import { getSlashData } from "./common.js";
|
||||
|
||||
const saveButton = document.body.querySelector("#save-button");
|
||||
const domainInput = document.body.querySelector("#domain-input");
|
||||
|
||||
saveButton.addEventListener("click", () => {
|
||||
chrome.storage.local.set({
|
||||
slash: {
|
||||
domain: domainInput.value,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const slashData = await getSlashData();
|
||||
if (slashData) {
|
||||
domainInput.value = slashData.domain;
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing.
|
||||
}
|
||||
})();
|
17
go.mod
17
go.mod
@ -10,10 +10,10 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/crypto v0.9.0
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/crypto v0.11.0
|
||||
golang.org/x/net v0.12.0 // indirect
|
||||
golang.org/x/sys v0.10.0 // indirect
|
||||
golang.org/x/text v0.11.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/friendsofgo/errors v0.9.2 // 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/inconshreveable/mousetrap v1.0.1 // 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/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // 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/cast v1.5.0 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
@ -51,6 +52,8 @@ require (
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
golang.org/x/tools v0.6.0 // 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/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
@ -66,10 +69,14 @@ require (
|
||||
|
||||
require (
|
||||
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
|
||||
go.deanishe.net/favicon v0.1.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
|
||||
)
|
||||
|
41
go.sum
41
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-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
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-20191227052852-215e87163ea7/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.2/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/google/btree v0.0.0-20180813153112-4030bb1f1f0c/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.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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/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.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/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.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
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/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
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/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/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/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
@ -222,7 +226,6 @@ github.com/mssola/useragent v1.0.0/go.mod h1:hz9Cqz4RXusgg1EdI4Al0INR62kP7aPSRNH
|
||||
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/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.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@ -306,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-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.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||
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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@ -377,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-20201224014010-6772e930b67b/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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
|
||||
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-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@ -445,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-20220908164124-27713097b956/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||
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/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=
|
||||
@ -454,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.3/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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
|
||||
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-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@ -582,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-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-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.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@ -598,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.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.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-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@ -608,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.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
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 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
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/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
|
@ -1,6 +1,18 @@
|
||||
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.
|
||||
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;
|
||||
}
|
BIN
resources/demo.png
Normal file
BIN
resources/demo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 969 KiB |
@ -3,13 +3,14 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
apiv1 "github.com/boojack/slash/api/v1"
|
||||
apiv2 "github.com/boojack/slash/api/v2"
|
||||
"github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
@ -19,6 +20,9 @@ type Server struct {
|
||||
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
|
||||
// API services.
|
||||
apiV2Service *apiv2.APIV2Service
|
||||
}
|
||||
|
||||
func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store) (*Server, error) {
|
||||
@ -66,10 +70,27 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
||||
apiV1Service := apiv1.NewAPIV1Service(profile, store)
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
|
@ -9,10 +9,10 @@ import (
|
||||
|
||||
// Version is the service current released version.
|
||||
// Semantic versioning: https://semver.org/
|
||||
var Version = "0.2.0"
|
||||
var Version = "0.4.0"
|
||||
|
||||
// DevVersion is the service current development version.
|
||||
var DevVersion = "0.2.0"
|
||||
var DevVersion = "0.4.0"
|
||||
|
||||
func GetCurrentVersion(mode string) string {
|
||||
if mode == "dev" || mode == "demo" {
|
||||
|
@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -49,8 +48,8 @@ func (l ActivityLevel) String() string {
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
ID int
|
||||
CreatorID int
|
||||
ID int32
|
||||
CreatorID int32
|
||||
CreatedTs int64
|
||||
Type ActivityType
|
||||
Level ActivityLevel
|
||||
@ -64,13 +63,7 @@ type FindActivity struct {
|
||||
}
|
||||
|
||||
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
INSERT INTO activity (
|
||||
creator_id,
|
||||
type,
|
||||
@ -80,7 +73,7 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, created_ts
|
||||
`
|
||||
if err := tx.QueryRowContext(ctx, query,
|
||||
if err := s.db.QueryRowContext(ctx, stmt,
|
||||
create.CreatorID,
|
||||
create.Type.String(),
|
||||
create.Level.String(),
|
||||
@ -92,50 +85,11 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activity := create
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
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{}
|
||||
if find.Type != "" {
|
||||
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
|
||||
FROM activity
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
list := []*Activity{}
|
||||
@ -187,3 +140,17 @@ func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Act
|
||||
|
||||
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"
|
||||
|
||||
func getUserSettingCacheKey(userID int, key string) string {
|
||||
func getUserSettingCacheKey(userID int32, key string) string {
|
||||
return fmt.Sprintf("%d-%s", userID, key)
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
)
|
||||
|
||||
// RowStatus is the status for a row.
|
||||
type RowStatus string
|
||||
|
||||
@ -19,3 +23,13 @@ func (e RowStatus) String() string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
@ -23,12 +23,12 @@ var migrationFS embed.FS
|
||||
var seedFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
profile *profile.Profile
|
||||
// sqlite db connection instance
|
||||
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 {
|
||||
db := &DB{
|
||||
profile: profile,
|
||||
@ -42,8 +42,21 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
return fmt.Errorf("dsn required")
|
||||
}
|
||||
|
||||
// Connect to the database without foreign_key.
|
||||
sqliteDB, err := sql.Open("sqlite", db.profile.DSN+"?cache=shared&_foreign_keys=0&_journal_mode=WAL")
|
||||
// Connect to the database with some sane settings:
|
||||
// - 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 {
|
||||
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
|
||||
}
|
||||
@ -52,16 +65,16 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
if db.profile.Mode == "prod" {
|
||||
_, err := os.Stat(db.profile.DSN)
|
||||
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 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 {
|
||||
return fmt.Errorf("failed to check database file: %w", err)
|
||||
return fmt.Errorf("failed to get db file stat, err: %w", err)
|
||||
}
|
||||
} 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)
|
||||
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
||||
if err != nil {
|
||||
@ -177,21 +190,15 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.DBInstance.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// upsert the newest version to migration_history
|
||||
// Upsert the newest version to migration_history.
|
||||
version := minorVersion + ".0"
|
||||
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
|
||||
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||
Version: version,
|
||||
}); err != nil {
|
||||
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 {
|
||||
@ -218,17 +225,11 @@ func (db *DB) seed(ctx context.Context) error {
|
||||
|
||||
// execute runs a single SQL statement within a transaction.
|
||||
func (db *DB) execute(ctx context.Context, stmt string) error {
|
||||
tx, err := db.DBInstance.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
if _, err := db.DBInstance.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("failed to execute statement, err: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// minorDirRegexp is a regular expression for minor version directory.
|
||||
|
@ -41,9 +41,11 @@ CREATE TABLE shortcut (
|
||||
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
link TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
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);
|
||||
|
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 '';
|
@ -41,9 +41,11 @@ CREATE TABLE shortcut (
|
||||
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
link TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
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);
|
||||
|
@ -2,7 +2,6 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -20,47 +19,13 @@ type MigrationHistoryFind struct {
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
if v := find.Version; v != nil {
|
||||
where, args = append(where, "version = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
SELECT
|
||||
version,
|
||||
created_ts
|
||||
@ -69,7 +34,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY created_ts DESC
|
||||
`
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
rows, err := db.DBInstance.QueryContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -84,7 +49,6 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
|
||||
}
|
||||
|
||||
@ -95,7 +59,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
||||
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 := `
|
||||
INSERT INTO migration_history (
|
||||
version
|
||||
@ -107,7 +71,7 @@ func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHi
|
||||
RETURNING version, created_ts
|
||||
`
|
||||
migrationHistory := &MigrationHistory{}
|
||||
if err := tx.QueryRowContext(ctx, query, upsert.Version).Scan(
|
||||
if err := db.DBInstance.QueryRowContext(ctx, query, upsert.Version).Scan(
|
||||
&migrationHistory.Version,
|
||||
&migrationHistory.CreatedTs,
|
||||
); err != nil {
|
||||
|
@ -10,10 +10,9 @@ VALUES
|
||||
(
|
||||
101,
|
||||
'ADMIN',
|
||||
'slash@stevenlgtm.com',
|
||||
'slash@yourselfhosted.com',
|
||||
'Slasher',
|
||||
-- raw password: secret
|
||||
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
|
||||
'$2a$10$H8HBWGcG/hoePhFy5SiNKOHxMD6omIpyEEWbl/fIorFC814bXW.Ua'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
|
@ -10,8 +10,8 @@ VALUES
|
||||
(
|
||||
1,
|
||||
101,
|
||||
'memos',
|
||||
'https://usememos.com',
|
||||
'discord',
|
||||
'https://discord.gg/QZqUuUAhDV',
|
||||
'PUBLIC'
|
||||
);
|
||||
|
||||
@ -21,14 +21,58 @@ INSERT INTO
|
||||
`creator_id`,
|
||||
`name`,
|
||||
`link`,
|
||||
`visibility`
|
||||
`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'
|
||||
);
|
||||
|
||||
@ -42,24 +86,7 @@ INSERT INTO
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
3,
|
||||
101,
|
||||
'schema-change',
|
||||
'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change',
|
||||
'PUBLIC'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
shortcut (
|
||||
`id`,
|
||||
`creator_id`,
|
||||
`name`,
|
||||
`link`,
|
||||
`visibility`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
4,
|
||||
5,
|
||||
102,
|
||||
'stevenlgtm',
|
||||
'https://github.com/boojack',
|
||||
|
@ -3,8 +3,12 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
)
|
||||
|
||||
// Visibility is the type of a visibility.
|
||||
@ -31,37 +35,28 @@ func (e Visibility) String() string {
|
||||
return "PRIVATE"
|
||||
}
|
||||
|
||||
type Shortcut struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
CreatedTs int64
|
||||
UpdatedTs int64
|
||||
RowStatus RowStatus
|
||||
|
||||
// Domain specific fields
|
||||
Name string
|
||||
Link string
|
||||
Description string
|
||||
Visibility Visibility
|
||||
Tag string
|
||||
type OpenGraphMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type UpdateShortcut struct {
|
||||
ID int
|
||||
ID int32
|
||||
|
||||
RowStatus *RowStatus
|
||||
Name *string
|
||||
Link *string
|
||||
Description *string
|
||||
Visibility *Visibility
|
||||
Tag *string
|
||||
RowStatus *RowStatus
|
||||
Name *string
|
||||
Link *string
|
||||
Title *string
|
||||
Description *string
|
||||
Visibility *Visibility
|
||||
Tag *string
|
||||
OpenGraphMetadata *OpenGraphMetadata
|
||||
}
|
||||
|
||||
type FindShortcut struct {
|
||||
ID *int
|
||||
CreatorID *int
|
||||
ID *int32
|
||||
CreatorID *int32
|
||||
RowStatus *RowStatus
|
||||
Name *string
|
||||
VisibilityList []Visibility
|
||||
@ -69,50 +64,46 @@ type FindShortcut struct {
|
||||
}
|
||||
|
||||
type DeleteShortcut struct {
|
||||
ID int
|
||||
ID int32
|
||||
}
|
||||
|
||||
func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (s *Store) CreateShortcut(ctx context.Context, create *storepb.Shortcut) (*storepb.Shortcut, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, string(openGraphMetadataBytes))
|
||||
placeholder = append(placeholder, "?")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
set := []string{"creator_id", "name", "link", "description", "visibility", "tag"}
|
||||
args := []any{create.CreatorID, create.Name, create.Link, create.Description, create.Visibility, create.Tag}
|
||||
placeholder := []string{"?", "?", "?", "?", "?", "?"}
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
INSERT INTO shortcut (
|
||||
` + strings.Join(set, ", ") + `
|
||||
)
|
||||
VALUES (` + strings.Join(placeholder, ",") + `)
|
||||
RETURNING id, created_ts, updated_ts, row_status
|
||||
`
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
&create.ID,
|
||||
var rowStatus string
|
||||
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||
&create.Id,
|
||||
&create.CreatedTs,
|
||||
&create.UpdatedTs,
|
||||
&create.RowStatus,
|
||||
&rowStatus,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return create, nil
|
||||
create.RowStatus = convertRowStatusStringToStorepb(rowStatus)
|
||||
shortcut := create
|
||||
s.shortcutCache.Store(shortcut.Id, shortcut)
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*storepb.Shortcut, error) {
|
||||
set, args := []string{}, []any{}
|
||||
if update.RowStatus != nil {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return nil, fmt.Errorf("no update specified")
|
||||
}
|
||||
args = append(args, update.ID)
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
UPDATE shortcut
|
||||
SET
|
||||
` + strings.Join(set, ", ") + `
|
||||
WHERE
|
||||
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{}
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
&shortcut.ID,
|
||||
&shortcut.CreatorID,
|
||||
shortcut := &storepb.Shortcut{}
|
||||
var rowStatus, visibility, tags, openGraphMetadataString string
|
||||
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||
&shortcut.Id,
|
||||
&shortcut.CreatorId,
|
||||
&shortcut.CreatedTs,
|
||||
&shortcut.UpdatedTs,
|
||||
&shortcut.RowStatus,
|
||||
&rowStatus,
|
||||
&shortcut.Name,
|
||||
&shortcut.Link,
|
||||
&shortcut.Title,
|
||||
&shortcut.Description,
|
||||
&shortcut.Visibility,
|
||||
&shortcut.Tag,
|
||||
&visibility,
|
||||
&tags,
|
||||
&openGraphMetadataString,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
shortcut.RowStatus = convertRowStatusStringToStorepb(rowStatus)
|
||||
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
|
||||
}
|
||||
|
||||
s.shortcutCache.Store(shortcut.ID, shortcut)
|
||||
shortcut.OgMetadata = &ogMetadata
|
||||
s.shortcutCache.Store(shortcut.Id, shortcut)
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListShortcuts(ctx context.Context, find *FindShortcut) ([]*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) {
|
||||
func (s *Store) ListShortcuts(ctx context.Context, find *FindShortcut) ([]*storepb.Shortcut, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
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+"%")
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
creator_id,
|
||||
@ -270,9 +211,11 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
||||
row_status,
|
||||
name,
|
||||
link,
|
||||
title,
|
||||
description,
|
||||
visibility,
|
||||
tag
|
||||
tag,
|
||||
og_metadata
|
||||
FROM shortcut
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY created_ts DESC`,
|
||||
@ -283,33 +226,77 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list := make([]*Shortcut, 0)
|
||||
list := make([]*storepb.Shortcut, 0)
|
||||
for rows.Next() {
|
||||
shortcut := &Shortcut{}
|
||||
shortcut := &storepb.Shortcut{}
|
||||
var rowStatus, visibility, tags, openGraphMetadataString string
|
||||
if err := rows.Scan(
|
||||
&shortcut.ID,
|
||||
&shortcut.CreatorID,
|
||||
&shortcut.Id,
|
||||
&shortcut.CreatorId,
|
||||
&shortcut.CreatedTs,
|
||||
&shortcut.UpdatedTs,
|
||||
&shortcut.RowStatus,
|
||||
&rowStatus,
|
||||
&shortcut.Name,
|
||||
&shortcut.Link,
|
||||
&shortcut.Title,
|
||||
&shortcut.Description,
|
||||
&shortcut.Visibility,
|
||||
&shortcut.Tag,
|
||||
&visibility,
|
||||
&tags,
|
||||
&openGraphMetadataString,
|
||||
); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
if err := rows.Err(); 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) (*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
|
||||
@ -328,3 +315,17 @@ func vacuumShortcut(ctx context.Context, tx *sql.Tx) error {
|
||||
|
||||
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])
|
||||
}
|
||||
|
167
store/user.go
167
store/user.go
@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@ -18,7 +17,7 @@ const (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
ID int32
|
||||
|
||||
// Standard fields
|
||||
CreatedTs int64
|
||||
@ -33,7 +32,7 @@ type User struct {
|
||||
}
|
||||
|
||||
type UpdateUser struct {
|
||||
ID int
|
||||
ID int32
|
||||
|
||||
RowStatus *RowStatus
|
||||
Email *string
|
||||
@ -43,7 +42,7 @@ type UpdateUser struct {
|
||||
}
|
||||
|
||||
type FindUser struct {
|
||||
ID *int
|
||||
ID *int32
|
||||
RowStatus *RowStatus
|
||||
Email *string
|
||||
Nickname *string
|
||||
@ -51,17 +50,11 @@ type FindUser struct {
|
||||
}
|
||||
|
||||
type DeleteUser struct {
|
||||
ID int
|
||||
ID int32
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
INSERT INTO user (
|
||||
email,
|
||||
nickname,
|
||||
@ -71,7 +64,7 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, created_ts, updated_ts, row_status
|
||||
`
|
||||
if err := tx.QueryRowContext(ctx, query,
|
||||
if err := s.db.QueryRowContext(ctx, stmt,
|
||||
create.Email,
|
||||
create.Nickname,
|
||||
create.PasswordHash,
|
||||
@ -85,22 +78,12 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := create
|
||||
s.userCache.Store(user.ID, user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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{}
|
||||
if v := update.RowStatus; v != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
UPDATE user
|
||||
SET ` + strings.Join(set, ", ") + `
|
||||
WHERE id = ?
|
||||
@ -130,7 +113,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
||||
`
|
||||
args = append(args, update.ID)
|
||||
user := &User{}
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||
&user.ID,
|
||||
&user.CreatedTs,
|
||||
&user.UpdatedTs,
|
||||
@ -143,23 +126,68 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.userCache.Store(user.ID, user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
where, args = append(where, "id = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.RowStatus; v != nil {
|
||||
where, args = append(where, "row_status = ?"), append(args, v.String())
|
||||
}
|
||||
if v := find.Email; v != nil {
|
||||
where, args = append(where, "email = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.Nickname; v != nil {
|
||||
where, args = append(where, "nickname = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.Role; v != nil {
|
||||
where, args = append(where, "role = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
created_ts,
|
||||
updated_ts,
|
||||
row_status,
|
||||
email,
|
||||
nickname,
|
||||
password_hash,
|
||||
role
|
||||
FROM user
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY updated_ts DESC, created_ts DESC
|
||||
`
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
defer rows.Close()
|
||||
|
||||
list, err := listUsers(ctx, tx, find)
|
||||
if err != nil {
|
||||
list := make([]*User, 0)
|
||||
for rows.Next() {
|
||||
user := &User{}
|
||||
if err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.CreatedTs,
|
||||
&user.UpdatedTs,
|
||||
&user.RowStatus,
|
||||
&user.Email,
|
||||
&user.Nickname,
|
||||
&user.PasswordHash,
|
||||
&user.Role,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, user)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -177,13 +205,7 @@ func (s *Store) GetUser(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)
|
||||
list, err := s.ListUsers(ctx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -217,7 +239,6 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
// do nothing here to prevent linter warning.
|
||||
return err
|
||||
}
|
||||
|
||||
@ -225,67 +246,3 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
where, args = append(where, "id = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.RowStatus; v != nil {
|
||||
where, args = append(where, "row_status = ?"), append(args, v.String())
|
||||
}
|
||||
if v := find.Email; v != nil {
|
||||
where, args = append(where, "email = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.Nickname; v != nil {
|
||||
where, args = append(where, "nickname = ?"), append(args, *v)
|
||||
}
|
||||
if v := find.Role; v != nil {
|
||||
where, args = append(where, "role = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
created_ts,
|
||||
updated_ts,
|
||||
row_status,
|
||||
email,
|
||||
nickname,
|
||||
password_hash,
|
||||
role
|
||||
FROM user
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY updated_ts DESC, created_ts DESC
|
||||
`
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list := make([]*User, 0)
|
||||
for rows.Next() {
|
||||
user := &User{}
|
||||
if err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.CreatedTs,
|
||||
&user.UpdatedTs,
|
||||
&user.RowStatus,
|
||||
&user.Email,
|
||||
&user.Nickname,
|
||||
&user.PasswordHash,
|
||||
&user.Role,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, user)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
@ -7,24 +7,18 @@ import (
|
||||
)
|
||||
|
||||
type UserSetting struct {
|
||||
UserID int
|
||||
UserID int32
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
type FindUserSetting struct {
|
||||
UserID *int
|
||||
UserID *int32
|
||||
Key string
|
||||
}
|
||||
|
||||
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*UserSetting, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
INSERT INTO user_setting (
|
||||
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
|
||||
SET value = EXCLUDED.value
|
||||
`
|
||||
if _, err := tx.ExecContext(ctx, query, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
if _, err := s.db.ExecContext(ctx, stmt, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
||||
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) {
|
||||
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{}
|
||||
|
||||
if v := find.Key; v != "" {
|
||||
@ -107,30 +52,54 @@ func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([
|
||||
value
|
||||
FROM user_setting
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
userSettingMessageList := make([]*UserSetting, 0)
|
||||
userSettingList := make([]*UserSetting, 0)
|
||||
for rows.Next() {
|
||||
userSettingMessage := &UserSetting{}
|
||||
userSetting := &UserSetting{}
|
||||
if err := rows.Scan(
|
||||
&userSettingMessage.UserID,
|
||||
&userSettingMessage.Key,
|
||||
&userSettingMessage.Value,
|
||||
&userSetting.UserID,
|
||||
&userSetting.Key,
|
||||
&userSetting.Value,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userSettingMessageList = append(userSettingMessageList, userSettingMessage)
|
||||
userSettingList = append(userSettingList, userSetting)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
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 {
|
||||
|
@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -17,13 +16,7 @@ const (
|
||||
|
||||
// String returns the string format of WorkspaceSettingKey type.
|
||||
func (key WorkspaceSettingKey) String() string {
|
||||
switch key {
|
||||
case WorkspaceDisallowSignUp:
|
||||
return "disallow-signup"
|
||||
case WorkspaceSecretSessionName:
|
||||
return "secret-session-name"
|
||||
}
|
||||
return ""
|
||||
return string(key)
|
||||
}
|
||||
|
||||
type WorkspaceSetting struct {
|
||||
@ -36,13 +29,7 @@ type FindWorkspaceSetting struct {
|
||||
}
|
||||
|
||||
func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSetting) (*WorkspaceSetting, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
INSERT INTO workspace_setting (
|
||||
key,
|
||||
value
|
||||
@ -51,11 +38,7 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSet
|
||||
ON CONFLICT(key) DO UPDATE
|
||||
SET value = EXCLUDED.value
|
||||
`
|
||||
if _, err := tx.ExecContext(ctx, query, upsert.Key, upsert.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
if _, err := s.db.ExecContext(ctx, stmt, upsert.Key, upsert.Value); err != nil {
|
||||
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) {
|
||||
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{}
|
||||
|
||||
if 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
|
||||
FROM workspace_setting
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -146,5 +84,30 @@ func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceS
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -13,13 +14,14 @@ func TestShortcutStore(t *testing.T) {
|
||||
ts := NewTestingStore(ctx, t)
|
||||
user, err := createTestingAdminUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
shortcut, err := ts.CreateShortcut(ctx, &store.Shortcut{
|
||||
CreatorID: user.ID,
|
||||
shortcut, err := ts.CreateShortcut(ctx, &storepb.Shortcut{
|
||||
CreatorId: user.ID,
|
||||
Name: "test",
|
||||
Link: "https://test.link",
|
||||
Description: "A test shortcut",
|
||||
Visibility: store.VisibilityPrivate,
|
||||
Tag: "test link",
|
||||
Visibility: storepb.Visibility_PRIVATE,
|
||||
Tags: []string{"test", "shortcut"},
|
||||
OgMetadata: &storepb.OpenGraphMetadata{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{
|
||||
@ -30,7 +32,7 @@ func TestShortcutStore(t *testing.T) {
|
||||
require.Equal(t, shortcut, shortcuts[0])
|
||||
newLink := "https://new.link"
|
||||
updatedShortcut, err := ts.UpdateShortcut(ctx, &store.UpdateShortcut{
|
||||
ID: shortcut.ID,
|
||||
ID: shortcut.Id,
|
||||
Link: &newLink,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@ -40,9 +42,8 @@ func TestShortcutStore(t *testing.T) {
|
||||
Tag: &tag,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updatedShortcut, shortcut)
|
||||
err = ts.DeleteShortcut(ctx, &store.DeleteShortcut{
|
||||
ID: shortcut.ID,
|
||||
ID: shortcut.Id,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
shortcuts, err = ts.ListShortcuts(ctx, &store.FindShortcut{
|
||||
|
@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
storepb "github.com/boojack/slash/proto/gen/store"
|
||||
"github.com/boojack/slash/store"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@ -26,11 +26,11 @@ func TestUserStore(t *testing.T) {
|
||||
Nickname: &userPatchNickname,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.CreateShortcut(ctx, &store.Shortcut{
|
||||
CreatorID: user.ID,
|
||||
_, err = ts.CreateShortcut(ctx, &storepb.Shortcut{
|
||||
CreatorId: user.ID,
|
||||
Name: "test_shortcut",
|
||||
Link: "https://www.google.com",
|
||||
Visibility: store.VisibilityPublic,
|
||||
Visibility: storepb.Visibility_PUBLIC,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userPatchNickname, user.Nickname)
|
||||
|
@ -2,5 +2,7 @@
|
||||
"printWidth": 140,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false
|
||||
"singleQuote": false,
|
||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]", ".less$"]
|
||||
}
|
||||
|
@ -4,45 +4,48 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"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": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/joy": "5.0.0-alpha.84",
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"@mui/joy": "5.0.0-beta.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"axios": "^0.27.2",
|
||||
"classnames": "^2.3.2",
|
||||
"copy-to-clipboard": "^3.3.2",
|
||||
"dayjs": "^1.11.3",
|
||||
"i18next": "^23.2.3",
|
||||
"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-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-i18next": "^13.0.1",
|
||||
"react-redux": "^8.0.1",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-router-dom": "^6.13.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.1.0",
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
||||
"@typescript-eslint/parser": "^5.6.0",
|
||||
"@types/react": "^18.2.18",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||
"@typescript-eslint/parser": "^6.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^8.4.1",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.27.1",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "2.5.1",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.0.0"
|
||||
"vite": "^4.2.1"
|
||||
}
|
||||
}
|
||||
|
1384
web/pnpm-lock.yaml
generated
1384
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import { globalService } from "./services";
|
||||
import useUserStore from "./stores/v1/user";
|
||||
|
||||
@ -27,7 +28,14 @@ function App() {
|
||||
initialState();
|
||||
}, []);
|
||||
|
||||
return <>{!loading && <Outlet />}</>;
|
||||
return !loading ? (
|
||||
<>
|
||||
<DemoBanner />
|
||||
<Outlet />
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
@ -19,12 +19,11 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
<div className="max-w-full w-80 sm:w-96">
|
||||
<p>
|
||||
<span className="font-medium">Slash</span> is a bookmarking and short link service that allows you to save and share links
|
||||
easily.
|
||||
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform.
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<span className="mr-2">See more in:</span>
|
||||
<Link variant="plain" href="https://github.com/boojack/slash">
|
||||
<span className="mr-2">See more in</span>
|
||||
<Link variant="plain" href="https://github.com/boojack/slash" target="_blank">
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Button, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import * as api from "../helpers/api";
|
||||
import AnalyticsView from "./AnalyticsView";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -10,14 +9,6 @@ interface Props {
|
||||
|
||||
const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
||||
const { shortcutId, onClose } = props;
|
||||
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("os");
|
||||
|
||||
useEffect(() => {
|
||||
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
||||
setAnalytics(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
@ -29,114 +20,7 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-w-full w-80 sm:w-96">
|
||||
{analytics ? (
|
||||
<>
|
||||
<p className="w-full py-1 px-2">Top Sources</p>
|
||||
<div className="mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="py-1 px-2 text-left font-semibold text-sm text-gray-500">
|
||||
Source
|
||||
</th>
|
||||
<th scope="col" className="py-1 pr-2 text-right font-semibold text-sm text-gray-500">
|
||||
Visitors
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{analytics.referenceData.map((reference) => (
|
||||
<tr key={reference.name}>
|
||||
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">
|
||||
{reference.name ? (
|
||||
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
||||
{reference.name}
|
||||
</a>
|
||||
) : (
|
||||
"Direct"
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-4 py-1 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 === "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>
|
||||
<span className="text-gray-200 font-mono mx-1">/</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
{selectedDeviceTab === "os" ? (
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="py-2 px-2 text-left text-sm font-semibold text-gray-500">
|
||||
Operating system
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">
|
||||
Visitors
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{analytics.deviceData.map((reference) => (
|
||||
<tr key={reference.name}>
|
||||
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">{reference.name || "Unknown"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="py-2 px-2 text-left text-sm font-semibold text-gray-500">
|
||||
Browsers
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">
|
||||
Visitors
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{analytics.browserData.map((reference) => (
|
||||
<tr key={reference.name}>
|
||||
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">{reference.name || "Unknown"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<AnalyticsView className="grid grid-cols-1 gap-2" shortcutId={shortcutId} />
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
|
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;
|
@ -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 { useTranslation } from "react-i18next";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { shortcutService } from "../services";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { shortcutService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -26,29 +27,39 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
shortcutCreate: {
|
||||
name: "",
|
||||
link: "",
|
||||
title: "",
|
||||
description: "",
|
||||
visibility: "PRIVATE",
|
||||
tags: [],
|
||||
openGraphMetadata: {
|
||||
title: "",
|
||||
description: "",
|
||||
image: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
|
||||
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||
const [tag, setTag] = useState<string>("");
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = isUndefined(shortcutId);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutId) {
|
||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
||||
if (shortcutTemp) {
|
||||
const shortcut = shortcutService.getShortcutById(shortcutId);
|
||||
if (shortcut) {
|
||||
setState({
|
||||
...state,
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
name: shortcutTemp.name,
|
||||
link: shortcutTemp.link,
|
||||
description: shortcutTemp.description,
|
||||
visibility: shortcutTemp.visibility,
|
||||
name: shortcut.name,
|
||||
link: shortcut.link,
|
||||
title: shortcut.title,
|
||||
description: shortcut.description,
|
||||
visibility: shortcut.visibility,
|
||||
openGraphMetadata: shortcut.openGraphMetadata,
|
||||
}),
|
||||
});
|
||||
setTag(shortcutTemp.tags.join(" "));
|
||||
setTag(shortcut.tags.join(" "));
|
||||
}
|
||||
}
|
||||
}, [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>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
@ -89,10 +116,35 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
setTag(text);
|
||||
};
|
||||
|
||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
visibility: e.target.value,
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
image: e.target.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
title: e.target.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
description: e.target.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
@ -109,9 +161,11 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
id: shortcutId,
|
||||
name: state.shortcutCreate.name,
|
||||
link: state.shortcutCreate.link,
|
||||
title: state.shortcutCreate.title,
|
||||
description: state.shortcutCreate.description,
|
||||
visibility: state.shortcutCreate.visibility,
|
||||
tags: tag.split(" "),
|
||||
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||
});
|
||||
} else {
|
||||
await shortcutService.createShortcut({
|
||||
@ -140,11 +194,9 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<span className="mb-2">Name</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
@ -156,35 +208,21 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Link <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<span className="mb-2">Destination URL</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="The full URL of the page you want to get to"
|
||||
placeholder="https://github.com/boojack/slash"
|
||||
value={state.shortcutCreate.link}
|
||||
onChange={handleLinkInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">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">
|
||||
<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 className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Visibility <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<span className="mb-2">Visibility</span>
|
||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
||||
{visibilities.map((visibility) => (
|
||||
@ -196,6 +234,101 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
{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-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}>
|
||||
Cancel
|
||||
|
@ -12,9 +12,9 @@ const DemoBanner: React.FC = () => {
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return (
|
||||
<div className="z-10 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-4xl px-4 flex flex-row justify-between items-center gap-x-3">
|
||||
<span>✨A bookmarking and url shortener, save and share your links very easily.✨</span>
|
||||
<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"
|
||||
|
@ -21,7 +21,8 @@ const FilterView = () => {
|
||||
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" />#{filter.tag}
|
||||
<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>
|
||||
)}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Button, Modal, ModalDialog } from "@mui/joy";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
import { useRef } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import Icon from "./Icon";
|
||||
|
||||
|
@ -3,9 +3,9 @@ import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as api from "../helpers/api";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
import AboutDialog from "./AboutDialog";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
@ -19,7 +19,7 @@ const Header: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<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="" />
|
||||
@ -40,18 +40,18 @@ const Header: React.FC = () => {
|
||||
<>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
<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)}
|
||||
>
|
||||
<Icon.Info className="w-4 h-auto mr-2" /> About
|
||||
</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()}
|
||||
>
|
||||
<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,34 +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>
|
||||
|
||||
<p className="w-full text-center text-gray-400 text-sm mt-2 mb-4 italic">Total {shortcutList.length} data</p>
|
||||
|
||||
{editingShortcutId && (
|
||||
<CreateShortcutDialog
|
||||
shortcutId={editingShortcutId}
|
||||
onClose={() => setEditingShortcutId(undefined)}
|
||||
onConfirm={() => setEditingShortcutId(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutListView;
|
@ -1,35 +1,19 @@
|
||||
import { Tooltip } from "@mui/joy";
|
||||
import copy from "copy-to-clipboard";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import toast from "react-hot-toast";
|
||||
import { shortcutService } from "../services";
|
||||
import useFaviconStore from "../stores/v1/favicon";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import { Link } from "react-router-dom";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import { showCommonDialog } from "./Alert";
|
||||
import useFaviconStore from "../stores/v1/favicon";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
import VisibilityIcon from "./VisibilityIcon";
|
||||
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
||||
import AnalyticsDialog from "./AnalyticsDialog";
|
||||
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||
|
||||
interface Props {
|
||||
shortcut: Shortcut;
|
||||
handleEdit: () => void;
|
||||
}
|
||||
|
||||
const ShortcutView = (props: Props) => {
|
||||
const { shortcut, handleEdit } = props;
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const viewStore = useViewStore();
|
||||
const { shortcut } = props;
|
||||
const faviconStore = useFaviconStore();
|
||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
|
||||
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||
|
||||
useEffect(() => {
|
||||
@ -40,142 +24,54 @@ const ShortcutView = (props: Props) => {
|
||||
});
|
||||
}, [shortcut.link]);
|
||||
|
||||
const handleCopyButtonClick = () => {
|
||||
copy(shortcutLink);
|
||||
toast.success("Shortcut link copied to clipboard.");
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="w-full flex flex-col justify-start items-start border px-4 py-3 mb-2 rounded-lg hover:shadow">
|
||||
<div
|
||||
className={classNames(
|
||||
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow"
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="group flex flex-row justify-start items-center pr-2 mr-1 shrink-0">
|
||||
<div className="w-6 h-6 mr-1 flex justify-center items-center overflow-clip">
|
||||
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
|
||||
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-6 h-auto text-gray-400" />
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className="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}
|
||||
>
|
||||
<span className="text-gray-400">s/</span>
|
||||
{shortcut.name}
|
||||
<span className="hidden group-hover:block ml-1 cursor-pointer">
|
||||
<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-full text-gray-500 hover:bg-gray-100 hover:shadow hover:text-blue-600"
|
||||
onClick={() => handleCopyButtonClick()}
|
||||
>
|
||||
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
|
||||
<button
|
||||
className="hidden group-hover:block ml-1 w-6 h-6 cursor-pointer rounded-full text-gray-500 hover:bg-gray-100 hover:shadow hover:text-blue-600"
|
||||
onClick={() => setShowQRCodeDialog(true)}
|
||||
>
|
||||
<Icon.QrCode className="w-4 h-auto mx-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center space-x-2">
|
||||
{havePermission && (
|
||||
<Dropdown
|
||||
actionsClassName="!w-32"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => handleEdit()}
|
||||
>
|
||||
<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={() => setShowAnalyticsDialog(true)}
|
||||
>
|
||||
<Icon.BarChart2 className="w-4 h-auto mr-2" /> Analytics
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => {
|
||||
handleDeleteShortcutButtonClick(shortcut);
|
||||
}}
|
||||
>
|
||||
<Icon.Trash className="w-4 h-auto mr-2" /> Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
></Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{shortcut.description && <p className="mt-1 text-gray-400 text-sm">{shortcut.description}</p>}
|
||||
{shortcut.tags.length > 0 && (
|
||||
<div className="mt-2 ml-1 flex flex-row justify-start items-start gap-2">
|
||||
<Icon.Tag className="text-gray-400 w-4 h-auto" />
|
||||
{shortcut.tags.map((tag) => {
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600"
|
||||
onClick={() => viewStore.setFilter({ tag: tag })}
|
||||
</Link>
|
||||
<div className="ml-1 w-[calc(100%-20px)] flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<a
|
||||
className={classNames(
|
||||
"max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
|
||||
)}
|
||||
href={shortcutLink}
|
||||
target="_blank"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<ShortcutActionsDropdown shortcut={shortcut} />
|
||||
</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" />
|
||||
{shortcut.creator.nickname}
|
||||
</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>
|
||||
|
||||
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||
|
||||
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
30
web/src/components/ShortcutsContainer.tsx
Normal file
30
web/src/components/ShortcutsContainer.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import classNames from "classnames";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import ShortcutCard from "./ShortcutCard";
|
||||
import ShortcutView from "./ShortcutView";
|
||||
|
||||
interface Props {
|
||||
shortcutList: Shortcut[];
|
||||
}
|
||||
|
||||
const ShortcutsContainer: React.FC<Props> = (props: Props) => {
|
||||
const { shortcutList } = props;
|
||||
const viewStore = useViewStore();
|
||||
const displayStyle = viewStore.displayStyle || "full";
|
||||
const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"w-full grid grid-cols-1 gap-y-2 sm:gap-2",
|
||||
displayStyle === "full" ? "sm:grid-cols-2" : "grid-cols-2 sm:grid-cols-4 gap-2"
|
||||
)}
|
||||
>
|
||||
{shortcutList.map((shortcut) => {
|
||||
return <ShortcutItemView key={shortcut.id} shortcut={shortcut} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutsContainer;
|
64
web/src/components/ViewSetting.tsx
Normal file
64
web/src/components/ViewSetting.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { Button, Divider, Option, Select } from "@mui/joy";
|
||||
import { toast } from "react-hot-toast";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const ViewSetting = () => {
|
||||
const viewStore = useViewStore();
|
||||
const order = viewStore.getOrder();
|
||||
const { field, direction } = order;
|
||||
const displayStyle = viewStore.displayStyle || "full";
|
||||
|
||||
const handleReset = () => {
|
||||
viewStore.setOrder({ field: "name", direction: "asc" });
|
||||
toast.success("Order reset");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button>
|
||||
<Icon.Settings2 className="w-4 h-auto text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
actionsClassName="!mt-3 !-right-2"
|
||||
actions={
|
||||
<div className="w-52 p-2 pt-0 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="w-full flex flex-row justify-between items-center mt-1">
|
||||
<span className="text-sm font-medium">View order</span>
|
||||
<Button size="sm" variant="plain" color="neutral" onClick={handleReset}>
|
||||
<Icon.RefreshCw className="w-4 h-auto text-gray-500" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-2">Order by</span>
|
||||
<Select size="sm" value={field} onChange={(_, value) => viewStore.setOrder({ field: value as any })}>
|
||||
<Option value={"name"}>Name</Option>
|
||||
<Option value={"updatedTs"}>CreatedAt</Option>
|
||||
<Option value={"createdTs"}>UpdatedAt</Option>
|
||||
<Option value={"view"}>Visits</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-2">Direction</span>
|
||||
<Select size="sm" value={direction} onChange={(_, value) => viewStore.setOrder({ direction: value as any })}>
|
||||
<Option value={"asc"}>ASC</Option>
|
||||
<Option value={"desc"}>DESC</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-2">Display</span>
|
||||
<Select size="sm" value={displayStyle} onChange={(_, value) => viewStore.setDisplayStyle(value as any)}>
|
||||
<Option value={"full"}>Full</Option>
|
||||
<Option value={"compact"}>Compact</Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
></Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewSetting;
|
@ -21,18 +21,28 @@ const Dropdown: React.FC<Props> = (props: Props) => {
|
||||
toggleDropdownStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [dropdownStatus]);
|
||||
|
||||
const handleToggleDropdownStatus = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdownStatus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownWrapperRef}
|
||||
className={`relative flex flex-col justify-start items-start select-none ${className ?? ""}`}
|
||||
onClick={() => toggleDropdownStatus()}
|
||||
onClick={handleToggleDropdownStatus}
|
||||
>
|
||||
{trigger ? (
|
||||
trigger
|
||||
|
@ -12,7 +12,7 @@ const AccountSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start gap-y-2">
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start gap-y-2">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900">Account</p>
|
||||
<p className="flex flex-row justify-start items-center mt-2">
|
||||
<span className="text-xl">{currentUser.nickname}</span>
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@mui/joy";
|
||||
import CreateUserDialog from "../CreateUserDialog";
|
||||
import { useEffect, useState } from "react";
|
||||
import useUserStore from "../../stores/v1/user";
|
||||
import CreateUserDialog from "../CreateUserDialog";
|
||||
|
||||
const MemberSection = () => {
|
||||
const userStore = useUserStore();
|
||||
const [showCreateUserDialog, setShowCreateUserDialog] = useState<boolean>(false);
|
||||
const [currentEditingUser, setCurrentEditingUser] = useState<User | undefined>(undefined);
|
||||
const userList = Object.values(userStore.userMap);
|
||||
const userList = Object.values(userStore.userMapById);
|
||||
|
||||
useEffect(() => {
|
||||
userStore.fetchUserList();
|
||||
@ -20,7 +20,7 @@ const MemberSection = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start space-y-4">
|
||||
<div className="w-full">
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
|
@ -17,7 +17,7 @@ const WorkspaceSection: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start space-y-4">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900">Workspace settings</p>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<Checkbox
|
||||
|
@ -10,3 +10,16 @@ html,
|
||||
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
|
@ -55,6 +55,10 @@ export function getShortcutList(shortcutFind?: ShortcutFind) {
|
||||
return axios.get<Shortcut[]>(`/api/v1/shortcut?${queryList.join("&")}`);
|
||||
}
|
||||
|
||||
export function getShortcutById(id: number) {
|
||||
return axios.get<Shortcut>(`/api/v1/shortcut/${id}`);
|
||||
}
|
||||
|
||||
export function createShortcut(shortcutCreate: ShortcutCreate) {
|
||||
return axios.post<Shortcut>("/api/v1/shortcut", shortcutCreate);
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Header from "../components/Header";
|
||||
import DemoBanner from "../components/DemoBanner";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const Root: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -19,8 +18,7 @@ const Root: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
{currentUser && (
|
||||
<div className="w-full h-full flex flex-col justify-start items-start">
|
||||
<DemoBanner />
|
||||
<div className="w-full h-auto flex flex-col justify-start items-start">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
|
@ -3,8 +3,8 @@ import { createRoot } from "react-dom/client";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { Provider } from "react-redux";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import store from "./stores";
|
||||
import router from "./routers";
|
||||
import store from "./stores";
|
||||
import "./i18n";
|
||||
import "./css/index.css";
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Button } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import ChangePasswordDialog from "../components/ChangePasswordDialog";
|
||||
import EditUserinfoDialog from "../components/EditUserinfoDialog";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const Account: React.FC = () => {
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
@ -11,7 +11,7 @@ const Account: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start space-y-4">
|
||||
<p className="text-3xl my-2">{currentUser.nickname}</p>
|
||||
<p className="leading-8 flex flex-row justify-start items-center">
|
||||
<span className="mr-3 text-gray-500 font-mono">Email: </span>
|
||||
|
@ -1,42 +1,21 @@
|
||||
import { Button, Tab, TabList, Tabs } from "@mui/joy";
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { shortcutService } from "../services";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useViewStore, { Filter } from "../stores/v1/view";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "../components/Icon";
|
||||
import ShortcutListView from "../components/ShortcutListView";
|
||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||
import FilterView from "../components/FilterView";
|
||||
import Icon from "../components/Icon";
|
||||
import Navigator from "../components/Navigator";
|
||||
import ShortcutsContainer from "../components/ShortcutsContainer";
|
||||
import ViewSetting from "../components/ViewSetting";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { shortcutService } from "../services";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import useViewStore, { getFilteredShortcutList, getOrderedShortcutList } from "../stores/v1/view";
|
||||
|
||||
interface State {
|
||||
showCreateShortcutDialog: boolean;
|
||||
}
|
||||
|
||||
const getFilteredShortcutList = (shortcutList: Shortcut[], filter: Filter, currentUser: User) => {
|
||||
const { tag, mineOnly, visibility } = filter;
|
||||
const filteredShortcutList = shortcutList.filter((shortcut) => {
|
||||
if (tag) {
|
||||
if (!shortcut.tags.includes(tag)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (mineOnly) {
|
||||
if (shortcut.creatorId !== currentUser.id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (visibility) {
|
||||
if (shortcut.visibility !== visibility) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return filteredShortcutList;
|
||||
};
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const loadingState = useLoading();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
@ -47,6 +26,7 @@ const Home: React.FC = () => {
|
||||
});
|
||||
const filter = viewStore.filter;
|
||||
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
||||
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
||||
@ -63,44 +43,41 @@ const Home: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center mb-4">
|
||||
<span className="font-mono text-gray-400 mr-2">Shortcuts</span>
|
||||
</div>
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<Navigator />
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<Tabs
|
||||
value={filter.mineOnly ? "PRIVATE" : "ALL"}
|
||||
size="sm"
|
||||
onChange={(_, value) => viewStore.setFilter({ mineOnly: value !== "ALL" })}
|
||||
>
|
||||
<TabList>
|
||||
<Tab value={"ALL"}>All</Tab>
|
||||
<Tab value={"PRIVATE"}>Mine</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div>
|
||||
<Button className="shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
||||
<Icon.Plus className="w-5 h-auto" /> New
|
||||
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
||||
<Icon.Plus className="w-5 h-auto" />
|
||||
<span className="hidden sm:block ml-0.5">Create</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<Input
|
||||
className="w-32 ml-2"
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="Search"
|
||||
startDecorator={<Icon.Search className="w-4 h-auto" />}
|
||||
endDecorator={<ViewSetting />}
|
||||
value={filter.search}
|
||||
onChange={(e) => viewStore.setFilter({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterView />
|
||||
|
||||
{loadingState.isLoading ? (
|
||||
<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>
|
||||
) : filteredShortcutList.length === 0 ? (
|
||||
) : orderedShortcutList.length === 0 ? (
|
||||
<div className="py-16 w-full flex flex-col justify-center items-center">
|
||||
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
|
||||
<p className="mt-4">No shortcuts found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ShortcutListView shortcutList={filteredShortcutList} />
|
||||
<ShortcutsContainer shortcutList={orderedShortcutList} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
@ -1,14 +1,14 @@
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import AccountSection from "../components/setting/AccountSection";
|
||||
import WorkspaceSection from "../components/setting/WorkspaceSection";
|
||||
import UserSection from "../components/setting/UserSection";
|
||||
import WorkspaceSection from "../components/setting/WorkspaceSection";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const Setting: React.FC = () => {
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const isAdmin = currentUser.role === "ADMIN";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start space-y-4">
|
||||
<AccountSection />
|
||||
{isAdmin && (
|
||||
<>
|
||||
|
203
web/src/pages/ShortcutDetail.tsx
Normal file
203
web/src/pages/ShortcutDetail.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
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 { useLoaderData, useNavigate } from "react-router-dom";
|
||||
import { showCommonDialog } from "../components/Alert";
|
||||
import AnalyticsView from "../components/AnalyticsView";
|
||||
import Dropdown from "../components/common/Dropdown";
|
||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||
import GenerateQRCodeDialog from "../components/GenerateQRCodeDialog";
|
||||
import Icon from "../components/Icon";
|
||||
import VisibilityIcon from "../components/VisibilityIcon";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import { shortcutService } from "../services";
|
||||
import useFaviconStore from "../stores/v1/favicon";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
interface State {
|
||||
showEditModal: boolean;
|
||||
}
|
||||
|
||||
const ShortcutDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const shortcutId = (useLoaderData() as Shortcut).id;
|
||||
const shortcut = shortcutService.getShortcutById(shortcutId) as Shortcut;
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const faviconStore = useFaviconStore();
|
||||
const [state, setState] = useState<State>({
|
||||
showEditModal: false,
|
||||
});
|
||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||
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.");
|
||||
};
|
||||
|
||||
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);
|
||||
navigate("/", {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<div className="mt-8 w-12 h-12 flex justify-center items-center overflow-clip">
|
||||
{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" />
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className={classNames(
|
||||
"group max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
|
||||
)}
|
||||
href={shortcutLink}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="truncate text-3xl">
|
||||
<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-6 h-auto text-gray-600" />
|
||||
</span>
|
||||
</a>
|
||||
<div className="mt-2 w-full flex flex-row justify-normal items-center space-x-2">
|
||||
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||
<button
|
||||
className="w-8 h-8 cursor-pointer border rounded-full text-gray-500 hover:bg-gray-100 hover:shadow"
|
||||
onClick={() => handleCopyButtonClick()}
|
||||
>
|
||||
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
|
||||
<button
|
||||
className="w-8 h-8 cursor-pointer border rounded-full text-gray-500 hover:bg-gray-100 hover:shadow"
|
||||
onClick={() => setShowQRCodeDialog(true)}
|
||||
>
|
||||
<Icon.QrCode className="w-4 h-auto mx-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{havePermission && (
|
||||
<Dropdown
|
||||
className="w-8 h-8 flex justify-center items-center border cursor-pointer rounded-full hover:bg-gray-100 hover:shadow"
|
||||
actionsClassName="!w-32 !-right-24"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => {
|
||||
setState({
|
||||
...state,
|
||||
showEditModal: 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 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>
|
||||
)}
|
||||
</div>
|
||||
{shortcut.description && <p className="w-full break-all mt-2 text-gray-400 text-sm">{shortcut.description}</p>}
|
||||
<div className="mt-4 ml-1 flex flex-row justify-start items-start flex-wrap gap-2">
|
||||
{shortcut.tags.map((tag) => {
|
||||
return (
|
||||
<span key={tag} className="max-w-[8rem] truncate text-gray-400 text font-mono leading-4">
|
||||
#{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-4 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 text-gray-500 text-sm">
|
||||
<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 text-gray-500 text-sm">
|
||||
<Icon.BarChart2 className="w-4 h-auto mr-1" />
|
||||
{shortcut.view} visits
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col mt-8">
|
||||
<h3 className="pl-1 font-medium text-lg flex flex-row justify-start items-center">
|
||||
<Icon.BarChart2 className="w-6 h-auto mr-1" />
|
||||
Analytics
|
||||
</h3>
|
||||
<AnalyticsView className="mt-4 w-full grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-4" shortcutId={shortcut.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||
|
||||
{state.showEditModal && (
|
||||
<CreateShortcutDialog
|
||||
shortcutId={shortcut.id}
|
||||
onClose={() =>
|
||||
setState({
|
||||
...state,
|
||||
showEditModal: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutDetail;
|
@ -1,10 +1,10 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import React, { FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import * as api from "../helpers/api";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const SignIn: React.FC = () => {
|
||||
@ -29,7 +29,7 @@ const SignIn: React.FC = () => {
|
||||
}
|
||||
|
||||
if (mode === "demo") {
|
||||
setEmail("slash@stevenlgtm.com");
|
||||
setEmail("steven@usememos.com");
|
||||
setPassword("secret");
|
||||
}
|
||||
}, []);
|
||||
@ -44,7 +44,8 @@ const SignIn: React.FC = () => {
|
||||
setPassword(text);
|
||||
};
|
||||
|
||||
const handleSigninBtnClick = async () => {
|
||||
const handleSigninBtnClick = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (actionBtnLoadingState.isLoading) {
|
||||
return;
|
||||
}
|
||||
@ -68,14 +69,14 @@ const SignIn: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center items-center w-full h-screen bg-white">
|
||||
<div className="flex flex-row justify-center items-center w-full h-auto mt-12 sm:mt-24 bg-white">
|
||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||
<div className="flex flex-col justify-start items-center w-full gap-y-2 mb-4">
|
||||
<img src="/logo.png" className="w-16 h-auto" alt="logo" />
|
||||
<span className="text-2xl font-medium font-mono opacity-80">Slash</span>
|
||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||
<img src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
||||
<span className="text-3xl font-medium font-mono opacity-80">Slash</span>
|
||||
</div>
|
||||
<form className="w-full" onSubmit={handleSigninBtnClick}>
|
||||
<form className="w-full mt-6" onSubmit={handleSigninBtnClick}>
|
||||
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading ? "opacity-80" : ""}`}>
|
||||
<div className="w-full flex flex-col mb-2">
|
||||
<span className="leading-8 mb-1 text-gray-600">Email</span>
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import React, { FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import * as api from "../helpers/api";
|
||||
import { globalService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { globalService } from "../services";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const SignUp: React.FC = () => {
|
||||
@ -48,7 +48,8 @@ const SignUp: React.FC = () => {
|
||||
setPassword(text);
|
||||
};
|
||||
|
||||
const handleSignupBtnClick = async () => {
|
||||
const handleSignupBtnClick = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (actionBtnLoadingState.isLoading) {
|
||||
return;
|
||||
}
|
||||
@ -72,15 +73,15 @@ const SignUp: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center items-center w-full h-screen bg-white">
|
||||
<div className="flex flex-row justify-center items-center w-full h-auto mt-12 sm:mt-24 bg-white">
|
||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||
<div className="flex flex-col justify-start items-center w-full gap-y-2 mb-4">
|
||||
<img src="/logo.png" className="w-16 h-auto" alt="logo" />
|
||||
<span className="text-2xl font-medium font-mono opacity-80">Slash</span>
|
||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||
<img src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
||||
<span className="text-3xl font-medium font-mono opacity-80">Slash</span>
|
||||
</div>
|
||||
<p className="w-full text-center mb-4 text-2xl">Create your account</p>
|
||||
<form className="w-full" onSubmit={handleSignupBtnClick}>
|
||||
<p className="w-full text-2xl mt-6">Create your account</p>
|
||||
<form className="w-full mt-4" onSubmit={handleSignupBtnClick}>
|
||||
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading ? "opacity-80" : ""}`}>
|
||||
<div className="w-full flex flex-col mb-2">
|
||||
<span className="leading-8 mb-1 text-gray-600">Email</span>
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import App from "../App";
|
||||
import Root from "../layouts/Root";
|
||||
import SignIn from "../pages/SignIn";
|
||||
import SignUp from "../pages/SignUp";
|
||||
import Home from "../pages/Home";
|
||||
import Setting from "../pages/Setting";
|
||||
import App from "../App";
|
||||
import ShortcutDetail from "../pages/ShortcutDetail";
|
||||
import SignIn from "../pages/SignIn";
|
||||
import SignUp from "../pages/SignUp";
|
||||
import { shortcutService } from "../services";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@ -27,6 +29,14 @@ const router = createBrowserRouter([
|
||||
path: "",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/shortcut/:shortcutId",
|
||||
element: <ShortcutDetail />,
|
||||
loader: async ({ params }) => {
|
||||
const shortcut = await shortcutService.getOrFetchShortcutById(Number(params.shortcutId));
|
||||
return shortcut;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/setting",
|
||||
element: <Setting />,
|
||||
|
@ -29,13 +29,25 @@ const shortcutService = {
|
||||
},
|
||||
|
||||
getShortcutById: (id: ShortcutId) => {
|
||||
for (const s of shortcutService.getState().shortcutList) {
|
||||
if (s.id === id) {
|
||||
return s;
|
||||
for (const shortcut of shortcutService.getState().shortcutList) {
|
||||
if (shortcut.id === id) {
|
||||
return shortcut;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getOrFetchShortcutById: async (id: ShortcutId) => {
|
||||
for (const shortcut of shortcutService.getState().shortcutList) {
|
||||
if (shortcut.id === id) {
|
||||
return shortcut;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
const data = (await api.getShortcutById(id)).data;
|
||||
const shortcut = convertResponseModelShortcut(data);
|
||||
store.dispatch(createShortcut(shortcut));
|
||||
return shortcut;
|
||||
},
|
||||
|
||||
createShortcut: async (shortcutCreate: ShortcutCreate) => {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user