Browse Source

Basic API works.

master
Gisle Aune 4 years ago
parent
commit
564ac0d24a
  1. 2
      .gitignore
  2. 2
      database/database.go
  3. 31
      database/drivers/mysqldriver/db.go
  4. 3
      database/drivers/mysqldriver/db_test.go
  5. 10
      database/drivers/mysqldriver/projects.go
  6. 70
      database/drivers/mysqldriver/projectstatuses.go
  7. 165
      database/drivers/mysqldriver/projectstatuses_test.go
  8. 1
      database/repositories/projectrepository.go
  9. 13
      database/repositories/projectstatusrepository.go
  10. 1
      go.mod
  11. 1
      go.sum
  12. 19
      graph/graph.go
  13. 2
      graph/graphcore/package.go
  14. 19
      graph/graphutil/fields.go
  15. 2
      graph/loaders/package.go
  16. 224
      graph/loaders/projectpermissionloader_gen.go
  17. 57
      graph/loaders/userloader.go
  18. 215
      graph/loaders/userloader_gen.go
  19. 62
      graph/resolvers/issue.resolvers.go
  20. 96
      graph/resolvers/mutation.resolvers.go
  21. 47
      graph/resolvers/project.resolvers.go
  22. 104
      graph/resolvers/query.resolvers.go
  23. 8
      graph/resolvers/resolver.go
  24. 25
      graph/schema/issue.gql
  25. 17
      graph/schema/mutation.gql
  26. 30
      graph/schema/project.gql
  27. 20
      graph/schema/user.gql
  28. 5
      internal/xlerrors/permission.go
  29. 101
      main.go
  30. 1
      migrations/mysql/20200406110301_create_table_item.sql
  31. 17
      migrations/mysql/20200503195913_create_table_project_status.sql
  32. 11
      models/item.go
  33. 4
      models/project.go
  34. 11
      models/projectstatus.go
  35. 120
      services/auth.go
  36. 14
      services/bundle.go

2
.gitignore

