diff --git a/.gitignore b/.gitignore index af95804..9d80a26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /.idea -/graph/graphcore/* \ No newline at end of file +/graph/graphcore/*_gen.go \ No newline at end of file diff --git a/database/database.go b/database/database.go index 00243b1..d058095 100644 --- a/database/database.go +++ b/database/database.go @@ -14,7 +14,7 @@ type Database interface { Projects() repositories.ProjectRepository Session() repositories.SessionRepository Users() repositories.UserRepository - + ProjectStatuses() repositories.ProjectStatusRepository // Migrate the database. Migrate() error } diff --git a/database/drivers/mysqldriver/db.go b/database/drivers/mysqldriver/db.go index 64fbd69..f7837bb 100644 --- a/database/drivers/mysqldriver/db.go +++ b/database/drivers/mysqldriver/db.go @@ -13,12 +13,13 @@ import ( ) type DB struct { - db *sqlx.DB - issues *issueRepository - items *itemRepository - projects *projectRepository - sessions *sessionRepository - users *userRepository + db *sqlx.DB + issues *issueRepository + items *itemRepository + projects *projectRepository + sessions *sessionRepository + users *userRepository + projectStatuses *projectStatusRepository } func (db *DB) Issues() repositories.IssueRepository { @@ -41,6 +42,10 @@ func (db *DB) Users() repositories.UserRepository { return db.users } +func (db *DB) ProjectStatuses() repositories.ProjectStatusRepository { + return db.projectStatuses +} + func (db *DB) Migrate() error { err := goose.SetDialect("mysql") if err != nil { @@ -75,13 +80,17 @@ func Open(connectionString string) (*DB, error) { items := &itemRepository{db: db} projects := &projectRepository{db: db} users := &userRepository{db: db} + sessions := &sessionRepository{db: db} + projectStatuses := &projectStatusRepository{db: db} return &DB{ - db: db, - issues: issues, - items: items, - projects: projects, - users: users, + db: db, + issues: issues, + items: items, + projects: projects, + users: users, + sessions: sessions, + projectStatuses: projectStatuses, }, nil } diff --git a/database/drivers/mysqldriver/db_test.go b/database/drivers/mysqldriver/db_test.go index 66be122..6c9131a 100644 --- a/database/drivers/mysqldriver/db_test.go +++ b/database/drivers/mysqldriver/db_test.go @@ -19,12 +19,13 @@ func TestMain(m *testing.M) { for i := 0; i < 10; i++ { db, err := Open(testDbConnect) if err != nil { - log.Println("DB ERROR", err) + log.Println("DB ERROR", i, err) time.Sleep(time.Second) continue } testDB = db + break } if testDB == nil { log.Println("DB FAILED") diff --git a/database/drivers/mysqldriver/projects.go b/database/drivers/mysqldriver/projects.go index 33219a4..77bef21 100644 --- a/database/drivers/mysqldriver/projects.go +++ b/database/drivers/mysqldriver/projects.go @@ -89,6 +89,16 @@ func (r *projectRepository) Save(ctx context.Context, project models.Project) er return err } +func (r *projectRepository) ListPermissions(ctx context.Context, project models.Project) ([]*models.ProjectPermission, error) { + permissions := make([]*models.ProjectPermission, 0, 4) + err := r.db.SelectContext(ctx, &permissions, "SELECT * FROM project_permission WHERE project_id=?", project.ID) + if err != nil { + return nil, err + } + + return permissions, nil +} + func (r *projectRepository) GetPermission(ctx context.Context, project models.Project, user models.User) (*models.ProjectPermission, error) { permission := models.ProjectPermission{} err := r.db.GetContext(ctx, &permission, "SELECT * FROM project_permission WHERE project_id=? AND user_id=?", project.ID, user.ID) diff --git a/database/drivers/mysqldriver/projectstatuses.go b/database/drivers/mysqldriver/projectstatuses.go new file mode 100644 index 0000000..702f873 --- /dev/null +++ b/database/drivers/mysqldriver/projectstatuses.go @@ -0,0 +1,70 @@ +package mysqldriver + +import ( + "context" + "database/sql" + "git.aiterp.net/stufflog/server/internal/xlerrors" + "git.aiterp.net/stufflog/server/models" + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +type projectStatusRepository struct { + db *sqlx.DB +} + +func (r *projectStatusRepository) Find(ctx context.Context, projectID string, name string) (*models.ProjectStatus, error) { + projectStatus := models.ProjectStatus{} + err := r.db.GetContext(ctx, &projectStatus, "SELECT * FROM project_status WHERE project_id=? AND name=?", projectID, name) + if err != nil { + if err == sql.ErrNoRows { + return nil, xlerrors.NotFound("Project status") + } + + return nil, err + } + + return &projectStatus, nil +} + +func (r *projectStatusRepository) List(ctx context.Context, filter models.ProjectStatusFilter) ([]*models.ProjectStatus, error) { + q := sq.Select("*").From("project_status") + if filter.ProjectID != nil { + q = q.Where(sq.Eq{"project_id": *filter.ProjectID}) + } + if filter.MinStage != nil { + q = q.Where(sq.GtOrEq{"stage": *filter.MinStage}) + } + if filter.MaxStage != nil { + q = q.Where(sq.LtOrEq{"stage": *filter.MaxStage}) + } + q = q.OrderBy("project_id", "stage", "name") + + query, args, err := q.ToSql() + if err != nil { + return nil, err + } + + statuses := make([]*models.ProjectStatus, 0, 16) + err = r.db.SelectContext(ctx, &statuses, query, args...) + if err != nil { + return nil, err + } + + return statuses, nil +} + +func (r *projectStatusRepository) Save(ctx context.Context, status models.ProjectStatus) error { + _, err := r.db.NamedExecContext(ctx, ` + REPLACE INTO project_status (project_id, name, stage, description) + VALUES (:project_id, :name, :stage, :description) + `, status) + + return err +} + +func (r *projectStatusRepository) Delete(ctx context.Context, status models.ProjectStatus) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM project_status WHERE project_id=? AND name=?", status.ProjectID, status.Name) + + return err +} diff --git a/database/drivers/mysqldriver/projectstatuses_test.go b/database/drivers/mysqldriver/projectstatuses_test.go new file mode 100644 index 0000000..8161241 --- /dev/null +++ b/database/drivers/mysqldriver/projectstatuses_test.go @@ -0,0 +1,165 @@ +package mysqldriver + +import ( + "context" + "git.aiterp.net/stufflog/server/internal/xlerrors" + "git.aiterp.net/stufflog/server/models" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +var projectStatus1 = models.ProjectStatus{ + ProjectID: project1.ID, + Stage: models.IssueStagePending, + Name: "Ready", + Description: "This task is ready to be done.", +} +var projectStatus2 = models.ProjectStatus{ + ProjectID: project1.ID, + Stage: models.IssueStageInactive, + Name: "Idea", + Description: "Maybe do this?", +} +var projectStatus3 = models.ProjectStatus{ + ProjectID: project2.ID, + Stage: models.IssueStageActive, + Name: "In Progress", + Description: "It is being done.", +} +var projectStatus4 = models.ProjectStatus{ + ProjectID: project2.ID, + Stage: models.IssueStageReview, + Name: "Rendered", + Description: "It is rendered and awaiting feedback.", +} +var projectStatus5 = models.ProjectStatus{ + ProjectID: project3.ID, + Stage: models.IssueStageCompleted, + Name: "Om Nom Nom", + Description: "Sustenance intake complete.", +} +var projectStatus6 = models.ProjectStatus{ + ProjectID: project1.ID, + Stage: models.IssueStageFailed, + Name: "Bad Stuff", + Description: "Stuff could not be done.", +} +var projectStatus7 = models.ProjectStatus{ + ProjectID: project2.ID, + Stage: models.IssueStagePostponed, + Name: "Too Hard", + Description: "Do it later.", +} +var projectStatus7Updated = models.ProjectStatus{ + ProjectID: project2.ID, + Stage: models.IssueStageInactive, + Name: "Too Hard", + Description: "Do it later, when you're less of newbie.", +} + +func TestProjectStatusRepository(t *testing.T) { + statuses := testDB.projectStatuses + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + assert.NoError(t, clearTable("project_status")) + + // INSERT + assert.NoError(t, statuses.Save(ctx, projectStatus1)) + assert.NoError(t, statuses.Save(ctx, projectStatus2)) + assert.NoError(t, statuses.Save(ctx, projectStatus3)) + assert.NoError(t, statuses.Save(ctx, projectStatus4)) + assert.NoError(t, statuses.Save(ctx, projectStatus5)) + assert.NoError(t, statuses.Save(ctx, projectStatus6)) + assert.NoError(t, statuses.Save(ctx, projectStatus7)) + + // FIND + result, err := statuses.Find(ctx, project2.ID, projectStatus3.Name) + assert.NoError(t, err) + assert.Equal(t, &projectStatus3, result) + result, err = statuses.Find(ctx, project1.ID, projectStatus6.Name) + assert.NoError(t, err) + assert.Equal(t, &projectStatus6, result) + + // FINDn't + result, err = statuses.Find(ctx, project3.ID, projectStatus6.Name) + assert.Error(t, err) + assert.True(t, xlerrors.IsNotFound(err)) + assert.Nil(t, result) + result, err = statuses.Find(ctx, project2.ID, "Non-existent Name") + assert.Error(t, err) + assert.True(t, xlerrors.IsNotFound(err)) + assert.Nil(t, result) + + // LIST + results, err := statuses.List(ctx, models.ProjectStatusFilter{}) + assert.NoError(t, err) + assert.Equal(t, []*models.ProjectStatus{ + &projectStatus5, + &projectStatus3, + &projectStatus4, + &projectStatus7, + &projectStatus2, + &projectStatus1, + &projectStatus6, + }, results) + results, err = statuses.List(ctx, models.ProjectStatusFilter{ProjectID: &project1.ID}) + assert.NoError(t, err) + assert.Equal(t, []*models.ProjectStatus{ + &projectStatus2, + &projectStatus1, + &projectStatus6, + }, results) + results, err = statuses.List(ctx, models.ProjectStatusFilter{ProjectID: &project2.ID, MinStage: ptrInt(models.IssueStageReview)}) + assert.NoError(t, err) + assert.Equal(t, []*models.ProjectStatus{ + &projectStatus4, + &projectStatus7, + }, results) + results, err = statuses.List(ctx, models.ProjectStatusFilter{ProjectID: &project2.ID, MaxStage: ptrInt(models.IssueStageReview)}) + assert.NoError(t, err) + assert.Equal(t, []*models.ProjectStatus{ + &projectStatus3, + &projectStatus4, + }, results) + results, err = statuses.List(ctx, models.ProjectStatusFilter{ProjectID: &project3.ID, MaxStage: ptrInt(models.IssueStagePending)}) + assert.NoError(t, err) + assert.Equal(t, []*models.ProjectStatus{}, results) + + // UPDATE + assert.NoError(t, statuses.Save(ctx, projectStatus7Updated)) + + // FIND after UPDATE + result, err = statuses.Find(ctx, project2.ID, projectStatus7.Name) + assert.NoError(t, err) + assert.Equal(t, &projectStatus7Updated, result) + + // LIST after UPDATE + results, err = statuses.List(ctx, models.ProjectStatusFilter{}) + assert.NoError(t, err) + assert.Equal(t, []*models.ProjectStatus{ + &projectStatus5, + &projectStatus7Updated, + &projectStatus3, + &projectStatus4, + &projectStatus2, + &projectStatus1, + &projectStatus6, + }, results) + + // DELETE + assert.NoError(t, statuses.Delete(ctx, projectStatus7Updated)) + + // LIST after DELETE + results, err = statuses.List(ctx, models.ProjectStatusFilter{}) + assert.NoError(t, err) + assert.Equal(t, []*models.ProjectStatus{ + &projectStatus5, + &projectStatus3, + &projectStatus4, + &projectStatus2, + &projectStatus1, + &projectStatus6, + }, results) +} diff --git a/database/repositories/projectrepository.go b/database/repositories/projectrepository.go index a1e34f3..a8a8814 100644 --- a/database/repositories/projectrepository.go +++ b/database/repositories/projectrepository.go @@ -10,6 +10,7 @@ type ProjectRepository interface { List(ctx context.Context, filter models.ProjectFilter) ([]*models.Project, error) Insert(ctx context.Context, project models.Project) (*models.Project, error) Save(ctx context.Context, project models.Project) error + ListPermissions(ctx context.Context, project models.Project) ([]*models.ProjectPermission, error) GetPermission(ctx context.Context, project models.Project, user models.User) (*models.ProjectPermission, error) GetIssuePermission(ctx context.Context, issue models.Issue, user models.User) (*models.ProjectPermission, error) SetPermission(ctx context.Context, permission models.ProjectPermission) error diff --git a/database/repositories/projectstatusrepository.go b/database/repositories/projectstatusrepository.go new file mode 100644 index 0000000..a13679c --- /dev/null +++ b/database/repositories/projectstatusrepository.go @@ -0,0 +1,13 @@ +package repositories + +import ( + "context" + "git.aiterp.net/stufflog/server/models" +) + +type ProjectStatusRepository interface { + Find(ctx context.Context, projectID string, id string) (*models.ProjectStatus, error) + List(ctx context.Context, filter models.ProjectStatusFilter) ([]*models.ProjectStatus, error) + Save(ctx context.Context, status models.ProjectStatus) error + Delete(ctx context.Context, status models.ProjectStatus) error +} diff --git a/go.mod b/go.mod index 630f1a3..5d3a6e6 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pressly/goose v2.6.0+incompatible github.com/stretchr/testify v1.5.1 github.com/urfave/cli/v2 v2.2.0 + github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e github.com/vektah/gqlparser/v2 v2.0.1 golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8 ) diff --git a/go.sum b/go.sum index 53859b8..698baa0 100644 --- a/go.sum +++ b/go.sum @@ -107,6 +107,7 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg= github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o= github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms= diff --git a/graph/graph.go b/graph/graph.go index e22fc2b..14f4b82 100644 --- a/graph/graph.go +++ b/graph/graph.go @@ -1,7 +1,9 @@ package graph import ( + "git.aiterp.net/stufflog/server/database" "git.aiterp.net/stufflog/server/graph/graphcore" + "git.aiterp.net/stufflog/server/graph/loaders" "git.aiterp.net/stufflog/server/graph/resolvers" "git.aiterp.net/stufflog/server/services" "github.com/99designs/gqlgen/graphql" @@ -12,18 +14,25 @@ import ( //go:generate go run github.com/99designs/gqlgen --verbose --config gqlgen.yml // New creates a new GraphQL schema. -func New(s services.Bundle) graphql.ExecutableSchema { +func New(bundle services.Bundle, database database.Database) graphql.ExecutableSchema { return graphcore.NewExecutableSchema(graphcore.Config{ - Resolvers: &resolvers.Resolver{S: s}, + Resolvers: &resolvers.Resolver{ + Bundle: bundle, + Database: database, + }, }) } -func Gin(s services.Bundle) gin.HandlerFunc { - schema := New(s) +func Gin(bundle services.Bundle, database database.Database) gin.HandlerFunc { + schema := New(bundle, database) gqlHandler := handler.NewDefaultServer(schema) return func(c *gin.Context) { - s.Auth.CheckGinSession(c) + bundle.Auth.CheckGinSession(c) + + c.Request = c.Request.WithContext( + loaders.NewUserLoaderContext(c.Request.Context(), database.Users()), + ) gqlHandler.ServeHTTP(c.Writer, c.Request) } diff --git a/graph/graphcore/package.go b/graph/graphcore/package.go new file mode 100644 index 0000000..fe89db1 --- /dev/null +++ b/graph/graphcore/package.go @@ -0,0 +1,2 @@ +// Package graphcore contains the generated code. +package graphcore diff --git a/graph/graphutil/fields.go b/graph/graphutil/fields.go new file mode 100644 index 0000000..1af2035 --- /dev/null +++ b/graph/graphutil/fields.go @@ -0,0 +1,19 @@ +package graphutil + +import ( + "context" + "github.com/99designs/gqlgen/graphql" +) + +func SelectsAnyField(ctx context.Context, fields ...string) bool { + collectedFields := graphql.CollectFieldsCtx(ctx, []string{"description"}) + for _, collectedField := range collectedFields { + for _, field := range fields { + if collectedField.Name == field { + return true + } + } + } + + return false +} diff --git a/graph/loaders/package.go b/graph/loaders/package.go new file mode 100644 index 0000000..a25b61e --- /dev/null +++ b/graph/loaders/package.go @@ -0,0 +1,2 @@ +// Package loaders is for the generated data loaders. +package loaders diff --git a/graph/loaders/projectpermissionloader_gen.go b/graph/loaders/projectpermissionloader_gen.go new file mode 100644 index 0000000..9d4dea5 --- /dev/null +++ b/graph/loaders/projectpermissionloader_gen.go @@ -0,0 +1,224 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" + + "git.aiterp.net/stufflog/server/models" +) + +// ProjectPermissionLoaderConfig captures the config to create a new ProjectPermissionLoader +type ProjectPermissionLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []string) ([]*models.ProjectPermission, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewProjectPermissionLoader creates a new ProjectPermissionLoader given a fetch, wait, and maxBatch +func NewProjectPermissionLoader(config ProjectPermissionLoaderConfig) *ProjectPermissionLoader { + return &ProjectPermissionLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// ProjectPermissionLoader batches and caches requests +type ProjectPermissionLoader struct { + // this method provides the data for the loader + fetch func(keys []string) ([]*models.ProjectPermission, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[string]*models.ProjectPermission + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *projectPermissionLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type projectPermissionLoaderBatch struct { + keys []string + data []*models.ProjectPermission + error []error + closing bool + done chan struct{} +} + +// Load a ProjectPermission by key, batching and caching will be applied automatically +func (l *ProjectPermissionLoader) Load(key string) (*models.ProjectPermission, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a ProjectPermission. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *ProjectPermissionLoader) LoadThunk(key string) func() (*models.ProjectPermission, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() (*models.ProjectPermission, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &projectPermissionLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() (*models.ProjectPermission, error) { + <-batch.done + + var data *models.ProjectPermission + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *ProjectPermissionLoader) LoadAll(keys []string) ([]*models.ProjectPermission, []error) { + results := make([]func() (*models.ProjectPermission, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + projectPermissions := make([]*models.ProjectPermission, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + projectPermissions[i], errors[i] = thunk() + } + return projectPermissions, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a ProjectPermissions. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *ProjectPermissionLoader) LoadAllThunk(keys []string) func() ([]*models.ProjectPermission, []error) { + results := make([]func() (*models.ProjectPermission, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([]*models.ProjectPermission, []error) { + projectPermissions := make([]*models.ProjectPermission, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + projectPermissions[i], errors[i] = thunk() + } + return projectPermissions, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *ProjectPermissionLoader) Prime(key string, value *models.ProjectPermission) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := *value + l.unsafeSet(key, &cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *ProjectPermissionLoader) Clear(key string) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *ProjectPermissionLoader) unsafeSet(key string, value *models.ProjectPermission) { + if l.cache == nil { + l.cache = map[string]*models.ProjectPermission{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *projectPermissionLoaderBatch) keyIndex(l *ProjectPermissionLoader, key string) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *projectPermissionLoaderBatch) startTimer(l *ProjectPermissionLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *projectPermissionLoaderBatch) end(l *ProjectPermissionLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/graph/loaders/userloader.go b/graph/loaders/userloader.go new file mode 100644 index 0000000..5d9295e --- /dev/null +++ b/graph/loaders/userloader.go @@ -0,0 +1,57 @@ +package loaders + +import ( + "context" + "git.aiterp.net/stufflog/server/database/repositories" + "git.aiterp.net/stufflog/server/internal/xlerrors" + "git.aiterp.net/stufflog/server/models" + "time" +) + +// go run github.com/vektah/dataloaden UserLoader string *git.aiterp.net/stufflog/server/models.User + +var userLoaderCtxKey = "ctx.stufflog.UserLoader" + +func NewUserLoaderContext(ctx context.Context, userRepo repositories.UserRepository) context.Context { + return context.WithValue(ctx, userLoaderCtxKey, NewUserLoader(ctx, userRepo)) +} + +func UserLoaderFromContext(ctx context.Context) *UserLoader { + return ctx.Value(userLoaderCtxKey).(*UserLoader) +} + +func NewUserLoader(ctx context.Context, userRepo repositories.UserRepository) *UserLoader { + return &UserLoader{ + wait: 2 * time.Millisecond, + maxBatch: 100, + fetch: func(keys []string) ([]*models.User, []error) { + results := make([]*models.User, len(keys)) + errors := make([]error, len(keys)) + + users, err := userRepo.List(ctx, models.UserFilter{UserIDs: keys}) + if err != nil { + for i := range errors { + errors[i] = err + } + + return results, errors + } + + userMap := make(map[string]*models.User, len(users)) + for _, user := range users { + userMap[user.ID] = user + } + + for i, id := range keys { + user := userMap[id] + if user != nil { + results[i] = user + } else { + errors[i] = xlerrors.NotFound("User") + } + } + + return results, errors + }, + } +} diff --git a/graph/loaders/userloader_gen.go b/graph/loaders/userloader_gen.go new file mode 100644 index 0000000..7498887 --- /dev/null +++ b/graph/loaders/userloader_gen.go @@ -0,0 +1,215 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" + + "git.aiterp.net/stufflog/server/models" +) + +// UserLoaderConfig captures the config to create a new UserLoader +type UserLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []string) ([]*models.User, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// UserLoader batches and caches requests +type UserLoader struct { + // this method provides the data for the loader + fetch func(keys []string) ([]*models.User, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[string]*models.User + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *userLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type userLoaderBatch struct { + keys []string + data []*models.User + error []error + closing bool + done chan struct{} +} + +// Load a User by key, batching and caching will be applied automatically +func (l *UserLoader) Load(key string) (*models.User, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a User. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *UserLoader) LoadThunk(key string) func() (*models.User, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() (*models.User, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &userLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() (*models.User, error) { + <-batch.done + + var data *models.User + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *UserLoader) LoadAll(keys []string) ([]*models.User, []error) { + results := make([]func() (*models.User, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + users := make([]*models.User, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + users[i], errors[i] = thunk() + } + return users, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a Users. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *UserLoader) LoadAllThunk(keys []string) func() ([]*models.User, []error) { + results := make([]func() (*models.User, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([]*models.User, []error) { + users := make([]*models.User, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + users[i], errors[i] = thunk() + } + return users, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *UserLoader) Prime(key string, value *models.User) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := *value + l.unsafeSet(key, &cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *UserLoader) Clear(key string) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *UserLoader) unsafeSet(key string, value *models.User) { + if l.cache == nil { + l.cache = map[string]*models.User{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *userLoaderBatch) keyIndex(l *UserLoader, key string) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *userLoaderBatch) startTimer(l *UserLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *userLoaderBatch) end(l *UserLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/graph/resolvers/issue.resolvers.go b/graph/resolvers/issue.resolvers.go index bfd433b..0f313ca 100644 --- a/graph/resolvers/issue.resolvers.go +++ b/graph/resolvers/issue.resolvers.go @@ -5,22 +5,74 @@ package resolvers import ( "context" - "fmt" - "git.aiterp.net/stufflog/server/graph/graphcore" + "git.aiterp.net/stufflog/server/graph/graphutil" + "git.aiterp.net/stufflog/server/graph/loaders" + "git.aiterp.net/stufflog/server/internal/xlerrors" "git.aiterp.net/stufflog/server/models" ) func (r *issueResolver) Project(ctx context.Context, obj *models.Issue) (*models.Project, error) { - panic(fmt.Errorf("not implemented")) + return r.Database.Projects().Find(ctx, obj.ProjectID) } func (r *issueResolver) Owner(ctx context.Context, obj *models.Issue) (*models.User, error) { - panic(fmt.Errorf("not implemented")) + if obj.OwnerID == "" { + return nil, nil + } + + // Shortcut: if only ID is needed, resolve with empty user. + if !graphutil.SelectsAnyField(ctx, "name", "active", "admin") { + return &models.User{ID: obj.OwnerID}, nil + } + + return loaders.UserLoaderFromContext(ctx).Load(obj.OwnerID) } func (r *issueResolver) Assignee(ctx context.Context, obj *models.Issue) (*models.User, error) { - panic(fmt.Errorf("not implemented")) + if obj.AssigneeID == "" { + return nil, nil + } + + // Shortcut: if only ID is needed, resolve with empty user. + if !graphutil.SelectsAnyField(ctx, "name", "active", "admin") { + return &models.User{ID: obj.AssigneeID}, nil + } + + return loaders.UserLoaderFromContext(ctx).Load(obj.AssigneeID) +} + +func (r *issueResolver) Status(ctx context.Context, obj *models.Issue) (*models.ProjectStatus, error) { + // Shortcut: if description isn't needed, resolve this with issue's properties. + if !graphutil.SelectsAnyField(ctx, "description") { + return &models.ProjectStatus{ + ProjectID: obj.ProjectID, + Stage: obj.StatusStage, + Name: obj.StatusName, + Description: "FAKE", + }, nil + } + + status, err := r.Database.ProjectStatuses().Find(ctx, obj.ProjectID, obj.StatusName) + if xlerrors.IsNotFound(err) { + return &models.ProjectStatus{ + ProjectID: obj.ProjectID, + Stage: obj.StatusStage, + Name: obj.StatusName, + Description: "(Deleted or unknown status)", + }, nil + } else if err != nil { + return nil, err + } + + // If the stage doesn't match, sneakily correct it for next time. + if status.Stage != obj.StatusStage { + updatedIssue := *obj + updatedIssue.StatusStage = status.Stage + _ = r.Database.Issues().Save(ctx, updatedIssue) + } + + return status, nil } // Issue returns graphcore.IssueResolver implementation. diff --git a/graph/resolvers/mutation.resolvers.go b/graph/resolvers/mutation.resolvers.go index 57fd69d..cbfa683 100644 --- a/graph/resolvers/mutation.resolvers.go +++ b/graph/resolvers/mutation.resolvers.go @@ -5,18 +5,104 @@ package resolvers import ( "context" - "fmt" - + "errors" "git.aiterp.net/stufflog/server/graph/graphcore" + "git.aiterp.net/stufflog/server/internal/xlerrors" "git.aiterp.net/stufflog/server/models" ) -func (r *mutationResolver) LoginUser(ctx context.Context, input *graphcore.UserLoginInput) (*models.User, error) { - panic(fmt.Errorf("not implemented")) +func (r *mutationResolver) CreateProject(ctx context.Context, input graphcore.ProjectCreateInput) (*models.Project, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, xlerrors.PermissionDenied + } + + project := &models.Project{ + ID: input.ID, + Name: input.Name, + Description: input.Description, + DailyPoints: input.DailyPoints, + } + if !project.ValidKey() { + return nil, errors.New("the project must have a valid ID (only uppercase allowed)") + } + if project.Name == "" || project.Description == "" { + return nil, errors.New("name and description cannot be left empty") + } + + project, err := r.Database.Projects().Insert(ctx, *project) + if err != nil { + return nil, err + } + + err = r.Database.Projects().SetPermission(ctx, models.ProjectPermission{ + ProjectID: project.ID, + UserID: user.ID, + Level: models.ProjectPermissionLevelOwner, + }) + + return project, nil +} + +func (r *mutationResolver) CreateIssue(ctx context.Context, input graphcore.IssueCreateInput) (*models.Issue, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, xlerrors.PermissionDenied + } + + project, err := r.Database.Projects().Find(ctx, input.ProjectID) + if err != nil { + return nil, err + } + if perm, err := r.Auth.ProjectPermission(ctx, *project); err != nil || !perm.CanManageOwnIssue() { + return nil, xlerrors.PermissionDenied + } + + issue := &models.Issue{ + ProjectID: project.ID, + OwnerID: user.ID, + AssigneeID: "", + StatusStage: input.StatusStage, + StatusName: input.StatusName, + Name: input.Name, + Title: input.Name, + Description: input.Description, + } + if input.Title != nil && *input.Title != "" { + issue.Title = *input.Title + } + if input.DueTime != nil && !input.DueTime.IsZero() { + issue.DueTime = *input.DueTime + } + if input.AssigneeID != nil && *input.AssigneeID != "" { + issue.AssigneeID = *input.AssigneeID + } + + issue, err = r.Database.Issues().Insert(ctx, *issue) + if err != nil { + return nil, err + } + + return issue, nil +} + +func (r *mutationResolver) LoginUser(ctx context.Context, input graphcore.UserLoginInput) (*models.User, error) { + return r.Auth.Login(ctx, input.Username, input.Password) } func (r *mutationResolver) LogoutUser(ctx context.Context) (*models.User, error) { - panic(fmt.Errorf("not implemented")) + return r.Auth.Logout(ctx) +} + +func (r *mutationResolver) CreateUser(ctx context.Context, input graphcore.UserCreateInput) (*models.User, error) { + active := input.Active == nil || *input.Active + admin := input.Admin != nil && *input.Admin + + return r.Auth.CreateUser(ctx, input.Username, input.Password, input.Name, active, admin) +} + +func (r *mutationResolver) EditUser(ctx context.Context, input graphcore.UserEditInput) (*models.User, error) { + return r.Auth.EditUser(ctx, input.Username, input.SetName, input.CurrentPassword, input.SetPassword) } // Mutation returns graphcore.MutationResolver implementation. diff --git a/graph/resolvers/project.resolvers.go b/graph/resolvers/project.resolvers.go index 40a3a30..b39eaf3 100644 --- a/graph/resolvers/project.resolvers.go +++ b/graph/resolvers/project.resolvers.go @@ -5,25 +5,60 @@ package resolvers import ( "context" - "fmt" "git.aiterp.net/stufflog/server/graph/graphcore" + "git.aiterp.net/stufflog/server/graph/loaders" + "git.aiterp.net/stufflog/server/internal/xlerrors" "git.aiterp.net/stufflog/server/models" ) func (r *projectResolver) Issues(ctx context.Context, obj *models.Project, filter *graphcore.ProjectIssueFilter) ([]*models.Issue, error) { - panic(fmt.Errorf("not implemented")) + if filter == nil { + filter = &graphcore.ProjectIssueFilter{} + } + + return r.Database.Issues().List(ctx, models.IssueFilter{ + ProjectIDs: []string{obj.ID}, + AssigneeIDs: filter.AssigneeIds, + Search: filter.Search, + MinStage: filter.MinStage, + MaxStage: filter.MaxStage, + Limit: filter.Limit, + }) } -func (r *projectResolver) Permissions(ctx context.Context, obj *models.Project) ([]*graphcore.ProjectPermissions, error) { - panic(fmt.Errorf("not implemented")) +func (r *projectResolver) Permissions(ctx context.Context, obj *models.Project) ([]*models.ProjectPermission, error) { + if perm, err := r.Auth.ProjectPermission(ctx, *obj); err != nil || !perm.CanManagePermissions() { + return nil, xlerrors.PermissionDenied + } + + return r.Database.Projects().ListPermissions(ctx, *obj) } -func (r *projectResolver) UserPermissions(ctx context.Context, obj *models.Project) (*graphcore.ProjectPermissions, error) { - panic(fmt.Errorf("not implemented")) +func (r *projectResolver) UserPermissions(ctx context.Context, obj *models.Project) (*models.ProjectPermission, error) { + return r.Auth.ProjectPermission(ctx, *obj) +} + +func (r *projectResolver) Statuses(ctx context.Context, obj *models.Project, filter *models.ProjectStatusFilter) ([]*models.ProjectStatus, error) { + if filter == nil { + filter = &models.ProjectStatusFilter{} + } + filter.ProjectID = &obj.ID + + return r.Database.ProjectStatuses().List(ctx, *filter) +} + +func (r *projectPermissionResolver) User(ctx context.Context, obj *models.ProjectPermission) (*models.User, error) { + return loaders.UserLoaderFromContext(ctx).Load(obj.UserID) } // Project returns graphcore.ProjectResolver implementation. func (r *Resolver) Project() graphcore.ProjectResolver { return &projectResolver{r} } +// ProjectPermission returns graphcore.ProjectPermissionResolver implementation. +func (r *Resolver) ProjectPermission() graphcore.ProjectPermissionResolver { + return &projectPermissionResolver{r} +} + type projectResolver struct{ *Resolver } +type projectPermissionResolver struct{ *Resolver } diff --git a/graph/resolvers/query.resolvers.go b/graph/resolvers/query.resolvers.go index b073878..087fe59 100644 --- a/graph/resolvers/query.resolvers.go +++ b/graph/resolvers/query.resolvers.go @@ -5,30 +5,122 @@ package resolvers import ( "context" - "fmt" + "errors" "git.aiterp.net/stufflog/server/graph/graphcore" "git.aiterp.net/stufflog/server/models" + "git.aiterp.net/stufflog/server/services" ) func (r *queryResolver) Issue(ctx context.Context, id string) (*models.Issue, error) { - panic(fmt.Errorf("not implemented")) + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, services.ErrPermissionDenied + } + + issue, err := r.Database.Issues().Find(ctx, id) + if err != nil { + return nil, err + } + _, err = r.Auth.IssuePermission(ctx, *issue) + if err != nil { + return nil, err + } + + return issue, nil } func (r *queryResolver) Issues(ctx context.Context, filter *models.IssueFilter) ([]*models.Issue, error) { - panic(fmt.Errorf("not implemented")) + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, services.ErrPermissionDenied + } + + if filter == nil { + filter = &models.IssueFilter{} + } + issues, err := r.Database.Issues().List(ctx, *filter) + if err != nil { + return nil, err + } + + deleteList := make([]int, 0, len(issues)) + for i, issue := range issues { + _, err := r.Auth.IssuePermission(ctx, *issue) + if err != nil { + deleteList = append(deleteList, i-len(deleteList)) + } + } + for _, index := range deleteList { + issues = append(issues[:index], issues[index+1:]...) + } + + return issues, nil } func (r *queryResolver) Project(ctx context.Context, id string) (*models.Project, error) { - panic(fmt.Errorf("not implemented")) + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, services.ErrPermissionDenied + } + + project, err := r.Database.Projects().Find(ctx, id) + if err != nil { + return nil, err + } + _, err = r.Auth.ProjectPermission(ctx, *project) + if err != nil { + return nil, err + } + + return project, nil } func (r *queryResolver) Projects(ctx context.Context, filter *models.ProjectFilter) ([]*models.Project, error) { - panic(fmt.Errorf("not implemented")) + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, services.ErrPermissionDenied + } + + skipCheck := false + if filter == nil { + filter = &models.ProjectFilter{} + } + if filter.Permission == nil { + skipCheck = true + filter.Permission = &models.ProjectFilterPermission{ + UserID: user.ID, + MinLevel: models.ProjectPermissionLevelObserver, + } + } + + projects, err := r.Database.Projects().List(ctx, *filter) + if err != nil { + return nil, err + } + + if !skipCheck && len(projects) > 0 { + deleteList := make([]int, 0, 4) + for i, project := range projects { + if _, err := r.Auth.ProjectPermission(ctx, *project); err != nil { + deleteList = append(deleteList, i-len(deleteList)) + } + } + for _, di := range deleteList { + projects = append(projects[:di], projects[di:]...) + } + } + + return projects, nil } func (r *queryResolver) Session(ctx context.Context) (*models.User, error) { - panic(fmt.Errorf("not implemented")) + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, errors.New("not logged in") + } + + return user, nil } // Query returns graphcore.QueryResolver implementation. diff --git a/graph/resolvers/resolver.go b/graph/resolvers/resolver.go index 1e87dbb..f231b48 100644 --- a/graph/resolvers/resolver.go +++ b/graph/resolvers/resolver.go @@ -1,11 +1,15 @@ package resolvers -import "git.aiterp.net/stufflog/server/services" +import ( + "git.aiterp.net/stufflog/server/database" + "git.aiterp.net/stufflog/server/services" +) // This file will not be regenerated automatically. // // It serves as dependency injection for your app, add any dependencies you require here. type Resolver struct { - S services.Bundle + services.Bundle + Database database.Database } diff --git a/graph/schema/issue.gql b/graph/schema/issue.gql index e035064..c67c196 100644 --- a/graph/schema/issue.gql +++ b/graph/schema/issue.gql @@ -1,10 +1,5 @@ type Issue { id: String! - projectId: String! - ownerId: String! - assigneeId: String! - statusStage: Int! - statusName: String! createdTime: Time! updatedTime: Time! dueTime: Time @@ -15,6 +10,7 @@ type Issue { project: Project owner: User assignee: User + status: ProjectStatus! } input IssueFilter { @@ -34,4 +30,23 @@ input IssueFilter { maxStage: Int "Limit the result set" limit: Int +} + +input IssueCreateInput { + "Project ID" + projectId: String! + "Status stage" + statusStage: Int! + "Status name" + statusName: String! + "A name for the issue." + name: String! + "Description of the issue." + description: String! + "Assign this issue, will default to unassigned" + assigneeId: String + "A date when this issue is due" + dueTime: Time + "Optional title to use instead of the name when not in a list." + title: String } \ No newline at end of file diff --git a/graph/schema/mutation.gql b/graph/schema/mutation.gql index f79733e..b8b2f5e 100644 --- a/graph/schema/mutation.gql +++ b/graph/schema/mutation.gql @@ -1,4 +1,19 @@ type Mutation { - loginUser(input: UserLoginInput): User! + # PROJECT + "Create a new project" + createProject(input: ProjectCreateInput!): Project! + + # ISSUE + "Create a new issue" + createIssue(input: IssueCreateInput!): Issue! + + # USER + "Log in" + loginUser(input: UserLoginInput!): User! + "Log out" logoutUser: User! + "Create a new user. This can only be done by administrators." + createUser(input: UserCreateInput!): User! + "Edit an existing user. This can only be done by administrators" + editUser(input: UserEditInput!): User! } \ No newline at end of file diff --git a/graph/schema/project.gql b/graph/schema/project.gql index db0169e..a9a093e 100644 --- a/graph/schema/project.gql +++ b/graph/schema/project.gql @@ -11,17 +11,17 @@ type Project { "Get issues within the project." issues(filter: ProjectIssueFilter): [Issue!]! "All users' permissions. Only available to administrators and up." - permissions: [ProjectPermissions!]! + permissions: [ProjectPermission!]! "Own permissions to the project. Available to any logged in user." - userPermissions: ProjectPermissions! + userPermissions: ProjectPermission! + "List all project statuses." + statuses(filter: ProjectStatusFilter): [ProjectStatus!]! } "The permissions of a user within the project." -type ProjectPermissions { - "User ID." - userId: String! +type ProjectPermission { "Access level." - accessLevel: Int! + level: Int! "The user whose permissions it is. Can be null if the user no longer exists." user: User @@ -67,4 +67,22 @@ input ProjectIssueFilter { maxStage: Int "Limit the result set" limit: Int +} + +input ProjectCreateInput { + "The ID of the project, which will be the prefix of all issue keys." + id: String! + "The name of the project, used in place of the ID in the UI." + name: String! + "Describe the project." + description: String! + "Bonus to points goal for any activity being done on a given day." + dailyPoints: Int! +} + +input ProjectStatusFilter { + "Minimum stage of the project status to list" + minStage: Int! + "Maximum stage of the project status to list" + maxStage: Int! } \ No newline at end of file diff --git a/graph/schema/user.gql b/graph/schema/user.gql index 2bb73de..fcf20f5 100644 --- a/graph/schema/user.gql +++ b/graph/schema/user.gql @@ -12,7 +12,23 @@ type User { "Input for loginUser." input UserLoginInput { - userId: String! + username: String! password: String! - rememberMe: Boolean! } + +input UserCreateInput { + username: String! + password: String! + name: String! + active: Boolean + admin: Boolean +} + +input UserEditInput { + username: String! + + setName: String + setPassword: String + + currentPassword: String +} \ No newline at end of file diff --git a/internal/xlerrors/permission.go b/internal/xlerrors/permission.go new file mode 100644 index 0000000..6d4a6a3 --- /dev/null +++ b/internal/xlerrors/permission.go @@ -0,0 +1,5 @@ +package xlerrors + +import "errors" + +var PermissionDenied = errors.New("permission denied") diff --git a/main.go b/main.go index 630c8fa..d4ca566 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,24 @@ package main import ( + "context" "git.aiterp.net/stufflog/server/database" + "git.aiterp.net/stufflog/server/graph" + "git.aiterp.net/stufflog/server/internal/generate" + "git.aiterp.net/stufflog/server/internal/xlerrors" + "git.aiterp.net/stufflog/server/models" + "git.aiterp.net/stufflog/server/services" + "github.com/99designs/gqlgen/graphql/playground" + "github.com/gin-gonic/gin" "github.com/pkg/errors" "github.com/pressly/goose" "github.com/urfave/cli/v2" "log" "os" + "os/signal" "sort" + "syscall" + "time" ) func main() { @@ -27,8 +38,66 @@ func main() { Usage: "Database connection string or path", EnvVars: []string{"DATABASE_CONNECT"}, }, + &cli.StringFlag{ + Name: "listen", + Value: ":8000", + Usage: "Address to bind the server to.", + EnvVars: []string{"SERVER_LISTEN"}, + }, }, Commands: []*cli.Command{ + { + Name: "reset-admin", + Usage: "Reset the admin user (or create it)", + Action: func(c *cli.Context) error { + db, err := database.Open(c.String("db-driver"), c.String("db-connect")) + if err != nil { + return errors.Wrap(err, "Failed to connect to database") + } + + timeout, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + newPass := generate.Generate(20, "") + + admin, err := db.Users().Find(timeout, "Admin") + if xlerrors.IsNotFound(err) { + admin = &models.User{ + ID: "Admin", + Name: "Administrator", + Active: true, + Admin: true, + } + + err = admin.SetPassword(newPass) + if err != nil { + return errors.Wrap(err, "Failed to set password") + } + + _, err = db.Users().Insert(timeout, *admin) + if err != nil { + return errors.Wrap(err, "Failed to inset user") + } + } else if err != nil { + return err + } else { + err = admin.SetPassword(newPass) + if err != nil { + return errors.Wrap(err, "Failed to set password") + } + + err = db.Users().Save(timeout, *admin) + if err != nil { + return errors.Wrap(err, "Failed to save user") + } + } + + log.Println("Username:", admin.ID) + log.Println("Password:", newPass) + + return nil + }, + }, { Name: "migrate", Usage: "Migrate the configured database", @@ -54,12 +123,40 @@ func main() { Name: "server", Usage: "Run the server", Action: func(c *cli.Context) error { - _, err := database.Open(c.String("db-driver"), c.String("db-connect")) + db, err := database.Open(c.String("db-driver"), c.String("db-connect")) if err != nil { return errors.Wrap(err, "Failed to connect to database") } - return nil + bundle := services.NewBundle(db) + + server := gin.New() + server.GET("/graphql", graph.Gin(bundle, db)) + server.POST("/graphql", graph.Gin(bundle, db)) + server.GET("/playground", gin.WrapH(playground.Handler("StuffLog GraphQL Playground", "/graphql"))) + + exitSignal := make(chan os.Signal) + signal.Notify(exitSignal, os.Interrupt, os.Kill, syscall.SIGTERM) + + errCh := make(chan error) + go func() { + err := server.Run(c.String("listen")) + if err != nil { + errCh <- err + } + }() + + select { + case signal := <-exitSignal: + { + log.Println("Received signal", signal) + return nil + } + case err := <-errCh: + { + return err + } + } }, }, }, diff --git a/migrations/mysql/20200406110301_create_table_item.sql b/migrations/mysql/20200406110301_create_table_item.sql index b9dc7c6..968561b 100644 --- a/migrations/mysql/20200406110301_create_table_item.sql +++ b/migrations/mysql/20200406110301_create_table_item.sql @@ -4,6 +4,7 @@ CREATE TABLE item ( item_id CHAR(32) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT NOT NULL, + quantity_unit VARCHAR(255), image_url VARCHAR(255), FULLTEXT(name, description) diff --git a/migrations/mysql/20200503195913_create_table_project_status.sql b/migrations/mysql/20200503195913_create_table_project_status.sql new file mode 100644 index 0000000..8e049e0 --- /dev/null +++ b/migrations/mysql/20200503195913_create_table_project_status.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE project_status ( + project_id CHAR(16) NOT NULL, + name VARCHAR(255) NOT NULL, + stage INT NOT NULL, + description VARCHAR(255) NOT NULL, + + PRIMARY KEY (project_id, name), + INDEX (project_id, stage, name) # for sorting +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE project_status; +-- +goose StatementEnd diff --git a/models/item.go b/models/item.go index a585f7a..e4e4e3e 100644 --- a/models/item.go +++ b/models/item.go @@ -1,11 +1,12 @@ package models type Item struct { - ID string `db:"item_id"` - Name string `db:"name"` - Description string `db:"description"` - Tags []string `db:"tags"` - ImageURL *string `db:"image_url"` + ID string `db:"item_id"` + Name string `db:"name"` + Description string `db:"description"` + Tags []string `db:"tags"` + QuantityUnit *string `db:"quantity_unit"` + ImageURL *string `db:"image_url"` } type ItemFilter struct { diff --git a/models/project.go b/models/project.go index 5c8b0eb..bf79164 100644 --- a/models/project.go +++ b/models/project.go @@ -1,5 +1,7 @@ package models +import "unicode" + // A Project is a vague category for issues. It's use depend on the user's judgement, as it could represent // an entire hobby/job or a single project within one. type Project struct { @@ -27,7 +29,7 @@ func (project *Project) ValidKey() bool { } for _, r := range project.ID { - if r < 'A' && r > 'Z' { + if !unicode.IsNumber(r) && !unicode.IsUpper(r) && !unicode.Is(unicode.Hiragana, r) && !unicode.Is(unicode.Katakana, r) && !unicode.Is(unicode.Han, r) { return false } } diff --git a/models/projectstatus.go b/models/projectstatus.go index 9da6305..dd6ed1d 100644 --- a/models/projectstatus.go +++ b/models/projectstatus.go @@ -3,7 +3,14 @@ package models // ProjectStatus is an entry denoting a status a project's issues can have. type ProjectStatus struct { ProjectID string `db:"project_id"` - Stage int `db:"status_stage"` - Name string `db:"status_name"` + Stage int `db:"stage"` + Name string `db:"name"` Description string `db:"description"` } + +// ProjectStatusFilter is a filter for listing ProjectStatus. +type ProjectStatusFilter struct { + ProjectID *string + MinStage *int + MaxStage *int +} diff --git a/services/auth.go b/services/auth.go index 03331c4..866c95a 100644 --- a/services/auth.go +++ b/services/auth.go @@ -5,6 +5,7 @@ import ( "errors" "git.aiterp.net/stufflog/server/database/repositories" "git.aiterp.net/stufflog/server/internal/generate" + "git.aiterp.net/stufflog/server/internal/xlerrors" "git.aiterp.net/stufflog/server/models" "github.com/gin-gonic/gin" "math/rand" @@ -14,7 +15,8 @@ import ( var ErrLoginFailed = errors.New("login failed") var ErrInternalLoginFailure = errors.New("login failed due to internal error") var ErrInternalPermissionFailure = errors.New("permission check failed due to internal error") -var ErrPermissionDenied = errors.New("permission denied") +var ErrWrongCurrentPassword = errors.New("current password is missing") +var ErrMissingCurrentPassword = errors.New("current password is missing") var authCookieName = "stufflog_cookie" var authCtxKey = "stufflog.auth" @@ -49,7 +51,7 @@ func (auth *Auth) Login(ctx context.Context, username, password string) (*models UserID: user.ID, ExpiryTime: time.Now().Add(time.Hour * 168), } - err = auth.session.Save(c.Request.Context(), *session) + err = auth.session.Save(ctx, session) if err != nil { return nil, ErrInternalLoginFailure } @@ -63,6 +65,47 @@ func (auth *Auth) Login(ctx context.Context, username, password string) (*models return user, nil } +func (auth *Auth) Logout(ctx context.Context) (*models.User, error) { + user := auth.UserFromContext(ctx) + if user == nil { + return nil, xlerrors.PermissionDenied + } + + c, ok := ctx.Value(ginCtxKey).(*gin.Context) + if !ok { + return nil, ErrInternalLoginFailure + } + + c.SetCookie(authCookieName, "", 0, "/", "", false, true) + + return user, nil +} + +func (auth *Auth) CreateUser(ctx context.Context, username, password, name string, active, admin bool) (*models.User, error) { + loggedInUser := auth.UserFromContext(ctx) + if loggedInUser == nil || !loggedInUser.Admin { + return nil, xlerrors.PermissionDenied + } + + user := &models.User{ + ID: username, + Name: name, + Active: active, + Admin: admin, + } + err := user.SetPassword(password) + if err != nil { + return nil, err + } + + user, err = auth.users.Insert(ctx, *user) + if err != nil { + return nil, err + } + + return user, nil +} + func (auth *Auth) UserFromContext(ctx context.Context) *models.User { user, _ := ctx.Value(authCtxKey).(*models.User) return user @@ -70,8 +113,8 @@ func (auth *Auth) UserFromContext(ctx context.Context) *models.User { func (auth *Auth) ProjectPermission(ctx context.Context, project models.Project) (*models.ProjectPermission, error) { user := auth.UserFromContext(ctx) - if user == nil { - return nil, ErrPermissionDenied + if user == nil || !user.Active { + return nil, xlerrors.PermissionDenied } permission, err := auth.projects.GetPermission(ctx, project, *user) @@ -79,7 +122,7 @@ func (auth *Auth) ProjectPermission(ctx context.Context, project models.Project) return nil, ErrInternalPermissionFailure } if permission.Level == models.ProjectPermissionLevelNoAccess { - return nil, ErrPermissionDenied + return nil, xlerrors.PermissionDenied } return permission, nil @@ -87,34 +130,38 @@ func (auth *Auth) ProjectPermission(ctx context.Context, project models.Project) func (auth *Auth) IssuePermission(ctx context.Context, issue models.Issue) (*models.ProjectPermission, error) { user := auth.UserFromContext(ctx) - if user == nil { - return nil, ErrPermissionDenied + if user == nil || !user.Active { + return nil, xlerrors.PermissionDenied } + isOwnedOrAssigned := issue.AssigneeID == user.ID || issue.OwnerID == user.ID + permission, err := auth.projects.GetIssuePermission(ctx, issue, *user) if err != nil { return nil, ErrInternalPermissionFailure } if permission.Level == models.ProjectPermissionLevelNoAccess { - return nil, ErrPermissionDenied + return nil, xlerrors.PermissionDenied + } + if !(permission.CanViewAnyIssue() || (permission.CanViewOwnIssue() && isOwnedOrAssigned)) { + return nil, xlerrors.PermissionDenied } return permission, nil } func (auth *Auth) CheckGinSession(c *gin.Context) { - ctx := context.WithValue(c.Request.Context(), ginCtxKey, authCtxKey) - defer func() { - c.Request = c.Request.WithContext(ctx) - }() + ctx := context.WithValue(c.Request.Context(), ginCtxKey, c) cookie, err := c.Cookie(authCookieName) if err != nil { + c.Request = c.Request.WithContext(ctx) return } session, err := auth.session.Find(c.Request.Context(), cookie) if err != nil { + c.Request = c.Request.WithContext(ctx) return } @@ -124,10 +171,57 @@ func (auth *Auth) CheckGinSession(c *gin.Context) { c.SetCookie(authCookieName, session.ID, 3600*168, "/", "", false, true) } - user, err := auth.users.Find(c.Request.Context(), cookie) + user, err := auth.users.Find(c.Request.Context(), session.UserID) if err != nil { + c.Request = c.Request.WithContext(ctx) return } ctx = context.WithValue(ctx, authCtxKey, user) + c.Request = c.Request.WithContext(ctx) +} + +func (auth *Auth) EditUser(ctx context.Context, username string, setName *string, currentPassword *string, newPassword *string) (*models.User, error) { + loggedInUser := auth.UserFromContext(ctx) + if loggedInUser == nil { + return nil, xlerrors.PermissionDenied + } + + user, err := auth.users.Find(ctx, username) + if err != nil { + return nil, err + } + + if user.ID != loggedInUser.ID && !loggedInUser.Admin { + return nil, xlerrors.PermissionDenied + } + + if newPassword != nil { + // Only require current password if it's given, or if the user is NOT an admin changing + // another user's password + if currentPassword != nil || !(loggedInUser.Admin && loggedInUser.ID != user.ID) { + if currentPassword == nil { + return nil, ErrMissingCurrentPassword + } + if !user.CheckPassword(*currentPassword) { + return nil, ErrWrongCurrentPassword + } + } + + err = user.SetPassword(*newPassword) + if err != nil { + return nil, err + } + } + + if setName != nil && *setName != "" { + user.Name = *setName + } + + err = auth.users.Save(ctx, *user) + if err != nil { + return nil, err + } + + return user, nil } diff --git a/services/bundle.go b/services/bundle.go index d0b8fa8..2174180 100644 --- a/services/bundle.go +++ b/services/bundle.go @@ -1,5 +1,19 @@ package services +import "git.aiterp.net/stufflog/server/database" + type Bundle struct { Auth *Auth } + +func NewBundle(db database.Database) Bundle { + auth := &Auth{ + users: db.Users(), + session: db.Session(), + projects: db.Projects(), + } + + return Bundle{ + Auth: auth, + } +}