diff --git a/api/v1/activity.go b/api/v1/activity.go deleted file mode 100644 index 3e1112d..0000000 --- a/api/v1/activity.go +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index e366d0b..0000000 --- a/api/v1/analytics.go +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index 3fc192e..0000000 --- a/api/v1/auth.go +++ /dev/null @@ -1,211 +0,0 @@ -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 deleted file mode 100644 index a471fb1..0000000 --- a/api/v1/common.go +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 545e62f..0000000 --- a/api/v1/jwt.go +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index 187d715..0000000 --- a/api/v1/redirector.go +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index 1e988a6..0000000 --- a/api/v1/redirector_test.go +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 9047620..0000000 --- a/api/v1/shortcut.go +++ /dev/null @@ -1,386 +0,0 @@ -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 deleted file mode 100644 index c516fd4..0000000 --- a/api/v1/user.go +++ /dev/null @@ -1,340 +0,0 @@ -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 deleted file mode 100644 index 0b42cda..0000000 --- a/api/v1/user_setting.go +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index cd9f085..0000000 --- a/api/v1/v1.go +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index ef218b5..0000000 --- a/api/v1/workspace.go +++ /dev/null @@ -1,39 +0,0 @@ -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 6d9fab4..9acc7d6 100644 --- a/server/server.go +++ b/server/server.go @@ -15,7 +15,6 @@ 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" @@ -104,10 +103,6 @@ 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 deleted file mode 100644 index aad2330..0000000 --- a/test/server/auth_test.go +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 8721bd3..0000000 --- a/test/server/server.go +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index ae67981..0000000 --- a/test/server/shortcut_test.go +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 6d19414..0000000 --- a/test/server/user_test.go +++ /dev/null @@ -1,104 +0,0 @@ -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 -}