diff --git a/api/v1/activity.go b/api/v1/activity.go
new file mode 100644
index 0000000..3e1112d
--- /dev/null
+++ b/api/v1/activity.go
@@ -0,0 +1,12 @@
+package v1
+
+type ActivityShorcutCreatePayload struct {
+ ShortcutID int32 `json:"shortcutId"`
+}
+
+type ActivityShorcutViewPayload struct {
+ ShortcutID int32 `json:"shortcutId"`
+ IP string `json:"ip"`
+ Referer string `json:"referer"`
+ UserAgent string `json:"userAgent"`
+}
diff --git a/api/v1/analytics.go b/api/v1/analytics.go
new file mode 100644
index 0000000..e366d0b
--- /dev/null
+++ b/api/v1/analytics.go
@@ -0,0 +1,131 @@
+package v1
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "github.com/labstack/echo/v4"
+ "github.com/mssola/useragent"
+ "golang.org/x/exp/slices"
+
+ "github.com/boojack/slash/server/metric"
+ "github.com/boojack/slash/store"
+)
+
+type ReferenceInfo struct {
+ Name string `json:"name"`
+ Count int `json:"count"`
+}
+
+type DeviceInfo struct {
+ Name string `json:"name"`
+ Count int `json:"count"`
+}
+
+type BrowserInfo struct {
+ Name string `json:"name"`
+ Count int `json:"count"`
+}
+
+type AnalysisData struct {
+ ReferenceData []ReferenceInfo `json:"referenceData"`
+ DeviceData []DeviceInfo `json:"deviceData"`
+ BrowserData []BrowserInfo `json:"browserData"`
+}
+
+func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
+ g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
+ }
+ activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
+ Type: store.ActivityShortcutView,
+ Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcutID)},
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
+ }
+
+ referenceMap := make(map[string]int)
+ deviceMap := make(map[string]int)
+ browserMap := make(map[string]int)
+ for _, activity := range activities {
+ payload := &ActivityShorcutViewPayload{}
+ if err := json.Unmarshal([]byte(activity.Payload), payload); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal payload, err: %s", err)).SetInternal(err)
+ }
+
+ if _, ok := referenceMap[payload.Referer]; !ok {
+ referenceMap[payload.Referer] = 0
+ }
+ referenceMap[payload.Referer]++
+
+ ua := useragent.New(payload.UserAgent)
+ deviceName := ua.OSInfo().Name
+ browserName, _ := ua.Browser()
+
+ if _, ok := deviceMap[deviceName]; !ok {
+ deviceMap[deviceName] = 0
+ }
+ deviceMap[deviceName]++
+
+ if _, ok := browserMap[browserName]; !ok {
+ browserMap[browserName] = 0
+ }
+ browserMap[browserName]++
+ }
+
+ metric.Enqueue("shortcut analytics")
+ return c.JSON(http.StatusOK, &AnalysisData{
+ ReferenceData: mapToReferenceInfoSlice(referenceMap),
+ DeviceData: mapToDeviceInfoSlice(deviceMap),
+ BrowserData: mapToBrowserInfoSlice(browserMap),
+ })
+ })
+}
+
+func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
+ referenceInfoSlice := make([]ReferenceInfo, 0)
+ for key, value := range m {
+ referenceInfoSlice = append(referenceInfoSlice, ReferenceInfo{
+ Name: key,
+ Count: value,
+ })
+ }
+ slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) int {
+ return i.Count - j.Count
+ })
+ return referenceInfoSlice
+}
+
+func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
+ deviceInfoSlice := make([]DeviceInfo, 0)
+ for key, value := range m {
+ deviceInfoSlice = append(deviceInfoSlice, DeviceInfo{
+ Name: key,
+ Count: value,
+ })
+ }
+ slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) int {
+ return i.Count - j.Count
+ })
+ return deviceInfoSlice
+}
+
+func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
+ browserInfoSlice := make([]BrowserInfo, 0)
+ for key, value := range m {
+ browserInfoSlice = append(browserInfoSlice, BrowserInfo{
+ Name: key,
+ Count: value,
+ })
+ }
+ slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) int {
+ return i.Count - j.Count
+ })
+ return browserInfoSlice
+}
diff --git a/api/v1/auth.go b/api/v1/auth.go
new file mode 100644
index 0000000..3fc192e
--- /dev/null
+++ b/api/v1/auth.go
@@ -0,0 +1,211 @@
+package v1
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/labstack/echo/v4"
+ "github.com/pkg/errors"
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/boojack/slash/api/auth"
+ storepb "github.com/boojack/slash/proto/gen/store"
+ "github.com/boojack/slash/server/metric"
+ "github.com/boojack/slash/server/service/license"
+ "github.com/boojack/slash/store"
+)
+
+type SignInRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+type SignUpRequest struct {
+ Nickname string `json:"nickname"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
+ g.POST("/auth/signin", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ signin := &SignInRequest{}
+ if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signin request, err: %s", err))
+ }
+
+ user, err := s.Store.GetUser(ctx, &store.FindUser{
+ Email: &signin.Email,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user by email %s", signin.Email)).SetInternal(err)
+ }
+ if user == nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("user not found with email %s", signin.Email))
+ } else if user.RowStatus == store.Archived {
+ return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("user has been archived with email %s", signin.Email))
+ }
+
+ // Compare the stored hashed password, with the hashed version of the password that was received.
+ if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
+ }
+
+ accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
+ }
+ if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
+ }
+
+ cookieExp := time.Now().Add(auth.CookieExpDuration)
+ setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
+ metric.Enqueue("user sign in")
+ return c.JSON(http.StatusOK, convertUserFromStore(user))
+ })
+
+ g.POST("/auth/signup", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
+ Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
+ }
+ if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
+ return echo.NewHTTPError(http.StatusForbidden, "sign up has been disabled")
+ }
+
+ if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
+ userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
+ }
+ if len(userList) >= 5 {
+ return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
+ }
+ }
+
+ signup := &SignUpRequest{}
+ if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signup request, err: %s", err)).SetInternal(err)
+ }
+
+ passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
+ }
+
+ create := &store.User{
+ Email: signup.Email,
+ Nickname: signup.Nickname,
+ PasswordHash: string(passwordHash),
+ }
+ existingUsers, err := s.Store.ListUsers(ctx, &store.FindUser{})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find existing users, err: %s", err)).SetInternal(err)
+ }
+ // The first user to sign up is an admin by default.
+ if len(existingUsers) == 0 {
+ create.Role = store.RoleAdmin
+ } else {
+ create.Role = store.RoleUser
+ }
+
+ user, err := s.Store.CreateUser(ctx, create)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
+ }
+
+ accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
+ }
+ if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
+ }
+
+ cookieExp := time.Now().Add(auth.CookieExpDuration)
+ setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
+ metric.Enqueue("user sign up")
+ return c.JSON(http.StatusOK, convertUserFromStore(user))
+ })
+
+ g.POST("/auth/logout", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ RemoveTokensAndCookies(c)
+ accessToken := findAccessToken(c)
+ userID, _ := getUserIDFromAccessToken(accessToken, secret)
+ userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
+ // Auto remove the current access token from the user access tokens.
+ if err == nil && len(userAccessTokens) != 0 {
+ accessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
+ for _, userAccessToken := range userAccessTokens {
+ if accessToken != userAccessToken.AccessToken {
+ accessTokens = append(accessTokens, userAccessToken)
+ }
+ }
+
+ if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
+ UserId: userID,
+ Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
+ Value: &storepb.UserSetting_AccessTokens{
+ AccessTokens: &storepb.AccessTokensUserSetting{
+ AccessTokens: accessTokens,
+ },
+ },
+ }); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
+ }
+ }
+ c.Response().WriteHeader(http.StatusOK)
+ return nil
+ })
+}
+
+func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
+ userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
+ if err != nil {
+ return errors.Wrap(err, "failed to get user access tokens")
+ }
+ userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
+ AccessToken: accessToken,
+ Description: "Account sign in",
+ }
+ userAccessTokens = append(userAccessTokens, &userAccessToken)
+ if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
+ UserId: user.ID,
+ Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
+ Value: &storepb.UserSetting_AccessTokens{
+ AccessTokens: &storepb.AccessTokensUserSetting{
+ AccessTokens: userAccessTokens,
+ },
+ },
+ }); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
+ }
+ return nil
+}
+
+// RemoveTokensAndCookies removes the jwt token from the cookies.
+func RemoveTokensAndCookies(c echo.Context) {
+ cookieExp := time.Now().Add(-1 * time.Hour)
+ setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
+}
+
+// setTokenCookie sets the token to the cookie.
+func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
+ cookie := new(http.Cookie)
+ cookie.Name = name
+ cookie.Value = token
+ cookie.Expires = expiration
+ cookie.Path = "/"
+ // Http-only helps mitigate the risk of client side script accessing the protected cookie.
+ cookie.HttpOnly = true
+ cookie.SameSite = http.SameSiteStrictMode
+ c.SetCookie(cookie)
+}
diff --git a/api/v1/common.go b/api/v1/common.go
new file mode 100644
index 0000000..a471fb1
--- /dev/null
+++ b/api/v1/common.go
@@ -0,0 +1,15 @@
+package v1
+
+// RowStatus is the status for a row.
+type RowStatus string
+
+const (
+ // Normal is the status for a normal row.
+ Normal RowStatus = "NORMAL"
+ // Archived is the status for an archived row.
+ Archived RowStatus = "ARCHIVED"
+)
+
+func (s RowStatus) String() string {
+ return string(s)
+}
diff --git a/api/v1/jwt.go b/api/v1/jwt.go
new file mode 100644
index 0000000..545e62f
--- /dev/null
+++ b/api/v1/jwt.go
@@ -0,0 +1,133 @@
+package v1
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/golang-jwt/jwt/v4"
+ "github.com/labstack/echo/v4"
+ "github.com/pkg/errors"
+
+ "github.com/boojack/slash/api/auth"
+ "github.com/boojack/slash/internal/util"
+ storepb "github.com/boojack/slash/proto/gen/store"
+ "github.com/boojack/slash/store"
+)
+
+const (
+ // The key name used to store user id in the context
+ // user id is extracted from the jwt token subject field.
+ userIDContextKey = "user-id"
+)
+
+func extractTokenFromHeader(c echo.Context) (string, error) {
+ authHeader := c.Request().Header.Get("Authorization")
+ if authHeader == "" {
+ return "", nil
+ }
+
+ authHeaderParts := strings.Fields(authHeader)
+ if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
+ return "", errors.New("Authorization header format must be Bearer {token}")
+ }
+
+ return authHeaderParts[1], nil
+}
+
+func findAccessToken(c echo.Context) string {
+ // Check the HTTP request header first.
+ accessToken, _ := extractTokenFromHeader(c)
+ if accessToken == "" {
+ // Check the cookie.
+ cookie, _ := c.Cookie(auth.AccessTokenCookieName)
+ if cookie != nil {
+ accessToken = cookie.Value
+ }
+ }
+ return accessToken
+}
+
+// JWTMiddleware validates the access token.
+func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ ctx := c.Request().Context()
+ path := c.Request().URL.Path
+ method := c.Request().Method
+
+ // Pass auth and profile endpoints.
+ if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
+ return next(c)
+ }
+
+ accessToken := findAccessToken(c)
+ if accessToken == "" {
+ // When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
+ if util.HasPrefixes(path, "/s/", "/api/v1/user/") && method == http.MethodGet {
+ return next(c)
+ }
+ return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
+ }
+
+ userID, err := getUserIDFromAccessToken(accessToken, secret)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
+ }
+
+ accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
+ }
+ if !validateAccessToken(accessToken, accessTokens) {
+ return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
+ }
+
+ // Even if there is no error, we still need to make sure the user still exists.
+ user, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: &userID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
+ }
+ if user == nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
+ }
+
+ // Stores userID into context.
+ c.Set(userIDContextKey, userID)
+ return next(c)
+ }
+}
+
+func getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
+ claims := &auth.ClaimsMessage{}
+ _, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
+ if t.Method.Alg() != jwt.SigningMethodHS256.Name {
+ return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
+ }
+ if kid, ok := t.Header["kid"].(string); ok {
+ if kid == "v1" {
+ return []byte(secret), nil
+ }
+ }
+ return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
+ })
+ if err != nil {
+ return 0, errors.Wrap(err, "Invalid or expired access token")
+ }
+ // We either have a valid access token or we will attempt to generate new access token.
+ userID, err := util.ConvertStringToInt32(claims.Subject)
+ if err != nil {
+ return 0, errors.Wrap(err, "Malformed ID in the token")
+ }
+ return userID, nil
+}
+
+func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
+ for _, userAccessToken := range userAccessTokens {
+ if accessTokenString == userAccessToken.AccessToken {
+ return true
+ }
+ }
+ return false
+}
diff --git a/api/v1/redirector.go b/api/v1/redirector.go
new file mode 100644
index 0000000..187d715
--- /dev/null
+++ b/api/v1/redirector.go
@@ -0,0 +1,118 @@
+package v1
+
+import (
+ "encoding/json"
+ "fmt"
+ "html"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+ "github.com/pkg/errors"
+
+ storepb "github.com/boojack/slash/proto/gen/store"
+ "github.com/boojack/slash/server/metric"
+ "github.com/boojack/slash/store"
+)
+
+func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
+ g.GET("/*", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ if len(c.ParamValues()) == 0 {
+ return echo.NewHTTPError(http.StatusBadRequest, "invalid shortcut name")
+ }
+
+ shortcutName := c.ParamValues()[0]
+ shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
+ Name: &shortcutName,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get shortcut, err: %s", err)).SetInternal(err)
+ }
+ if shortcut == nil {
+ return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/404?shortcut=%s", shortcutName))
+ }
+ if shortcut.Visibility != storepb.Visibility_PUBLIC {
+ userID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
+ }
+ if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
+ return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
+ }
+ }
+
+ if err := s.createShortcutViewActivity(c, shortcut); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
+ }
+
+ metric.Enqueue("shortcut redirect")
+ return redirectToShortcut(c, shortcut)
+ })
+}
+
+func redirectToShortcut(c echo.Context, shortcut *storepb.Shortcut) error {
+ isValidURL := isValidURLString(shortcut.Link)
+ if shortcut.OgMetadata == nil || (shortcut.OgMetadata.Title == "" && shortcut.OgMetadata.Description == "" && shortcut.OgMetadata.Image == "") {
+ if isValidURL {
+ return c.Redirect(http.StatusSeeOther, shortcut.Link)
+ }
+ return c.String(http.StatusOK, shortcut.Link)
+ }
+
+ htmlTemplate := `
%s%s`
+ metadataList := []string{
+ fmt.Sprintf(`%s`, shortcut.OgMetadata.Title),
+ fmt.Sprintf(``, shortcut.OgMetadata.Description),
+ fmt.Sprintf(``, shortcut.OgMetadata.Title),
+ fmt.Sprintf(``, shortcut.OgMetadata.Description),
+ fmt.Sprintf(``, shortcut.OgMetadata.Image),
+ ``,
+ // Twitter related metadata.
+ fmt.Sprintf(``, shortcut.OgMetadata.Title),
+ fmt.Sprintf(``, shortcut.OgMetadata.Description),
+ fmt.Sprintf(``, shortcut.OgMetadata.Image),
+ ``,
+ }
+ if isValidURL {
+ metadataList = append(metadataList, fmt.Sprintf(``, shortcut.Link))
+ }
+ body := ""
+ if isValidURL {
+ body = fmt.Sprintf(``, shortcut.Link)
+ } else {
+ body = html.EscapeString(shortcut.Link)
+ }
+ htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
+ return c.HTML(http.StatusOK, htmlString)
+}
+
+func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *storepb.Shortcut) error {
+ payload := &ActivityShorcutViewPayload{
+ ShortcutID: shortcut.Id,
+ IP: c.RealIP(),
+ Referer: c.Request().Referer(),
+ UserAgent: c.Request().UserAgent(),
+ }
+ payloadStr, err := json.Marshal(payload)
+ if err != nil {
+ return errors.Wrap(err, "Failed to marshal activity payload")
+ }
+ activity := &store.Activity{
+ CreatorID: BotID,
+ Type: store.ActivityShortcutView,
+ Level: store.ActivityInfo,
+ Payload: string(payloadStr),
+ }
+ _, err = s.Store.CreateActivity(c.Request().Context(), activity)
+ if err != nil {
+ return errors.Wrap(err, "Failed to create activity")
+ }
+ return nil
+}
+
+func isValidURLString(s string) bool {
+ _, err := url.ParseRequestURI(s)
+ return err == nil
+}
diff --git a/api/v1/redirector_test.go b/api/v1/redirector_test.go
new file mode 100644
index 0000000..1e988a6
--- /dev/null
+++ b/api/v1/redirector_test.go
@@ -0,0 +1,33 @@
+package v1
+
+import "testing"
+
+func TestIsValidURLString(t *testing.T) {
+ tests := []struct {
+ link string
+ expected bool
+ }{
+ {
+ link: "https://google.com",
+ expected: true,
+ },
+ {
+ link: "http://google.com",
+ expected: true,
+ },
+ {
+ link: "google.com",
+ expected: false,
+ },
+ {
+ link: "mailto:email@example.com",
+ expected: true,
+ },
+ }
+
+ for _, test := range tests {
+ if isValidURLString(test.link) != test.expected {
+ t.Errorf("isValidURLString(%s) = %v, expected %v", test.link, !test.expected, test.expected)
+ }
+ }
+}
diff --git a/api/v1/shortcut.go b/api/v1/shortcut.go
new file mode 100644
index 0000000..9047620
--- /dev/null
+++ b/api/v1/shortcut.go
@@ -0,0 +1,386 @@
+package v1
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+ "github.com/pkg/errors"
+
+ "github.com/boojack/slash/internal/util"
+ storepb "github.com/boojack/slash/proto/gen/store"
+ "github.com/boojack/slash/server/metric"
+ "github.com/boojack/slash/store"
+)
+
+// Visibility is the type of a shortcut visibility.
+type Visibility string
+
+const (
+ // VisibilityPublic is the PUBLIC visibility.
+ VisibilityPublic Visibility = "PUBLIC"
+ // VisibilityWorkspace is the WORKSPACE visibility.
+ VisibilityWorkspace Visibility = "WORKSPACE"
+ // VisibilityPrivate is the PRIVATE visibility.
+ VisibilityPrivate Visibility = "PRIVATE"
+)
+
+func (v Visibility) String() string {
+ return string(v)
+}
+
+type OpenGraphMetadata struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Image string `json:"image"`
+}
+
+type Shortcut struct {
+ ID int32 `json:"id"`
+
+ // Standard fields
+ CreatorID int32 `json:"creatorId"`
+ Creator *User `json:"creator"`
+ CreatedTs int64 `json:"createdTs"`
+ UpdatedTs int64 `json:"updatedTs"`
+ RowStatus RowStatus `json:"rowStatus"`
+
+ // Domain specific fields
+ Name string `json:"name"`
+ Link string `json:"link"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Visibility Visibility `json:"visibility"`
+ Tags []string `json:"tags"`
+ View int `json:"view"`
+ OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
+}
+
+type CreateShortcutRequest struct {
+ Name string `json:"name"`
+ Link string `json:"link"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Visibility Visibility `json:"visibility"`
+ Tags []string `json:"tags"`
+ OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
+}
+
+type PatchShortcutRequest struct {
+ RowStatus *RowStatus `json:"rowStatus"`
+ Name *string `json:"name"`
+ Link *string `json:"link"`
+ Title *string `json:"title"`
+ Description *string `json:"description"`
+ Visibility *Visibility `json:"visibility"`
+ Tags []string `json:"tags"`
+ OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
+}
+
+func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
+ g.POST("/shortcut", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ userID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
+ }
+ create := &CreateShortcutRequest{}
+ if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
+ }
+
+ shortcut := &storepb.Shortcut{
+ CreatorId: userID,
+ Name: create.Name,
+ Link: create.Link,
+ Title: create.Title,
+ Description: create.Description,
+ Visibility: convertVisibilityToStorepb(create.Visibility),
+ Tags: create.Tags,
+ OgMetadata: &storepb.OpenGraphMetadata{},
+ }
+ if create.Name == "" {
+ return echo.NewHTTPError(http.StatusBadRequest, "name is required")
+ }
+ if create.OpenGraphMetadata != nil {
+ shortcut.OgMetadata = &storepb.OpenGraphMetadata{
+ Title: create.OpenGraphMetadata.Title,
+ Description: create.OpenGraphMetadata.Description,
+ Image: create.OpenGraphMetadata.Image,
+ }
+ }
+ shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
+ }
+
+ if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
+ }
+
+ shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
+ }
+ metric.Enqueue("shortcut create")
+ return c.JSON(http.StatusOK, shortcutMessage)
+ })
+
+ g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
+ }
+ userID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
+ }
+ currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: &userID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
+ }
+
+ shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
+ ID: &shortcutID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find shortcut, err: %s", err)).SetInternal(err)
+ }
+ if shortcut == nil {
+ return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
+ }
+ if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
+ return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
+ }
+
+ patch := &PatchShortcutRequest{}
+ if err := json.NewDecoder(c.Request().Body).Decode(patch); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode patch shortcut request, err: %s", err)).SetInternal(err)
+ }
+
+ shortcutUpdate := &store.UpdateShortcut{
+ ID: shortcutID,
+ Name: patch.Name,
+ Link: patch.Link,
+ Title: patch.Title,
+ Description: patch.Description,
+ }
+ if patch.RowStatus != nil {
+ shortcutUpdate.RowStatus = (*store.RowStatus)(patch.RowStatus)
+ }
+ if patch.Visibility != nil {
+ shortcutUpdate.Visibility = (*store.Visibility)(patch.Visibility)
+ }
+ if patch.Tags != nil {
+ tag := strings.Join(patch.Tags, " ")
+ shortcutUpdate.Tag = &tag
+ }
+ if patch.OpenGraphMetadata != nil {
+ shortcutUpdate.OpenGraphMetadata = &storepb.OpenGraphMetadata{
+ Title: patch.OpenGraphMetadata.Title,
+ Description: patch.OpenGraphMetadata.Description,
+ Image: patch.OpenGraphMetadata.Image,
+ }
+ }
+ shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
+ }
+
+ shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
+ }
+ return c.JSON(http.StatusOK, shortcutMessage)
+ })
+
+ g.GET("/shortcut", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ userID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
+ }
+
+ find := &store.FindShortcut{}
+ if tag := c.QueryParam("tag"); tag != "" {
+ find.Tag = &tag
+ }
+
+ list := []*storepb.Shortcut{}
+ find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
+ visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut list, err: %s", err)).SetInternal(err)
+ }
+ list = append(list, visibleShortcutList...)
+
+ find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
+ find.CreatorID = &userID
+ privateShortcutList, err := s.Store.ListShortcuts(ctx, find)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch private shortcut list, err: %s", err)).SetInternal(err)
+ }
+ list = append(list, privateShortcutList...)
+
+ shortcutMessageList := []*Shortcut{}
+ for _, shortcut := range list {
+ shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
+ }
+ shortcutMessageList = append(shortcutMessageList, shortcutMessage)
+ }
+ return c.JSON(http.StatusOK, shortcutMessageList)
+ })
+
+ g.GET("/shortcut/:id", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
+ }
+
+ shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
+ ID: &shortcutID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
+ }
+ if shortcut == nil {
+ return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
+ }
+
+ shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
+ }
+ return c.JSON(http.StatusOK, shortcutMessage)
+ })
+
+ g.DELETE("/shortcut/:id", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
+ }
+ userID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
+ }
+ currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: &userID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
+ }
+
+ shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
+ ID: &shortcutID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
+ }
+ if shortcut == nil {
+ return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
+ }
+ if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
+ return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
+ }
+
+ err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
+ }
+ return c.JSON(http.StatusOK, true)
+ })
+}
+
+func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
+ if shortcut == nil {
+ return nil, nil
+ }
+
+ user, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: &shortcut.CreatorID,
+ })
+ if err != nil {
+ return nil, errors.Wrap(err, "Failed to get creator")
+ }
+ if user == nil {
+ return nil, errors.New("Creator not found")
+ }
+ shortcut.Creator = convertUserFromStore(user)
+
+ activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
+ Type: store.ActivityShortcutView,
+ Level: store.ActivityInfo,
+ Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcut.ID)},
+ })
+ if err != nil {
+ return nil, errors.Wrap(err, "Failed to list activities")
+ }
+ shortcut.View = len(activityList)
+
+ return shortcut, nil
+}
+
+func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
+ return &Shortcut{
+ ID: shortcut.Id,
+ CreatedTs: shortcut.CreatedTs,
+ UpdatedTs: shortcut.UpdatedTs,
+ CreatorID: shortcut.CreatorId,
+ RowStatus: RowStatus(shortcut.RowStatus.String()),
+ Name: shortcut.Name,
+ Link: shortcut.Link,
+ Title: shortcut.Title,
+ Description: shortcut.Description,
+ Visibility: Visibility(shortcut.Visibility.String()),
+ Tags: shortcut.Tags,
+ OpenGraphMetadata: &OpenGraphMetadata{
+ Title: shortcut.OgMetadata.Title,
+ Description: shortcut.OgMetadata.Description,
+ Image: shortcut.OgMetadata.Image,
+ },
+ }
+}
+
+func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
+ switch visibility {
+ case VisibilityPublic:
+ return storepb.Visibility_PUBLIC
+ case VisibilityWorkspace:
+ return storepb.Visibility_WORKSPACE
+ case VisibilityPrivate:
+ return storepb.Visibility_PRIVATE
+ default:
+ return storepb.Visibility_PUBLIC
+ }
+}
+
+func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
+ payload := &ActivityShorcutCreatePayload{
+ ShortcutID: shortcut.Id,
+ }
+ payloadStr, err := json.Marshal(payload)
+ if err != nil {
+ return errors.Wrap(err, "Failed to marshal activity payload")
+ }
+ activity := &store.Activity{
+ CreatorID: shortcut.CreatorId,
+ Type: store.ActivityShortcutCreate,
+ Level: store.ActivityInfo,
+ Payload: string(payloadStr),
+ }
+ _, err = s.Store.CreateActivity(ctx, activity)
+ if err != nil {
+ return errors.Wrap(err, "Failed to create activity")
+ }
+ return nil
+}
diff --git a/api/v1/user.go b/api/v1/user.go
new file mode 100644
index 0000000..c516fd4
--- /dev/null
+++ b/api/v1/user.go
@@ -0,0 +1,340 @@
+package v1
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/mail"
+
+ "github.com/labstack/echo/v4"
+ "github.com/pkg/errors"
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/boojack/slash/internal/util"
+ "github.com/boojack/slash/server/metric"
+ "github.com/boojack/slash/server/service/license"
+ "github.com/boojack/slash/store"
+)
+
+const (
+ // BotID is the id of bot.
+ BotID = 0
+)
+
+// Role is the type of a role.
+type Role string
+
+const (
+ // RoleAdmin is the ADMIN role.
+ RoleAdmin Role = "ADMIN"
+ // RoleUser is the USER role.
+ RoleUser Role = "USER"
+)
+
+func (r Role) String() string {
+ switch r {
+ case RoleAdmin:
+ return "ADMIN"
+ case RoleUser:
+ return "USER"
+ }
+ return "USER"
+}
+
+type User struct {
+ ID int32 `json:"id"`
+
+ // Standard fields
+ CreatedTs int64 `json:"createdTs"`
+ UpdatedTs int64 `json:"updatedTs"`
+ RowStatus RowStatus `json:"rowStatus"`
+
+ // Domain specific fields
+ Email string `json:"email"`
+ Nickname string `json:"nickname"`
+ Role Role `json:"role"`
+}
+
+type CreateUserRequest struct {
+ Email string `json:"email"`
+ Nickname string `json:"nickname"`
+ Password string `json:"password"`
+ Role Role `json:"role"`
+}
+
+func (create CreateUserRequest) Validate() error {
+ if create.Email != "" && !validateEmail(create.Email) {
+ return errors.New("invalid email format")
+ }
+ if create.Nickname != "" && len(create.Nickname) < 3 {
+ return errors.New("nickname is too short, minimum length is 3")
+ }
+ if len(create.Password) < 3 {
+ return errors.New("password is too short, minimum length is 3")
+ }
+
+ return nil
+}
+
+type PatchUserRequest struct {
+ RowStatus *RowStatus `json:"rowStatus"`
+ Email *string `json:"email"`
+ Nickname *string `json:"nickname"`
+ Password *string `json:"password"`
+ Role *Role `json:"role"`
+}
+
+func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
+ g.POST("/user", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ userID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
+ }
+ currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: &userID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
+ }
+ if currentUser == nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
+ }
+ if currentUser.Role != store.RoleAdmin {
+ return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
+ }
+
+ if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
+ userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
+ }
+ if len(userList) >= 5 {
+ return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
+ }
+ }
+
+ userCreate := &CreateUserRequest{}
+ if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
+ }
+ if err := userCreate.Validate(); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
+ }
+
+ passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
+ }
+
+ user, err := s.Store.CreateUser(ctx, &store.User{
+ Role: store.Role(userCreate.Role),
+ Email: userCreate.Email,
+ Nickname: userCreate.Nickname,
+ PasswordHash: string(passwordHash),
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
+ }
+
+ userMessage := convertUserFromStore(user)
+ metric.Enqueue("user create")
+ return c.JSON(http.StatusOK, userMessage)
+ })
+
+ g.GET("/user", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ list, err := s.Store.ListUsers(ctx, &store.FindUser{})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to list users, err: %s", err)).SetInternal(err)
+ }
+
+ userList := []*User{}
+ for _, user := range list {
+ userList = append(userList, convertUserFromStore(user))
+ }
+ return c.JSON(http.StatusOK, userList)
+ })
+
+ // GET /api/user/me is used to check if the user is logged in.
+ g.GET("/user/me", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ userID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
+ }
+
+ user, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: &userID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
+ }
+
+ return c.JSON(http.StatusOK, convertUserFromStore(user))
+ })
+
+ g.GET("/user/:id", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ userID, err := util.ConvertStringToInt32(c.Param("id"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
+ }
+
+ user, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: &userID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
+ }
+
+ userMessage := convertUserFromStore(user)
+ userID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ userMessage.Email = ""
+ }
+ return c.JSON(http.StatusOK, userMessage)
+ })
+
+ g.PATCH("/user/:id", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ userID, err := util.ConvertStringToInt32(c.Param("id"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
+ }
+ currentUserID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
+ }
+ currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: ¤tUserID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err)
+ }
+ if currentUser == nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
+ }
+ if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
+ return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
+ }
+
+ userPatch := &PatchUserRequest{}
+ if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
+ }
+
+ updateUser := &store.UpdateUser{
+ ID: userID,
+ }
+ if userPatch.Email != nil {
+ if !validateEmail(*userPatch.Email) {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
+ }
+ updateUser.Email = userPatch.Email
+ }
+ if userPatch.Nickname != nil {
+ updateUser.Nickname = userPatch.Nickname
+ }
+ if userPatch.Password != nil && *userPatch.Password != "" {
+ passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to hash password, err: %s", err)).SetInternal(err)
+ }
+
+ passwordHashStr := string(passwordHash)
+ updateUser.PasswordHash = &passwordHashStr
+ }
+ if userPatch.RowStatus != nil {
+ rowStatus := store.RowStatus(*userPatch.RowStatus)
+ updateUser.RowStatus = &rowStatus
+ }
+ if userPatch.Role != nil {
+ adminRole := store.RoleAdmin
+ adminUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
+ Role: &adminRole,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list admin users, err: %s", err)).SetInternal(err)
+ }
+ if len(adminUsers) == 1 && adminUsers[0].ID == userID && *userPatch.Role != RoleAdmin {
+ return echo.NewHTTPError(http.StatusBadRequest, "cannot remove admin role from the last admin user")
+ }
+ role := store.Role(*userPatch.Role)
+ updateUser.Role = &role
+ }
+
+ user, err := s.Store.UpdateUser(ctx, updateUser)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to update user, err: %s", err)).SetInternal(err)
+ }
+
+ return c.JSON(http.StatusOK, convertUserFromStore(user))
+ })
+
+ g.DELETE("/user/:id", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ currentUserID, ok := c.Get(userIDContextKey).(int32)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
+ }
+ currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: ¤tUserID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find current session user, err: %s", err)).SetInternal(err)
+ }
+ if currentUser == nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("current session user not found with ID: %d", currentUserID)).SetInternal(err)
+ }
+ if currentUser.Role != store.RoleAdmin {
+ return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
+ }
+
+ userID, err := util.ConvertStringToInt32(c.Param("id"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
+ }
+ user, err := s.Store.GetUser(ctx, &store.FindUser{
+ ID: &userID,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
+ }
+ if user == nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
+ }
+ if user.Role == store.RoleAdmin {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
+ }
+
+ if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
+ ID: userID,
+ }); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete user, err: %s", err)).SetInternal(err)
+ }
+
+ return c.JSON(http.StatusOK, true)
+ })
+}
+
+// validateEmail validates the email.
+func validateEmail(email string) bool {
+ if _, err := mail.ParseAddress(email); err != nil {
+ return false
+ }
+ return true
+}
+
+// convertUserFromStore converts a store user to a user.
+func convertUserFromStore(user *store.User) *User {
+ return &User{
+ ID: user.ID,
+ CreatedTs: user.CreatedTs,
+ UpdatedTs: user.UpdatedTs,
+ RowStatus: RowStatus(user.RowStatus),
+ Email: user.Email,
+ Nickname: user.Nickname,
+ Role: Role(user.Role),
+ }
+}
diff --git a/api/v1/user_setting.go b/api/v1/user_setting.go
new file mode 100644
index 0000000..0b42cda
--- /dev/null
+++ b/api/v1/user_setting.go
@@ -0,0 +1,67 @@
+package v1
+
+import (
+ "encoding/json"
+
+ "github.com/pkg/errors"
+)
+
+type UserSettingKey string
+
+const (
+ // UserSettingLocaleKey is the key type for user locale.
+ UserSettingLocaleKey UserSettingKey = "locale"
+)
+
+// String returns the string format of UserSettingKey type.
+func (k UserSettingKey) String() string {
+ return string(k)
+}
+
+var (
+ UserSettingLocaleValue = []string{"en", "zh"}
+)
+
+type UserSetting struct {
+ UserID int
+ Key UserSettingKey `json:"key"`
+ // Value is a JSON string with basic value.
+ Value string `json:"value"`
+}
+
+type UserSettingUpsert struct {
+ UserID int
+ Key UserSettingKey `json:"key"`
+ Value string `json:"value"`
+}
+
+func (upsert UserSettingUpsert) Validate() error {
+ if upsert.Key == UserSettingLocaleKey {
+ localeValue := "en"
+ err := json.Unmarshal([]byte(upsert.Value), &localeValue)
+ if err != nil {
+ return errors.New("failed to unmarshal user setting locale value")
+ }
+
+ invalid := true
+ for _, value := range UserSettingLocaleValue {
+ if localeValue == value {
+ invalid = false
+ break
+ }
+ }
+ if invalid {
+ return errors.New("invalid user setting locale value")
+ }
+ } else {
+ return errors.New("invalid user setting key")
+ }
+
+ return nil
+}
+
+type UserSettingFind struct {
+ UserID int
+
+ Key *UserSettingKey `json:"key"`
+}
diff --git a/api/v1/v1.go b/api/v1/v1.go
new file mode 100644
index 0000000..cd9f085
--- /dev/null
+++ b/api/v1/v1.go
@@ -0,0 +1,41 @@
+package v1
+
+import (
+ "github.com/labstack/echo/v4"
+
+ "github.com/boojack/slash/server/profile"
+ "github.com/boojack/slash/server/service/license"
+ "github.com/boojack/slash/store"
+)
+
+type APIV1Service struct {
+ Profile *profile.Profile
+ Store *store.Store
+ LicenseService *license.LicenseService
+}
+
+func NewAPIV1Service(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *APIV1Service {
+ return &APIV1Service{
+ Profile: profile,
+ Store: store,
+ LicenseService: licenseService,
+ }
+}
+
+func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
+ apiV1Group := apiGroup.Group("/api/v1")
+ apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
+ return JWTMiddleware(s, next, secret)
+ })
+ s.registerWorkspaceRoutes(apiV1Group)
+ s.registerAuthRoutes(apiV1Group, secret)
+ s.registerUserRoutes(apiV1Group)
+ s.registerShortcutRoutes(apiV1Group)
+ s.registerAnalyticsRoutes(apiV1Group)
+
+ redirectorGroup := apiGroup.Group("/s")
+ redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
+ return JWTMiddleware(s, next, secret)
+ })
+ s.registerRedirectorRoutes(redirectorGroup)
+}
diff --git a/api/v1/workspace.go b/api/v1/workspace.go
new file mode 100644
index 0000000..ef218b5
--- /dev/null
+++ b/api/v1/workspace.go
@@ -0,0 +1,39 @@
+package v1
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/labstack/echo/v4"
+
+ storepb "github.com/boojack/slash/proto/gen/store"
+ "github.com/boojack/slash/server/profile"
+ "github.com/boojack/slash/store"
+)
+
+type WorkspaceProfile struct {
+ Profile *profile.Profile `json:"profile"`
+ DisallowSignUp bool `json:"disallowSignUp"`
+}
+
+func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
+ g.GET("/workspace/profile", func(c echo.Context) error {
+ ctx := c.Request().Context()
+ workspaceProfile := WorkspaceProfile{
+ Profile: s.Profile,
+ DisallowSignUp: false,
+ }
+
+ enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
+ Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
+ }
+ if enableSignUpSetting != nil {
+ workspaceProfile.DisallowSignUp = !enableSignUpSetting.GetEnableSignup()
+ }
+
+ return c.JSON(http.StatusOK, workspaceProfile)
+ })
+}
diff --git a/server/server.go b/server/server.go
index 9acc7d6..6d9fab4 100644
--- a/server/server.go
+++ b/server/server.go
@@ -15,6 +15,7 @@ import (
"github.com/pkg/errors"
"go.uber.org/zap"
+ apiv1 "github.com/boojack/slash/api/v1"
apiv2 "github.com/boojack/slash/api/v2"
"github.com/boojack/slash/internal/log"
storepb "github.com/boojack/slash/proto/gen/store"
@@ -103,6 +104,10 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
s.Secret = secret
rootGroup := e.Group("")
+ // Register API v1 routes.
+ apiV1Service := apiv1.NewAPIV1Service(profile, store, licenseService)
+ apiV1Service.Start(rootGroup, secret)
+
s.apiV2Service = apiv2.NewAPIV2Service(secret, profile, store, licenseService, s.Profile.Port+1)
// Register gRPC gateway as api v2.
if err := s.apiV2Service.RegisterGateway(ctx, e); err != nil {
diff --git a/test/server/auth_test.go b/test/server/auth_test.go
new file mode 100644
index 0000000..aad2330
--- /dev/null
+++ b/test/server/auth_test.go
@@ -0,0 +1,94 @@
+package testserver
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/require"
+
+ apiv1 "github.com/boojack/slash/api/v1"
+)
+
+func TestAuthServer(t *testing.T) {
+ ctx := context.Background()
+ s, err := NewTestingServer(ctx, t)
+ require.NoError(t, err)
+ defer s.Shutdown(ctx)
+
+ signup := &apiv1.SignUpRequest{
+ Email: "slash@yourselfhosted.com",
+ Password: "testpassword",
+ }
+ user, err := s.postAuthSignUp(signup)
+ require.NoError(t, err)
+ require.Equal(t, signup.Email, user.Email)
+
+ signin := &apiv1.SignInRequest{
+ Email: "slash@yourselfhosted.com",
+ Password: "testpassword",
+ }
+ user, err = s.postAuthSignIn(signin)
+ require.NoError(t, err)
+ require.Equal(t, signup.Email, user.Email)
+ err = s.postLogout()
+ require.NoError(t, err)
+}
+
+func (s *TestingServer) postAuthSignUp(signup *apiv1.SignUpRequest) (*apiv1.User, error) {
+ rawData, err := json.Marshal(&signup)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to marshal signup")
+ }
+ reader := bytes.NewReader(rawData)
+ body, err := s.post("/api/v1/auth/signup", reader, nil)
+ if err != nil {
+ return nil, errors.Wrap(err, "fail to post request")
+ }
+
+ buf := &bytes.Buffer{}
+ _, err = buf.ReadFrom(body)
+ if err != nil {
+ return nil, errors.Wrap(err, "fail to read response body")
+ }
+
+ user := &apiv1.User{}
+ if err = json.Unmarshal(buf.Bytes(), user); err != nil {
+ return nil, errors.Wrap(err, "fail to unmarshal post signup response")
+ }
+ return user, nil
+}
+
+func (s *TestingServer) postAuthSignIn(signip *apiv1.SignInRequest) (*apiv1.User, error) {
+ rawData, err := json.Marshal(&signip)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to marshal signin")
+ }
+ reader := bytes.NewReader(rawData)
+ body, err := s.post("/api/v1/auth/signin", reader, nil)
+ if err != nil {
+ return nil, errors.Wrap(err, "fail to post request")
+ }
+
+ buf := &bytes.Buffer{}
+ _, err = buf.ReadFrom(body)
+ if err != nil {
+ return nil, errors.Wrap(err, "fail to read response body")
+ }
+
+ user := &apiv1.User{}
+ if err = json.Unmarshal(buf.Bytes(), user); err != nil {
+ return nil, errors.Wrap(err, "fail to unmarshal post signin response")
+ }
+ return user, nil
+}
+
+func (s *TestingServer) postLogout() error {
+ _, err := s.post("/api/v1/auth/logout", nil, nil)
+ if err != nil {
+ return errors.Wrap(err, "fail to post request")
+ }
+ return nil
+}
diff --git a/test/server/server.go b/test/server/server.go
new file mode 100644
index 0000000..8721bd3
--- /dev/null
+++ b/test/server/server.go
@@ -0,0 +1,178 @@
+package testserver
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pkg/errors"
+ // sqlite driver.
+ _ "modernc.org/sqlite"
+
+ "github.com/boojack/slash/api/auth"
+ "github.com/boojack/slash/server"
+ "github.com/boojack/slash/server/profile"
+ "github.com/boojack/slash/store"
+ "github.com/boojack/slash/store/db"
+ "github.com/boojack/slash/test"
+)
+
+type TestingServer struct {
+ server *server.Server
+ client *http.Client
+ profile *profile.Profile
+ cookie string
+}
+
+func NewTestingServer(ctx context.Context, t *testing.T) (*TestingServer, error) {
+ profile := test.GetTestingProfile(t)
+ db := db.NewDB(profile)
+ if err := db.Open(ctx); err != nil {
+ return nil, errors.Wrap(err, "failed to open db")
+ }
+
+ store := store.New(db.DBInstance, profile)
+ server, err := server.NewServer(ctx, profile, store)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to create server")
+ }
+
+ s := &TestingServer{
+ server: server,
+ client: &http.Client{},
+ profile: profile,
+ cookie: "",
+ }
+ errChan := make(chan error, 1)
+
+ go func() {
+ if err := s.server.Start(ctx); err != nil {
+ if err != http.ErrServerClosed {
+ errChan <- errors.Wrap(err, "failed to run main server")
+ }
+ }
+ }()
+
+ if err := s.waitForServerStart(errChan); err != nil {
+ return nil, errors.Wrap(err, "failed to start server")
+ }
+
+ return s, nil
+}
+
+func (s *TestingServer) Shutdown(ctx context.Context) {
+ s.server.Shutdown(ctx)
+}
+
+func (s *TestingServer) waitForServerStart(errChan <-chan error) error {
+ ticker := time.NewTicker(100 * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ if s == nil {
+ continue
+ }
+ e := s.server.GetEcho()
+ if e == nil {
+ continue
+ }
+ addr := e.ListenerAddr()
+ if addr != nil && strings.Contains(addr.String(), ":") {
+ return nil // was started
+ }
+ case err := <-errChan:
+ if err == http.ErrServerClosed {
+ return nil
+ }
+ return err
+ }
+ }
+}
+
+func (s *TestingServer) request(method, uri string, body io.Reader, params, header map[string]string) (io.ReadCloser, error) {
+ fullURL := fmt.Sprintf("http://localhost:%d%s", s.profile.Port, uri)
+ req, err := http.NewRequest(method, fullURL, body)
+ if err != nil {
+ return nil, errors.Wrapf(err, "fail to create a new %s request(%q)", method, fullURL)
+ }
+
+ for k, v := range header {
+ req.Header.Set(k, v)
+ }
+
+ q := url.Values{}
+ for k, v := range params {
+ q.Add(k, v)
+ }
+ if len(q) > 0 {
+ req.URL.RawQuery = q.Encode()
+ }
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, errors.Wrapf(err, "fail to send a %s request(%q)", method, fullURL)
+ }
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to read http response body")
+ }
+ return nil, errors.Errorf("http response error code %v body %q", resp.StatusCode, string(body))
+ }
+
+ if method == "POST" {
+ if strings.Contains(uri, "/api/v1/auth/signin") || strings.Contains(uri, "/api/v1/auth/signup") {
+ cookie := ""
+ h := resp.Header.Get("Set-Cookie")
+ parts := strings.Split(h, "; ")
+ for _, p := range parts {
+ if strings.HasPrefix(p, fmt.Sprintf("%s=", auth.AccessTokenCookieName)) {
+ cookie = p
+ break
+ }
+ }
+ if cookie == "" {
+ return nil, errors.Errorf("unable to find access token in the login response headers")
+ }
+ s.cookie = cookie
+ } else if strings.Contains(uri, "/api/v1/auth/logout") {
+ s.cookie = ""
+ }
+ }
+ return resp.Body, nil
+}
+
+// get sends a GET client request.
+func (s *TestingServer) get(url string, params map[string]string) (io.ReadCloser, error) {
+ return s.request("GET", url, nil, params, map[string]string{
+ "Cookie": s.cookie,
+ })
+}
+
+// post sends a POST client request.
+func (s *TestingServer) post(url string, body io.Reader, params map[string]string) (io.ReadCloser, error) {
+ return s.request("POST", url, body, params, map[string]string{
+ "Cookie": s.cookie,
+ })
+}
+
+// patch sends a PATCH client request.
+func (s *TestingServer) patch(url string, body io.Reader, params map[string]string) (io.ReadCloser, error) {
+ return s.request("PATCH", url, body, params, map[string]string{
+ "Cookie": s.cookie,
+ })
+}
+
+// delete sends a DELETE client request.
+func (s *TestingServer) delete(url string, params map[string]string) (io.ReadCloser, error) {
+ return s.request("DELETE", url, nil, params, map[string]string{
+ "Cookie": s.cookie,
+ })
+}
diff --git a/test/server/shortcut_test.go b/test/server/shortcut_test.go
new file mode 100644
index 0000000..ae67981
--- /dev/null
+++ b/test/server/shortcut_test.go
@@ -0,0 +1,73 @@
+package testserver
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/require"
+
+ apiv1 "github.com/boojack/slash/api/v1"
+)
+
+func TestShortcutServer(t *testing.T) {
+ ctx := context.Background()
+ s, err := NewTestingServer(ctx, t)
+ require.NoError(t, err)
+ defer s.Shutdown(ctx)
+
+ signup := &apiv1.SignUpRequest{
+ Email: "slash@yourselfhosted.com",
+ Password: "testpassword",
+ }
+ user, err := s.postAuthSignUp(signup)
+ require.NoError(t, err)
+ require.Equal(t, signup.Email, user.Email)
+ user, err = s.getCurrentUser()
+ require.NoError(t, err)
+ require.Equal(t, signup.Email, user.Email)
+ shortcutCreate := &apiv1.CreateShortcutRequest{
+ Name: "test",
+ Link: "https://google.com",
+ Visibility: apiv1.VisibilityPublic,
+ Tags: []string{},
+ }
+ shortcut, err := s.postShortcutCreate(shortcutCreate)
+ require.NoError(t, err)
+ require.Equal(t, shortcutCreate.Name, shortcut.Name)
+ require.Equal(t, shortcutCreate.Link, shortcut.Link)
+ err = s.deleteShortcut(shortcut.ID)
+ require.NoError(t, err)
+}
+
+func (s *TestingServer) postShortcutCreate(request *apiv1.CreateShortcutRequest) (*apiv1.Shortcut, error) {
+ rawData, err := json.Marshal(&request)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to marshal shortcut create")
+ }
+ reader := bytes.NewReader(rawData)
+ body, err := s.post("/api/v1/shortcut", reader, nil)
+ if err != nil {
+ return nil, errors.Wrap(err, "fail to post request")
+ }
+
+ buf := &bytes.Buffer{}
+ _, err = buf.ReadFrom(body)
+ if err != nil {
+ return nil, errors.Wrap(err, "fail to read response body")
+ }
+
+ shortcut := &apiv1.Shortcut{}
+ if err = json.Unmarshal(buf.Bytes(), &shortcut); err != nil {
+ return nil, errors.Wrap(err, "fail to unmarshal post shortcut response")
+ }
+ return shortcut, nil
+}
+
+func (s *TestingServer) deleteShortcut(shortcutID int32) error {
+ _, err := s.delete(fmt.Sprintf("/api/v1/shortcut/%d", shortcutID), nil)
+ return err
+}
diff --git a/test/server/user_test.go b/test/server/user_test.go
new file mode 100644
index 0000000..6d19414
--- /dev/null
+++ b/test/server/user_test.go
@@ -0,0 +1,104 @@
+package testserver
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/require"
+
+ apiv1 "github.com/boojack/slash/api/v1"
+)
+
+func TestUserServer(t *testing.T) {
+ ctx := context.Background()
+ s, err := NewTestingServer(ctx, t)
+ require.NoError(t, err)
+ defer s.Shutdown(ctx)
+
+ signup := &apiv1.SignUpRequest{
+ Email: "slash@yourselfhosted.com",
+ Password: "testpassword",
+ }
+ user, err := s.postAuthSignUp(signup)
+ require.NoError(t, err)
+ require.Equal(t, signup.Email, user.Email)
+ user, err = s.getCurrentUser()
+ require.NoError(t, err)
+ require.Equal(t, signup.Email, user.Email)
+ user, err = s.getUserByID(user.ID)
+ require.NoError(t, err)
+ require.Equal(t, signup.Email, user.Email)
+ newEmail := "test@yourselfhosted.com"
+ userPatch := &apiv1.PatchUserRequest{
+ Email: &newEmail,
+ }
+ user, err = s.patchUser(user.ID, userPatch)
+ require.NoError(t, err)
+ require.Equal(t, newEmail, user.Email)
+}
+
+func (s *TestingServer) getCurrentUser() (*apiv1.User, error) {
+ body, err := s.get("/api/v1/user/me", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ buf := &bytes.Buffer{}
+ _, err = buf.ReadFrom(body)
+ if err != nil {
+ return nil, errors.Wrap(err, "fail to read response body")
+ }
+
+ user := &apiv1.User{}
+ if err = json.Unmarshal(buf.Bytes(), &user); err != nil {
+ return nil, errors.Wrap(err, "fail to unmarshal get user response")
+ }
+ return user, nil
+}
+
+func (s *TestingServer) getUserByID(userID int32) (*apiv1.User, error) {
+ body, err := s.get(fmt.Sprintf("/api/v1/user/%d", userID), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ buf := &bytes.Buffer{}
+ _, err = buf.ReadFrom(body)
+ if err != nil {
+ return nil, errors.Wrap(err, "fail to read response body")
+ }
+
+ user := &apiv1.User{}
+ if err = json.Unmarshal(buf.Bytes(), &user); err != nil {
+ return nil, errors.Wrap(err, "fail to unmarshal get user response")
+ }
+ return user, nil
+}
+
+func (s *TestingServer) patchUser(userID int32, request *apiv1.PatchUserRequest) (*apiv1.User, error) {
+ rawData, err := json.Marshal(&request)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to marshal request")
+ }
+ reader := bytes.NewReader(rawData)
+ body, err := s.patch(fmt.Sprintf("/api/v1/user/%d", userID), reader, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ buf := &bytes.Buffer{}
+ _, err = buf.ReadFrom(body)
+ if err != nil {
+ return nil, errors.Wrap(err, "fail to read response body")
+ }
+
+ user := &apiv1.User{}
+ if err = json.Unmarshal(buf.Bytes(), user); err != nil {
+ return nil, errors.Wrap(err, "fail to unmarshal patch user response")
+ }
+ return user, nil
+}