2020-04-10 04:40:22 +02:00
|
|
|
package graph
|
|
|
|
|
|
|
|
import (
|
2020-04-20 05:02:55 +02:00
|
|
|
"context"
|
2020-09-20 03:20:36 +02:00
|
|
|
"database/sql"
|
2020-08-01 03:01:14 +02:00
|
|
|
"errors"
|
2020-04-10 04:40:22 +02:00
|
|
|
"net/http"
|
2020-04-11 04:22:22 +02:00
|
|
|
"os"
|
2020-08-01 03:01:14 +02:00
|
|
|
"reflect"
|
2020-09-20 03:20:36 +02:00
|
|
|
"strings"
|
2021-11-02 20:45:05 +01:00
|
|
|
"sync"
|
2020-04-11 04:22:22 +02:00
|
|
|
"time"
|
2020-04-10 04:40:22 +02:00
|
|
|
|
2020-08-01 03:01:14 +02:00
|
|
|
"github.com/99designs/gqlgen/graphql"
|
2020-04-11 04:22:22 +02:00
|
|
|
"github.com/99designs/gqlgen/graphql/handler"
|
|
|
|
"github.com/99designs/gqlgen/graphql/handler/extension"
|
|
|
|
"github.com/99designs/gqlgen/graphql/handler/lru"
|
|
|
|
"github.com/99designs/gqlgen/graphql/handler/transport"
|
|
|
|
"github.com/99designs/gqlgen/graphql/playground"
|
2021-11-18 00:11:28 +01:00
|
|
|
"github.com/go-redis/redis/v8"
|
2020-04-20 05:02:55 +02:00
|
|
|
"github.com/google/uuid"
|
2021-10-27 05:10:29 +02:00
|
|
|
"github.com/jordanknott/taskcafe/internal/config"
|
2020-08-07 03:50:35 +02:00
|
|
|
"github.com/jordanknott/taskcafe/internal/db"
|
2021-11-18 00:11:28 +01:00
|
|
|
"github.com/jordanknott/taskcafe/internal/jobs"
|
2020-10-21 01:52:09 +02:00
|
|
|
"github.com/jordanknott/taskcafe/internal/logger"
|
2020-08-22 06:08:30 +02:00
|
|
|
"github.com/jordanknott/taskcafe/internal/utils"
|
2020-08-01 03:01:14 +02:00
|
|
|
log "github.com/sirupsen/logrus"
|
2020-09-20 03:20:36 +02:00
|
|
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
2020-04-10 04:40:22 +02:00
|
|
|
)
|
|
|
|
|
2021-11-18 00:11:28 +01:00
|
|
|
type NotificationObservers struct {
|
|
|
|
Mu sync.Mutex
|
|
|
|
Subscribers map[string]map[string]chan *Notified
|
|
|
|
}
|
|
|
|
|
2020-04-10 04:40:22 +02:00
|
|
|
// NewHandler returns a new graphql endpoint handler.
|
2021-11-18 00:11:28 +01:00
|
|
|
func NewHandler(repo db.Repository, appConfig config.AppConfig, jobQueue jobs.JobQueue, redisClient *redis.Client) http.Handler {
|
|
|
|
resolver := &Resolver{
|
|
|
|
Repository: repo,
|
|
|
|
Redis: redisClient,
|
|
|
|
AppConfig: appConfig,
|
|
|
|
Job: jobQueue,
|
|
|
|
Notifications: &NotificationObservers{
|
|
|
|
Mu: sync.Mutex{},
|
|
|
|
Subscribers: make(map[string]map[string]chan *Notified),
|
2020-04-10 04:40:22 +02:00
|
|
|
},
|
2020-08-01 03:01:14 +02:00
|
|
|
}
|
2021-11-18 00:11:28 +01:00
|
|
|
resolver.SubscribeRedis()
|
|
|
|
c := Config{
|
|
|
|
Resolvers: resolver,
|
|
|
|
}
|
2020-08-01 03:01:14 +02:00
|
|
|
c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, roles []RoleLevel, level ActionLevel, typeArg ObjectType) (interface{}, error) {
|
2021-05-01 06:24:23 +02:00
|
|
|
userID, ok := GetUser(ctx)
|
2021-05-01 05:55:37 +02:00
|
|
|
if !ok {
|
|
|
|
return nil, errors.New("user must be logged in")
|
|
|
|
}
|
2021-05-01 06:24:23 +02:00
|
|
|
user, err := repo.GetUserAccountByID(ctx, userID)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if user.RoleCode == "admin" {
|
|
|
|
return next(ctx)
|
|
|
|
} else if level == ActionLevelOrg {
|
|
|
|
return nil, errors.New("must be an org admin")
|
|
|
|
}
|
2021-05-01 05:55:37 +02:00
|
|
|
|
2020-08-01 03:01:14 +02:00
|
|
|
var subjectID uuid.UUID
|
2020-11-25 15:37:35 +01:00
|
|
|
in := graphql.GetFieldContext(ctx).Args["input"]
|
2020-08-13 03:54:14 +02:00
|
|
|
val := reflect.ValueOf(in) // could be any underlying type
|
|
|
|
if val.Kind() == reflect.Ptr {
|
|
|
|
val = reflect.Indirect(val)
|
|
|
|
}
|
|
|
|
var fieldName string
|
|
|
|
switch typeArg {
|
|
|
|
case ObjectTypeTeam:
|
|
|
|
fieldName = "TeamID"
|
|
|
|
case ObjectTypeTask:
|
|
|
|
fieldName = "TaskID"
|
2020-09-20 00:26:02 +02:00
|
|
|
case ObjectTypeTaskGroup:
|
|
|
|
fieldName = "TaskGroupID"
|
|
|
|
case ObjectTypeTaskChecklist:
|
|
|
|
fieldName = "TaskChecklistID"
|
|
|
|
case ObjectTypeTaskChecklistItem:
|
|
|
|
fieldName = "TaskChecklistItemID"
|
2020-08-13 03:54:14 +02:00
|
|
|
default:
|
|
|
|
fieldName = "ProjectID"
|
|
|
|
}
|
2020-10-21 01:52:09 +02:00
|
|
|
logger.New(ctx).WithFields(log.Fields{"typeArg": typeArg, "fieldName": fieldName}).Info("getting field by name")
|
2020-09-20 00:26:02 +02:00
|
|
|
subjectField := val.FieldByName(fieldName)
|
|
|
|
if !subjectField.IsValid() {
|
2020-10-21 01:52:09 +02:00
|
|
|
logger.New(ctx).Error("subject field name does not exist on input type")
|
2020-09-20 00:26:02 +02:00
|
|
|
return nil, errors.New("subject field name does not exist on input type")
|
|
|
|
}
|
2020-09-20 03:20:36 +02:00
|
|
|
if fieldName == "TeamID" && subjectField.IsNil() {
|
|
|
|
// Is a personal project, no check
|
|
|
|
// TODO: add config setting to disable personal projects
|
|
|
|
return next(ctx)
|
|
|
|
}
|
2021-05-01 05:55:37 +02:00
|
|
|
subjectID, ok = subjectField.Interface().(uuid.UUID)
|
2020-08-13 03:54:14 +02:00
|
|
|
if !ok {
|
2020-10-21 01:52:09 +02:00
|
|
|
logger.New(ctx).Error("error while casting subject UUID")
|
2020-08-13 03:54:14 +02:00
|
|
|
return nil, errors.New("error while casting subject uuid")
|
2020-08-01 03:01:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if level == ActionLevelProject {
|
2020-10-21 01:52:09 +02:00
|
|
|
logger.New(ctx).WithFields(log.Fields{"subjectID": subjectID}).Info("fetching subject ID by typeArg")
|
2020-08-13 03:54:14 +02:00
|
|
|
if typeArg == ObjectTypeTask {
|
|
|
|
subjectID, err = repo.GetProjectIDForTask(ctx, subjectID)
|
|
|
|
}
|
2020-09-20 00:26:02 +02:00
|
|
|
if typeArg == ObjectTypeTaskGroup {
|
|
|
|
subjectID, err = repo.GetProjectIDForTaskGroup(ctx, subjectID)
|
|
|
|
}
|
|
|
|
if typeArg == ObjectTypeTaskChecklist {
|
|
|
|
subjectID, err = repo.GetProjectIDForTaskChecklist(ctx, subjectID)
|
|
|
|
}
|
|
|
|
if typeArg == ObjectTypeTaskChecklistItem {
|
|
|
|
subjectID, err = repo.GetProjectIDForTaskChecklistItem(ctx, subjectID)
|
|
|
|
}
|
2020-08-01 03:01:14 +02:00
|
|
|
if err != nil {
|
2020-10-21 01:52:09 +02:00
|
|
|
logger.New(ctx).WithError(err).Error("error while getting subject ID")
|
2020-08-01 03:01:14 +02:00
|
|
|
return nil, err
|
|
|
|
}
|
2020-09-20 00:26:02 +02:00
|
|
|
projectRoles, err := GetProjectRoles(ctx, repo, subjectID)
|
|
|
|
if err != nil {
|
2020-09-20 03:20:36 +02:00
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return nil, &gqlerror.Error{
|
|
|
|
Message: "not authorized",
|
|
|
|
Extensions: map[string]interface{}{
|
|
|
|
"code": "401",
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
2020-10-21 01:52:09 +02:00
|
|
|
logger.New(ctx).WithError(err).Error("error while getting project roles")
|
2020-09-20 00:26:02 +02:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
for _, validRole := range roles {
|
2020-10-21 01:52:09 +02:00
|
|
|
logger.New(ctx).WithFields(log.Fields{"validRole": validRole}).Info("checking role")
|
2020-09-20 03:20:36 +02:00
|
|
|
if CompareRoleLevel(projectRoles.TeamRole, validRole) || CompareRoleLevel(projectRoles.ProjectRole, validRole) {
|
2020-10-21 01:52:09 +02:00
|
|
|
logger.New(ctx).WithFields(log.Fields{"teamRole": projectRoles.TeamRole, "projectRole": projectRoles.ProjectRole}).Info("is team or project role")
|
2020-09-20 00:26:02 +02:00
|
|
|
return next(ctx)
|
|
|
|
}
|
2020-08-01 03:01:14 +02:00
|
|
|
}
|
2020-09-20 03:20:36 +02:00
|
|
|
return nil, &gqlerror.Error{
|
|
|
|
Message: "not authorized",
|
|
|
|
Extensions: map[string]interface{}{
|
|
|
|
"code": "401",
|
|
|
|
},
|
|
|
|
}
|
2020-08-01 03:01:14 +02:00
|
|
|
} else if level == ActionLevelTeam {
|
|
|
|
role, err := repo.GetTeamRoleForUserID(ctx, db.GetTeamRoleForUserIDParams{UserID: userID, TeamID: subjectID})
|
|
|
|
if err != nil {
|
2020-10-21 01:52:09 +02:00
|
|
|
logger.New(ctx).WithError(err).Error("error while getting team roles for user ID")
|
2020-08-01 03:01:14 +02:00
|
|
|
return nil, err
|
|
|
|
}
|
2020-09-20 00:26:02 +02:00
|
|
|
for _, validRole := range roles {
|
2020-09-20 03:20:36 +02:00
|
|
|
if CompareRoleLevel(role.RoleCode, validRole) || CompareRoleLevel(role.RoleCode, validRole) {
|
2020-09-20 00:26:02 +02:00
|
|
|
return next(ctx)
|
|
|
|
}
|
2020-08-01 03:01:14 +02:00
|
|
|
}
|
2020-09-20 03:20:36 +02:00
|
|
|
return nil, &gqlerror.Error{
|
|
|
|
Message: "not authorized",
|
|
|
|
Extensions: map[string]interface{}{
|
|
|
|
"code": "401",
|
|
|
|
},
|
|
|
|
}
|
2020-08-01 03:01:14 +02:00
|
|
|
|
|
|
|
}
|
2020-09-20 03:20:36 +02:00
|
|
|
return nil, &gqlerror.Error{
|
|
|
|
Message: "bad path",
|
|
|
|
Extensions: map[string]interface{}{
|
|
|
|
"code": "500",
|
|
|
|
},
|
|
|
|
}
|
2020-08-01 03:01:14 +02:00
|
|
|
}
|
|
|
|
srv := handler.New(NewExecutableSchema(c))
|
2020-04-11 04:22:22 +02:00
|
|
|
srv.AddTransport(transport.Websocket{
|
|
|
|
KeepAlivePingInterval: 10 * time.Second,
|
|
|
|
})
|
|
|
|
srv.AddTransport(transport.Options{})
|
|
|
|
srv.AddTransport(transport.GET{})
|
|
|
|
srv.AddTransport(transport.POST{})
|
|
|
|
srv.AddTransport(transport.MultipartForm{})
|
|
|
|
|
|
|
|
srv.SetQueryCache(lru.New(1000))
|
|
|
|
|
|
|
|
srv.Use(extension.AutomaticPersistedQuery{
|
|
|
|
Cache: lru.New(100),
|
|
|
|
})
|
|
|
|
if isProd := os.Getenv("PRODUCTION") == "true"; isProd {
|
|
|
|
srv.Use(extension.FixedComplexityLimit(10))
|
|
|
|
} else {
|
|
|
|
srv.Use(extension.Introspection{})
|
|
|
|
}
|
|
|
|
return srv
|
2020-04-10 04:40:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// NewPlaygroundHandler returns a new GraphQL Playground handler.
|
|
|
|
func NewPlaygroundHandler(endpoint string) http.Handler {
|
2020-04-11 04:22:22 +02:00
|
|
|
return playground.Handler("GraphQL Playground", endpoint)
|
2020-04-10 04:40:22 +02:00
|
|
|
}
|
2020-07-17 02:40:23 +02:00
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// GetUserID retrieves the UserID out of a context
|
2020-04-20 05:02:55 +02:00
|
|
|
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
|
2020-08-22 06:08:30 +02:00
|
|
|
userID, ok := ctx.Value(utils.UserIDKey).(uuid.UUID)
|
2020-04-20 05:02:55 +02:00
|
|
|
return userID, ok
|
|
|
|
}
|
2020-08-21 01:11:24 +02:00
|
|
|
|
|
|
|
// GetUser retrieves both the user id & user role out of a context
|
2021-04-29 04:32:19 +02:00
|
|
|
func GetUser(ctx context.Context) (uuid.UUID, bool) {
|
2020-08-01 03:01:14 +02:00
|
|
|
userID, userOK := GetUserID(ctx)
|
2021-04-29 04:32:19 +02:00
|
|
|
return userID, userOK
|
2020-07-17 02:40:23 +02:00
|
|
|
}
|
2020-08-01 03:01:14 +02:00
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// GetProjectRoles retrieves the team & project role for the given project ID
|
2020-08-01 03:01:14 +02:00
|
|
|
func GetProjectRoles(ctx context.Context, r db.Repository, projectID uuid.UUID) (db.GetUserRolesForProjectRow, error) {
|
|
|
|
userID, ok := GetUserID(ctx)
|
|
|
|
if !ok {
|
|
|
|
return db.GetUserRolesForProjectRow{}, errors.New("user ID is not found")
|
|
|
|
}
|
|
|
|
return r.GetUserRolesForProject(ctx, db.GetUserRolesForProjectParams{UserID: userID, ProjectID: projectID})
|
|
|
|
}
|
|
|
|
|
2020-09-20 03:20:36 +02:00
|
|
|
// CompareRoleLevel compares a string against a role level
|
|
|
|
func CompareRoleLevel(a string, b RoleLevel) bool {
|
|
|
|
if strings.ToLower(a) == strings.ToLower(b.String()) {
|
|
|
|
return true
|
2020-09-20 00:26:02 +02:00
|
|
|
}
|
2020-09-20 03:20:36 +02:00
|
|
|
return false
|
2020-09-20 00:26:02 +02:00
|
|
|
}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// ConvertToRoleCode converts a role code string to a RoleCode type
|
2020-08-01 03:01:14 +02:00
|
|
|
func ConvertToRoleCode(r string) RoleCode {
|
|
|
|
if r == RoleCodeAdmin.String() {
|
|
|
|
return RoleCodeAdmin
|
|
|
|
}
|
|
|
|
if r == RoleCodeMember.String() {
|
|
|
|
return RoleCodeMember
|
|
|
|
}
|
|
|
|
return RoleCodeObserver
|
|
|
|
}
|
2020-08-13 03:54:14 +02:00
|
|
|
|
2020-12-19 03:34:35 +01:00
|
|
|
type MemberType string
|
|
|
|
|
|
|
|
const (
|
|
|
|
MemberTypeInvited MemberType = "INVITED"
|
|
|
|
MemberTypeJoined MemberType = "JOINED"
|
|
|
|
)
|
|
|
|
|
|
|
|
type MasterEntry struct {
|
|
|
|
MemberType MemberType
|
|
|
|
ID uuid.UUID
|
|
|
|
}
|
|
|
|
|
|
|
|
const (
|
|
|
|
TASK_ADDED_TO_TASK_GROUP int32 = 1
|
|
|
|
TASK_MOVED_TO_TASK_GROUP int32 = 2
|
|
|
|
TASK_MARK_COMPLETE int32 = 3
|
|
|
|
TASK_MARK_INCOMPLETE int32 = 4
|
|
|
|
TASK_DUE_DATE_CHANGED int32 = 5
|
|
|
|
TASK_DUE_DATE_ADDED int32 = 6
|
|
|
|
TASK_DUE_DATE_REMOVED int32 = 7
|
|
|
|
TASK_CHECKLIST_CHANGED int32 = 8
|
|
|
|
TASK_CHECKLIST_ADDED int32 = 9
|
|
|
|
TASK_CHECKLIST_REMOVED int32 = 10
|
|
|
|
)
|
2021-05-01 05:55:37 +02:00
|
|
|
|
|
|
|
func NotAuthorized() error {
|
|
|
|
return &gqlerror.Error{
|
|
|
|
Message: "Not authorized",
|
|
|
|
Extensions: map[string]interface{}{
|
|
|
|
"code": "UNAUTHENTICATED",
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func IsProjectPublic(ctx context.Context, repo db.Repository, projectID uuid.UUID) (bool, error) {
|
|
|
|
publicOn, err := repo.GetPublicOn(ctx, projectID)
|
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
if !publicOn.Valid {
|
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
return true, nil
|
|
|
|
}
|