Browse Source

Basic API works.

master
Gisle Aune 5 years ago
parent
commit
564ac0d24a
  1. 2
      .gitignore
  2. 2
      database/database.go
  3. 9
      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. 99
      main.go
  30. 1
      migrations/mysql/20200406110301_create_table_item.sql
  31. 17
      migrations/mysql/20200503195913_create_table_project_status.sql
  32. 1
      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
/graph/graphcore/*
/graph/graphcore/*_gen.go

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

9
database/drivers/mysqldriver/db.go

@ -19,6 +19,7 @@ type DB struct {
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,6 +80,8 @@ 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,
@ -82,6 +89,8 @@ func Open(connectionString string) (*DB, error) {
items: items,
projects: projects,
users: users,
sessions: sessions,
projectStatuses: projectStatuses,
}, nil
}

3
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")

10
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)

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

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/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
)

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.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=

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

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 (
"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.

96
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.

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

104
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.

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

25
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 {
@ -35,3 +31,22 @@ input IssueFilter {
"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
}

17
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!
}

30
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
@ -68,3 +68,21 @@ input ProjectIssueFilter {
"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!
}

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

5
internal/xlerrors/permission.go

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

99
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")
}
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,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
quantity_unit VARCHAR(255),
image_url VARCHAR(255),
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

1
models/item.go

@ -5,6 +5,7 @@ type Item struct {
Name string `db:"name"`
Description string `db:"description"`
Tags []string `db:"tags"`
QuantityUnit *string `db:"quantity_unit"`
ImageURL *string `db:"image_url"`
}

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

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

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

14
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,
}
}
Loading…
Cancel
Save