@ -1,2 +1,2 @@
/.idea /.idea
/graph/graphcore/*
/graph/graphcore/*_gen.go

2
database/database.go

@ -14,7 +14,7 @@ type Database interface {
Projects() repositories.ProjectRepository Projects() repositories.ProjectRepository
Session() repositories.SessionRepository Session() repositories.SessionRepository
Users() repositories.UserRepository Users() repositories.UserRepository
ProjectStatuses() repositories.ProjectStatusRepository
// Migrate the database. // Migrate the database.
Migrate() error Migrate() error
} }

31
database/drivers/mysqldriver/db.go

@ -13,12 +13,13 @@ import (
) )
type DB struct { 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 { func (db *DB) Issues() repositories.IssueRepository {
@ -41,6 +42,10 @@ func (db *DB) Users() repositories.UserRepository {
return db.users return db.users
} }
func (db *DB) ProjectStatuses() repositories.ProjectStatusRepository {
return db.projectStatuses
}
func (db *DB) Migrate() error { func (db *DB) Migrate() error {
err := goose.SetDialect("mysql") err := goose.SetDialect("mysql")
if err != nil { if err != nil {
@ -75,13 +80,17 @@ func Open(connectionString string) (*DB, error) {
items := &itemRepository{db: db} items := &itemRepository{db: db}
projects := &projectRepository{db: db} projects := &projectRepository{db: db}
users := &userRepository{db: db} users := &userRepository{db: db}
sessions := &sessionRepository{db: db}
projectStatuses := &projectStatusRepository{db: db}
return &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 }, nil
} }

3
database/drivers/mysqldriver/db_test.go

@ -19,12 +19,13 @@ func TestMain(m *testing.M) {
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
db, err := Open(testDbConnect) db, err := Open(testDbConnect)
if err != nil { if err != nil {
log.Println("DB ERROR", err)
log.Println("DB ERROR", i, err)
time.Sleep(time.Second) time.Sleep(time.Second)
continue continue
} }
testDB = db testDB = db
break
} }
if testDB == nil { if testDB == nil {
log.Println("DB FAILED") log.Println("DB FAILED")

10
database/drivers/mysqldriver/projects.go

@ -89,6 +89,16 @@ func (r *projectRepository) Save(ctx context.Context, project models.Project) er
return err 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) { func (r *projectRepository) GetPermission(ctx context.Context, project models.Project, user models.User) (*models.ProjectPermission, error) {
permission := models.ProjectPermission{} permission := models.ProjectPermission{}
err := r.db.GetContext(ctx, &permission, "SELECT * FROM project_permission WHERE project_id=? AND user_id=?", project.ID, user.ID) err := r.db.GetContext(ctx, &permission, "SELECT * FROM project_permission WHERE project_id=? AND user_id=?", project.ID, user.ID)

70
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
}

165
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)
}

1
database/repositories/projectrepository.go

@ -10,6 +10,7 @@ type ProjectRepository interface {
List(ctx context.Context, filter models.ProjectFilter) ([]*models.Project, error) List(ctx context.Context, filter models.ProjectFilter) ([]*models.Project, error)
Insert(ctx context.Context, project models.Project) (*models.Project, error) Insert(ctx context.Context, project models.Project) (*models.Project, error)
Save(ctx context.Context, 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) 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) GetIssuePermission(ctx context.Context, issue models.Issue, user models.User) (*models.ProjectPermission, error)
SetPermission(ctx context.Context, permission models.ProjectPermission) error SetPermission(ctx context.Context, permission models.ProjectPermission) error

13
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
}

1
go.mod

@ -12,6 +12,7 @@ require (
github.com/pressly/goose v2.6.0+incompatible github.com/pressly/goose v2.6.0+incompatible
github.com/stretchr/testify v1.5.1 github.com/stretchr/testify v1.5.1
github.com/urfave/cli/v2 v2.2.0 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 github.com/vektah/gqlparser/v2 v2.0.1
golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8 golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8
) )

1
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.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4=
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= 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/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 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms= github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=

19
graph/graph.go

@ -1,7 +1,9 @@
package graph package graph
import ( import (
"git.aiterp.net/stufflog/server/database"
"git.aiterp.net/stufflog/server/graph/graphcore" "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/graph/resolvers"
"git.aiterp.net/stufflog/server/services" "git.aiterp.net/stufflog/server/services"
"github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql"
@ -12,18 +14,25 @@ import (
//go:generate go run github.com/99designs/gqlgen --verbose --config gqlgen.yml //go:generate go run github.com/99designs/gqlgen --verbose --config gqlgen.yml
// New creates a new GraphQL schema. // 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{ 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) gqlHandler := handler.NewDefaultServer(schema)
return func(c *gin.Context) { 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) gqlHandler.ServeHTTP(c.Writer, c.Request)
} }

2
graph/graphcore/package.go

@ -0,0 +1,2 @@
// Package graphcore contains the generated code.
package graphcore

19
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
}

2
graph/loaders/package.go

@ -0,0 +1,2 @@
// Package loaders is for the generated data loaders.
package loaders

224
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)
}

57
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
},
}
}

215
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)
}

62
graph/resolvers/issue.resolvers.go

@ -5,22 +5,74 @@ package resolvers
import ( import (
"context" "context"
"fmt"
"git.aiterp.net/stufflog/server/graph/graphcore" "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" "git.aiterp.net/stufflog/server/models"
) )
func (r *issueResolver) Project(ctx context.Context, obj *models.Issue) (*models.Project, error) { 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) { 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) { 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. // Issue returns graphcore.IssueResolver implementation.

96
graph/resolvers/mutation.resolvers.go

@ -5,18 +5,104 @@ package resolvers
import ( import (
"context" "context"
"fmt"
"errors"
"git.aiterp.net/stufflog/server/graph/graphcore" "git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models" "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) { 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. // Mutation returns graphcore.MutationResolver implementation.

47
graph/resolvers/project.resolvers.go

@ -5,25 +5,60 @@ package resolvers
import ( import (
"context" "context"
"fmt"
"git.aiterp.net/stufflog/server/graph/graphcore" "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" "git.aiterp.net/stufflog/server/models"
) )
func (r *projectResolver) Issues(ctx context.Context, obj *models.Project, filter *graphcore.ProjectIssueFilter) ([]*models.Issue, error) { 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. // Project returns graphcore.ProjectResolver implementation.
func (r *Resolver) Project() graphcore.ProjectResolver { return &projectResolver{r} } 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 projectResolver struct{ *Resolver }
type projectPermissionResolver struct{ *Resolver }

104
graph/resolvers/query.resolvers.go

@ -5,30 +5,122 @@ package resolvers
import ( import (
"context" "context"
"fmt"
"errors"
"git.aiterp.net/stufflog/server/graph/graphcore" "git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/models" "git.aiterp.net/stufflog/server/models"
"git.aiterp.net/stufflog/server/services"
) )
func (r *queryResolver) Issue(ctx context.Context, id string) (*models.Issue, error) { 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) { 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) { 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) { 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) { 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. // Query returns graphcore.QueryResolver implementation.

8
graph/resolvers/resolver.go

@ -1,11 +1,15 @@
package resolvers 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. // This file will not be regenerated automatically.
// //
// It serves as dependency injection for your app, add any dependencies you require here. // It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct { type Resolver struct {
S services.Bundle
services.Bundle
Database database.Database
} }

25
graph/schema/issue.gql

@ -1,10 +1,5 @@
type Issue { type Issue {
id: String! id: String!
projectId: String!
ownerId: String!
assigneeId: String!
statusStage: Int!
statusName: String!
createdTime: Time! createdTime: Time!
updatedTime: Time! updatedTime: Time!
dueTime: Time dueTime: Time
@ -15,6 +10,7 @@ type Issue {
project: Project project: Project
owner: User owner: User
assignee: User assignee: User
status: ProjectStatus!
} }
input IssueFilter { input IssueFilter {
@ -34,4 +30,23 @@ input IssueFilter {
maxStage: Int maxStage: Int
"Limit the result set" "Limit the result set"
limit: Int 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
} }

17
graph/schema/mutation.gql

@ -1,4 +1,19 @@
type Mutation { 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! 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!
} }

30
graph/schema/project.gql

@ -11,17 +11,17 @@ type Project {
"Get issues within the project." "Get issues within the project."
issues(filter: ProjectIssueFilter): [Issue!]! issues(filter: ProjectIssueFilter): [Issue!]!
"All users' permissions. Only available to administrators and up." "All users' permissions. Only available to administrators and up."
permissions: [ProjectPermissions!]!
permissions: [ProjectPermission!]!
"Own permissions to the project. Available to any logged in user." "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." "The permissions of a user within the project."
type ProjectPermissions {
"User ID."
userId: String!
type ProjectPermission {
"Access level." "Access level."
accessLevel: Int!
level: Int!
"The user whose permissions it is. Can be null if the user no longer exists." "The user whose permissions it is. Can be null if the user no longer exists."
user: User user: User
@ -67,4 +67,22 @@ input ProjectIssueFilter {
maxStage: Int maxStage: Int
"Limit the result set" "Limit the result set"
limit: Int 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!
} }

20
graph/schema/user.gql

@ -12,7 +12,23 @@ type User {
"Input for loginUser." "Input for loginUser."
input UserLoginInput { input UserLoginInput {
userId: String!
username: String!
password: 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
}

5
internal/xlerrors/permission.go

@ -0,0 +1,5 @@
package xlerrors
import "errors"
var PermissionDenied = errors.New("permission denied")

101
main.go

@ -1,13 +1,24 @@
package main package main
import ( import (
"context"
"git.aiterp.net/stufflog/server/database" "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/pkg/errors"
"github.com/pressly/goose" "github.com/pressly/goose"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"log" "log"
"os" "os"
"os/signal"
"sort" "sort"
"syscall"
"time"
) )
func main() { func main() {
@ -27,8 +38,66 @@ func main() {
Usage: "Database connection string or path", Usage: "Database connection string or path",
EnvVars: []string{"DATABASE_CONNECT"}, EnvVars: []string{"DATABASE_CONNECT"},
}, },
&cli.StringFlag{
Name: "listen",
Value: ":8000",
Usage: "Address to bind the server to.",
EnvVars: []string{"SERVER_LISTEN"},
},
}, },
Commands: []*cli.Command{ 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", Name: "migrate",
Usage: "Migrate the configured database", Usage: "Migrate the configured database",
@ -54,12 +123,40 @@ func main() {
Name: "server", Name: "server",
Usage: "Run the server", Usage: "Run the server",
Action: func(c *cli.Context) error { 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 { if err != nil {
return errors.Wrap(err, "Failed to connect to database") 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
}
}
}, },
}, },
}, },

1
migrations/mysql/20200406110301_create_table_item.sql

@ -4,6 +4,7 @@ CREATE TABLE item (
item_id CHAR(32) NOT NULL PRIMARY KEY, item_id CHAR(32) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
quantity_unit VARCHAR(255),
image_url VARCHAR(255), image_url VARCHAR(255),
FULLTEXT(name, description) FULLTEXT(name, description)

17
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

11
models/item.go

@ -1,11 +1,12 @@
package models package models
type Item struct { 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 { type ItemFilter struct {

4
models/project.go

@ -1,5 +1,7 @@
package models 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 // 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. // an entire hobby/job or a single project within one.
type Project struct { type Project struct {
@ -27,7 +29,7 @@ func (project *Project) ValidKey() bool {
} }
for _, r := range project.ID { 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 return false
} }
} }

11
models/projectstatus.go

@ -3,7 +3,14 @@ package models
// ProjectStatus is an entry denoting a status a project's issues can have. // ProjectStatus is an entry denoting a status a project's issues can have.
type ProjectStatus struct { type ProjectStatus struct {
ProjectID string `db:"project_id"` 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"` Description string `db:"description"`
} }
// ProjectStatusFilter is a filter for listing ProjectStatus.
type ProjectStatusFilter struct {
ProjectID *string
MinStage *int
MaxStage *int
}

120
services/auth.go

@ -5,6 +5,7 @@ import (
"errors" "errors"
"git.aiterp.net/stufflog/server/database/repositories" "git.aiterp.net/stufflog/server/database/repositories"
"git.aiterp.net/stufflog/server/internal/generate" "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/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"math/rand" "math/rand"
@ -14,7 +15,8 @@ import (
var ErrLoginFailed = errors.New("login failed") var ErrLoginFailed = errors.New("login failed")
var ErrInternalLoginFailure = errors.New("login failed due to internal error") var ErrInternalLoginFailure = errors.New("login failed due to internal error")
var ErrInternalPermissionFailure = errors.New("permission check 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 authCookieName = "stufflog_cookie"
var authCtxKey = "stufflog.auth" var authCtxKey = "stufflog.auth"
@ -49,7 +51,7 @@ func (auth *Auth) Login(ctx context.Context, username, password string) (*models
UserID: user.ID, UserID: user.ID,
ExpiryTime: time.Now().Add(time.Hour * 168), ExpiryTime: time.Now().Add(time.Hour * 168),
} }
err = auth.session.Save(c.Request.Context(), *session)
err = auth.session.Save(ctx, session)
if err != nil { if err != nil {
return nil, ErrInternalLoginFailure return nil, ErrInternalLoginFailure
} }
@ -63,6 +65,47 @@ func (auth *Auth) Login(ctx context.Context, username, password string) (*models
return user, nil 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 { func (auth *Auth) UserFromContext(ctx context.Context) *models.User {
user, _ := ctx.Value(authCtxKey).(*models.User) user, _ := ctx.Value(authCtxKey).(*models.User)
return 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) { func (auth *Auth) ProjectPermission(ctx context.Context, project models.Project) (*models.ProjectPermission, error) {
user := auth.UserFromContext(ctx) 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) 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 return nil, ErrInternalPermissionFailure
} }
if permission.Level == models.ProjectPermissionLevelNoAccess { if permission.Level == models.ProjectPermissionLevelNoAccess {
return nil, ErrPermissionDenied
return nil, xlerrors.PermissionDenied
} }
return permission, nil 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) { func (auth *Auth) IssuePermission(ctx context.Context, issue models.Issue) (*models.ProjectPermission, error) {
user := auth.UserFromContext(ctx) 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) permission, err := auth.projects.GetIssuePermission(ctx, issue, *user)
if err != nil { if err != nil {
return nil, ErrInternalPermissionFailure return nil, ErrInternalPermissionFailure
} }
if permission.Level == models.ProjectPermissionLevelNoAccess { 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 return permission, nil
} }
func (auth *Auth) CheckGinSession(c *gin.Context) { 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) cookie, err := c.Cookie(authCookieName)
if err != nil { if err != nil {
c.Request = c.Request.WithContext(ctx)
return return
} }
session, err := auth.session.Find(c.Request.Context(), cookie) session, err := auth.session.Find(c.Request.Context(), cookie)
if err != nil { if err != nil {
c.Request = c.Request.WithContext(ctx)
return return
} }
@ -124,10 +171,57 @@ func (auth *Auth) CheckGinSession(c *gin.Context) {
c.SetCookie(authCookieName, session.ID, 3600*168, "/", "", false, true) 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 { if err != nil {
c.Request = c.Request.WithContext(ctx)
return return
} }
ctx = context.WithValue(ctx, authCtxKey, user) 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
} }

14
services/bundle.go

@ -1,5 +1,19 @@
package services package services
import "git.aiterp.net/stufflog/server/database"
type Bundle struct { type Bundle struct {
Auth *Auth Auth *Auth
} }
func NewBundle(db database.Database) Bundle {
auth := &Auth{
users: db.Users(),
session: db.Session(),
projects: db.Projects(),
}
return Bundle{
Auth: auth,
}
}
Loading…
Cancel
Save