├── .dockerignore ├── schema ├── schema.graphql ├── type │ ├── UpdateTeamRequestInput.graphql │ ├── TeamMember.graphql │ ├── Shortcode.graphql │ ├── CreateTeamRequestInput.graphql │ ├── User.graphql │ ├── TeamEnvironment.graphql │ ├── TeamCollection.graphql │ ├── TeamInvitation.graphql │ ├── TeamRequest.graphql │ └── Team.graphql ├── schema.go ├── query.graphql ├── subscription.graphql └── mutation.graphql ├── models ├── team.go ├── shortcode.go ├── team_collection.go ├── team_environment.go ├── user.go ├── migrate.go ├── team_request.go ├── team_invitation.go └── team_member.go ├── helpers ├── responses │ ├── codes.go │ ├── messages.go │ └── responses.go └── scalars │ └── datetime.go ├── .gitignore ├── api ├── controllers │ ├── controllers.go │ └── graphql │ │ ├── resolvers │ │ ├── bus.go │ │ ├── helpers.go │ │ ├── query.go │ │ ├── user.go │ │ ├── team_member.go │ │ ├── shortcode.go │ │ ├── team_request.go │ │ ├── team_environment.go │ │ ├── team_invitation.go │ │ ├── team.go │ │ └── team_collection.go │ │ ├── controllers.go │ │ ├── setup.go │ │ ├── context │ │ └── context.go │ │ └── request.go └── api.go ├── Dockerfile ├── fb └── fb.go ├── main.go ├── docker-compose.yml ├── config └── config.go ├── LICENSE ├── config.example.yaml ├── README.md ├── db └── db.go └── go.mod /.dockerignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | config.yaml 3 | -------------------------------------------------------------------------------- /schema/schema.graphql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | mutation: Mutation 4 | subscription: Subscription 5 | } -------------------------------------------------------------------------------- /models/team.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gorm.io/gorm" 4 | 5 | type Team struct { 6 | gorm.Model 7 | Name string 8 | } 9 | -------------------------------------------------------------------------------- /helpers/responses/codes.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | const ( 4 | InternalError = 100000 5 | InputValidationError = 100001 6 | Unauthorized = 100002 7 | ) 8 | -------------------------------------------------------------------------------- /models/shortcode.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | ) 6 | 7 | type Shortcode struct { 8 | gorm.Model 9 | Code string 10 | Request string 11 | UserID uint 12 | User User 13 | } 14 | -------------------------------------------------------------------------------- /models/team_collection.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gorm.io/gorm" 4 | 5 | type TeamCollection struct { 6 | gorm.Model 7 | TeamID uint 8 | Team Team 9 | Title string 10 | ParentID uint 11 | } 12 | -------------------------------------------------------------------------------- /models/team_environment.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gorm.io/gorm" 4 | 5 | type TeamEnvironment struct { 6 | gorm.Model 7 | TeamID uint 8 | Team Team 9 | Name string 10 | Variables string 11 | } 12 | -------------------------------------------------------------------------------- /models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gorm.io/gorm" 4 | 5 | type User struct { 6 | gorm.Model 7 | FBUID string `gorm:"column:fb_uid;index"` // Firebase UID 8 | DisplayName string 9 | Email string 10 | PhotoURL string 11 | } 12 | -------------------------------------------------------------------------------- /schema/type/UpdateTeamRequestInput.graphql: -------------------------------------------------------------------------------- 1 | input UpdateTeamRequestInput { 2 | """ 3 | JSON string representing the request data 4 | """ 5 | request: String 6 | 7 | """ 8 | Displayed title of the request 9 | """ 10 | title: String 11 | } -------------------------------------------------------------------------------- /models/migrate.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/jerbob92/hoppscotch-backend/db" 4 | 5 | func AutoMigrate() error { 6 | return db.DB.AutoMigrate(&Shortcode{}, &Team{}, &TeamCollection{}, &TeamInvitation{}, &TeamMember{}, &TeamRequest{}, &TeamEnvironment{}, &User{}) 7 | } 8 | -------------------------------------------------------------------------------- /models/team_request.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gorm.io/gorm" 4 | 5 | type TeamRequest struct { 6 | gorm.Model 7 | TeamID uint 8 | Team Team 9 | TeamCollectionID uint 10 | TeamCollection TeamCollection 11 | Request string 12 | Title string 13 | } 14 | -------------------------------------------------------------------------------- /models/team_invitation.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gorm.io/gorm" 4 | 5 | type TeamInvitation struct { 6 | gorm.Model 7 | TeamID uint 8 | Team Team 9 | UserID uint 10 | User User 11 | InviteeRole TeamMemberRole 12 | InviteeEmail string 13 | Code string 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | !/bin/.gitkeep 3 | /.idea 4 | /*.log 5 | /*.log.* 6 | /out 7 | /config.gcfg 8 | /config.yml 9 | /config.yaml 10 | /pkg 11 | /google-auth.json 12 | /static/swagger.json 13 | /maven 14 | !/maven/.gitkeep 15 | /podspecs 16 | !/podspecs/.gitkeep 17 | /tmp 18 | 19 | # Binaries 20 | API 21 | /vendor 22 | -------------------------------------------------------------------------------- /schema/type/TeamMember.graphql: -------------------------------------------------------------------------------- 1 | type TeamMember { 2 | """ 3 | Membership ID of the Team Member 4 | """ 5 | membershipID: ID! 6 | 7 | """ 8 | Role of the given team member in the given team 9 | """ 10 | role: TeamMemberRole! 11 | user: User! 12 | } 13 | 14 | enum TeamMemberRole { 15 | EDITOR 16 | OWNER 17 | VIEWER 18 | } -------------------------------------------------------------------------------- /schema/type/Shortcode.graphql: -------------------------------------------------------------------------------- 1 | scalar DateTime 2 | 3 | type Shortcode { 4 | """ 5 | The shortcode. 12 digit alphanumeric. 6 | """ 7 | id: ID! 8 | 9 | """ 10 | JSON string representing the request data 11 | """ 12 | request: String! 13 | 14 | """ 15 | Timestamp of when the Shortcode was created 16 | """ 17 | createdOn: DateTime! 18 | } 19 | -------------------------------------------------------------------------------- /schema/type/CreateTeamRequestInput.graphql: -------------------------------------------------------------------------------- 1 | input CreateTeamRequestInput { 2 | """ 3 | JSON string representing the request data 4 | """ 5 | request: String! 6 | 7 | """ 8 | ID of the team the collection belongs to 9 | """ 10 | teamID: ID! 11 | 12 | """ 13 | Displayed title of the request 14 | """ 15 | title: String! 16 | } -------------------------------------------------------------------------------- /api/controllers/controllers.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func AttachControllers(engine *gin.Engine) error { 10 | if err := graphql.AttachControllers(engine.RouterGroup.Group("/graphql")); err != nil { 11 | return err 12 | } 13 | 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /schema/type/User.graphql: -------------------------------------------------------------------------------- 1 | type User { 2 | """ 3 | Firebase UID of the user 4 | """ 5 | uid: ID! 6 | 7 | """ 8 | Displayed name of the user (if given) 9 | """ 10 | displayName: String 11 | 12 | """ 13 | Email of the user (if given) 14 | """ 15 | email: String 16 | 17 | """ 18 | URL to the profile photo of the user (if given) 19 | """ 20 | photoURL: String 21 | } -------------------------------------------------------------------------------- /models/team_member.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gorm.io/gorm" 4 | 5 | type TeamMember struct { 6 | gorm.Model 7 | TeamID uint 8 | Team Team 9 | UserID uint 10 | User User 11 | Role TeamMemberRole 12 | } 13 | 14 | type TeamMemberRole string 15 | 16 | const ( 17 | Editor TeamMemberRole = "EDITOR" 18 | Owner TeamMemberRole = "OWNER" 19 | Viewer TeamMemberRole = "VIEWER" 20 | ) 21 | -------------------------------------------------------------------------------- /schema/type/TeamEnvironment.graphql: -------------------------------------------------------------------------------- 1 | type TeamEnvironment { 2 | """ 3 | ID of the Team Environment 4 | """ 5 | id: ID! 6 | 7 | """ 8 | Name of the environment 9 | """ 10 | name: String! 11 | 12 | """ 13 | ID of the team this environment belongs to 14 | """ 15 | teamID: ID! 16 | 17 | """ 18 | All variables present in the environment 19 | """ 20 | variables: String! 21 | } -------------------------------------------------------------------------------- /schema/type/TeamCollection.graphql: -------------------------------------------------------------------------------- 1 | type TeamCollection { 2 | """ 3 | ID of the collection 4 | """ 5 | id: ID! 6 | 7 | """ 8 | Displayed title of the collection 9 | """ 10 | title: String! 11 | 12 | """ 13 | Team the collection belongs to 14 | """ 15 | team: Team! 16 | 17 | """ 18 | The collection whom is the parent of this collection (null if this is root collection) 19 | """ 20 | parent: TeamCollection 21 | 22 | """ 23 | List of children collection 24 | """ 25 | children(cursor: String): [TeamCollection!]! 26 | } -------------------------------------------------------------------------------- /schema/type/TeamInvitation.graphql: -------------------------------------------------------------------------------- 1 | type TeamInvitation { 2 | """ 3 | ID of the invite 4 | """ 5 | id: ID! 6 | 7 | """ 8 | ID of the team the invite is to 9 | """ 10 | teamID: ID! 11 | 12 | """ 13 | UID of the creator of the invite 14 | """ 15 | creatorUid: ID! 16 | 17 | """ 18 | Email of the invitee 19 | """ 20 | inviteeEmail: ID! 21 | 22 | """ 23 | The role that will be given to the invitee 24 | """ 25 | inviteeRole: TeamMemberRole! 26 | 27 | """ 28 | Get the team associated to the invite 29 | """ 30 | team: Team! 31 | 32 | """ 33 | Get the creator of the invite 34 | """ 35 | creator: User! 36 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18 AS builder 2 | 3 | # Build go binary 4 | COPY . /go/src/hoppscotch-backend 5 | WORKDIR /go/src/hoppscotch-backend 6 | RUN go build -v 7 | 8 | FROM alpine:3.15 9 | 10 | # Update 11 | RUN apk update upgrade 12 | 13 | # Add libc6 compat, needed to run Go binaries in Alpine 14 | RUN apk add --no-cache libc6-compat 15 | 16 | # Set timezone to Europe/Amsterdam 17 | RUN apk add tzdata 18 | RUN ln -s /usr/share/zoneinfo/Europe/Amsterdam /etc/localtime 19 | 20 | # Copy Go binary from builder stage to Alpine stage. 21 | COPY --from=builder /go/src/hoppscotch-backend/hoppscotch-backend /usr/bin/ 22 | 23 | CMD [ "/usr/bin/hoppscotch-backend"] 24 | -------------------------------------------------------------------------------- /schema/type/TeamRequest.graphql: -------------------------------------------------------------------------------- 1 | type TeamRequest { 2 | """ 3 | ID of the request 4 | """ 5 | id: ID! 6 | 7 | """ 8 | ID of the collection the request belongs to. 9 | """ 10 | collectionID: ID! 11 | 12 | """ 13 | ID of the team the request belongs to. 14 | """ 15 | teamID: ID! 16 | 17 | """ 18 | JSON string representing the request data 19 | """ 20 | request: String! 21 | 22 | """ 23 | Displayed title of the request 24 | """ 25 | title: String! 26 | 27 | """ 28 | Team the request belongs to 29 | """ 30 | team: Team! 31 | 32 | """ 33 | Collection the request belongs to 34 | """ 35 | collection: TeamCollection! 36 | } -------------------------------------------------------------------------------- /helpers/responses/messages.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | var ( 4 | PathUsername = "data.username" 5 | PathPassword = "data.password" 6 | PathEmail = "data.email" 7 | 8 | UsernameExists = "username already exists" 9 | UsernameTooShort = "username too short" 10 | EmailExists = "email address already exists" 11 | EmailIncorrect = "email address incorrect" 12 | PasswordTooShort = "password too short" 13 | 14 | WrongUsernamePassword = "wrong username or password" 15 | UserInactive = "user is inactive" 16 | 17 | InsufficientPermissionOrNonExistentResource = "user has insufficient permissions for this action or the resource does not exist" 18 | ) 19 | -------------------------------------------------------------------------------- /fb/fb.go: -------------------------------------------------------------------------------- 1 | package fb 2 | 3 | import ( 4 | "context" 5 | 6 | firebase "firebase.google.com/go" 7 | "firebase.google.com/go/auth" 8 | "github.com/spf13/viper" 9 | "google.golang.org/api/option" 10 | ) 11 | 12 | var FBApp *firebase.App 13 | var FBAuth *auth.Client 14 | 15 | func Initialize() error { 16 | opt := option.WithCredentialsFile(viper.GetString("firebase.serviceAccountFile")) 17 | app, err := firebase.NewApp(context.Background(), nil, opt) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | FBApp = app 23 | authClient, err := FBApp.Auth(context.Background()) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | FBAuth = authClient 29 | 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/bus.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/asaskevich/EventBus" 7 | ) 8 | 9 | var bus = EventBus.New() 10 | 11 | func subscribeUntilDone(ctx context.Context, topic string, eventHandler interface{}) error { 12 | // Execute eventHandler for every message on topic. 13 | err := bus.SubscribeAsync(topic, eventHandler, false) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | // Launch subroutine that will block until context is done (which is the 19 | // end of the GraphQL subscription), after which we unsubscribe. 20 | go func() { 21 | select { 22 | case <-ctx.Done(): 23 | bus.Unsubscribe(topic, eventHandler) 24 | return 25 | } 26 | }() 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jerbob92/hoppscotch-backend/db" 5 | "github.com/jerbob92/hoppscotch-backend/models" 6 | "log" 7 | 8 | "github.com/jerbob92/hoppscotch-backend/api" 9 | "github.com/jerbob92/hoppscotch-backend/config" 10 | "github.com/jerbob92/hoppscotch-backend/fb" 11 | ) 12 | 13 | func init() { 14 | if err := config.LoadConfig(); err != nil { 15 | log.Fatal(err) 16 | } 17 | } 18 | 19 | func main() { 20 | if err := db.ConnectDB(); err != nil { 21 | log.Fatal(err) 22 | } 23 | if err := models.AutoMigrate(); err != nil { 24 | log.Fatal(err) 25 | } 26 | if err := fb.Initialize(); err != nil { 27 | log.Fatal(err) 28 | } 29 | if err := api.StartAPI(); err != nil { 30 | log.Fatal(err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | backend: 5 | image: jerbob92/hoppscotch-backend:latest 6 | restart: always 7 | ports: 8 | - "8989:8989" 9 | depends_on: 10 | - mysql 11 | volumes: 12 | # Copy config.example.yaml to ./tmp/config.yaml and change the values 13 | - ./tmp/config.yaml:/etc/api-config/config.yaml 14 | # Download Firebase admin sdk service account and save it as ./tmp/firebase-admin-sdk.json 15 | - ./tmp/firebase-admin-sdk.json:/etc/api-config/firebase-admin-sdk.json 16 | mysql: 17 | image: mysql:8.0 18 | environment: 19 | MYSQL_ROOT_PASSWORD: hoppscotch 20 | MYSQL_DATABASE: hoppscotch 21 | MYSQL_USER: hoppscotch 22 | MYSQL_PASSWORD: hoppscotch 23 | ports: 24 | - "3306:3306" 25 | volumes: 26 | - mysql-data:/var/lib/mysql 27 | volumes: 28 | mysql-data: 29 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/helpers.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | 6 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 7 | "github.com/jerbob92/hoppscotch-backend/models" 8 | 9 | "gorm.io/gorm" 10 | ) 11 | 12 | func getUserRoleInTeam(ctx context.Context, c *graphql_context.Context, teamID interface{}) (*models.TeamMemberRole, error) { 13 | currentUser, err := c.GetUser(ctx) 14 | if err != nil { 15 | c.LogErr(err) 16 | return nil, err 17 | } 18 | 19 | db := c.GetDB() 20 | 21 | existingTeamMember := &models.TeamMember{} 22 | err = db.Where("user_id = ? AND team_id = ?", currentUser.ID, teamID).Preload("Team").First(existingTeamMember).Error 23 | if err != nil && err == gorm.ErrRecordNotFound { 24 | return nil, nil 25 | } 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return &existingTeamMember.Role, nil 31 | } 32 | -------------------------------------------------------------------------------- /schema/type/Team.graphql: -------------------------------------------------------------------------------- 1 | type Team { 2 | """ 3 | ID of the team 4 | """ 5 | id: ID! 6 | 7 | """ 8 | Displayed name of the team 9 | """ 10 | name: String! 11 | 12 | """ 13 | Returns the list of members of a team 14 | """ 15 | members(cursor: ID): [TeamMember!]! 16 | 17 | """ 18 | Returns the list of members of a team 19 | """ 20 | teamMembers: [TeamMember!]! 21 | 22 | """ 23 | The role of the current user in the team 24 | """ 25 | myRole: TeamMemberRole! 26 | 27 | """ 28 | The number of users with the OWNER role in the team 29 | """ 30 | ownersCount: Int! 31 | 32 | """ 33 | Returns all Team Environments for the given Team 34 | """ 35 | teamEnvironments: [TeamEnvironment!]! 36 | 37 | """ 38 | The number of users with the EDITOR role in the team 39 | """ 40 | editorsCount: Int! 41 | 42 | """ 43 | The number of users with the VIEWER role in the team 44 | """ 45 | viewersCount: Int! 46 | 47 | """ 48 | Get all the active invites in the team 49 | """ 50 | teamInvitations: [TeamInvitation!]! 51 | } -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/viper" 6 | "os" 7 | ) 8 | 9 | func LoadConfig() error { 10 | viper.SetConfigName("config") // name of config file (without extension) 11 | viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name 12 | viper.AddConfigPath(".") // optionally look for config in the working directory 13 | viper.AddConfigPath("/etc/api-config") // look for config in the api-config directory on the server 14 | configPath := os.Getenv("CONFIG_PATH_DIR") 15 | if configPath != "" { 16 | viper.AddConfigPath(configPath) 17 | } 18 | if err := viper.ReadInConfig(); err != nil { 19 | return err 20 | } 21 | 22 | // Check for deprecated fields 23 | if viper.GetString("database.address") != "" { 24 | log.Warningln("database.address is deprecated and will be removed in future releases, please use database.host, database.port and database.driver fields for database connectivity") 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/query.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | 6 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | // BaseQuery is the query resolvers 12 | type BaseQuery struct { 13 | c *graphql_context.Context // Only set/overwrite this context when constructing new baseQueries. 14 | } 15 | 16 | // GetReqC returns the request context 17 | func (b *BaseQuery) GetReqC(ctx context.Context) *graphql_context.Context { 18 | if b.c == nil { 19 | interf := ctx.Value("graphqlC") 20 | c, ok := interf.(*graphql_context.Context) 21 | if ok { 22 | return c.Clone() 23 | } 24 | 25 | ginctx := ctx.Value("ginctx") 26 | c2, ok := ginctx.(*gin.Context) 27 | if ok { 28 | reqC := graphql_context.GetContext(c2) 29 | reqC.DisableResponses = true 30 | return reqC.Clone() 31 | } 32 | 33 | return nil 34 | } 35 | 36 | // Clone the request context so mutationCompany and queryCompany only have effects on the inner request of mutationCompany and queryCompany 37 | return b.c.Clone() 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jeroen Bobbeldijk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/controllers/graphql/controllers.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | goctx "context" 5 | "net/http" 6 | 7 | "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/graph-gophers/graphql-transport-ws/graphqlws" 11 | ) 12 | 13 | func AttachControllers(r *gin.RouterGroup) error { 14 | r.Any("", graphqlRequest()) 15 | r.Any("ws", graphqlRequest()) 16 | return nil 17 | } 18 | 19 | type contextGenerator struct { 20 | } 21 | 22 | func (t contextGenerator) BuildContext(ctx goctx.Context, r *http.Request) (goctx.Context, error) { 23 | c := r.Context().Value("ginctx").(*gin.Context) 24 | reqC := context.GetContext(c) 25 | reqC.DisableResponses = true 26 | return goctx.WithValue(ctx, "graphqlC", reqC), nil 27 | } 28 | 29 | func graphqlRequest() gin.HandlerFunc { 30 | graphQLHandler := graphqlws.NewHandlerFunc(Handler.Schema, Handler, graphqlws.WithContextGenerator(&contextGenerator{})) 31 | 32 | return func(c *gin.Context) { 33 | clonedRequest := c.Request.WithContext(goctx.WithValue(c.Request.Context(), "ginctx", c)) 34 | graphQLHandler.ServeHTTP(c.Writer, clonedRequest) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | api: 2 | port: "8989" 3 | # development/production/test 4 | environment: "development" 5 | ssl: 6 | enabled: false 7 | certificate: "path/to/certificate.pem" 8 | key: "path/to/key.pem" 9 | database: 10 | username: "hoppscotch" 11 | password: "hoppscotch" 12 | database: "hoppscotch" 13 | host: "127.0.0.1" 14 | port: "3306" 15 | driver: "mysql" # mysql or postgres 16 | debug: true 17 | allowed_domains: # This is to allow CORS to do it's magic. 18 | - "https://hoppscotch.io" 19 | frontend_domain: "https://hoppscotch.io" # This is to format mail links. 20 | firebase: 21 | serviceAccountFile: "/etc/api-config/firebase-admin-sdk.json" # Path to Firebase SDK admin Service Account JSON file. 22 | smtp: # SMTP information to send invite mails. 23 | host: "" 24 | port: 587 25 | username: "" 26 | password: "" 27 | from: 28 | name: "" 29 | email: "" 30 | mailTemplates: 31 | teamInvite: 32 | subject: "{{.InvitingUserName}} invited you to join {{.TeamName}} in Hoppscotch" 33 | body: "{{.InvitingUserName}} with {{.TeamName}} has invited you to use Hoppscotch to collaborate with them. Click here to set up your account and get started." 34 | -------------------------------------------------------------------------------- /schema/schema.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "fmt" 7 | "io/fs" 8 | "strings" 9 | ) 10 | 11 | // content holds all the SDL file content. 12 | //go:embed *.graphql type/*.graphql 13 | var content embed.FS 14 | 15 | // String reads the .graphql schema files from the embed.FS, concatenating the 16 | // files together into one string. 17 | // 18 | // If this method complains about not finding functions AssetNames() or MustAsset(), 19 | // run `go generate` against this package to generate the functions. 20 | func String() (string, error) { 21 | var buf bytes.Buffer 22 | 23 | fn := func(path string, d fs.DirEntry, err error) error { 24 | if err != nil { 25 | return fmt.Errorf("walking dir: %w", err) 26 | } 27 | 28 | // Only add files with the .graphql extension. 29 | if !strings.HasSuffix(path, ".graphql") { 30 | return nil 31 | } 32 | 33 | b, err := content.ReadFile(path) 34 | if err != nil { 35 | return fmt.Errorf("reading file %q: %w", path, err) 36 | } 37 | 38 | // Add a newline to separate each file. 39 | b = append(b, []byte("\n")...) 40 | 41 | if _, err := buf.Write(b); err != nil { 42 | return fmt.Errorf("writing %q bytes to buffer: %w", path, err) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // Recursively walk this directory and append all the file contents together. 49 | if err := fs.WalkDir(content, ".", fn); err != nil { 50 | return buf.String(), fmt.Errorf("walking content directory: %w", err) 51 | } 52 | 53 | return buf.String(), nil 54 | } 55 | -------------------------------------------------------------------------------- /helpers/scalars/datetime.go: -------------------------------------------------------------------------------- 1 | package scalars 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // Time is a custom GraphQL type to represent an instant in time. It has to be added to a schema 10 | // via "scalar DateTime" since it is not a predeclared GraphQL type like "ID". 11 | type Time struct { 12 | time.Time 13 | } 14 | 15 | // ImplementsGraphQLType maps this custom Go type 16 | // to the graphql scalar type in the schema. 17 | func (Time) ImplementsGraphQLType(name string) bool { 18 | return name == "DateTime" 19 | } 20 | 21 | // UnmarshalGraphQL is a custom unmarshaler for DateTime 22 | // 23 | // This function will be called whenever you use the 24 | // time scalar as an input 25 | func (t *Time) UnmarshalGraphQL(input interface{}) error { 26 | switch input := input.(type) { 27 | case time.Time: 28 | t.Time = input 29 | return nil 30 | case string: 31 | var err error 32 | t.Time, err = time.Parse(time.RFC3339, input) 33 | return err 34 | case []byte: 35 | var err error 36 | t.Time, err = time.Parse(time.RFC3339, string(input)) 37 | return err 38 | case int32: 39 | t.Time = time.Unix(int64(input), 0) 40 | return nil 41 | case int64: 42 | if input >= 1e10 { 43 | sec := input / 1e9 44 | nsec := input - (sec * 1e9) 45 | t.Time = time.Unix(sec, nsec) 46 | } else { 47 | t.Time = time.Unix(input, 0) 48 | } 49 | return nil 50 | case float64: 51 | t.Time = time.Unix(int64(input), 0) 52 | return nil 53 | default: 54 | return fmt.Errorf("wrong type for Time: %T", input) 55 | } 56 | } 57 | 58 | // MarshalJSON is a custom marshaler for DateTime 59 | // 60 | // This function will be called whenever you 61 | // query for fields that use the DateTime type 62 | func (t Time) MarshalJSON() ([]byte, error) { 63 | return json.Marshal(t.Time) 64 | } 65 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/jerbob92/hoppscotch-backend/api/controllers" 5 | "github.com/jerbob92/hoppscotch-backend/db" 6 | "github.com/jerbob92/hoppscotch-backend/helpers/responses" 7 | 8 | "github.com/gin-contrib/cors" 9 | "github.com/gin-gonic/gin" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/viper" 12 | ginlogrus "github.com/toorop/gin-logrus" 13 | ) 14 | 15 | func StartAPI() error { 16 | environment := viper.GetString("api.environment") 17 | switch environment { 18 | case "production": 19 | gin.SetMode(gin.ReleaseMode) 20 | case "test": 21 | gin.SetMode(gin.TestMode) 22 | case "development": 23 | default: 24 | gin.SetMode(gin.DebugMode) 25 | } 26 | 27 | r := gin.New() 28 | 29 | if environment != "development" { 30 | log.SetFormatter(&log.JSONFormatter{}) 31 | r.Use(ginlogrus.Logger(log.StandardLogger()), gin.CustomRecovery(responses.RecoveryHandler)) 32 | } else { 33 | r.Use(gin.Logger()) 34 | } 35 | 36 | // Automatic panic recovery 37 | r.Use(gin.Recovery()) 38 | 39 | // Configuration of CORS 40 | corsConfig := cors.DefaultConfig() 41 | corsConfig.AllowCredentials = true 42 | if environment == "development" { 43 | corsConfig.AllowAllOrigins = true 44 | } else { 45 | corsConfig.AllowOrigins = viper.GetStringSlice("allowed_domains") 46 | } 47 | corsConfig.AllowHeaders = append(corsConfig.AllowHeaders, "Authorization") 48 | r.Use(cors.New(corsConfig)) 49 | 50 | // Attach a DB session to every request. 51 | r.Use(db.AttachRequestSession()) 52 | 53 | // Bind all the routing handlers 54 | if err := controllers.AttachControllers(r); err != nil { 55 | return err 56 | } 57 | 58 | if viper.GetBool("api.ssl.enabled") { 59 | return r.RunTLS(":"+viper.GetString("api.port"), viper.GetString("api.ssl.certificate"), viper.GetString("api.ssl.key")) 60 | } 61 | 62 | return r.Run(":" + viper.GetString("api.port")) 63 | } 64 | -------------------------------------------------------------------------------- /schema/query.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | """ 3 | Finds a user by their UID or null if no match 4 | @deprecated Deprecated due to privacy concerns. Try to get the user from the context-relevant queries 5 | """ 6 | user(uid: ID!): User 7 | 8 | """ 9 | Gives details of the user executing this query (pass Authorization 'Bearer' header) 10 | """ 11 | me: User! 12 | 13 | """ 14 | List of teams that the executing user belongs to. 15 | """ 16 | myTeams(cursor: ID): [Team!]! 17 | 18 | """ 19 | Returns the detail of the team with the given ID 20 | """ 21 | team(teamID: ID!): Team 22 | 23 | """ 24 | Returns the JSON string giving the collections and their contents of the team 25 | """ 26 | exportCollectionsToJSON(teamID: ID!): String! 27 | 28 | """ 29 | Returns the collections of the team 30 | """ 31 | rootCollectionsOfTeam(cursor: ID, teamID: ID!): [TeamCollection!]! 32 | 33 | """ 34 | Returns the collections of the team 35 | @deprecated Deprecated because of no practical use. Use `rootCollectionsOfTeam` instead. 36 | """ 37 | collectionsOfTeam(cursor: ID, teamID: ID!): [TeamCollection!]! 38 | 39 | """ 40 | Get a collection with the given ID or null (if not exists) 41 | """ 42 | collection(collectionID: ID!): TeamCollection 43 | 44 | """ 45 | Search the team for a specific request with title 46 | """ 47 | searchForRequest(cursor: ID, searchTerm: String!, teamID: ID!): [TeamRequest!]! 48 | 49 | """ 50 | Gives a request with the given ID or null (if not exists) 51 | """ 52 | request(requestID: ID!): TeamRequest 53 | 54 | """ 55 | Gives a list of requests in the collection 56 | """ 57 | requestsInCollection(collectionID: ID!, cursor: ID): [TeamRequest!]! 58 | 59 | """ 60 | Gets the Team Invitation with the given ID, or null if not exists 61 | """ 62 | teamInvitation(inviteID: ID!): TeamInvitation! 63 | 64 | """ 65 | Resolves and returns a shortcode data 66 | """ 67 | shortcode(code: ID!): Shortcode 68 | 69 | """ 70 | List all shortcodes the current user has generated 71 | """ 72 | myShortcodes(cursor: ID): [Shortcode!]! 73 | } 74 | -------------------------------------------------------------------------------- /schema/subscription.graphql: -------------------------------------------------------------------------------- 1 | type Subscription { 2 | """ 3 | Listen to when a new team member being added to the team. The emitted value is the new team member added. 4 | """ 5 | teamMemberAdded(teamID: ID!): TeamMember! 6 | 7 | """ 8 | Listen to when a team member status has been updated. The emitted value is the new team member status 9 | """ 10 | teamMemberUpdated(teamID: ID!): TeamMember! 11 | 12 | """ 13 | Listen to when a team member has been removed. The emitted value is the uid of the user removed 14 | """ 15 | teamMemberRemoved(teamID: ID!): ID! 16 | 17 | """ 18 | Listen to when a collection has been added to a team. The emitted value is the team added 19 | """ 20 | teamCollectionAdded(teamID: ID!): TeamCollection! 21 | 22 | """ 23 | Listen to when a collection has been updated. 24 | """ 25 | teamCollectionUpdated(teamID: ID!): TeamCollection! 26 | 27 | """ 28 | Listen for Team Environment Creation Messages 29 | """ 30 | teamEnvironmentCreated(teamID: ID!): TeamEnvironment! 31 | 32 | """ 33 | Listen for Team Environment Deletion Messages 34 | """ 35 | teamEnvironmentDeleted(teamID: ID!): TeamEnvironment! 36 | 37 | """ 38 | Listen for Team Environment Updates 39 | """ 40 | teamEnvironmentUpdated(teamID: ID!): TeamEnvironment! 41 | 42 | """ 43 | Listen to when a collection has been removed 44 | """ 45 | teamCollectionRemoved(teamID: ID!): ID! 46 | 47 | """ 48 | Emits when a new request is added to a team 49 | """ 50 | teamRequestAdded(teamID: ID!): TeamRequest! 51 | 52 | """ 53 | Emitted when a request has been updated 54 | """ 55 | teamRequestUpdated(teamID: ID!): TeamRequest! 56 | 57 | """ 58 | Listen for user deletion 59 | """ 60 | userDeleted(): User! 61 | 62 | """ 63 | Emitted when a request has been deleted. Only the id of the request is emitted. 64 | """ 65 | teamRequestDeleted(teamID: ID!): ID! 66 | 67 | """ 68 | Listens to when a Team Invitation is added 69 | """ 70 | teamInvitationAdded(teamID: ID!): TeamInvitation! 71 | 72 | """ 73 | Listens to when a Team Invitation is removed 74 | """ 75 | teamInvitationRemoved(teamID: ID!): ID! 76 | 77 | """ 78 | Listen for shortcode creation 79 | """ 80 | myShortcodesCreated(): Shortcode! 81 | 82 | """ 83 | Listen for shortcode deletion 84 | """ 85 | myShortcodesRevoked(): Shortcode! 86 | 87 | 88 | } 89 | -------------------------------------------------------------------------------- /api/controllers/graphql/setup.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/resolvers" 9 | "github.com/jerbob92/hoppscotch-backend/schema" 10 | 11 | "github.com/graph-gophers/graphql-go" 12 | gqlerrors "github.com/graph-gophers/graphql-go/errors" 13 | "github.com/graph-gophers/graphql-go/introspection" 14 | "github.com/graph-gophers/graphql-go/relay" 15 | "github.com/graph-gophers/graphql-go/trace" 16 | _ "github.com/sanae10001/graphql-go-extension-scalars" 17 | ) 18 | 19 | var Handler *relay.Handler 20 | 21 | func init() { 22 | s, err := schema.String() 23 | if err != nil { 24 | log.Fatalf("reading embedded schema contents: %s", err) 25 | } 26 | 27 | parsedSchema := graphql.MustParseSchema( 28 | s, 29 | &resolvers.BaseQuery{}, 30 | graphql.UseFieldResolvers(), 31 | graphql.MaxParallelism(5), 32 | graphql.UseStringDescriptions(), 33 | graphql.MaxDepth(20), // Just to be sure 34 | graphql.Logger(graphqlLogger{}), 35 | graphql.PanicHandler(PanicHandler{}), 36 | graphql.Tracer(graphqlTracer{}), 37 | ) 38 | 39 | Handler = &relay.Handler{Schema: parsedSchema} 40 | } 41 | 42 | type graphqlTracer struct{} 43 | 44 | func (t graphqlTracer) TraceQuery(ctx context.Context, queryString string, operationName string, variables map[string]interface{}, varTypes map[string]*introspection.Type) (context.Context, trace.TraceQueryFinishFunc) { 45 | //log.Println("Trace query") 46 | //log.Println(queryString) 47 | //log.Println(operationName) 48 | //log.Println(variables) 49 | return ctx, func(errs []*gqlerrors.QueryError) {} 50 | } 51 | 52 | func (t graphqlTracer) TraceField(ctx context.Context, label, typeName, fieldName string, trivial bool, args map[string]interface{}) (context.Context, trace.TraceFieldFinishFunc) { 53 | //log.Println("Trace field") 54 | //log.Println(label) 55 | //log.Println(typeName) 56 | //log.Println(fieldName) 57 | //log.Println(args) 58 | return ctx, func(err *gqlerrors.QueryError) {} 59 | } 60 | 61 | type graphqlLogger struct{} 62 | 63 | func (g graphqlLogger) LogPanic(ctx context.Context, value interface{}) { 64 | errorstring := fmt.Sprintf("graphql: panic occurred: %v", value) 65 | log.Println(errorstring) 66 | } 67 | 68 | type PanicHandler struct{} 69 | 70 | func (p PanicHandler) MakePanicError(ctx context.Context, value interface{}) *gqlerrors.QueryError { 71 | return &gqlerrors.QueryError{ 72 | Message: fmt.Sprintf("something went wrong processing your request"), 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hoppscotch Backend API 2 | 3 | This repository contains an open-source implementation of the Hoppscotch Backend to allow the collaborative features to 4 | work on a self-hosted instance of Hoppscotch. 5 | 6 | This API has the exact same GraphQL schema as the "official" API. 7 | 8 | This API does not store its data in Firebase (which the official probably does), but in a local MySQL database. 9 | 10 | ## Requirements 11 | 12 | - MySQL/Postgres 13 | - An SMTP mail server 14 | - A Firebase project & webapp credentials & Admin SDK credentials 15 | 16 | ## Get requirements up and running 17 | 18 | ### MySQL (optional when using `docker-compose`): 19 | 20 | ``` 21 | docker run \ 22 | --name hoppscotch_api_mysql \ 23 | -p 127.0.0.1:3306:3306 \ 24 | -e MYSQL_ROOT_PASSWORD=hoppscotch \ 25 | -e MYSQL_DATABASE=hoppscotch \ 26 | -e MYSQL_USER=hoppscotch \ 27 | -e MYSQL_PASSWORD=hoppscotch \ 28 | -d mysql:8.0 29 | ``` 30 | 31 | ### Next runs: 32 | 33 | ``` 34 | docker start hoppscotch_api_mysql 35 | ``` 36 | 37 | ## Firebase 38 | 39 | You will need to create a Firebase project to get this whole thing running (frontend and backend). 40 | 41 | Copy the .env.example in the frontend project to .env en fill in your Firebase credentials. 42 | 43 | Generate 44 | a [Firebase Admin SDK service account](https://console.firebase.google.com/project/_/settings/serviceaccounts/adminsdk) 45 | and reference the JSON from the config.yaml. 46 | 47 | Create Firestore Database 48 | 49 | Go to [Firestore Rules](https://github.com/hoppscotch/hoppscotch/blob/main/firestore.rules) and configure them in your 50 | firestore database. 51 | 52 | ## Quickstart 53 | 54 | - Copy the config.example.yaml to config.yaml 55 | - Start the API by running `go run main.go` 56 | 57 | ## Quickstart (Docker Compose) 58 | 59 | - Copy config.example.yaml to tmp/config.yaml 60 | - Put `Firebase Admin SDK service account` file in tmp folder 61 | - Ensure file mappings at volumes are correct in docker-compose.yml 62 | - run `docker compose up -d` or `docker-compose up -d` 63 | 64 | ## Deployment 65 | 66 | This backend is available as 67 | a [docker image](https://hub.docker.com/r/jerbob92/hoppscotch-backend) `jerbob92/hoppscotch-backend`. 68 | 69 | The configuration is expected in the working directory or the folder `/etc/api-config`. 70 | 71 | When using docker, the easiest way is to mount a local configuration folder as `/etc/api-config` that contains 72 | your `config.yaml` and your Firebase Admin SDK Service User json. 73 | 74 | If you're behind a reverse proxy, it might be useful to use `/graphql` for the normal GraphQL traffic, and 75 | use `/graphql/ws` for the Subscription/WebSocket traffic. 76 | 77 | ## Frontend deployment 78 | 79 | To connect to your own backend, you will need to set the `VITE_BACKEND_GQL_URL` and `VITE_BACKEND_WS_URL` to the correct URLs for your backend in `packages/hoppscotch-app/.env` when building the frontend. -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/user.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strconv" 7 | 8 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 9 | "github.com/jerbob92/hoppscotch-backend/models" 10 | 11 | "github.com/graph-gophers/graphql-go" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | type UserResolver struct { 16 | c *graphql_context.Context 17 | user *models.User 18 | } 19 | 20 | func NewUserResolver(c *graphql_context.Context, user *models.User) (*UserResolver, error) { 21 | if user == nil { 22 | return nil, nil 23 | } 24 | 25 | return &UserResolver{c: c, user: user}, nil 26 | } 27 | 28 | func (u *UserResolver) UID() (graphql.ID, error) { 29 | id := graphql.ID(u.user.FBUID) 30 | return id, nil 31 | } 32 | 33 | func (u *UserResolver) DisplayName() (*string, error) { 34 | if u.user.DisplayName == "" { 35 | return nil, nil 36 | } 37 | return &u.user.DisplayName, nil 38 | } 39 | 40 | func (u *UserResolver) Email() (*string, error) { 41 | if u.user.Email == "" { 42 | return nil, nil 43 | } 44 | return &u.user.Email, nil 45 | } 46 | 47 | func (u *UserResolver) PhotoURL() (*string, error) { 48 | if u.user.PhotoURL == "" { 49 | return nil, nil 50 | } 51 | return &u.user.PhotoURL, nil 52 | } 53 | 54 | func (b *BaseQuery) Me(ctx context.Context) (*UserResolver, error) { 55 | c := b.GetReqC(ctx) 56 | currentUser, err := c.GetUser(ctx) 57 | if err != nil { 58 | c.LogErr(err) 59 | return nil, err 60 | } 61 | 62 | return NewUserResolver(c, currentUser) 63 | } 64 | 65 | type UserArgs struct { 66 | Uid graphql.ID 67 | } 68 | 69 | func (b *BaseQuery) User(ctx context.Context, args *UserArgs) (*UserResolver, error) { 70 | c := b.GetReqC(ctx) 71 | _, err := c.GetUser(ctx) 72 | if err != nil { 73 | c.LogErr(err) 74 | return nil, err 75 | } 76 | 77 | db := c.GetDB() 78 | existingUser := &models.User{} 79 | err = db.Where("fb_uid = ?", args.Uid).First(existingUser).Error 80 | if err != nil && err == gorm.ErrRecordNotFound { 81 | return nil, errors.New("user not found") 82 | } 83 | 84 | return NewUserResolver(c, existingUser) 85 | } 86 | 87 | func (b *BaseQuery) DeleteUser(ctx context.Context) (bool, error) { 88 | c := b.GetReqC(ctx) 89 | user, err := c.GetUser(ctx) 90 | if err != nil { 91 | c.LogErr(err) 92 | return false, err 93 | } 94 | 95 | db := c.GetDB() 96 | err = db.Delete(user).Error 97 | if err != nil { 98 | return false, err 99 | } 100 | 101 | resolver, err := NewUserResolver(c, user) 102 | if err != nil { 103 | return false, err 104 | } 105 | 106 | go bus.Publish("user:"+strconv.Itoa(int(user.ID))+":deleted", resolver) 107 | 108 | return true, nil 109 | } 110 | 111 | func (b *BaseQuery) UserDeleted(ctx context.Context) (<-chan *UserResolver, error) { 112 | c := b.GetReqC(ctx) 113 | 114 | user, err := c.GetUser(ctx) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | notificationChannel := make(chan *UserResolver) 120 | eventHandler := func(resolver *UserResolver) { 121 | notificationChannel <- resolver 122 | } 123 | 124 | err = subscribeUntilDone(ctx, "user:"+strconv.Itoa(int(user.ID))+":deleted", eventHandler) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | return notificationChannel, nil 130 | } 131 | -------------------------------------------------------------------------------- /helpers/responses/responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gin-gonic/gin/render" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // The serializable Error structure. 13 | type Error struct { 14 | Result string `json:"Result" description:"Whether the call was successful." enum:"success,error"` 15 | ErrorCode int `json:"Code" description:"The code of the error."` 16 | ErrorMessage string `json:"Message" description:"The human message of the error."` 17 | RequestId string `json:"RequestId" description:"A request ID for support and debugging purposes."` 18 | } 19 | 20 | type RequestError struct { 21 | Code int 22 | Message string 23 | } 24 | 25 | func (r RequestError) Error() string { 26 | return fmt.Sprintf("%d: %s", r.Code, r.Message) 27 | } 28 | 29 | type RequestInternalError struct { 30 | OriginalError error 31 | Code int 32 | Message string 33 | } 34 | 35 | func (r RequestInternalError) Error() string { 36 | return fmt.Sprintf("%d: %s: %v", r.Code, r.Message, r.OriginalError) 37 | } 38 | 39 | type Success struct { 40 | Result string `json:"Result" description:"Whether the call was successful." enum:"success,error"` 41 | Data interface{} `json:"Data"` 42 | RequestId string `json:"RequestId" description:"A request ID for support and debugging purposes."` 43 | } 44 | 45 | func GraphQLInternalError(originalError error, requestID string) error { 46 | // Information for in the logging 47 | log.Error(fmt.Sprintf("error occurred, message: %v", originalError)) 48 | 49 | // Information the user sees 50 | return fmt.Errorf("something went wrong processing your request: %s", requestID) 51 | } 52 | 53 | func GraphQLError(message string, requestID string) error { 54 | return fmt.Errorf("%s: %s", message, requestID) 55 | } 56 | 57 | func JSONAbort(c *gin.Context, code int, obj interface{}) { 58 | c.Abort() 59 | JSON(c, code, obj) 60 | } 61 | 62 | // JSON serializes the given struct as JSON into the response body. 63 | // It also sets the Content-Type as "application/json". 64 | func JSON(c *gin.Context, code int, obj interface{}) { 65 | if code >= 400 { 66 | returnCode, returnObject := RenderInternalError(c, code, obj) 67 | c.Render(returnCode, render.JSON{Data: returnObject}) 68 | return 69 | } 70 | 71 | c.Render(code, render.JSON{Data: Success{ 72 | Result: "success", 73 | Data: obj, 74 | }}) 75 | } 76 | 77 | func RenderInternalError(c *gin.Context, code int, obj interface{}) (int, interface{}) { 78 | errorObj := Error{ 79 | Result: "error", 80 | ErrorCode: InputValidationError, 81 | ErrorMessage: "Request failed", 82 | } 83 | 84 | if requestError, ok := obj.(RequestError); ok { 85 | if requestError.Code != 0 { 86 | errorObj.ErrorCode = requestError.Code 87 | } 88 | if requestError.Message != "" { 89 | errorObj.ErrorMessage = requestError.Message 90 | } 91 | 92 | if errorObj.ErrorCode == InputValidationError { 93 | code = http.StatusBadRequest 94 | } 95 | 96 | if errorObj.ErrorCode == Unauthorized { 97 | code = http.StatusUnauthorized 98 | } 99 | } 100 | 101 | if requestError, ok := obj.(RequestInternalError); ok { 102 | errorObj.ErrorCode = InternalError 103 | if requestError.Code != 0 { 104 | errorObj.ErrorCode = requestError.Code 105 | } 106 | if requestError.Message != "" { 107 | errorObj.ErrorMessage = requestError.Message 108 | } 109 | code = http.StatusInternalServerError 110 | } 111 | 112 | return code, errorObj 113 | } 114 | 115 | func RecoveryHandler(c *gin.Context, err interface{}) { 116 | errorObj := Error{ 117 | Result: "error", 118 | ErrorCode: InternalError, 119 | ErrorMessage: "Request failed", 120 | } 121 | c.AbortWithStatusJSON(http.StatusInternalServerError, errorObj) 122 | } 123 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/spf13/viper" 9 | "gorm.io/driver/mysql" 10 | "gorm.io/driver/postgres" 11 | "gorm.io/driver/sqlserver" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | var DB *gorm.DB 16 | 17 | type DatabaseDSN struct { 18 | driver string 19 | database string 20 | username string 21 | password string 22 | host string 23 | port string 24 | address string 25 | connectionOptions string 26 | } 27 | 28 | func (dsn *DatabaseDSN) GetMysqlDSN() string { 29 | var MysqlConnectionOptions = "charset=utf8mb4&parseTime=True&loc=Local" 30 | if dsn.connectionOptions != "" { 31 | MysqlConnectionOptions += "&" + dsn.connectionOptions 32 | } 33 | // For backward compatibility 34 | if dsn.address != "" && dsn.driver == "" { 35 | return fmt.Sprintf("%s:%s@%s/%s?%s", dsn.username, dsn.password, dsn.address, dsn.database, MysqlConnectionOptions) 36 | } 37 | return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?%s", dsn.username, dsn.password, dsn.host, dsn.port, dsn.database, MysqlConnectionOptions) 38 | } 39 | 40 | func (dsn *DatabaseDSN) GetPostgresDSN() string { 41 | var PostgresConnectionOptions = "TimeZone=Europe/Amsterdam" 42 | if dsn.connectionOptions != "" { 43 | PostgresConnectionOptions = dsn.connectionOptions 44 | } 45 | return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s %s", dsn.host, dsn.username, dsn.password, dsn.database, dsn.port, PostgresConnectionOptions) 46 | } 47 | 48 | func (dsn *DatabaseDSN) GetMSSQLDSN() string { 49 | 50 | var MSSQLConnectionOptions = "" 51 | if dsn.connectionOptions != "" { 52 | MSSQLConnectionOptions += "?" + dsn.connectionOptions 53 | } 54 | return fmt.Sprintf("sqlserver://%s:%s@%s:%s?database=%s%s", dsn.username, dsn.password, dsn.host, dsn.port, dsn.database, MSSQLConnectionOptions) 55 | } 56 | 57 | func (dsn *DatabaseDSN) GetMysql() gorm.Dialector { 58 | return mysql.Open(dsn.GetMysqlDSN()) 59 | } 60 | 61 | func (dsn *DatabaseDSN) GetPostgres() gorm.Dialector { 62 | return postgres.Open(dsn.GetPostgresDSN()) 63 | } 64 | 65 | func (dsn *DatabaseDSN) GetMSSQL() gorm.Dialector { 66 | return sqlserver.Open(dsn.GetMSSQLDSN()) 67 | } 68 | 69 | func (dsn *DatabaseDSN) GetDialector() (gorm.Dialector, error) { 70 | // Assuming old version won't have driver field but will have address field 71 | if dsn.address != "" && dsn.driver == "" { 72 | return dsn.GetMysql(), nil 73 | } 74 | 75 | switch dsn.driver { 76 | case "postgres": 77 | return dsn.GetPostgres(), nil 78 | case "mysql": 79 | return dsn.GetMysql(), nil 80 | case "mssql": 81 | return dsn.GetMSSQL(), nil 82 | default: 83 | return nil, errors.New("invalid driver") 84 | } 85 | } 86 | 87 | func ConnectDB() error { 88 | var connectionData = &DatabaseDSN{ 89 | driver: viper.GetString("database.driver"), 90 | database: viper.GetString("database.database"), 91 | username: viper.GetString("database.username"), 92 | password: viper.GetString("database.password"), 93 | host: viper.GetString("database.host"), 94 | port: viper.GetString("database.port"), 95 | address: viper.GetString("database.address"), 96 | connectionOptions: viper.GetString("database.connectionOptions"), 97 | } 98 | 99 | dialector, err := connectionData.GetDialector() 100 | 101 | if err != nil { 102 | return err 103 | } 104 | 105 | db, err := gorm.Open(dialector, &gorm.Config{}) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | DB = db 111 | if viper.GetBool("database.debug") { 112 | DB = DB.Debug() 113 | } 114 | 115 | return nil 116 | } 117 | 118 | func AttachRequestSession() gin.HandlerFunc { 119 | return func(c *gin.Context) { 120 | c.Set("DB", DB.Session(&gorm.Session{})) 121 | 122 | // Process request 123 | c.Next() 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/team_member.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strconv" 7 | 8 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 9 | "github.com/jerbob92/hoppscotch-backend/models" 10 | 11 | "github.com/graph-gophers/graphql-go" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | type TeamMemberResolver struct { 16 | c *graphql_context.Context 17 | team_member *models.TeamMember 18 | } 19 | 20 | func NewTeamMemberResolver(c *graphql_context.Context, team_member *models.TeamMember) (*TeamMemberResolver, error) { 21 | if team_member == nil { 22 | return nil, nil 23 | } 24 | 25 | return &TeamMemberResolver{c: c, team_member: team_member}, nil 26 | } 27 | 28 | func (r *TeamMemberResolver) MembershipID() (graphql.ID, error) { 29 | id := graphql.ID(strconv.Itoa(int(r.team_member.ID))) 30 | return id, nil 31 | } 32 | 33 | func (r *TeamMemberResolver) Role() (models.TeamMemberRole, error) { 34 | return r.team_member.Role, nil 35 | } 36 | 37 | func (r *TeamMemberResolver) User() (*UserResolver, error) { 38 | db := r.c.GetDB() 39 | existingUser := &models.User{} 40 | err := db.Where("id = ?", r.team_member.UserID).First(existingUser).Error 41 | if err != nil && err == gorm.ErrRecordNotFound { 42 | return nil, errors.New("user not found") 43 | } 44 | 45 | return NewUserResolver(r.c, existingUser) 46 | } 47 | 48 | type RemoveTeamMemberArgs struct { 49 | TeamID graphql.ID 50 | UserUID graphql.ID 51 | } 52 | 53 | func (b *BaseQuery) RemoveTeamMember(ctx context.Context, args *RemoveTeamMemberArgs) (bool, error) { 54 | c := b.GetReqC(ctx) 55 | db := c.GetDB() 56 | 57 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 58 | if err != nil { 59 | return false, err 60 | } 61 | 62 | if userRole == nil { 63 | return false, errors.New("you do not have access to this team") 64 | } 65 | 66 | if *userRole == models.Owner { 67 | existingUser := &models.User{} 68 | err = db.Where("fb_uid = ?", args.UserUID).First(existingUser).Error 69 | if err != nil { 70 | return false, err 71 | } 72 | 73 | teamMember := &models.TeamMember{} 74 | err := db.Model(&models.TeamMember{}).Where("team_id = ? AND user_id = ?", args.TeamID, existingUser.ID).First(teamMember).Error 75 | if err != nil { 76 | return false, err 77 | } 78 | 79 | err = db.Delete(teamMember).Error 80 | if err != nil { 81 | return false, err 82 | } 83 | 84 | go bus.Publish("team:"+strconv.Itoa(int(teamMember.TeamID))+":members:removed", graphql.ID(existingUser.FBUID)) 85 | 86 | return true, nil 87 | } 88 | 89 | return false, errors.New("you do not have access to remove a team member on this team") 90 | } 91 | 92 | type UpdateTeamMemberRoleArgs struct { 93 | NewRole models.TeamMemberRole 94 | TeamID graphql.ID 95 | UserUID graphql.ID 96 | } 97 | 98 | func (b *BaseQuery) UpdateTeamMemberRole(ctx context.Context, args *UpdateTeamMemberRoleArgs) (*TeamMemberResolver, error) { 99 | c := b.GetReqC(ctx) 100 | db := c.GetDB() 101 | 102 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | if userRole == nil { 108 | return nil, errors.New("you do not have access to this team") 109 | } 110 | 111 | if *userRole == models.Owner { 112 | existingUser := &models.User{} 113 | err = db.Where("fb_uid = ?", args.UserUID).First(existingUser).Error 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | teamMember := &models.TeamMember{} 119 | err := db.Model(&models.TeamMember{}).Where("team_id = ? AND user_id = ?", args.TeamID, existingUser.ID).First(teamMember).Error 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | teamMember.Role = args.NewRole 125 | err = db.Save(teamMember).Error 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | resolver, err := NewTeamMemberResolver(c, teamMember) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | go bus.Publish("team:"+strconv.Itoa(int(teamMember.TeamID))+":members:updated", resolver) 136 | 137 | return resolver, nil 138 | } 139 | 140 | return nil, errors.New("you do not have access to update a team member's role on this team") 141 | } 142 | -------------------------------------------------------------------------------- /schema/mutation.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | """ 3 | Creates a team owned by the executing user 4 | """ 5 | createTeam(name: String!): Team! 6 | 7 | """ 8 | Create a new Team Environment for given Team ID 9 | """ 10 | createTeamEnvironment(name: String!, teamID: ID!, variables: String!): TeamEnvironment! 11 | 12 | """ 13 | Leaves a team the executing user is a part of 14 | """ 15 | leaveTeam(teamID: ID!): Boolean! 16 | 17 | """ 18 | Removes the team member from the team 19 | """ 20 | removeTeamMember(teamID: ID!, userUid: ID!): Boolean! 21 | 22 | """ 23 | Renames a team 24 | """ 25 | renameTeam(newName: String!, teamID: ID!): Team! 26 | 27 | """ 28 | Deletes the team 29 | """ 30 | deleteTeam(teamID: ID!): Boolean! 31 | 32 | """ 33 | Delete a Team Environment for given Team ID 34 | """ 35 | deleteTeamEnvironment(id: ID!): Boolean! 36 | 37 | """ 38 | Delete an user account 39 | """ 40 | deleteUser(): Boolean! 41 | 42 | """ 43 | Adds a team member to the team via email 44 | @deprecated This is only present for backwards compatibility and will be removed soon use team invitations instead 45 | """ 46 | addTeamMemberByEmail(teamID: ID!, userEmail: String!, userRole: TeamMemberRole!): TeamMember! 47 | 48 | """ 49 | Update role of a team member the executing user owns 50 | """ 51 | updateTeamMemberRole(newRole: TeamMemberRole!, teamID: ID!, userUid: ID!): TeamMember! 52 | 53 | """ 54 | Creates a collection at the root of the team hierarchy (no parent collection) 55 | """ 56 | createRootCollection(teamID: ID!, title: String!): TeamCollection! 57 | 58 | """ 59 | Import collection from user firestore 60 | """ 61 | importCollectionFromUserFirestore(fbCollectionPath: String!, parentCollectionID: ID, teamID: ID!): TeamCollection! 62 | 63 | """ 64 | Import collections from JSON string to the specified Team 65 | """ 66 | importCollectionsFromJSON(jsonString: String!, parentCollectionID: ID, teamID: ID!): Boolean! 67 | 68 | """ 69 | Replace existing collections of a specific team with collections in JSON string 70 | """ 71 | replaceCollectionsWithJSON(jsonString: String!, parentCollectionID: ID, teamID: ID!): Boolean! 72 | 73 | """ 74 | Create a collection that has a parent collection 75 | """ 76 | createChildCollection(childTitle: String!, collectionID: ID!): TeamCollection! 77 | 78 | """ 79 | Create a duplicate of an existing environment 80 | """ 81 | createDuplicateEnvironment(id: ID!): TeamEnvironment! 82 | 83 | """ 84 | Rename a collection 85 | """ 86 | renameCollection(collectionID: ID!, newTitle: String!): TeamCollection! 87 | 88 | """ 89 | Delete a collection 90 | """ 91 | deleteCollection(collectionID: ID!): Boolean! 92 | 93 | """ 94 | Create a request in the given collection. 95 | """ 96 | createRequestInCollection(collectionID: ID!, data: CreateTeamRequestInput!): TeamRequest! 97 | 98 | """ 99 | Update a request with the given ID 100 | """ 101 | updateRequest(data: UpdateTeamRequestInput!, requestID: ID!): TeamRequest! 102 | 103 | """ 104 | Add/Edit a single environment variable or variables to a Team Environment 105 | """ 106 | updateTeamEnvironment(id: ID!, name: String!, variables: String!): TeamEnvironment! 107 | 108 | """ 109 | Delete a request with the given ID 110 | """ 111 | deleteRequest(requestID: ID!): Boolean! 112 | 113 | """ 114 | Move a request to the given collection 115 | """ 116 | moveRequest(destCollID: ID!, requestID: ID!): TeamRequest! 117 | 118 | """ 119 | Creates a Team Invitation 120 | """ 121 | createTeamInvitation(inviteeEmail: String!, inviteeRole: TeamMemberRole!, teamID: ID!): TeamInvitation! 122 | 123 | """ 124 | Delete all variables from a Team Environment 125 | """ 126 | deleteAllVariablesFromTeamEnvironment(id: ID!): TeamEnvironment! 127 | 128 | """ 129 | Revokes an invitation and deletes it 130 | """ 131 | revokeTeamInvitation(inviteID: ID!): Boolean! 132 | 133 | """ 134 | Accept an Invitation 135 | """ 136 | acceptTeamInvitation(inviteID: ID!): TeamMember! 137 | 138 | """ 139 | Create a shortcode for the given request. 140 | """ 141 | createShortcode(request: String!): Shortcode! 142 | 143 | """ 144 | Revoke a user generated shortcode 145 | """ 146 | revokeShortcode(code: ID!): Boolean! 147 | } 148 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jerbob92/hoppscotch-backend 2 | 3 | go 1.18 4 | 5 | require ( 6 | firebase.google.com/go v3.13.0+incompatible 7 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef 8 | github.com/gin-contrib/cors v1.4.0 9 | github.com/gin-gonic/gin v1.8.1 10 | github.com/graph-gophers/graphql-go v1.4.0 11 | github.com/graph-gophers/graphql-transport-ws v0.0.2 12 | github.com/sanae10001/graphql-go-extension-scalars v0.0.0-20181112092257-e9ea23d1612d 13 | github.com/sirupsen/logrus v1.9.0 14 | github.com/spf13/viper v1.13.0 15 | github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f 16 | google.golang.org/api v0.100.0 17 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 18 | gorm.io/driver/mysql v1.4.3 19 | gorm.io/driver/postgres v1.4.5 20 | gorm.io/driver/sqlserver v1.4.2 21 | 22 | gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755 23 | ) 24 | 25 | require ( 26 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect 27 | github.com/golang-sql/sqlexp v0.1.0 // indirect 28 | github.com/microsoft/go-mssqldb v0.19.0 // indirect 29 | ) 30 | 31 | require ( 32 | cloud.google.com/go v0.104.0 // indirect 33 | cloud.google.com/go/compute v1.10.0 // indirect 34 | cloud.google.com/go/firestore v1.8.0 // indirect 35 | cloud.google.com/go/iam v0.5.0 // indirect 36 | cloud.google.com/go/storage v1.27.0 // indirect 37 | github.com/fsnotify/fsnotify v1.6.0 // indirect 38 | github.com/gin-contrib/sse v0.1.0 // indirect 39 | github.com/go-playground/locales v0.14.0 // indirect 40 | github.com/go-playground/universal-translator v0.18.0 // indirect 41 | github.com/go-playground/validator/v10 v10.11.1 // indirect 42 | github.com/go-sql-driver/mysql v1.6.0 // indirect 43 | github.com/goccy/go-json v0.9.11 // indirect 44 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 45 | github.com/golang/protobuf v1.5.2 // indirect 46 | github.com/google/go-cmp v0.5.9 // indirect 47 | github.com/google/uuid v1.3.0 // indirect 48 | github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect 49 | github.com/googleapis/gax-go/v2 v2.6.0 // indirect 50 | github.com/gorilla/websocket v1.5.0 // indirect 51 | github.com/hashicorp/hcl v1.0.0 // indirect 52 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 53 | github.com/jackc/pgconn v1.13.0 // indirect 54 | github.com/jackc/pgio v1.0.0 // indirect 55 | github.com/jackc/pgpassfile v1.0.0 // indirect 56 | github.com/jackc/pgproto3/v2 v2.3.1 // indirect 57 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect 58 | github.com/jackc/pgtype v1.12.0 // indirect 59 | github.com/jackc/pgx/v4 v4.17.2 // indirect 60 | github.com/jinzhu/inflection v1.0.0 // indirect 61 | github.com/jinzhu/now v1.1.5 // indirect 62 | github.com/json-iterator/go v1.1.12 // indirect 63 | github.com/leodido/go-urn v1.2.1 // indirect 64 | github.com/magiconair/properties v1.8.6 // indirect 65 | github.com/mattn/go-isatty v0.0.16 // indirect 66 | github.com/mitchellh/mapstructure v1.5.0 // indirect 67 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 68 | github.com/modern-go/reflect2 v1.0.2 // indirect 69 | github.com/opentracing/opentracing-go v1.2.0 // indirect 70 | github.com/pelletier/go-toml v1.9.5 // indirect 71 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 72 | github.com/spf13/afero v1.9.2 // indirect 73 | github.com/spf13/cast v1.5.0 // indirect 74 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 75 | github.com/spf13/pflag v1.0.5 // indirect 76 | github.com/subosito/gotenv v1.4.1 // indirect 77 | github.com/ugorji/go/codec v1.2.7 // indirect 78 | go.opencensus.io v0.23.0 // indirect 79 | golang.org/x/crypto v0.1.0 // indirect 80 | golang.org/x/net v0.1.0 // indirect 81 | golang.org/x/oauth2 v0.1.0 // indirect 82 | golang.org/x/sync v0.1.0 // indirect 83 | golang.org/x/sys v0.1.0 // indirect 84 | golang.org/x/text v0.4.0 // indirect 85 | golang.org/x/time v0.1.0 // indirect 86 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 87 | google.golang.org/appengine v1.6.7 // indirect 88 | google.golang.org/genproto v0.0.0-20221018160656-63c7b68cfc55 // indirect 89 | google.golang.org/grpc v1.50.1 // indirect 90 | google.golang.org/protobuf v1.28.1 // indirect 91 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 92 | gopkg.in/ini.v1 v1.67.0 // indirect 93 | gopkg.in/yaml.v2 v2.4.0 // indirect 94 | gopkg.in/yaml.v3 v3.0.1 // indirect 95 | gorm.io/driver/sqlserver v1.4.2 96 | ) 97 | -------------------------------------------------------------------------------- /api/controllers/graphql/context/context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/jerbob92/hoppscotch-backend/fb" 12 | "github.com/jerbob92/hoppscotch-backend/models" 13 | 14 | "github.com/gin-gonic/gin" 15 | log "github.com/sirupsen/logrus" 16 | "gorm.io/gorm" 17 | ) 18 | 19 | // Context is a global request context 20 | type Context struct { 21 | ReqUser *models.User 22 | GinContext *gin.Context 23 | loggingMeta map[string]interface{} 24 | locking sync.Mutex 25 | 26 | // DisableResponses is mainly used for the graphql routes because the library handles error messages and we don't want to return custom errors 27 | DisableResponses bool 28 | } 29 | 30 | func GetContext(c *gin.Context) *Context { 31 | return &Context{ 32 | GinContext: c, 33 | loggingMeta: map[string]interface{}{}, 34 | locking: sync.Mutex{}, 35 | } 36 | } 37 | 38 | func (c *Context) SetLoggingMetaValue(key string, value interface{}) { 39 | c.locking.Lock() 40 | defer c.locking.Unlock() 41 | c.loggingMeta[key] = value 42 | } 43 | 44 | func (c *Context) Clone() *Context { 45 | c.locking.Lock() 46 | defer c.locking.Unlock() 47 | 48 | newLoggingMeta := map[string]interface{}{} 49 | for k, v := range c.loggingMeta { 50 | newLoggingMeta[k] = v 51 | } 52 | 53 | newContext := &Context{ 54 | GinContext: c.GinContext, 55 | loggingMeta: newLoggingMeta, 56 | locking: sync.Mutex{}, 57 | DisableResponses: c.DisableResponses, 58 | } 59 | 60 | if c.ReqUser != nil { 61 | newContext.ReqUser = &*c.ReqUser 62 | } 63 | 64 | return newContext 65 | } 66 | 67 | func (c *Context) LogErr(err error, meta ...map[string]interface{}) { 68 | data := log.Fields{ 69 | "type": "USER_API_ERROR", 70 | } 71 | 72 | c.locking.Lock() 73 | for key, value := range c.loggingMeta { 74 | data[key] = value 75 | } 76 | c.locking.Unlock() 77 | 78 | for _, metaItem := range meta { 79 | for key, value := range metaItem { 80 | data[key] = value 81 | } 82 | } 83 | log.WithFields(data).Error(err) 84 | } 85 | 86 | func (c *Context) GetDB() *gorm.DB { 87 | if c.GinContext == nil { 88 | return nil 89 | } 90 | db, exists := c.GinContext.Get("DB") 91 | if !exists { 92 | return nil 93 | } 94 | 95 | return db.(*gorm.DB) 96 | } 97 | 98 | func (c *Context) GetUser(ctx context.Context) (*models.User, error) { 99 | if c.ReqUser != nil { 100 | return c.ReqUser, nil 101 | } 102 | 103 | if c.GinContext == nil { 104 | return nil, errors.New("could not load user from request") 105 | } 106 | 107 | header := c.GinContext.Request.Header.Get("Authorization") 108 | 109 | // Fallback for subscription header. 110 | if header == "" { 111 | headerJSON, ok := ctx.Value("Header").(json.RawMessage) 112 | if ok { 113 | type initMessagePayload struct { 114 | Authorization string `json:"authorization"` 115 | } 116 | 117 | var initMsg initMessagePayload 118 | if err := json.Unmarshal(headerJSON, &initMsg); err != nil { 119 | return nil, err 120 | } 121 | 122 | header = initMsg.Authorization 123 | } 124 | } 125 | if strings.HasPrefix(strings.ToLower(header), "bearer ") { 126 | header = header[7:] 127 | } 128 | 129 | token, err := fb.FBAuth.VerifyIDToken(context.Background(), header) 130 | if err != nil { 131 | return nil, fmt.Errorf("could not validate ID token: %s", header) 132 | } 133 | 134 | db := c.GetDB() 135 | if db == nil { 136 | return nil, errors.New("can't get DB") 137 | } 138 | 139 | existingUser := &models.User{} 140 | err = db.Where("fb_uid = ?", token.UID).First(existingUser).Error 141 | if err != nil && err != gorm.ErrRecordNotFound { 142 | return nil, err 143 | } 144 | 145 | if err != nil && err == gorm.ErrRecordNotFound { 146 | newUser := &models.User{ 147 | FBUID: token.UID, 148 | DisplayName: "", 149 | PhotoURL: "", 150 | } 151 | 152 | email, ok := token.Claims["email"].(string) 153 | if ok { 154 | newUser.Email = email 155 | } 156 | 157 | userObj, err := fb.FBAuth.GetUser(ctx, token.UID) 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | newUser.DisplayName = userObj.UserInfo.DisplayName 163 | newUser.PhotoURL = userObj.UserInfo.PhotoURL 164 | 165 | err = db.Create(newUser).Error 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | c.ReqUser = newUser 171 | 172 | return newUser, nil 173 | } 174 | 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | // Overwrite email when there is a difference between db and JWT claims. 180 | // This might be the case when the profile has been updated or when the 181 | // email address have been verified. 182 | email, ok := token.Claims["email"].(string) 183 | if ok && email != "" && existingUser.Email != email { 184 | existingUser.Email = email 185 | err = db.Save(existingUser).Error 186 | if err != nil { 187 | return nil, err 188 | } 189 | } 190 | 191 | c.ReqUser = existingUser 192 | 193 | return existingUser, nil 194 | } 195 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/shortcode.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "errors" 7 | "strconv" 8 | 9 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 10 | "github.com/jerbob92/hoppscotch-backend/models" 11 | 12 | "github.com/graph-gophers/graphql-go" 13 | "github.com/sanae10001/graphql-go-extension-scalars" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | type ShortcodeResolver struct { 18 | c *graphql_context.Context 19 | shortcode *models.Shortcode 20 | } 21 | 22 | func NewShortcodeResolver(c *graphql_context.Context, shortcode *models.Shortcode) (*ShortcodeResolver, error) { 23 | if shortcode == nil { 24 | return nil, nil 25 | } 26 | 27 | return &ShortcodeResolver{c: c, shortcode: shortcode}, nil 28 | } 29 | 30 | func (r *ShortcodeResolver) ID() (graphql.ID, error) { 31 | id := graphql.ID(r.shortcode.Code) 32 | return id, nil 33 | } 34 | 35 | func (r *ShortcodeResolver) Request() (string, error) { 36 | return r.shortcode.Request, nil 37 | } 38 | 39 | func (r *ShortcodeResolver) CreatedOn() (scalars.DateTime, error) { 40 | return *scalars.NewDateTime(r.shortcode.CreatedAt), nil 41 | } 42 | 43 | type ShortcodeArgs struct { 44 | Code graphql.ID 45 | } 46 | 47 | func (b *BaseQuery) Shortcode(ctx context.Context, args *ShortcodeArgs) (*ShortcodeResolver, error) { 48 | c := b.GetReqC(ctx) 49 | db := c.GetDB() 50 | shortcode := &models.Shortcode{} 51 | err := db.Model(&models.Shortcode{}).Where("code = ?", args.Code).First(shortcode).Error 52 | if err != nil && err == gorm.ErrRecordNotFound { 53 | return nil, errors.New("you do not have access to this shortcode") 54 | } 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | return NewShortcodeResolver(c, shortcode) 60 | } 61 | 62 | type CreateShortcodeArgs struct { 63 | Request string 64 | } 65 | 66 | func (b *BaseQuery) CreateShortcode(ctx context.Context, args *CreateShortcodeArgs) (*ShortcodeResolver, error) { 67 | c := b.GetReqC(ctx) 68 | currentUser, err := c.GetUser(ctx) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | db := c.GetDB() 74 | newShortCode := &models.Shortcode{ 75 | Code: RandString(12), 76 | Request: args.Request, 77 | UserID: currentUser.ID, 78 | } 79 | 80 | err = db.Save(newShortCode).Error 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | resolver, err := NewShortcodeResolver(c, newShortCode) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | go bus.Publish("user:"+strconv.Itoa(int(currentUser.ID))+":shortcodes:created", resolver) 91 | 92 | return resolver, nil 93 | } 94 | 95 | type RevokeShortcodeArgs struct { 96 | Code graphql.ID 97 | } 98 | 99 | func (b *BaseQuery) RevokeShortcode(ctx context.Context, args *RevokeShortcodeArgs) (bool, error) { 100 | c := b.GetReqC(ctx) 101 | currentUser, err := c.GetUser(ctx) 102 | if err != nil { 103 | return false, err 104 | } 105 | 106 | shortcode := &models.Shortcode{} 107 | db := c.GetDB() 108 | err = db.Model(&models.Shortcode{}).Where("code = ?", args.Code).First(shortcode).Error 109 | if err != nil { 110 | return false, err 111 | } 112 | 113 | if shortcode.UserID != currentUser.ID { 114 | return false, errors.New("you do not have access to this shortcode") 115 | } 116 | 117 | err = db.Delete(shortcode).Error 118 | if err != nil { 119 | return false, err 120 | } 121 | 122 | resolver, err := NewShortcodeResolver(c, shortcode) 123 | if err != nil { 124 | return false, err 125 | } 126 | 127 | go bus.Publish("user:"+strconv.Itoa(int(currentUser.ID))+":shortcodes:revoked", resolver) 128 | 129 | return true, nil 130 | } 131 | 132 | type MyShortcodeArgs struct { 133 | Cursor *graphql.ID 134 | } 135 | 136 | func (b BaseQuery) MyShortcodes(ctx context.Context, args *MyShortcodeArgs) ([]*ShortcodeResolver, error) { 137 | c := b.GetReqC(ctx) 138 | currentUser, err := c.GetUser(ctx) 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | shortcodes := []*models.Shortcode{} 144 | db := c.GetDB() 145 | query := db.Model(&models.Shortcode{}).Where("user_id = ?", currentUser.ID) 146 | if args.Cursor != nil && *args.Cursor != "" { 147 | query.Where("id > ?", args.Cursor) 148 | } 149 | err = query.Find(&shortcodes).Error 150 | if err != nil { 151 | return nil, err 152 | } 153 | 154 | shortcodesResolvers := []*ShortcodeResolver{} 155 | for i := range shortcodes { 156 | newResolver, err := NewShortcodeResolver(c, shortcodes[i]) 157 | if err != nil { 158 | return nil, err 159 | } 160 | shortcodesResolvers = append(shortcodesResolvers, newResolver) 161 | } 162 | 163 | return shortcodesResolvers, nil 164 | } 165 | 166 | func (b *BaseQuery) MyShortcodesCreated(ctx context.Context) (<-chan *ShortcodeResolver, error) { 167 | c := b.GetReqC(ctx) 168 | currentUser, err := c.GetUser(ctx) 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | notificationChannel := make(chan *ShortcodeResolver) 174 | eventHandler := func(resolver *ShortcodeResolver) { 175 | notificationChannel <- resolver 176 | } 177 | err = subscribeUntilDone(ctx, "user:"+strconv.Itoa(int(currentUser.ID))+":shortcodes:created", eventHandler) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | return notificationChannel, nil 183 | } 184 | 185 | func (b *BaseQuery) MyShortcodesRevoked(ctx context.Context) (<-chan *ShortcodeResolver, error) { 186 | c := b.GetReqC(ctx) 187 | currentUser, err := c.GetUser(ctx) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | notificationChannel := make(chan *ShortcodeResolver) 193 | eventHandler := func(resolver *ShortcodeResolver) { 194 | notificationChannel <- resolver 195 | } 196 | err = subscribeUntilDone(ctx, "user:"+strconv.Itoa(int(currentUser.ID))+":shortcodes:revoked", eventHandler) 197 | if err != nil { 198 | return nil, err 199 | } 200 | 201 | return notificationChannel, nil 202 | } 203 | 204 | const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 205 | 206 | func RandString(n int) string { 207 | var bytes = make([]byte, n) 208 | rand.Read(bytes) 209 | for i, b := range bytes { 210 | bytes[i] = alphanum[b%byte(len(alphanum))] 211 | } 212 | return string(bytes) 213 | } 214 | -------------------------------------------------------------------------------- /api/controllers/graphql/request.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "mime/multipart" 7 | "net/http" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | 12 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 13 | 14 | "github.com/graph-gophers/graphql-go" 15 | ) 16 | 17 | // Request is the request content 18 | type Request struct { 19 | OperationName string 20 | Query string 21 | Variables map[string]interface{} 22 | Context context.Context 23 | } 24 | 25 | func set(v interface{}, m interface{}, path string) error { 26 | var parts []interface{} 27 | for _, p := range strings.Split(path, ".") { 28 | if isNumber, err := regexp.MatchString(`\d+`, p); err != nil { 29 | return err 30 | } else if isNumber { 31 | index, _ := strconv.Atoi(p) 32 | parts = append(parts, index) 33 | } else { 34 | parts = append(parts, p) 35 | } 36 | } 37 | for i, p := range parts { 38 | last := i == len(parts)-1 39 | switch idx := p.(type) { 40 | case string: 41 | if last { 42 | m.(map[string]interface{})[idx] = v 43 | } else { 44 | m = m.(map[string]interface{})[idx] 45 | } 46 | case int: 47 | if last { 48 | m.([]interface{})[idx] = v 49 | } else { 50 | m = m.([]interface{})[idx] 51 | } 52 | } 53 | } 54 | return nil 55 | } 56 | 57 | type File struct { 58 | File multipart.File 59 | Filename string 60 | Size int64 61 | } 62 | 63 | func Handle(c *graphql_context.Context, exec func(req *Request) *graphql.Response) { 64 | c.GinContext.Writer.Header().Set("Content-Type", "application/json") 65 | 66 | var operations interface{} 67 | 68 | logRequestErrors := func(r *Request, result *graphql.Response) { 69 | if result.Errors != nil && len(result.Errors) > 0 { 70 | for i, _ := range result.Errors { 71 | meta := map[string]interface{}{ 72 | "graphql_query": r.Query, 73 | "http_method": c.GinContext.Request.Method, 74 | "http_content_type": c.GinContext.Request.Header.Get("Content-Type"), 75 | } 76 | if r.Variables != nil && len(r.Variables) > 0 { 77 | meta["graphql_variables"] = r.Variables 78 | } 79 | c.LogErr(result.Errors[i], meta) 80 | } 81 | } 82 | } 83 | 84 | makeRequest := func(r *Request) { 85 | result := exec(r) 86 | logRequestErrors(r, result) 87 | err := json.NewEncoder(c.GinContext.Writer).Encode(result) 88 | if err != nil { 89 | http.Error(c.GinContext.Writer, "Could not encode the graphql response", http.StatusInternalServerError) 90 | return 91 | } 92 | } 93 | 94 | switch c.GinContext.Request.Method { 95 | case "GET": 96 | request := Request{ 97 | Context: c.GinContext.Request.Context(), 98 | Variables: map[string]interface{}{}, 99 | } 100 | 101 | for key, args := range c.GinContext.Request.URL.Query() { 102 | if args == nil || len(args) == 0 { 103 | continue 104 | } 105 | arg := args[0] 106 | if arg == "" { 107 | continue 108 | } 109 | switch strings.ToLower(key) { 110 | case "query": 111 | request.Query = arg 112 | case "variables": 113 | err := json.Unmarshal([]byte(arg), &request.Variables) 114 | if err != nil { 115 | http.Error(c.GinContext.Writer, "Graphql url variables are not valid json", http.StatusBadRequest) 116 | return 117 | } 118 | case "operationname": 119 | request.OperationName = arg 120 | } 121 | } 122 | 123 | if request.Query == "" { 124 | http.Error(c.GinContext.Writer, "Missing graphql query in url", http.StatusBadRequest) 125 | return 126 | } 127 | 128 | makeRequest(&request) 129 | default: // "POST", "PATCH", "DELETE", "PUT" 130 | contentType := strings.SplitN(c.GinContext.Request.Header.Get("Content-Type"), ";", 2)[0] 131 | 132 | switch contentType { 133 | case "text/plain", "application/json", "application/graphql": 134 | err := json.NewDecoder(c.GinContext.Request.Body).Decode(&operations) 135 | if err != nil { 136 | http.Error(c.GinContext.Writer, "Could not read the json request body", http.StatusBadRequest) 137 | return 138 | } 139 | case "multipart/form-data": 140 | // Parse multipart form 141 | err := c.GinContext.Request.ParseMultipartForm(8192) 142 | if err != nil { 143 | http.Error(c.GinContext.Writer, "Could not access uploaded file", http.StatusBadRequest) 144 | return 145 | } 146 | 147 | // Unmarshal uploads 148 | var uploads = map[File][]string{} 149 | var uploadsMap = map[string][]string{} 150 | if err := json.Unmarshal([]byte(c.GinContext.Request.Form.Get("map")), &uploadsMap); err != nil { 151 | panic(err) 152 | } else { 153 | for key, path := range uploadsMap { 154 | file, header, err := c.GinContext.Request.FormFile(key) 155 | if err != nil { 156 | http.Error(c.GinContext.Writer, "Could not access uploaded file", http.StatusInternalServerError) 157 | return 158 | } 159 | uploads[File{ 160 | File: file, 161 | Size: header.Size, 162 | Filename: header.Filename, 163 | }] = path 164 | } 165 | } 166 | 167 | // Unmarshal operations 168 | if err := json.Unmarshal([]byte(c.GinContext.Request.Form.Get("operations")), &operations); err != nil { 169 | http.Error(c.GinContext.Writer, "the request form operations field doesn't exist or has invalid json data", http.StatusInternalServerError) 170 | return 171 | } 172 | 173 | // set uploads to operations 174 | for file, paths := range uploads { 175 | for _, path := range paths { 176 | if err := set(file, operations, path); err != nil { 177 | http.Error(c.GinContext.Writer, "Could not access uploaded file", http.StatusInternalServerError) 178 | return 179 | } 180 | } 181 | } 182 | } 183 | 184 | switch data := operations.(type) { 185 | case map[string]interface{}: 186 | request := Request{} 187 | 188 | for key, raw := range data { 189 | switch strings.ToLower(key) { 190 | case "operationname": 191 | if val, ok := raw.(string); ok { 192 | request.OperationName = val 193 | } 194 | case "query": 195 | if val, ok := raw.(string); ok { 196 | request.Query = val 197 | } 198 | case "variables": 199 | if val, ok := raw.(map[string]interface{}); ok { 200 | request.Variables = val 201 | } 202 | } 203 | } 204 | 205 | request.Context = c.GinContext.Request.Context() 206 | makeRequest(&request) 207 | case []interface{}: 208 | result := make([]interface{}, len(data)) 209 | for index, operation := range data { 210 | data, ok := operation.(map[string]interface{}) 211 | if !ok { 212 | http.Error(c.GinContext.Writer, "Invalid "+c.GinContext.Request.Method+" data", http.StatusInternalServerError) 213 | return 214 | } 215 | 216 | request := Request{} 217 | 218 | for key, raw := range data { 219 | switch strings.ToLower(key) { 220 | case "operationname": 221 | if val, ok := raw.(string); ok { 222 | request.OperationName = val 223 | } 224 | case "query": 225 | if val, ok := raw.(string); ok { 226 | request.Query = val 227 | } 228 | case "variables": 229 | if val, ok := raw.(map[string]interface{}); ok { 230 | request.Variables = val 231 | } 232 | } 233 | } 234 | 235 | request.Context = c.GinContext.Request.Context() 236 | reqResults := exec(&request) 237 | logRequestErrors(&request, reqResults) 238 | result[index] = reqResults 239 | } 240 | 241 | err := json.NewEncoder(c.GinContext.Writer).Encode(result) 242 | if err != nil { 243 | http.Error(c.GinContext.Writer, "Internal error", http.StatusInternalServerError) 244 | return 245 | } 246 | default: 247 | http.Error(c.GinContext.Writer, "Could not encode the graphql response", http.StatusBadRequest) 248 | return 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/team_request.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strconv" 7 | 8 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 9 | "github.com/jerbob92/hoppscotch-backend/models" 10 | 11 | "github.com/graph-gophers/graphql-go" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | type TeamRequestResolver struct { 16 | c *graphql_context.Context 17 | team_request *models.TeamRequest 18 | } 19 | 20 | func NewTeamRequestResolver(c *graphql_context.Context, team_request *models.TeamRequest) (*TeamRequestResolver, error) { 21 | if team_request == nil { 22 | return nil, nil 23 | } 24 | 25 | return &TeamRequestResolver{c: c, team_request: team_request}, nil 26 | } 27 | 28 | func (r *TeamRequestResolver) ID() (graphql.ID, error) { 29 | id := graphql.ID(strconv.Itoa(int(r.team_request.ID))) 30 | return id, nil 31 | } 32 | 33 | func (r *TeamRequestResolver) Collection() (*TeamCollectionResolver, error) { 34 | db := r.c.GetDB() 35 | collection := &models.TeamCollection{} 36 | err := db.Model(&models.TeamCollection{}).Where("id = ?", r.team_request.TeamCollectionID).First(collection).Error 37 | if err != nil && err == gorm.ErrRecordNotFound { 38 | return nil, errors.New("you do not have access to this collection") 39 | } 40 | if err != nil { 41 | return nil, err 42 | } 43 | return NewTeamCollectionResolver(r.c, collection) 44 | } 45 | 46 | func (r *TeamRequestResolver) CollectionID() (graphql.ID, error) { 47 | return graphql.ID(strconv.Itoa(int(r.team_request.TeamCollectionID))), nil 48 | } 49 | 50 | func (r *TeamRequestResolver) Request() (string, error) { 51 | return r.team_request.Request, nil 52 | } 53 | 54 | func (r *TeamRequestResolver) Team() (*TeamResolver, error) { 55 | db := r.c.GetDB() 56 | team := &models.Team{} 57 | err := db.Model(&models.Team{}).Where("id = ?", r.team_request.TeamID).First(team).Error 58 | if err != nil && err == gorm.ErrRecordNotFound { 59 | return nil, errors.New("you do not have access to this team") 60 | } 61 | if err != nil { 62 | return nil, err 63 | } 64 | return NewTeamResolver(r.c, team) 65 | } 66 | 67 | func (r *TeamRequestResolver) TeamID() (graphql.ID, error) { 68 | return graphql.ID(strconv.Itoa(int(r.team_request.TeamID))), nil 69 | } 70 | 71 | func (r *TeamRequestResolver) Title() (string, error) { 72 | return r.team_request.Title, nil 73 | } 74 | 75 | type DeleteRequestArgs struct { 76 | RequestID graphql.ID 77 | } 78 | 79 | func (b *BaseQuery) DeleteRequest(ctx context.Context, args *DeleteRequestArgs) (bool, error) { 80 | c := b.GetReqC(ctx) 81 | db := c.GetDB() 82 | request := &models.TeamRequest{} 83 | err := db.Model(&models.TeamRequest{}).Where("id = ?", args.RequestID).First(request).Error 84 | if err != nil && err == gorm.ErrRecordNotFound { 85 | return false, errors.New("you do not have access to this request") 86 | } 87 | if err != nil { 88 | return false, err 89 | } 90 | 91 | userRole, err := getUserRoleInTeam(ctx, c, request.TeamID) 92 | if err != nil { 93 | return false, err 94 | } 95 | 96 | if userRole == nil { 97 | return false, errors.New("you do not have access to delete to this request") 98 | } 99 | 100 | if *userRole == models.Owner || *userRole == models.Editor { 101 | err := db.Delete(request).Error 102 | if err != nil { 103 | return false, err 104 | } 105 | 106 | go bus.Publish("team:"+strconv.Itoa(int(request.TeamID))+":requests:deleted", graphql.ID(strconv.Itoa(int(request.ID)))) 107 | 108 | return true, nil 109 | } 110 | 111 | return false, errors.New("you are not allowed to delete a request in this team") 112 | } 113 | 114 | type MoveRequestArgs struct { 115 | DestCollID graphql.ID 116 | RequestID graphql.ID 117 | } 118 | 119 | func (b *BaseQuery) MoveRequest(ctx context.Context, args *MoveRequestArgs) (*TeamRequestResolver, error) { 120 | c := b.GetReqC(ctx) 121 | db := c.GetDB() 122 | request := &models.TeamRequest{} 123 | err := db.Model(&models.TeamRequest{}).Where("id = ?", args.RequestID).First(request).Error 124 | if err != nil && err == gorm.ErrRecordNotFound { 125 | return nil, errors.New("you do not have access to this request") 126 | } 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | userRole, err := getUserRoleInTeam(ctx, c, request.TeamID) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | if userRole == nil { 137 | return nil, errors.New("you do not have move to this request") 138 | } 139 | 140 | collection := &models.TeamCollection{} 141 | err = db.Model(&models.TeamCollection{}).Where("id = ?", args.DestCollID).First(collection).Error 142 | if err != nil && err == gorm.ErrRecordNotFound { 143 | return nil, errors.New("you do not have access to this collection") 144 | } 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | targetUserRole, err := getUserRoleInTeam(ctx, c, collection.TeamID) 150 | if err != nil { 151 | return nil, err 152 | } 153 | 154 | if targetUserRole == nil { 155 | return nil, errors.New("you do not have access to move to this request") 156 | } 157 | 158 | if (*userRole == models.Owner || *userRole == models.Editor) && (*targetUserRole == models.Owner || *targetUserRole == models.Editor) { 159 | teamChanged := false 160 | oldTeamID := request.TeamID 161 | newTeamID := collection.TeamID 162 | if collection.TeamID != request.TeamID { 163 | teamChanged = true 164 | } 165 | 166 | request.TeamCollectionID = collection.ID 167 | request.TeamID = collection.TeamID 168 | err := db.Save(request).Error 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | resolver, err := NewTeamRequestResolver(c, request) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | if teamChanged { 179 | go bus.Publish("team:"+strconv.Itoa(int(oldTeamID))+":requests:deleted", graphql.ID(strconv.Itoa(int(request.ID)))) 180 | go bus.Publish("team:"+strconv.Itoa(int(newTeamID))+":requests:added", graphql.ID(strconv.Itoa(int(request.ID)))) 181 | } else { 182 | go bus.Publish("team:"+strconv.Itoa(int(newTeamID))+":requests:updated", resolver) 183 | } 184 | 185 | return resolver, nil 186 | } 187 | 188 | return nil, errors.New("you are not allowed to delete a request in this team") 189 | } 190 | 191 | type UpdateTeamRequestInput struct { 192 | Request *string 193 | Title *string 194 | } 195 | 196 | type UpdateRequestArgs struct { 197 | Data UpdateTeamRequestInput 198 | RequestID graphql.ID 199 | } 200 | 201 | func (b *BaseQuery) UpdateRequest(ctx context.Context, args *UpdateRequestArgs) (*TeamRequestResolver, error) { 202 | c := b.GetReqC(ctx) 203 | db := c.GetDB() 204 | request := &models.TeamRequest{} 205 | err := db.Model(&models.TeamRequest{}).Where("id = ?", args.RequestID).First(request).Error 206 | if err != nil && err == gorm.ErrRecordNotFound { 207 | return nil, errors.New("you do not have access to this request") 208 | } 209 | if err != nil { 210 | return nil, err 211 | } 212 | 213 | userRole, err := getUserRoleInTeam(ctx, c, request.TeamID) 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | if userRole == nil { 219 | return nil, errors.New("you do not have access to update to this request") 220 | } 221 | 222 | if *userRole == models.Owner || *userRole == models.Editor { 223 | if args.Data.Title != nil { 224 | request.Title = *args.Data.Title 225 | } 226 | if args.Data.Request != nil { 227 | request.Request = *args.Data.Request 228 | } 229 | err := db.Save(request).Error 230 | if err != nil { 231 | return nil, err 232 | } 233 | 234 | requestResolver, err := NewTeamRequestResolver(c, request) 235 | if err != nil { 236 | return nil, err 237 | } 238 | 239 | go bus.Publish("team:"+strconv.Itoa(int(request.TeamID))+":requests:updated", requestResolver) 240 | 241 | return requestResolver, nil 242 | } 243 | 244 | return nil, errors.New("you are not allowed to update a request in this team") 245 | } 246 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/team_environment.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "gorm.io/gorm" 7 | "strconv" 8 | 9 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 10 | "github.com/jerbob92/hoppscotch-backend/models" 11 | 12 | "github.com/graph-gophers/graphql-go" 13 | ) 14 | 15 | type TeamEnvironmentResolver struct { 16 | c *graphql_context.Context 17 | team_environment *models.TeamEnvironment 18 | } 19 | 20 | func NewTeamEnvironmentResolver(c *graphql_context.Context, team_environment *models.TeamEnvironment) (*TeamEnvironmentResolver, error) { 21 | if team_environment == nil { 22 | return nil, nil 23 | } 24 | 25 | return &TeamEnvironmentResolver{c: c, team_environment: team_environment}, nil 26 | } 27 | 28 | func (r *TeamEnvironmentResolver) ID() (graphql.ID, error) { 29 | id := graphql.ID(strconv.Itoa(int(r.team_environment.ID))) 30 | return id, nil 31 | } 32 | 33 | func (r *TeamEnvironmentResolver) Variables() (string, error) { 34 | return r.team_environment.Variables, nil 35 | } 36 | 37 | func (r *TeamEnvironmentResolver) TeamID() (graphql.ID, error) { 38 | return graphql.ID(strconv.Itoa(int(r.team_environment.TeamID))), nil 39 | } 40 | 41 | func (r *TeamEnvironmentResolver) Name() (string, error) { 42 | return r.team_environment.Name, nil 43 | } 44 | 45 | type CreateTeamEnvironmentRequestArgs struct { 46 | Name string 47 | TeamID graphql.ID 48 | Variables string 49 | } 50 | 51 | func (b *BaseQuery) CreateTeamEnvironment(ctx context.Context, args *CreateTeamEnvironmentRequestArgs) (*TeamEnvironmentResolver, error) { 52 | c := b.GetReqC(ctx) 53 | db := c.GetDB() 54 | 55 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | if userRole == nil { 61 | return nil, errors.New("you are not allowed to create an environment in this team") 62 | } 63 | 64 | teamID, _ := strconv.Atoi(string(args.TeamID)) 65 | 66 | if *userRole == models.Owner || *userRole == models.Editor { 67 | newTeamEnvironment := &models.TeamEnvironment{ 68 | TeamID: uint(teamID), 69 | Name: args.Name, 70 | Variables: args.Variables, 71 | } 72 | 73 | err := db.Save(newTeamEnvironment).Error 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | resolver, err := NewTeamEnvironmentResolver(c, newTeamEnvironment) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | go bus.Publish("team:"+strconv.Itoa(int(teamID))+":environments:created", resolver) 84 | 85 | return resolver, nil 86 | } 87 | 88 | return nil, errors.New("you are not allowed to create an environment in this team") 89 | } 90 | 91 | type DeleteTeamEnvironmentRequestArgs struct { 92 | ID graphql.ID 93 | } 94 | 95 | func (b *BaseQuery) DeleteTeamEnvironment(ctx context.Context, args *DeleteTeamEnvironmentRequestArgs) (bool, error) { 96 | c := b.GetReqC(ctx) 97 | db := c.GetDB() 98 | 99 | teamEnvironment := &models.TeamEnvironment{} 100 | err := db.Model(&models.TeamEnvironment{}).Where("id = ?", args.ID).First(teamEnvironment).Error 101 | if err != nil && err == gorm.ErrRecordNotFound { 102 | return false, errors.New("you do not have access to this team") 103 | } 104 | if err != nil { 105 | return false, err 106 | } 107 | 108 | userRole, err := getUserRoleInTeam(ctx, c, teamEnvironment.TeamID) 109 | if err != nil { 110 | return false, err 111 | } 112 | 113 | if userRole == nil { 114 | return false, errors.New("you are not allowed to delete an environment in this team") 115 | } 116 | 117 | if *userRole == models.Owner || *userRole == models.Editor { 118 | err := db.Delete(teamEnvironment).Error 119 | if err != nil { 120 | return false, err 121 | } 122 | 123 | resolver, err := NewTeamEnvironmentResolver(c, teamEnvironment) 124 | if err != nil { 125 | return false, err 126 | } 127 | 128 | go bus.Publish("team:"+strconv.Itoa(int(teamEnvironment.TeamID))+":environments:deleted", resolver) 129 | 130 | return true, nil 131 | } 132 | 133 | return false, errors.New("you are not allowed to create an environment in this team") 134 | } 135 | 136 | type UpdateTeamEnvironmentRequestArgs struct { 137 | ID graphql.ID 138 | Name string 139 | Variables string 140 | } 141 | 142 | func (b *BaseQuery) UpdateTeamEnvironment(ctx context.Context, args *UpdateTeamEnvironmentRequestArgs) (*TeamEnvironmentResolver, error) { 143 | c := b.GetReqC(ctx) 144 | db := c.GetDB() 145 | 146 | teamEnvironment := &models.TeamEnvironment{} 147 | err := db.Model(&models.TeamEnvironment{}).Where("id = ?", args.ID).First(teamEnvironment).Error 148 | if err != nil && err == gorm.ErrRecordNotFound { 149 | return nil, errors.New("you do not have access to this team") 150 | } 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | userRole, err := getUserRoleInTeam(ctx, c, teamEnvironment.TeamID) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | if userRole == nil { 161 | return nil, errors.New("you are not allowed to update an environment in this team") 162 | } 163 | 164 | if *userRole == models.Owner || *userRole == models.Editor { 165 | teamEnvironment.Name = args.Name 166 | teamEnvironment.Variables = args.Variables 167 | 168 | err := db.Save(teamEnvironment).Error 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | resolver, err := NewTeamEnvironmentResolver(c, teamEnvironment) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | go bus.Publish("team:"+strconv.Itoa(int(teamEnvironment.TeamID))+":environments:updated", resolver) 179 | 180 | return resolver, nil 181 | } 182 | 183 | return nil, errors.New("you are not allowed to update an environment in this team") 184 | } 185 | 186 | type DeleteAllVariablesFromTeamEnvironmentRequestArgs struct { 187 | ID graphql.ID 188 | } 189 | 190 | func (b *BaseQuery) DeleteAllVariablesFromTeamEnvironment(ctx context.Context, args *DeleteAllVariablesFromTeamEnvironmentRequestArgs) (*TeamEnvironmentResolver, error) { 191 | c := b.GetReqC(ctx) 192 | db := c.GetDB() 193 | 194 | teamEnvironment := &models.TeamEnvironment{} 195 | err := db.Model(&models.TeamEnvironment{}).Where("id = ?", args.ID).First(teamEnvironment).Error 196 | if err != nil && err == gorm.ErrRecordNotFound { 197 | return nil, errors.New("you do not have access to this team") 198 | } 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | userRole, err := getUserRoleInTeam(ctx, c, teamEnvironment.TeamID) 204 | if err != nil { 205 | return nil, err 206 | } 207 | 208 | if userRole == nil { 209 | return nil, errors.New("you are not allowed to update an environment in this team") 210 | } 211 | 212 | if *userRole == models.Owner || *userRole == models.Editor { 213 | teamEnvironment.Variables = "" 214 | 215 | err := db.Save(teamEnvironment).Error 216 | if err != nil { 217 | return nil, err 218 | } 219 | 220 | resolver, err := NewTeamEnvironmentResolver(c, teamEnvironment) 221 | if err != nil { 222 | return nil, err 223 | } 224 | 225 | go bus.Publish("team:"+strconv.Itoa(int(teamEnvironment.TeamID))+":environments:updated", resolver) 226 | 227 | return resolver, nil 228 | } 229 | 230 | return nil, errors.New("you are not allowed to update an environment in this team") 231 | } 232 | 233 | type CreateDuplicateEnvironmentRequestArgs struct { 234 | ID graphql.ID 235 | } 236 | 237 | func (b *BaseQuery) CreateDuplicateEnvironment(ctx context.Context, args *CreateDuplicateEnvironmentRequestArgs) (*TeamEnvironmentResolver, error) { 238 | c := b.GetReqC(ctx) 239 | db := c.GetDB() 240 | 241 | teamEnvironment := &models.TeamEnvironment{} 242 | err := db.Model(&models.TeamEnvironment{}).Where("id = ?", args.ID).First(teamEnvironment).Error 243 | if err != nil && err == gorm.ErrRecordNotFound { 244 | return nil, errors.New("you do not have access to this team") 245 | } 246 | if err != nil { 247 | return nil, err 248 | } 249 | 250 | userRole, err := getUserRoleInTeam(ctx, c, teamEnvironment.TeamID) 251 | if err != nil { 252 | return nil, err 253 | } 254 | 255 | if userRole == nil { 256 | return nil, errors.New("you are not allowed to duplicate an environment in this team") 257 | } 258 | 259 | if *userRole == models.Owner || *userRole == models.Editor { 260 | newTeamEnvironment := &models.TeamEnvironment{ 261 | TeamID: teamEnvironment.TeamID, 262 | Name: teamEnvironment.Name, 263 | Variables: teamEnvironment.Variables, 264 | } 265 | 266 | err := db.Save(newTeamEnvironment).Error 267 | if err != nil { 268 | return nil, err 269 | } 270 | 271 | resolver, err := NewTeamEnvironmentResolver(c, newTeamEnvironment) 272 | if err != nil { 273 | return nil, err 274 | } 275 | 276 | go bus.Publish("team:"+strconv.Itoa(int(teamEnvironment.TeamID))+":environments:created", resolver) 277 | 278 | return resolver, nil 279 | } 280 | 281 | return nil, errors.New("you are not allowed to duplicate an environment in this team") 282 | } 283 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/team_invitation.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "html/template" 9 | "strconv" 10 | 11 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 12 | "github.com/jerbob92/hoppscotch-backend/models" 13 | 14 | "github.com/graph-gophers/graphql-go" 15 | "github.com/spf13/viper" 16 | "gopkg.in/gomail.v2" 17 | "gorm.io/gorm" 18 | ) 19 | 20 | type TeamInvitationResolver struct { 21 | c *graphql_context.Context 22 | team_invitation *models.TeamInvitation 23 | } 24 | 25 | func NewTeamInvitationResolver(c *graphql_context.Context, team_invitation *models.TeamInvitation) (*TeamInvitationResolver, error) { 26 | if team_invitation == nil { 27 | return nil, nil 28 | } 29 | 30 | return &TeamInvitationResolver{c: c, team_invitation: team_invitation}, nil 31 | } 32 | 33 | func (r *TeamInvitationResolver) ID() (graphql.ID, error) { 34 | id := graphql.ID(r.team_invitation.Code) 35 | return id, nil 36 | } 37 | 38 | func (r *TeamInvitationResolver) Creator() (*UserResolver, error) { 39 | db := r.c.GetDB() 40 | existingUser := &models.User{} 41 | err := db.Where("id = ?", r.team_invitation.UserID).First(existingUser).Error 42 | if err != nil && err == gorm.ErrRecordNotFound { 43 | return nil, errors.New("user not found") 44 | } 45 | 46 | return NewUserResolver(r.c, existingUser) 47 | } 48 | 49 | func (r *TeamInvitationResolver) CreatorUid() (graphql.ID, error) { 50 | db := r.c.GetDB() 51 | existingUser := &models.User{} 52 | err := db.Where("id = ?", r.team_invitation.UserID).First(existingUser).Error 53 | if err != nil && err == gorm.ErrRecordNotFound { 54 | return graphql.ID(""), errors.New("user not found") 55 | } 56 | 57 | return graphql.ID(existingUser.FBUID), nil 58 | } 59 | 60 | func (r *TeamInvitationResolver) InviteeEmail() (graphql.ID, error) { 61 | return graphql.ID(r.team_invitation.InviteeEmail), nil 62 | } 63 | 64 | func (r *TeamInvitationResolver) InviteeRole() (models.TeamMemberRole, error) { 65 | return r.team_invitation.InviteeRole, nil 66 | } 67 | 68 | func (r *TeamInvitationResolver) Team() (*TeamResolver, error) { 69 | db := r.c.GetDB() 70 | existingTeam := &models.Team{} 71 | err := db.Where("id = ?", r.team_invitation.TeamID).First(existingTeam).Error 72 | if err != nil && err == gorm.ErrRecordNotFound { 73 | return nil, errors.New("team not found") 74 | } 75 | 76 | return NewTeamResolver(r.c, existingTeam) 77 | } 78 | 79 | func (r *TeamInvitationResolver) TeamID() (graphql.ID, error) { 80 | return graphql.ID(strconv.Itoa(int(r.team_invitation.TeamID))), nil 81 | } 82 | 83 | type TeamInvitationArgs struct { 84 | InviteID graphql.ID 85 | } 86 | 87 | func (b *BaseQuery) TeamInvitation(ctx context.Context, args *TeamInvitationArgs) (*TeamInvitationResolver, error) { 88 | c := b.GetReqC(ctx) 89 | db := c.GetDB() 90 | 91 | invite := &models.TeamInvitation{} 92 | err := db.Model(&models.TeamInvitation{}).Where("code = ?", args.InviteID).First(invite).Error 93 | if err != nil && err == gorm.ErrRecordNotFound { 94 | return nil, errors.New("team_invite/no_invite_found") 95 | } 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | currentUser, err := c.GetUser(ctx) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | if invite.InviteeEmail != currentUser.Email { 106 | return nil, errors.New("team_invite/email_do_not_match") 107 | } 108 | 109 | return NewTeamInvitationResolver(c, invite) 110 | } 111 | 112 | type AcceptTeamInvitationArgs struct { 113 | InviteID graphql.ID 114 | } 115 | 116 | func (b *BaseQuery) AcceptTeamInvitation(ctx context.Context, args *AcceptTeamInvitationArgs) (*TeamMemberResolver, error) { 117 | c := b.GetReqC(ctx) 118 | db := c.GetDB() 119 | 120 | invite := &models.TeamInvitation{} 121 | err := db.Model(&models.TeamInvitation{}).Where("code = ?", args.InviteID).First(invite).Error 122 | if err != nil && err == gorm.ErrRecordNotFound { 123 | return nil, errors.New("you do not have access to this invite") 124 | } 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | currentUser, err := c.GetUser(ctx) 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | if invite.InviteeEmail != currentUser.Email { 135 | return nil, errors.New("team_invite/email_do_not_match") 136 | } 137 | 138 | newTeamMember := &models.TeamMember{ 139 | TeamID: invite.TeamID, 140 | UserID: currentUser.ID, 141 | Role: invite.InviteeRole, 142 | } 143 | 144 | err = db.Create(newTeamMember).Error 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | err = db.Delete(invite).Error 150 | if err != nil { 151 | return nil, err 152 | } 153 | 154 | resolver, err := NewTeamMemberResolver(c, newTeamMember) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | go bus.Publish("team:"+strconv.Itoa(int(invite.TeamID))+":members:added", resolver) 160 | 161 | return resolver, nil 162 | } 163 | 164 | type AddTeamMemberByEmailArgs struct { 165 | TeamID graphql.ID 166 | UserEmail string 167 | UserRole models.TeamMemberRole 168 | } 169 | 170 | func (b *BaseQuery) AddTeamMemberByEmail(ctx context.Context, args *AddTeamMemberByEmailArgs) (*TeamMemberResolver, error) { 171 | // This doesn't seem to be used (anymore). 172 | return nil, nil 173 | } 174 | 175 | type CreateTeamInvitationArgs struct { 176 | InviteeEmail string 177 | InviteeRole models.TeamMemberRole 178 | TeamID graphql.ID 179 | } 180 | 181 | func (b *BaseQuery) CreateTeamInvitation(ctx context.Context, args *CreateTeamInvitationArgs) (*TeamInvitationResolver, error) { 182 | c := b.GetReqC(ctx) 183 | 184 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 185 | if err != nil { 186 | return nil, err 187 | } 188 | 189 | if userRole == nil { 190 | return nil, errors.New("you do not have access to this team") 191 | } 192 | 193 | if *userRole != models.Owner { 194 | return nil, errors.New("you do not have access to this team") 195 | } 196 | 197 | currentUser, err := c.GetUser(ctx) 198 | if err != nil { 199 | return nil, err 200 | } 201 | 202 | db := c.GetDB() 203 | 204 | parsedTeamID, _ := strconv.Atoi(string(args.TeamID)) 205 | invite := &models.TeamInvitation{ 206 | TeamID: uint(parsedTeamID), 207 | UserID: currentUser.ID, 208 | InviteeRole: args.InviteeRole, 209 | InviteeEmail: args.InviteeEmail, 210 | Code: RandString(32), 211 | } 212 | 213 | err = db.Save(invite).Error 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | name := "A user" 219 | if currentUser.DisplayName != "" { 220 | name = currentUser.DisplayName 221 | } else if currentUser.Email != "" { 222 | name = currentUser.Email 223 | } 224 | 225 | team := &models.Team{} 226 | err = db.Model(&models.Team{}).Where("id = ?", args.TeamID).First(team).Error 227 | if err != nil && err == gorm.ErrRecordNotFound { 228 | return nil, errors.New("you do not have access to this team") 229 | } 230 | if err != nil { 231 | return nil, err 232 | } 233 | 234 | from := fmt.Sprintf("%s <%s>", viper.GetString("smtp.from.name"), viper.GetString("smtp.from.email")) 235 | 236 | m := gomail.NewMessage() 237 | m.SetHeader("From", from) 238 | m.SetHeader("To", invite.InviteeEmail) 239 | 240 | joinLink := viper.GetString("frontend_domain") + "/join-team?id=" + invite.Code 241 | 242 | templateVariables := struct { 243 | InvitingUserName string 244 | TeamName string 245 | JoinLink string 246 | }{ 247 | InvitingUserName: name, 248 | TeamName: team.Name, 249 | JoinLink: joinLink, 250 | } 251 | 252 | subjectTemplate := template.New("Subject") 253 | subjectTemplate, err = subjectTemplate.Parse(viper.GetString("mailTemplates.teamInvite.subject")) 254 | if err != nil { 255 | return nil, err 256 | } 257 | 258 | var subject bytes.Buffer 259 | err = subjectTemplate.Execute(&subject, templateVariables) 260 | if err != nil { 261 | return nil, err 262 | } 263 | 264 | m.SetHeader("Subject", subject.String()) 265 | 266 | bodyTemplate := template.New("Body") 267 | bodyTemplate, err = bodyTemplate.Parse(viper.GetString("mailTemplates.teamInvite.body")) 268 | if err != nil { 269 | return nil, err 270 | } 271 | 272 | var body bytes.Buffer 273 | err = bodyTemplate.Execute(&body, templateVariables) 274 | if err != nil { 275 | return nil, err 276 | } 277 | 278 | m.SetBody("text/html", body.String()) 279 | 280 | d := gomail.NewDialer(viper.GetString("smtp.host"), viper.GetInt("smtp.port"), viper.GetString("smtp.username"), viper.GetString("smtp.password")) 281 | if err := d.DialAndSend(m); err != nil { 282 | return nil, err 283 | } 284 | 285 | resolver, err := NewTeamInvitationResolver(c, invite) 286 | if err != nil { 287 | return nil, err 288 | } 289 | 290 | go bus.Publish("team:"+strconv.Itoa(int(invite.TeamID))+":invitations:added", resolver) 291 | 292 | return resolver, nil 293 | } 294 | 295 | type RevokeTeamInvitationArgs struct { 296 | InviteID graphql.ID 297 | } 298 | 299 | func (b *BaseQuery) RevokeTeamInvitation(ctx context.Context, args *RevokeTeamInvitationArgs) (bool, error) { 300 | c := b.GetReqC(ctx) 301 | db := c.GetDB() 302 | 303 | invite := &models.TeamInvitation{} 304 | err := db.Model(&models.TeamInvitation{}).Where("code = ?", args.InviteID).First(invite).Error 305 | if err != nil && err == gorm.ErrRecordNotFound { 306 | return false, errors.New("you do not have access to this invite") 307 | } 308 | if err != nil { 309 | return false, err 310 | } 311 | 312 | userRole, err := getUserRoleInTeam(ctx, c, invite.TeamID) 313 | if err != nil { 314 | return false, err 315 | } 316 | 317 | if userRole == nil { 318 | return false, errors.New("you do not have access to this invite") 319 | } 320 | 321 | if *userRole != models.Owner { 322 | return false, errors.New("you do not have access to this invite") 323 | } 324 | 325 | err = db.Delete(invite).Error 326 | if err != nil { 327 | return false, err 328 | } 329 | 330 | go bus.Publish("team:"+strconv.Itoa(int(invite.TeamID))+":invitations:removed", graphql.ID(invite.Code)) 331 | 332 | return true, nil 333 | } 334 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/team.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strconv" 7 | "strings" 8 | 9 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 10 | "github.com/jerbob92/hoppscotch-backend/models" 11 | 12 | "github.com/graph-gophers/graphql-go" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | type TeamResolver struct { 17 | c *graphql_context.Context 18 | team *models.Team 19 | } 20 | 21 | func NewTeamResolver(c *graphql_context.Context, team *models.Team) (*TeamResolver, error) { 22 | if team == nil { 23 | return nil, nil 24 | } 25 | 26 | return &TeamResolver{c: c, team: team}, nil 27 | } 28 | 29 | func (r *TeamResolver) ID() (graphql.ID, error) { 30 | id := graphql.ID(strconv.Itoa(int(r.team.ID))) 31 | return id, nil 32 | } 33 | 34 | func (r *TeamResolver) EditorsCount() (int32, error) { 35 | db := r.c.GetDB() 36 | 37 | ownerCount := int64(0) 38 | 39 | err := db.Model(&models.TeamMember{}).Where("team_id = ? AND role = ?", r.team.ID, models.Editor).Count(&ownerCount).Error 40 | if err != nil { 41 | return 0, err 42 | } 43 | 44 | return int32(ownerCount), nil 45 | } 46 | 47 | type TeamMembersArgs struct { 48 | Cursor *graphql.ID 49 | } 50 | 51 | func (r *TeamResolver) Members(args *TeamMembersArgs) ([]*TeamMemberResolver, error) { 52 | members := []*models.TeamMember{} 53 | db := r.c.GetDB() 54 | 55 | query := db.Model(&models.TeamMember{}).Where("team_id = ?", r.team.ID) 56 | if args.Cursor != nil && *args.Cursor != "" { 57 | query.Where("id > ?", args.Cursor) 58 | } 59 | 60 | err := query.Find(&members).Error 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | teamMemberResolves := []*TeamMemberResolver{} 66 | for i := range members { 67 | newResolver, err := NewTeamMemberResolver(r.c, members[i]) 68 | if err != nil { 69 | return nil, err 70 | } 71 | teamMemberResolves = append(teamMemberResolves, newResolver) 72 | } 73 | 74 | return teamMemberResolves, nil 75 | } 76 | 77 | func (r *TeamResolver) MyRole(ctx context.Context) (models.TeamMemberRole, error) { 78 | currentUser, err := r.c.GetUser(ctx) 79 | if err != nil { 80 | return models.Viewer, err 81 | } 82 | 83 | db := r.c.GetDB() 84 | 85 | existingTeamMember := &models.TeamMember{} 86 | err = db.Where("user_id = ? AND team_id = ?", currentUser.ID, r.team.ID).First(existingTeamMember).Error 87 | if err != nil { 88 | return models.Viewer, err 89 | } 90 | 91 | return existingTeamMember.Role, nil 92 | } 93 | 94 | func (r *TeamResolver) Name() (string, error) { 95 | return r.team.Name, nil 96 | } 97 | 98 | func (r *TeamResolver) OwnersCount() (int32, error) { 99 | db := r.c.GetDB() 100 | 101 | ownerCount := int64(0) 102 | 103 | err := db.Model(&models.TeamMember{}).Where("team_id = ? AND role = ?", r.team.ID, models.Owner).Count(&ownerCount).Error 104 | if err != nil { 105 | return 0, err 106 | } 107 | 108 | return int32(ownerCount), nil 109 | } 110 | 111 | func (r *TeamResolver) TeamInvitations() ([]*TeamInvitationResolver, error) { 112 | invitations := []*models.TeamInvitation{} 113 | db := r.c.GetDB() 114 | err := db.Model(&models.TeamInvitation{}).Where("team_id = ?", r.team.ID).Find(&invitations).Error 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | teamInvitationResolvers := []*TeamInvitationResolver{} 120 | for i := range invitations { 121 | newResolver, err := NewTeamInvitationResolver(r.c, invitations[i]) 122 | if err != nil { 123 | return nil, err 124 | } 125 | teamInvitationResolvers = append(teamInvitationResolvers, newResolver) 126 | } 127 | 128 | return teamInvitationResolvers, nil 129 | } 130 | 131 | func (r *TeamResolver) TeamMembers() ([]*TeamMemberResolver, error) { 132 | members := []*models.TeamMember{} 133 | db := r.c.GetDB() 134 | err := db.Model(&models.TeamMember{}).Where("team_id = ?", r.team.ID).Find(&members).Error 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | teamMemberResolves := []*TeamMemberResolver{} 140 | for i := range members { 141 | newResolver, err := NewTeamMemberResolver(r.c, members[i]) 142 | if err != nil { 143 | return nil, err 144 | } 145 | teamMemberResolves = append(teamMemberResolves, newResolver) 146 | } 147 | 148 | return teamMemberResolves, nil 149 | } 150 | 151 | func (r *TeamResolver) ViewersCount() (int32, error) { 152 | db := r.c.GetDB() 153 | 154 | ownerCount := int64(0) 155 | 156 | err := db.Model(&models.TeamMember{}).Where("team_id = ? AND role = ?", r.team.ID, models.Viewer).Count(&ownerCount).Error 157 | if err != nil { 158 | return 0, err 159 | } 160 | 161 | return int32(ownerCount), nil 162 | } 163 | 164 | func (r *TeamResolver) TeamEnvironments() ([]*TeamEnvironmentResolver, error) { 165 | environments := []*models.TeamEnvironment{} 166 | db := r.c.GetDB() 167 | err := db.Model(&models.TeamEnvironment{}).Where("team_id = ?", r.team.ID).Find(&environments).Error 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | teamEnvironmentResolves := []*TeamEnvironmentResolver{} 173 | for i := range environments { 174 | newResolver, err := NewTeamEnvironmentResolver(r.c, environments[i]) 175 | if err != nil { 176 | return nil, err 177 | } 178 | teamEnvironmentResolves = append(teamEnvironmentResolves, newResolver) 179 | } 180 | 181 | return teamEnvironmentResolves, nil 182 | } 183 | 184 | type MyTeamsArgs struct { 185 | Cursor *graphql.ID 186 | } 187 | 188 | func (b *BaseQuery) MyTeams(ctx context.Context, args *MyTeamsArgs) ([]*TeamResolver, error) { 189 | c := b.GetReqC(ctx) 190 | currentUser, err := c.GetUser(ctx) 191 | if err != nil { 192 | c.LogErr(err) 193 | return nil, err 194 | } 195 | 196 | db := c.GetDB() 197 | teams := []*models.Team{} 198 | query := db.Model(&models.Team{}).Joins("JOIN team_members ON team_members.team_id = teams.id").Where("team_members.user_id = ? AND team_members.deleted_at IS NULL", currentUser.ID) 199 | if args.Cursor != nil && *args.Cursor != "" { 200 | query.Where("id > ?", args.Cursor) 201 | } 202 | 203 | err = query.Find(&teams).Error 204 | if err != nil { 205 | return nil, err 206 | } 207 | 208 | teamResolvers := []*TeamResolver{} 209 | for i := range teams { 210 | newResolver, err := NewTeamResolver(c, teams[i]) 211 | if err != nil { 212 | return nil, err 213 | } 214 | teamResolvers = append(teamResolvers, newResolver) 215 | } 216 | 217 | return teamResolvers, nil 218 | } 219 | 220 | type RequestArg struct { 221 | RequestID graphql.ID 222 | } 223 | 224 | func (b *BaseQuery) Request(ctx context.Context, args *RequestArg) (*TeamRequestResolver, error) { 225 | c := b.GetReqC(ctx) 226 | db := c.GetDB() 227 | request := &models.TeamRequest{} 228 | err := db.Model(&models.TeamRequest{}).Where("id = ?", args.RequestID).First(request).Error 229 | if err != nil && err == gorm.ErrRecordNotFound { 230 | return nil, errors.New("you do not have access to this request") 231 | } 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | userRole, err := getUserRoleInTeam(ctx, c, request.TeamID) 237 | if err != nil { 238 | return nil, err 239 | } 240 | 241 | if userRole == nil { 242 | return nil, errors.New("you do not have access to this request") 243 | } 244 | 245 | return NewTeamRequestResolver(c, request) 246 | } 247 | 248 | type RootCollectionsOfTeamArgs struct { 249 | Cursor *graphql.ID 250 | TeamID graphql.ID 251 | } 252 | 253 | func (b *BaseQuery) RootCollectionsOfTeam(ctx context.Context, args *RootCollectionsOfTeamArgs) ([]*TeamCollectionResolver, error) { 254 | c := b.GetReqC(ctx) 255 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 256 | if err != nil { 257 | return nil, err 258 | } 259 | if userRole == nil { 260 | return nil, errors.New("user not in team") 261 | } 262 | 263 | db := c.GetDB() 264 | teamCollections := []*models.TeamCollection{} 265 | query := db.Model(&models.TeamCollection{}).Where("team_id = ? AND parent_id = ?", args.TeamID, 0) 266 | if args.Cursor != nil && *args.Cursor != "" { 267 | query.Where("id > ?", args.Cursor) 268 | } 269 | err = query.Find(&teamCollections).Error 270 | if err != nil { 271 | return nil, err 272 | } 273 | 274 | teamCollectionResolvers := []*TeamCollectionResolver{} 275 | for i := range teamCollections { 276 | newResolver, err := NewTeamCollectionResolver(c, teamCollections[i]) 277 | if err != nil { 278 | return nil, err 279 | } 280 | teamCollectionResolvers = append(teamCollectionResolvers, newResolver) 281 | } 282 | 283 | return teamCollectionResolvers, nil 284 | } 285 | 286 | type SearchForRequestArgs struct { 287 | Cursor *graphql.ID 288 | SearchTerm string 289 | TeamID graphql.ID 290 | } 291 | 292 | func (b *BaseQuery) SearchForRequest(ctx context.Context, args *SearchForRequestArgs) ([]*TeamRequestResolver, error) { 293 | c := b.GetReqC(ctx) 294 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 295 | if err != nil { 296 | return nil, err 297 | } 298 | if userRole == nil { 299 | return nil, errors.New("user not in team") 300 | } 301 | 302 | db := c.GetDB() 303 | teamRequests := []*models.TeamRequest{} 304 | args.SearchTerm = strings.Replace(args.SearchTerm, "%", "\\%", -1) 305 | args.SearchTerm = strings.Replace(args.SearchTerm, "_", "\\_", -1) 306 | args.SearchTerm = "%" + args.SearchTerm + "%" 307 | query := db.Model(&models.TeamRequest{}).Where("team_id = ? AND title LIKE ?", args.TeamID, args.SearchTerm) 308 | if args.Cursor != nil && *args.Cursor != "" { 309 | query.Where("id > ?", args.Cursor) 310 | } 311 | err = query.Find(&teamRequests).Error 312 | if err != nil { 313 | return nil, err 314 | } 315 | 316 | teamRequestResolvers := []*TeamRequestResolver{} 317 | for i := range teamRequests { 318 | newResolver, err := NewTeamRequestResolver(c, teamRequests[i]) 319 | if err != nil { 320 | return nil, err 321 | } 322 | teamRequestResolvers = append(teamRequestResolvers, newResolver) 323 | } 324 | 325 | return teamRequestResolvers, nil 326 | } 327 | 328 | type TeamArgs struct { 329 | TeamID graphql.ID 330 | } 331 | 332 | func (b *BaseQuery) Team(ctx context.Context, args *TeamArgs) (*TeamResolver, error) { 333 | c := b.GetReqC(ctx) 334 | currentUser, err := c.GetUser(ctx) 335 | if err != nil { 336 | c.LogErr(err) 337 | return nil, err 338 | } 339 | 340 | db := c.GetDB() 341 | membership := &models.TeamMember{} 342 | err = db.Model(&models.TeamMember{}).Where("user_id = ? AND team_id = ?", currentUser.ID, args.TeamID).Preload("Team").First(membership).Error 343 | if err != nil && err == gorm.ErrRecordNotFound { 344 | return nil, errors.New("you do not have access to this team") 345 | } 346 | if err != nil { 347 | return nil, err 348 | } 349 | 350 | return NewTeamResolver(c, &membership.Team) 351 | } 352 | 353 | type CreateTeamArgs struct { 354 | Name string 355 | } 356 | 357 | func (b *BaseQuery) CreateTeam(ctx context.Context, args *CreateTeamArgs) (*TeamResolver, error) { 358 | c := b.GetReqC(ctx) 359 | currentUser, err := c.GetUser(ctx) 360 | if err != nil { 361 | c.LogErr(err) 362 | return nil, err 363 | } 364 | 365 | db := c.GetDB() 366 | newTeam := &models.Team{ 367 | Name: args.Name, 368 | } 369 | 370 | err = db.Create(newTeam).Error 371 | if err != nil { 372 | return nil, err 373 | } 374 | 375 | newTeamMember := &models.TeamMember{ 376 | TeamID: newTeam.ID, 377 | UserID: currentUser.ID, 378 | Role: models.Owner, 379 | } 380 | 381 | err = db.Create(newTeamMember).Error 382 | if err != nil { 383 | return nil, err 384 | } 385 | 386 | return NewTeamResolver(c, newTeam) 387 | } 388 | 389 | type DeleteTeamArgs struct { 390 | TeamID graphql.ID 391 | } 392 | 393 | func (b *BaseQuery) DeleteTeam(ctx context.Context, args *DeleteTeamArgs) (bool, error) { 394 | c := b.GetReqC(ctx) 395 | 396 | currentUser, err := c.GetUser(ctx) 397 | if err != nil { 398 | c.LogErr(err) 399 | return false, err 400 | } 401 | 402 | db := c.GetDB() 403 | existingTeamMember := &models.TeamMember{} 404 | err = db.Where("user_id = ? AND team_id = ?", currentUser.ID, args.TeamID).First(existingTeamMember).Error 405 | if err != nil { 406 | return false, err 407 | } 408 | 409 | if existingTeamMember.Role != models.Owner { 410 | return false, errors.New("no access to delete") 411 | } 412 | 413 | // Cleanup related records. 414 | err = db.Delete(&models.TeamCollection{}, "team_id = ?", args.TeamID).Error 415 | if err != nil { 416 | return false, err 417 | } 418 | err = db.Delete(&models.TeamInvitation{}, "team_id = ?", args.TeamID).Error 419 | if err != nil { 420 | return false, err 421 | } 422 | err = db.Delete(&models.TeamMember{}, "team_id = ?", args.TeamID).Error 423 | if err != nil { 424 | return false, err 425 | } 426 | err = db.Delete(&models.TeamRequest{}, "team_id = ?", args.TeamID).Error 427 | if err != nil { 428 | return false, err 429 | } 430 | 431 | err = db.Delete(&models.Team{}, "id = ?", args.TeamID).Error 432 | if err != nil { 433 | return false, err 434 | } 435 | 436 | return true, nil 437 | } 438 | 439 | type LeaveTeamArgs struct { 440 | TeamID graphql.ID 441 | } 442 | 443 | func (b *BaseQuery) LeaveTeam(ctx context.Context, args *LeaveTeamArgs) (bool, error) { 444 | c := b.GetReqC(ctx) 445 | 446 | currentUser, err := c.GetUser(ctx) 447 | if err != nil { 448 | c.LogErr(err) 449 | return false, err 450 | } 451 | 452 | db := c.GetDB() 453 | existingTeamMember := &models.TeamMember{} 454 | err = db.Where("user_id = ? AND team_id = ?", currentUser.ID, args.TeamID).First(existingTeamMember).Error 455 | if err != nil { 456 | return false, err 457 | } 458 | 459 | err = db.Delete(&models.TeamMember{}, "user_id = ? AND team_id = ?", currentUser.ID, args.TeamID).Error 460 | if err != nil { 461 | return false, err 462 | } 463 | 464 | go bus.Publish("team:"+strconv.Itoa(int(existingTeamMember.TeamID))+":members:removed", graphql.ID(currentUser.FBUID)) 465 | 466 | return true, nil 467 | } 468 | 469 | type RenameTeamArgs struct { 470 | NewName string 471 | TeamID graphql.ID 472 | } 473 | 474 | func (b *BaseQuery) RenameTeam(ctx context.Context, args *RenameTeamArgs) (*TeamResolver, error) { 475 | c := b.GetReqC(ctx) 476 | 477 | currentUser, err := c.GetUser(ctx) 478 | if err != nil { 479 | c.LogErr(err) 480 | return nil, err 481 | } 482 | 483 | db := c.GetDB() 484 | existingTeamMember := &models.TeamMember{} 485 | err = db.Where("user_id = ? AND team_id = ?", currentUser.ID, args.TeamID).Preload("Team").First(existingTeamMember).Error 486 | if err != nil { 487 | return nil, err 488 | } 489 | 490 | if existingTeamMember.Role != models.Owner { 491 | return nil, errors.New("no access to rename") 492 | } 493 | 494 | existingTeamMember.Team.Name = args.NewName 495 | 496 | err = db.Save(&existingTeamMember.Team).Error 497 | if err != nil { 498 | return nil, err 499 | } 500 | 501 | return NewTeamResolver(c, &existingTeamMember.Team) 502 | } 503 | -------------------------------------------------------------------------------- /api/controllers/graphql/resolvers/team_collection.go: -------------------------------------------------------------------------------- 1 | package resolvers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "log" 8 | "strconv" 9 | 10 | graphql_context "github.com/jerbob92/hoppscotch-backend/api/controllers/graphql/context" 11 | "github.com/jerbob92/hoppscotch-backend/models" 12 | 13 | "github.com/graph-gophers/graphql-go" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | type TeamCollectionResolver struct { 18 | c *graphql_context.Context 19 | team_collection *models.TeamCollection 20 | } 21 | 22 | func NewTeamCollectionResolver(c *graphql_context.Context, team_collection *models.TeamCollection) (*TeamCollectionResolver, error) { 23 | if team_collection == nil { 24 | return nil, nil 25 | } 26 | 27 | return &TeamCollectionResolver{c: c, team_collection: team_collection}, nil 28 | } 29 | 30 | func (r *TeamCollectionResolver) ID() (graphql.ID, error) { 31 | id := graphql.ID(strconv.Itoa(int(r.team_collection.ID))) 32 | return id, nil 33 | } 34 | 35 | func (r *TeamCollectionResolver) Parent() (*TeamCollectionResolver, error) { 36 | if r.team_collection.ParentID == 0 { 37 | return nil, nil 38 | } 39 | 40 | db := r.c.GetDB() 41 | teamCollection := &models.TeamCollection{} 42 | err := db.Where("id = ?", r.team_collection.ParentID).First(teamCollection).Error 43 | if err != nil && err == gorm.ErrRecordNotFound { 44 | return nil, errors.New("team collection not found") 45 | } 46 | 47 | if err != nil { 48 | log.Println(err) 49 | return nil, err 50 | } 51 | 52 | return NewTeamCollectionResolver(r.c, teamCollection) 53 | } 54 | 55 | func (r *TeamCollectionResolver) Team() (*TeamResolver, error) { 56 | db := r.c.GetDB() 57 | team := &models.Team{} 58 | err := db.Where("id = ?", r.team_collection.TeamID).First(team).Error 59 | if err != nil && err == gorm.ErrRecordNotFound { 60 | return nil, errors.New("team collection not found") 61 | } 62 | 63 | return NewTeamResolver(r.c, team) 64 | } 65 | 66 | type TeamCollectionChildrenArgs struct { 67 | Cursor *string 68 | } 69 | 70 | func (r *TeamCollectionResolver) Children(args *TeamCollectionChildrenArgs) ([]*TeamCollectionResolver, error) { 71 | db := r.c.GetDB() 72 | teamCollections := []*models.TeamCollection{} 73 | query := db.Model(&models.TeamCollection{}).Where("parent_id = ?", r.team_collection.ID) 74 | if args.Cursor != nil && *args.Cursor != "" { 75 | query.Where("id > ?", args.Cursor) 76 | } 77 | err := query.Preload("Team").Find(&teamCollections).Error 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | teamCollectionResolvers := []*TeamCollectionResolver{} 83 | for i := range teamCollections { 84 | newResolver, err := NewTeamCollectionResolver(r.c, teamCollections[i]) 85 | if err != nil { 86 | return nil, err 87 | } 88 | teamCollectionResolvers = append(teamCollectionResolvers, newResolver) 89 | } 90 | 91 | return teamCollectionResolvers, nil 92 | } 93 | 94 | func (r *TeamCollectionResolver) Title() (string, error) { 95 | return r.team_collection.Title, nil 96 | } 97 | 98 | type CollectionArgs struct { 99 | CollectionID graphql.ID 100 | } 101 | 102 | func (b *BaseQuery) Collection(ctx context.Context, args *CollectionArgs) (*TeamCollectionResolver, error) { 103 | c := b.GetReqC(ctx) 104 | db := c.GetDB() 105 | collection := &models.TeamCollection{} 106 | err := db.Model(&models.TeamCollection{}).Where("id = ?", args.CollectionID).First(collection).Error 107 | if err != nil && err == gorm.ErrRecordNotFound { 108 | return nil, errors.New("you do not have access to this collection") 109 | } 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | userRole, err := getUserRoleInTeam(ctx, c, collection.TeamID) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | if userRole == nil { 120 | return nil, errors.New("you do not have access to this collection") 121 | } 122 | 123 | return NewTeamCollectionResolver(c, collection) 124 | } 125 | 126 | type CollectionsOfTeamArgs struct { 127 | Cursor *graphql.ID 128 | TeamID graphql.ID 129 | } 130 | 131 | func (b *BaseQuery) CollectionsOfTeam(ctx context.Context, args *CollectionsOfTeamArgs) ([]*TeamCollectionResolver, error) { 132 | c := b.GetReqC(ctx) 133 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 134 | if err != nil { 135 | return nil, err 136 | } 137 | if userRole == nil { 138 | return nil, errors.New("user not in team") 139 | } 140 | 141 | db := c.GetDB() 142 | teamCollections := []*models.TeamCollection{} 143 | query := db.Model(&models.TeamCollection{}).Where("team_id = ?", args.TeamID) 144 | if args.Cursor != nil && *args.Cursor != "" { 145 | query.Where("id > ?", args.Cursor) 146 | } 147 | err = query.Find(&teamCollections).Error 148 | if err != nil { 149 | return nil, err 150 | } 151 | 152 | teamCollectionResolvers := []*TeamCollectionResolver{} 153 | for i := range teamCollections { 154 | newResolver, err := NewTeamCollectionResolver(c, teamCollections[i]) 155 | if err != nil { 156 | return nil, err 157 | } 158 | teamCollectionResolvers = append(teamCollectionResolvers, newResolver) 159 | } 160 | 161 | return teamCollectionResolvers, nil 162 | } 163 | 164 | type ExportCollectionsToJSONArgs struct { 165 | TeamID graphql.ID 166 | } 167 | 168 | type ExportJSONCollectionRequest map[string]interface{} 169 | 170 | type ExportJSONCollection struct { 171 | Version int `json:"v"` 172 | Name string `json:"name"` 173 | Folders []ExportJSONCollection `json:"folders"` 174 | Requests []ExportJSONCollectionRequest `json:"requests"` 175 | } 176 | 177 | func GetTeamExportJSON(c *graphql_context.Context, teamID graphql.ID, parentID uint) ([]ExportJSONCollection, error) { 178 | db := c.GetDB() 179 | collections := []*models.TeamCollection{} 180 | err := db.Model(&models.TeamCollection{}).Where("team_id = ? AND parent_id = ?", teamID, parentID).Find(&collections).Error 181 | if err != nil { 182 | return nil, err 183 | } 184 | 185 | output := []ExportJSONCollection{} 186 | for i := range collections { 187 | collection := ExportJSONCollection{ 188 | Version: 1, 189 | Name: collections[i].Title, 190 | Folders: []ExportJSONCollection{}, 191 | Requests: []ExportJSONCollectionRequest{}, 192 | } 193 | 194 | requests := []*models.TeamRequest{} 195 | err := db.Model(&models.TeamRequest{}).Where("team_id = ? AND team_collection_id = ?", teamID, collections[i].ID).Find(&requests).Error 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | for ri := range requests { 201 | requestDecode := ExportJSONCollectionRequest{} 202 | json.Unmarshal([]byte(requests[ri].Request), &requestDecode) 203 | 204 | collection.Requests = append(collection.Requests, requestDecode) 205 | } 206 | 207 | subfolders, err := GetTeamExportJSON(c, teamID, collections[i].ID) 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | collection.Folders = subfolders 213 | 214 | output = append(output, collection) 215 | } 216 | return output, nil 217 | } 218 | 219 | func (b *BaseQuery) ExportCollectionsToJSON(ctx context.Context, args *ExportCollectionsToJSONArgs) (string, error) { 220 | c := b.GetReqC(ctx) 221 | 222 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 223 | if err != nil { 224 | return "", err 225 | } 226 | 227 | if userRole == nil { 228 | return "", errors.New("you do not have access to this team") 229 | } 230 | 231 | teamExport, err := GetTeamExportJSON(c, args.TeamID, 0) 232 | if err != nil { 233 | return "", err 234 | } 235 | 236 | exportJSON, err := json.MarshalIndent(teamExport, "", " ") 237 | if err != nil { 238 | return "", err 239 | } 240 | 241 | return string(exportJSON), nil 242 | } 243 | 244 | type RequestsInCollectionArgs struct { 245 | CollectionID graphql.ID 246 | Cursor *graphql.ID 247 | } 248 | 249 | func (b *BaseQuery) RequestsInCollection(ctx context.Context, args *RequestsInCollectionArgs) ([]*TeamRequestResolver, error) { 250 | c := b.GetReqC(ctx) 251 | db := c.GetDB() 252 | collection := &models.TeamCollection{} 253 | err := db.Model(&models.TeamCollection{}).Where("id = ?", args.CollectionID).First(collection).Error 254 | if err != nil && err == gorm.ErrRecordNotFound { 255 | return nil, errors.New("you do not have access to this collection") 256 | } 257 | if err != nil { 258 | return nil, err 259 | } 260 | 261 | userRole, err := getUserRoleInTeam(ctx, c, collection.TeamID) 262 | if err != nil { 263 | return nil, err 264 | } 265 | 266 | if userRole == nil { 267 | return nil, errors.New("you do not have access to this collection") 268 | } 269 | 270 | teamRequests := []*models.TeamRequest{} 271 | query := db.Model(&models.TeamRequest{}).Where("team_collection_id", args.CollectionID) 272 | if args.Cursor != nil && *args.Cursor != "" { 273 | query.Where("id > ?", args.Cursor) 274 | } 275 | err = query.Find(&teamRequests).Error 276 | if err != nil { 277 | return nil, err 278 | } 279 | 280 | teamRequestResolvers := []*TeamRequestResolver{} 281 | for i := range teamRequests { 282 | newResolver, err := NewTeamRequestResolver(c, teamRequests[i]) 283 | if err != nil { 284 | return nil, err 285 | } 286 | teamRequestResolvers = append(teamRequestResolvers, newResolver) 287 | } 288 | 289 | return teamRequestResolvers, nil 290 | } 291 | 292 | type CreateChildCollectionArgs struct { 293 | ChildTitle string 294 | CollectionID graphql.ID 295 | } 296 | 297 | func (b *BaseQuery) CreateChildCollection(ctx context.Context, args *CreateChildCollectionArgs) (*TeamCollectionResolver, error) { 298 | c := b.GetReqC(ctx) 299 | db := c.GetDB() 300 | collection := &models.TeamCollection{} 301 | err := db.Model(&models.TeamCollection{}).Where("id = ?", args.CollectionID).First(collection).Error 302 | if err != nil && err == gorm.ErrRecordNotFound { 303 | return nil, errors.New("you do not have access to this collection") 304 | } 305 | if err != nil { 306 | return nil, err 307 | } 308 | 309 | userRole, err := getUserRoleInTeam(ctx, c, collection.TeamID) 310 | if err != nil { 311 | return nil, err 312 | } 313 | 314 | if userRole == nil { 315 | return nil, errors.New("you do not have access to this collection") 316 | } 317 | 318 | if *userRole == models.Owner || *userRole == models.Editor { 319 | newCollection := &models.TeamCollection{ 320 | Title: args.ChildTitle, 321 | ParentID: collection.ID, 322 | TeamID: collection.TeamID, 323 | } 324 | err := db.Save(newCollection).Error 325 | if err != nil { 326 | return nil, err 327 | } 328 | 329 | resolver, err := NewTeamCollectionResolver(c, newCollection) 330 | if err != nil { 331 | return nil, err 332 | } 333 | 334 | go bus.Publish("team:"+strconv.Itoa(int(newCollection.TeamID))+":collections:added", resolver) 335 | 336 | return resolver, nil 337 | } 338 | 339 | return nil, errors.New("you are not allowed to create a collection in this team") 340 | } 341 | 342 | type CreateTeamRequestInput struct { 343 | Request string 344 | TeamID graphql.ID 345 | Title string 346 | } 347 | 348 | type CreateRequestInCollectionArgs struct { 349 | CollectionID graphql.ID 350 | Data CreateTeamRequestInput 351 | } 352 | 353 | func (b *BaseQuery) CreateRequestInCollection(ctx context.Context, args *CreateRequestInCollectionArgs) (*TeamRequestResolver, error) { 354 | c := b.GetReqC(ctx) 355 | db := c.GetDB() 356 | collection := &models.TeamCollection{} 357 | err := db.Model(&models.TeamCollection{}).Where("id = ?", args.CollectionID).First(collection).Error 358 | if err != nil && err == gorm.ErrRecordNotFound { 359 | return nil, errors.New("you do not have access to this collection") 360 | } 361 | if err != nil { 362 | return nil, err 363 | } 364 | 365 | userRole, err := getUserRoleInTeam(ctx, c, collection.TeamID) 366 | if err != nil { 367 | return nil, err 368 | } 369 | 370 | if userRole == nil { 371 | return nil, errors.New("you do not have access to this collection") 372 | } 373 | 374 | if *userRole == models.Owner || *userRole == models.Editor { 375 | newRequest := &models.TeamRequest{ 376 | TeamCollectionID: collection.ID, 377 | TeamID: collection.TeamID, 378 | Title: args.Data.Title, 379 | Request: args.Data.Request, 380 | } 381 | err := db.Save(newRequest).Error 382 | if err != nil { 383 | return nil, err 384 | } 385 | 386 | resolver, err := NewTeamRequestResolver(c, newRequest) 387 | if err != nil { 388 | return nil, err 389 | } 390 | 391 | go bus.Publish("team:"+strconv.Itoa(int(newRequest.TeamID))+":requests:added", resolver) 392 | 393 | return resolver, nil 394 | } 395 | 396 | return nil, errors.New("you are not allowed to create a request in this team") 397 | } 398 | 399 | type CreateRootCollectionArgs struct { 400 | TeamID graphql.ID 401 | Title string 402 | } 403 | 404 | func (b *BaseQuery) CreateRootCollection(ctx context.Context, args *CreateRootCollectionArgs) (*TeamCollectionResolver, error) { 405 | c := b.GetReqC(ctx) 406 | db := c.GetDB() 407 | 408 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 409 | if err != nil { 410 | return nil, err 411 | } 412 | 413 | if userRole == nil { 414 | return nil, errors.New("you do not have access to this collection") 415 | } 416 | 417 | if *userRole == models.Owner || *userRole == models.Editor { 418 | parsedTeamID, _ := strconv.Atoi(string(args.TeamID)) 419 | newCollection := &models.TeamCollection{ 420 | Title: args.Title, 421 | TeamID: uint(parsedTeamID), 422 | } 423 | err := db.Save(newCollection).Error 424 | if err != nil { 425 | return nil, err 426 | } 427 | 428 | resolver, err := NewTeamCollectionResolver(c, newCollection) 429 | if err != nil { 430 | return nil, err 431 | } 432 | 433 | go bus.Publish("team:"+strconv.Itoa(int(newCollection.TeamID))+":collections:added", resolver) 434 | 435 | return resolver, nil 436 | } 437 | 438 | return nil, errors.New("you are not allowed to create a collection in this team") 439 | } 440 | 441 | type DeleteCollectionArgs struct { 442 | CollectionID graphql.ID 443 | } 444 | 445 | func (b *BaseQuery) DeleteCollection(ctx context.Context, args *DeleteCollectionArgs) (bool, error) { 446 | c := b.GetReqC(ctx) 447 | db := c.GetDB() 448 | collection := &models.TeamCollection{} 449 | err := db.Model(&models.TeamCollection{}).Where("id = ?", args.CollectionID).First(collection).Error 450 | if err != nil && err == gorm.ErrRecordNotFound { 451 | return false, errors.New("you do not have access to this collection") 452 | } 453 | if err != nil { 454 | return false, err 455 | } 456 | 457 | userRole, err := getUserRoleInTeam(ctx, c, collection.TeamID) 458 | if err != nil { 459 | return false, err 460 | } 461 | 462 | if userRole == nil { 463 | return false, errors.New("you do not have access to this collection") 464 | } 465 | 466 | if *userRole == models.Owner || *userRole == models.Editor { 467 | err := db.Delete(collection).Error 468 | if err != nil { 469 | return false, err 470 | } 471 | 472 | go bus.Publish("team:"+strconv.Itoa(int(collection.TeamID))+":collections:removed", graphql.ID(strconv.Itoa(int(collection.ID)))) 473 | 474 | return true, nil 475 | } 476 | 477 | return false, errors.New("you are not allowed to delete a collection in this team") 478 | } 479 | 480 | type ImportCollectionFromUserFirestoreArgs struct { 481 | FBCollectionPath string 482 | ParentCollectionID *graphql.ID 483 | TeamID graphql.ID 484 | } 485 | 486 | func (b *BaseQuery) ImportCollectionFromUserFirestore(ctx context.Context, args *ImportCollectionFromUserFirestoreArgs) (*TeamCollectionResolver, error) { 487 | // This doesn't seem to be used (anymore). 488 | return nil, nil 489 | } 490 | 491 | type ImportCollectionsFromJSONArgs struct { 492 | JSONString string 493 | ParentCollectionID *graphql.ID 494 | TeamID graphql.ID 495 | } 496 | 497 | func importJSON(c *graphql_context.Context, teamID uint, parentID uint, folders []ExportJSONCollection) error { 498 | db := c.GetDB() 499 | for i := range folders { 500 | newCollection := &models.TeamCollection{ 501 | TeamID: teamID, 502 | Title: folders[i].Name, 503 | ParentID: parentID, 504 | } 505 | 506 | err := db.Save(newCollection).Error 507 | if err != nil { 508 | return err 509 | } 510 | 511 | resolver, err := NewTeamCollectionResolver(c, newCollection) 512 | if err != nil { 513 | return err 514 | } 515 | 516 | go bus.Publish("team:"+strconv.Itoa(int(teamID))+":collections:added", resolver) 517 | 518 | if folders[i].Requests != nil && len(folders[i].Requests) > 0 { 519 | for ri := range folders[i].Requests { 520 | newTeamRequest := &models.TeamRequest{ 521 | TeamID: teamID, 522 | TeamCollectionID: newCollection.ID, 523 | } 524 | 525 | if nameVal, ok := folders[i].Requests[ri]["name"]; ok { 526 | name, ok := nameVal.(string) 527 | if ok { 528 | newTeamRequest.Title = name 529 | } 530 | } 531 | 532 | requestData, err := json.Marshal(folders[i].Requests[ri]) 533 | if err != nil { 534 | return err 535 | } 536 | 537 | newTeamRequest.Request = string(requestData) 538 | 539 | err = db.Save(newTeamRequest).Error 540 | if err != nil { 541 | return err 542 | } 543 | 544 | requestResolver, err := NewTeamRequestResolver(c, newTeamRequest) 545 | if err != nil { 546 | return err 547 | } 548 | 549 | go bus.Publish("team:"+strconv.Itoa(int(teamID))+":requests:added", requestResolver) 550 | } 551 | } 552 | 553 | if folders[i].Folders != nil && len(folders[i].Folders) > 0 { 554 | err = importJSON(c, teamID, newCollection.ID, folders[i].Folders) 555 | if err != nil { 556 | return err 557 | } 558 | } 559 | } 560 | 561 | return nil 562 | } 563 | 564 | func (b *BaseQuery) ImportCollectionsFromJSON(ctx context.Context, args *ImportCollectionsFromJSONArgs) (bool, error) { 565 | c := b.GetReqC(ctx) 566 | db := c.GetDB() 567 | 568 | parentCollectionID := uint(0) 569 | if args.ParentCollectionID != nil { 570 | collection := &models.TeamCollection{} 571 | err := db.Model(&models.TeamCollection{}).Where("id = ?", args.ParentCollectionID).First(collection).Error 572 | if err != nil && err == gorm.ErrRecordNotFound { 573 | return false, errors.New("you do not have access to this collection") 574 | } 575 | if err != nil { 576 | return false, err 577 | } 578 | 579 | userRole, err := getUserRoleInTeam(ctx, c, collection.TeamID) 580 | if err != nil { 581 | return false, err 582 | } 583 | 584 | if userRole == nil { 585 | return false, errors.New("you do not have access to this collection") 586 | } 587 | 588 | if *userRole == models.Owner || *userRole == models.Editor { 589 | parentCollectionID = collection.ID 590 | } else { 591 | return false, errors.New("you do not have write access to this collection") 592 | } 593 | } 594 | 595 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 596 | if err != nil { 597 | return false, err 598 | } 599 | 600 | if userRole == nil { 601 | return false, errors.New("you do not have access to this collection") 602 | } 603 | 604 | if *userRole == models.Owner || *userRole == models.Editor { 605 | importData := []ExportJSONCollection{} 606 | err := json.Unmarshal([]byte(args.JSONString), &importData) 607 | if err != nil { 608 | return false, err 609 | } 610 | 611 | teamID, _ := strconv.Atoi(string(args.TeamID)) 612 | err = importJSON(c, uint(teamID), parentCollectionID, importData) 613 | if err != nil { 614 | return false, err 615 | } 616 | 617 | return true, nil 618 | } 619 | 620 | return false, errors.New("you do not have write access to this team") 621 | } 622 | 623 | type RenameCollectionArgs struct { 624 | CollectionID graphql.ID 625 | NewTitle string 626 | } 627 | 628 | func (b *BaseQuery) RenameCollection(ctx context.Context, args *RenameCollectionArgs) (*TeamCollectionResolver, error) { 629 | c := b.GetReqC(ctx) 630 | db := c.GetDB() 631 | collection := &models.TeamCollection{} 632 | err := db.Model(&models.TeamCollection{}).Where("id = ?", args.CollectionID).First(collection).Error 633 | if err != nil && err == gorm.ErrRecordNotFound { 634 | return nil, errors.New("you do not have access to this collection") 635 | } 636 | if err != nil { 637 | return nil, err 638 | } 639 | 640 | userRole, err := getUserRoleInTeam(ctx, c, collection.TeamID) 641 | if err != nil { 642 | return nil, err 643 | } 644 | 645 | if userRole == nil { 646 | return nil, errors.New("you do not have access to this collection") 647 | } 648 | 649 | if *userRole == models.Owner || *userRole == models.Editor { 650 | collection.Title = args.NewTitle 651 | err := db.Save(collection).Error 652 | if err != nil { 653 | return nil, err 654 | } 655 | 656 | resolver, err := NewTeamCollectionResolver(c, collection) 657 | if err != nil { 658 | return nil, err 659 | } 660 | 661 | go bus.Publish("team:"+strconv.Itoa(int(collection.TeamID))+":collections:updated", resolver) 662 | 663 | return NewTeamCollectionResolver(c, collection) 664 | } 665 | 666 | return nil, errors.New("you are not allowed to rename a collection in this team") 667 | } 668 | 669 | type ReplaceCollectionsWithJSONArgs struct { 670 | JSONString string 671 | ParentCollectionID *graphql.ID 672 | TeamID graphql.ID 673 | } 674 | 675 | func (b *BaseQuery) ReplaceCollectionsWithJSON(ctx context.Context, args *ReplaceCollectionsWithJSONArgs) (bool, error) { 676 | // This doesn't seem to be used (anymore). 677 | return false, nil 678 | } 679 | 680 | type SubscriptionArgs struct { 681 | TeamID graphql.ID 682 | } 683 | 684 | func (b *BaseQuery) TeamCollectionAdded(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamCollectionResolver, error) { 685 | c := b.GetReqC(ctx) 686 | 687 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 688 | if err != nil { 689 | return nil, err 690 | } 691 | if userRole == nil { 692 | return nil, errors.New("no access to team") 693 | } 694 | 695 | teamID, _ := strconv.Atoi(string(args.TeamID)) 696 | notificationChannel := make(chan *TeamCollectionResolver) 697 | eventHandler := func(resolver *TeamCollectionResolver) { 698 | notificationChannel <- resolver 699 | } 700 | 701 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":collections:added", eventHandler) 702 | if err != nil { 703 | return nil, err 704 | } 705 | 706 | return notificationChannel, nil 707 | } 708 | 709 | func (b *BaseQuery) TeamCollectionRemoved(ctx context.Context, args *SubscriptionArgs) (<-chan graphql.ID, error) { 710 | c := b.GetReqC(ctx) 711 | 712 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 713 | if err != nil { 714 | return nil, err 715 | } 716 | if userRole == nil { 717 | return nil, errors.New("no access to team") 718 | } 719 | 720 | teamID, _ := strconv.Atoi(string(args.TeamID)) 721 | notificationChannel := make(chan graphql.ID) 722 | eventHandler := func(resolver graphql.ID) { 723 | notificationChannel <- resolver 724 | } 725 | 726 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":collections:removed", eventHandler) 727 | if err != nil { 728 | return nil, err 729 | } 730 | 731 | return notificationChannel, nil 732 | } 733 | 734 | func (b *BaseQuery) TeamCollectionUpdated(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamCollectionResolver, error) { 735 | c := b.GetReqC(ctx) 736 | 737 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 738 | if err != nil { 739 | return nil, err 740 | } 741 | if userRole == nil { 742 | return nil, errors.New("no access to team") 743 | } 744 | 745 | teamID, _ := strconv.Atoi(string(args.TeamID)) 746 | notificationChannel := make(chan *TeamCollectionResolver) 747 | eventHandler := func(resolver *TeamCollectionResolver) { 748 | notificationChannel <- resolver 749 | } 750 | 751 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":collections:updated", eventHandler) 752 | if err != nil { 753 | return nil, err 754 | } 755 | 756 | return notificationChannel, nil 757 | } 758 | 759 | func (b *BaseQuery) TeamInvitationAdded(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamInvitationResolver, error) { 760 | c := b.GetReqC(ctx) 761 | 762 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 763 | if err != nil { 764 | return nil, err 765 | } 766 | if userRole == nil { 767 | return nil, errors.New("no access to team") 768 | } 769 | 770 | teamID, _ := strconv.Atoi(string(args.TeamID)) 771 | notificationChannel := make(chan *TeamInvitationResolver) 772 | eventHandler := func(resolver *TeamInvitationResolver) { 773 | notificationChannel <- resolver 774 | } 775 | 776 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":invitations:added", eventHandler) 777 | if err != nil { 778 | return nil, err 779 | } 780 | 781 | return notificationChannel, nil 782 | } 783 | 784 | func (b *BaseQuery) TeamInvitationRemoved(ctx context.Context, args *SubscriptionArgs) (<-chan graphql.ID, error) { 785 | c := b.GetReqC(ctx) 786 | 787 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 788 | if err != nil { 789 | return nil, err 790 | } 791 | if userRole == nil { 792 | return nil, errors.New("no access to team") 793 | } 794 | 795 | teamID, _ := strconv.Atoi(string(args.TeamID)) 796 | notificationChannel := make(chan graphql.ID) 797 | eventHandler := func(resolver graphql.ID) { 798 | notificationChannel <- resolver 799 | } 800 | 801 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":invitations:removed", eventHandler) 802 | if err != nil { 803 | return nil, err 804 | } 805 | 806 | return notificationChannel, nil 807 | } 808 | 809 | func (b *BaseQuery) TeamMemberAdded(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamMemberResolver, error) { 810 | c := b.GetReqC(ctx) 811 | 812 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 813 | if err != nil { 814 | return nil, err 815 | } 816 | if userRole == nil { 817 | return nil, errors.New("no access to team") 818 | } 819 | 820 | teamID, _ := strconv.Atoi(string(args.TeamID)) 821 | notificationChannel := make(chan *TeamMemberResolver) 822 | eventHandler := func(resolver *TeamMemberResolver) { 823 | notificationChannel <- resolver 824 | } 825 | 826 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":members:added", eventHandler) 827 | if err != nil { 828 | return nil, err 829 | } 830 | 831 | return notificationChannel, nil 832 | } 833 | 834 | func (b *BaseQuery) TeamMemberRemoved(ctx context.Context, args *SubscriptionArgs) (<-chan graphql.ID, error) { 835 | c := b.GetReqC(ctx) 836 | 837 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 838 | if err != nil { 839 | return nil, err 840 | } 841 | if userRole == nil { 842 | return nil, errors.New("no access to team") 843 | } 844 | 845 | teamID, _ := strconv.Atoi(string(args.TeamID)) 846 | notificationChannel := make(chan graphql.ID) 847 | eventHandler := func(resolver graphql.ID) { 848 | notificationChannel <- resolver 849 | } 850 | 851 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":members:removed", eventHandler) 852 | if err != nil { 853 | return nil, err 854 | } 855 | 856 | return notificationChannel, nil 857 | } 858 | 859 | func (b *BaseQuery) TeamMemberUpdated(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamMemberResolver, error) { 860 | c := b.GetReqC(ctx) 861 | 862 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 863 | if err != nil { 864 | return nil, err 865 | } 866 | if userRole == nil { 867 | return nil, errors.New("no access to team") 868 | } 869 | 870 | teamID, _ := strconv.Atoi(string(args.TeamID)) 871 | notificationChannel := make(chan *TeamMemberResolver) 872 | eventHandler := func(resolver *TeamMemberResolver) { 873 | notificationChannel <- resolver 874 | } 875 | 876 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":members:updated", eventHandler) 877 | if err != nil { 878 | return nil, err 879 | } 880 | 881 | return notificationChannel, nil 882 | } 883 | 884 | func (b *BaseQuery) TeamRequestAdded(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamRequestResolver, error) { 885 | c := b.GetReqC(ctx) 886 | 887 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 888 | if err != nil { 889 | return nil, err 890 | } 891 | if userRole == nil { 892 | return nil, errors.New("no access to team") 893 | } 894 | 895 | teamID, _ := strconv.Atoi(string(args.TeamID)) 896 | notificationChannel := make(chan *TeamRequestResolver) 897 | eventHandler := func(resolver *TeamRequestResolver) { 898 | notificationChannel <- resolver 899 | } 900 | 901 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":requests:added", eventHandler) 902 | if err != nil { 903 | return nil, err 904 | } 905 | 906 | return notificationChannel, nil 907 | } 908 | 909 | func (b *BaseQuery) TeamRequestDeleted(ctx context.Context, args *SubscriptionArgs) (<-chan graphql.ID, error) { 910 | c := b.GetReqC(ctx) 911 | 912 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 913 | if err != nil { 914 | return nil, err 915 | } 916 | if userRole == nil { 917 | return nil, errors.New("no access to team") 918 | } 919 | 920 | teamID, _ := strconv.Atoi(string(args.TeamID)) 921 | notificationChannel := make(chan graphql.ID) 922 | eventHandler := func(resolver graphql.ID) { 923 | notificationChannel <- resolver 924 | } 925 | 926 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":requests:deleted", eventHandler) 927 | if err != nil { 928 | return nil, err 929 | } 930 | 931 | return notificationChannel, nil 932 | } 933 | 934 | func (b *BaseQuery) TeamRequestUpdated(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamRequestResolver, error) { 935 | c := b.GetReqC(ctx) 936 | 937 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 938 | if err != nil { 939 | return nil, err 940 | } 941 | if userRole == nil { 942 | return nil, errors.New("no access to team") 943 | } 944 | 945 | teamID, _ := strconv.Atoi(string(args.TeamID)) 946 | notificationChannel := make(chan *TeamRequestResolver) 947 | eventHandler := func(resolver *TeamRequestResolver) { 948 | notificationChannel <- resolver 949 | } 950 | 951 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":requests:updated", eventHandler) 952 | if err != nil { 953 | return nil, err 954 | } 955 | 956 | return notificationChannel, nil 957 | } 958 | 959 | func (b *BaseQuery) TeamEnvironmentCreated(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamEnvironmentResolver, error) { 960 | c := b.GetReqC(ctx) 961 | 962 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 963 | if err != nil { 964 | return nil, err 965 | } 966 | if userRole == nil { 967 | return nil, errors.New("no access to team") 968 | } 969 | 970 | teamID, _ := strconv.Atoi(string(args.TeamID)) 971 | notificationChannel := make(chan *TeamEnvironmentResolver) 972 | eventHandler := func(resolver *TeamEnvironmentResolver) { 973 | notificationChannel <- resolver 974 | } 975 | 976 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":environments:created", eventHandler) 977 | if err != nil { 978 | return nil, err 979 | } 980 | 981 | return notificationChannel, nil 982 | } 983 | 984 | func (b *BaseQuery) TeamEnvironmentDeleted(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamEnvironmentResolver, error) { 985 | c := b.GetReqC(ctx) 986 | 987 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 988 | if err != nil { 989 | return nil, err 990 | } 991 | if userRole == nil { 992 | return nil, errors.New("no access to team") 993 | } 994 | 995 | teamID, _ := strconv.Atoi(string(args.TeamID)) 996 | notificationChannel := make(chan *TeamEnvironmentResolver) 997 | eventHandler := func(resolver *TeamEnvironmentResolver) { 998 | notificationChannel <- resolver 999 | } 1000 | 1001 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":environments:deleted", eventHandler) 1002 | if err != nil { 1003 | return nil, err 1004 | } 1005 | 1006 | return notificationChannel, nil 1007 | } 1008 | 1009 | func (b *BaseQuery) TeamEnvironmentUpdated(ctx context.Context, args *SubscriptionArgs) (<-chan *TeamEnvironmentResolver, error) { 1010 | c := b.GetReqC(ctx) 1011 | 1012 | userRole, err := getUserRoleInTeam(ctx, c, args.TeamID) 1013 | if err != nil { 1014 | return nil, err 1015 | } 1016 | if userRole == nil { 1017 | return nil, errors.New("no access to team") 1018 | } 1019 | 1020 | teamID, _ := strconv.Atoi(string(args.TeamID)) 1021 | notificationChannel := make(chan *TeamEnvironmentResolver) 1022 | eventHandler := func(resolver *TeamEnvironmentResolver) { 1023 | notificationChannel <- resolver 1024 | } 1025 | 1026 | err = subscribeUntilDone(ctx, "team:"+strconv.Itoa(teamID)+":environments:updated", eventHandler) 1027 | if err != nil { 1028 | return nil, err 1029 | } 1030 | 1031 | return notificationChannel, nil 1032 | } 1033 | --------------------------------------------------------------------------------