commit 8fc0220e515be012df1e96cd504a6a4659f32532 Author: Gisle Aune Date: Tue Jun 27 09:48:33 2023 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..706fd07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +.vscode diff --git a/auth/provider.go b/auth/provider.go new file mode 100644 index 0000000..853d282 --- /dev/null +++ b/auth/provider.go @@ -0,0 +1,12 @@ +package auth + +import ( + "context" +) + +type Provider interface { + ListUsers(ctx context.Context) ([]UserInfo, error) + LoginUser(ctx context.Context, username, password string) (*Result, error) + SetupUser(ctx context.Context, session, username, preferredUsername, newPassword string) (*UserInfo, error) + ValidateToken(ctx context.Context, token string) *UserInfo +} diff --git a/auth/service.go b/auth/service.go new file mode 100644 index 0000000..43b85d8 --- /dev/null +++ b/auth/service.go @@ -0,0 +1,64 @@ +package auth + +import ( + "context" + "sync" + "time" +) + +type Service struct { + Provider Provider + + userListMutex sync.Mutex + userList []UserInfo + userMap map[string]string + userListTime time.Time + + key struct{ Stuff uint64 } + key2 struct{ Stuff2 string } +} + +func (s *Service) ValidateUser(ctx context.Context, token string) *UserInfo { + return s.Provider.ValidateToken(ctx, token) +} + +func (s *Service) AddContextUser(ctx context.Context, user UserInfo) context.Context { + return context.WithValue(ctx, &s.key, &user) +} + +func (s *Service) Users(ctx context.Context) ([]UserInfo, map[string]string, error) { + s.userListMutex.Lock() + if time.Since(s.userListTime) < time.Minute { + m := s.userMap + l := s.userList + s.userListMutex.Unlock() + return l, m, nil + } + s.userListMutex.Unlock() + + users, err := s.Provider.ListUsers(ctx) + if err != nil { + return nil, nil, err + } + + s.userListMutex.Lock() + s.userList = users + s.userMap = make(map[string]string, len(users)) + for _, user := range users { + s.userMap[user.ID] = user.Name + } + m := s.userMap + s.userListTime = time.Now() + s.userListMutex.Unlock() + + return users, m, nil +} + +func (s *Service) GetUser(ctx context.Context) *UserInfo { + v := ctx.Value(&s.key) + if v == nil { + return nil + } + + return v.(*UserInfo) +} diff --git a/auth/user.go b/auth/user.go new file mode 100644 index 0000000..11114bd --- /dev/null +++ b/auth/user.go @@ -0,0 +1,61 @@ +package auth + +import ( + "fmt" +) + +type User struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type UserInfo struct { + User + Permissions []string `json:"permissions"` +} + +type Result struct { + User *UserInfo `json:"user"` + Token string `json:"token,omitempty"` + Session string `json:"session,omitempty"` + PasswordChangeRequired bool `json:"passwordChangeRequired"` +} + +func (user *UserInfo) HasIDOrPermission(userID, subject, action string) bool { + return user.HasID(userID) || user.HasPermission(subject, action) +} + +func (user *UserInfo) HasID(id string) bool { + return user != nil && user.ID == id +} + +func (user *UserInfo) HasOpPermission(userID, subject, action string) bool { + if !user.HasID(userID) && !user.HasPermission(subject, "admin") { + return false + } + + return user.HasPermission(subject, action) +} + +func (user *UserInfo) HasPermission(subject, action string) bool { + if user == nil { + return false + } + + anyAll := "*.*" + anyAction := fmt.Sprintf("%s.*", subject) + anySubject := fmt.Sprintf("*.%s", action) + specific := fmt.Sprintf("%s.%s", subject, action) + + if action == "admin" { + anyAction = specific + } + + for _, perm := range user.Permissions { + if perm == anyAll || perm == anyAction || perm == anySubject || perm == specific { + return true + } + } + + return false +} diff --git a/character/character.go b/character/character.go new file mode 100644 index 0000000..c1eed78 --- /dev/null +++ b/character/character.go @@ -0,0 +1,37 @@ +package character + +import ( + "git.aiterp.net/rpdata2-take2/rpdata2/auth" + "git.aiterp.net/rpdata2-take2/rpdata2/tag" +) + +type Character struct { + ID string `json:"id"` + Name string `json:"name"` + ShortName string `json:"shortName"` + DisplayName string `json:"displayName,omitempty"` + Description string `json:"description"` + FavoriteColor string `json:"favoriteColor"` + WikiPage string `json:"wikiPage"` + + Author auth.User `json:"author"` + Attributes []Attribute `json:"attributes"` + Relations []Relation `json:"relations"` + Tags []Tag `json:"tags"` +} + +type Attribute struct { + Label string `json:"label,omitempty"` + Value string `json:"value,omitempty"` +} + +type Relation struct { + Label string `json:"label,omitempty"` + Character +} + +type Tag struct { + Label string `json:"label,omitempty"` + Restricted bool `json:"restricted,omitempty"` + tag.Tag +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..c28bbba --- /dev/null +++ b/error.go @@ -0,0 +1,9 @@ +package rpdata2 + +import "fmt" + +type NotFound string + +func (e NotFound) Error() string { + return fmt.Sprintf("%s not found", string(e)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ab94422 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.aiterp.net/rpdata2-take2/rpdata2 + +go 1.19 + +require ( + github.com/Masterminds/squirrel v1.5.3 + github.com/google/uuid v1.3.0 + github.com/stretchr/testify v1.8.2 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d04e740 --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/Masterminds/squirrel v1.5.3 h1:YPpoceAcxuzIljlr5iWpNKaql7hLeG1KLSrhvdHpkZc= +github.com/Masterminds/squirrel v1.5.3/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/generate/id.go b/internal/generate/id.go new file mode 100644 index 0000000..1a6bb69 --- /dev/null +++ b/internal/generate/id.go @@ -0,0 +1,86 @@ +package generate + +import ( + "crypto/rand" + "encoding/binary" + mathRand "math/rand" + "strconv" + "strings" +) + +// ID generates an ID using crypto-random, falling back to math random when that fails +// to avoid disrupting operation because of a faulty RNG. +func ID(prefix string, length int) string { + var data [32]byte + + result := strings.Builder{} + result.Grow(length + 32) + result.WriteString(prefix) + + pos := 0 + for result.Len() < length { + if pos == 0 { + randRead(data[:]) + } + + result.WriteString(strconv.FormatUint(binary.BigEndian.Uint64(data[pos:pos+8]), 36)) + + pos = (pos + 8) % 32 + } + + return result.String()[:length] +} + +func randRead(data []byte) { + n, err := rand.Read(data) + if err != nil { + mathRand.Read(data[n:]) + } +} + +// InternalErrorID generates a long string +func InternalErrorID() string { + return ID("ISE", 32) +} + +// TagID generates a location ID. len=8 +func TagID(longName string) string { + return friendlyID('T', longName, 8) +} + +// CharacterID generates a character ID. len=8 +func CharacterID(longName string) string { + return friendlyID('C', longName, 8) +} + +func friendlyID(prefix byte, name string, length int) string { + b := strings.Builder{} + b.Grow(4) + b.WriteByte(prefix) + for _, ch := range strings.ToLower(name) { + if ch >= 'a' && ch <= 'z' { + b.WriteRune(ch) + + if b.Len() > 3 { + break + } + } + } + + return ID(b.String(), length) +} + +// StoryID generates a story ID: len=8 +func StoryID() string { + return ID("S", 12) +} + +// PostID generates a post ID. len=12 +func PostID() string { + return ID("P", 16) +} + +// AnnotationID generates an annotation.sql ID. len=12 +func AnnotationID() string { + return ID("A", 16) +} diff --git a/internal/genutils/array.go b/internal/genutils/array.go new file mode 100644 index 0000000..def28d5 --- /dev/null +++ b/internal/genutils/array.go @@ -0,0 +1,29 @@ +package genutils + +func UpsertIntoArray[T comparable](arr []T, values ...T) []T { +outer: + for _, value := range values { + for _, value2 := range arr { + if value2 == value { + continue outer + } + } + + arr = append(arr, value) + } + + return arr +} + +func RemoveFromArray[T comparable](arr []T, values ...T) []T { + for _, value := range values { + for i, value2 := range arr { + if value2 == value { + arr = append(arr[:i], arr[i+1:]...) + break + } + } + } + + return arr +} diff --git a/internal/genutils/ptr.go b/internal/genutils/ptr.go new file mode 100644 index 0000000..fccc8d2 --- /dev/null +++ b/internal/genutils/ptr.go @@ -0,0 +1,5 @@ +package genutils + +func Ptr[T any](t T) *T { + return &t +} diff --git a/internal/genutils/set.go b/internal/genutils/set.go new file mode 100644 index 0000000..53da051 --- /dev/null +++ b/internal/genutils/set.go @@ -0,0 +1,29 @@ +package genutils + +type Set[T comparable] struct { + m map[T]bool + a []T +} + +func (set *Set[T]) Add(values ...T) { + if set.m == nil { + set.m = make(map[T]bool, len(values)*4) + } + + for _, value := range values { + if set.m[value] { + continue + } + + set.m[value] = true + set.a = append(set.a, value) + } +} + +func (set *Set[T]) Len() int { + return len(set.a) +} + +func (set *Set[T]) Values() []T { + return set.a[:len(set.a):len(set.a)] +} diff --git a/internal/genutils/sort.go b/internal/genutils/sort.go new file mode 100644 index 0000000..6b56f49 --- /dev/null +++ b/internal/genutils/sort.go @@ -0,0 +1,13 @@ +package genutils + +import "sort" + +type Lesser[T any] interface { + Less(T) bool +} + +func SortSlice[T Lesser[T]](slice []T) { + sort.Slice(slice, func(i, j int) bool { + return slice[i].Less(slice[j]) + }) +} diff --git a/internal/genutils/update.go b/internal/genutils/update.go new file mode 100644 index 0000000..fa29170 --- /dev/null +++ b/internal/genutils/update.go @@ -0,0 +1,70 @@ +package genutils + +type hasValid interface { + Valid() bool +} + +func ApplyUpdate[T any](dst *T, value *T) { + if value != nil { + *dst = *value + } +} + +func ApplyUpdateValid[T hasValid](dst *T, value *T) { + if value != nil && (*value).Valid() { + *dst = *value + } +} + +func ApplyUpdateNonZero[T comparable](dst *T, value *T) { + var zero T + if value != nil && *value != zero { + *dst = *value + } +} + +func ApplyUpdateNilZero[T comparable](dst **T, value *T) { + if value != nil { + var zero T + + if *value == zero { + *dst = nil + } else { + valueCopy := *value + *dst = &valueCopy + } + } +} + +func ApplyMapUpdate[K comparable, V any](dst *map[K]V, src map[K]*V) { + if *dst == nil { + dst = &map[K]V{} + } + for key, value := range src { + if value != nil { + (*dst)[key] = *value + } else { + delete(*dst, key) + } + } +} + +func ApplyUpdateMapNilZero[K comparable, V comparable](dst *map[K]V, src map[K]V) { + var zero V + if *dst == nil { + dst = &map[K]V{} + } + for key, value := range src { + if value != zero { + (*dst)[key] = value + } else { + delete(*dst, key) + } + } +} + +func ApplyArrayUpdate[T comparable](arr *[]T, upsert []T, remove []T) { + *arr = make([]T, 0, len(*arr)+len(upsert)) + *arr = UpsertIntoArray(*arr, upsert...) + *arr = RemoveFromArray(*arr, remove...) +} diff --git a/ports/mysql/database.go b/ports/mysql/database.go new file mode 100644 index 0000000..adbb966 --- /dev/null +++ b/ports/mysql/database.go @@ -0,0 +1,47 @@ +package mysql + +import ( + "context" + "database/sql" + "fmt" + "git.aiterp.net/rpdata2/rpdata2-take2/ports/mysql/mysqlgen" + "git.aiterp.net/rpdata2/rpdata2-take2/tag" + "time" +) + +type Database struct { + db *sql.DB + q *mysqlgen.Queries +} + +func (db *Database) Tags() tag.Repository { + return &tagRepository{ + db: db.db, + q: db.q, + } +} + +func Connect(host string, port int, username, password, database string) (*Database, error) { + db, err := sql.Open("mysql", fmt.Sprintf( + "%s:%s@(%s:%d)/%s?parseTime=true", username, password, host, port, database, + )) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(10) + db.SetConnMaxIdleTime(time.Minute) + + err = db.Ping() + if err != nil { + return nil, err + } + + q, err := mysqlgen.Prepare(context.Background(), db) + if err != nil { + return nil, err + } + + return &Database{db: db, q: q}, nil +} diff --git a/ports/mysql/migrations/20230312124831_create_table_tag.sql b/ports/mysql/migrations/20230312124831_create_table_tag.sql new file mode 100644 index 0000000..ca001a9 --- /dev/null +++ b/ports/mysql/migrations/20230312124831_create_table_tag.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE tag +( + `id` CHAR(8) NOT NULL PRIMARY KEY, + `parent_id` CHAR(8) NULL, + `owner_id` VARCHAR(255) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `kind` INT NOT NULL, + `description` TEXT NOT NULL, + `listed` BOOL NOT NULL, + `secret` BOOL NOT NULL +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE tag; +-- +goose StatementEnd diff --git a/ports/mysql/mysqlgen/db.go b/ports/mysql/mysqlgen/db.go new file mode 100644 index 0000000..590ab75 --- /dev/null +++ b/ports/mysql/mysqlgen/db.go @@ -0,0 +1,128 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.13.0 + +package mysqlgen + +import ( + "context" + "database/sql" + "fmt" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +func Prepare(ctx context.Context, db DBTX) (*Queries, error) { + q := Queries{db: db} + var err error + if q.deleteTagStmt, err = db.PrepareContext(ctx, deleteTag); err != nil { + return nil, fmt.Errorf("error preparing query DeleteTag: %w", err) + } + if q.findTagStmt, err = db.PrepareContext(ctx, findTag); err != nil { + return nil, fmt.Errorf("error preparing query FindTag: %w", err) + } + if q.listListedTagsStmt, err = db.PrepareContext(ctx, listListedTags); err != nil { + return nil, fmt.Errorf("error preparing query ListListedTags: %w", err) + } + if q.listTagsStmt, err = db.PrepareContext(ctx, listTags); err != nil { + return nil, fmt.Errorf("error preparing query ListTags: %w", err) + } + if q.replaceTagStmt, err = db.PrepareContext(ctx, replaceTag); err != nil { + return nil, fmt.Errorf("error preparing query ReplaceTag: %w", err) + } + return &q, nil +} + +func (q *Queries) Close() error { + var err error + if q.deleteTagStmt != nil { + if cerr := q.deleteTagStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteTagStmt: %w", cerr) + } + } + if q.findTagStmt != nil { + if cerr := q.findTagStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing findTagStmt: %w", cerr) + } + } + if q.listListedTagsStmt != nil { + if cerr := q.listListedTagsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listListedTagsStmt: %w", cerr) + } + } + if q.listTagsStmt != nil { + if cerr := q.listTagsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTagsStmt: %w", cerr) + } + } + if q.replaceTagStmt != nil { + if cerr := q.replaceTagStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing replaceTagStmt: %w", cerr) + } + } + return err +} + +func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) + case stmt != nil: + return stmt.ExecContext(ctx, args...) + default: + return q.db.ExecContext(ctx, query, args...) + } +} + +func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) + case stmt != nil: + return stmt.QueryContext(ctx, args...) + default: + return q.db.QueryContext(ctx, query, args...) + } +} + +func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) + case stmt != nil: + return stmt.QueryRowContext(ctx, args...) + default: + return q.db.QueryRowContext(ctx, query, args...) + } +} + +type Queries struct { + db DBTX + tx *sql.Tx + deleteTagStmt *sql.Stmt + findTagStmt *sql.Stmt + listListedTagsStmt *sql.Stmt + listTagsStmt *sql.Stmt + replaceTagStmt *sql.Stmt +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + tx: tx, + deleteTagStmt: q.deleteTagStmt, + findTagStmt: q.findTagStmt, + listListedTagsStmt: q.listListedTagsStmt, + listTagsStmt: q.listTagsStmt, + replaceTagStmt: q.replaceTagStmt, + } +} diff --git a/ports/mysql/mysqlgen/models.go b/ports/mysql/mysqlgen/models.go new file mode 100644 index 0000000..a0d2553 --- /dev/null +++ b/ports/mysql/mysqlgen/models.go @@ -0,0 +1,20 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.13.0 + +package mysqlgen + +import ( + "database/sql" +) + +type Tag struct { + ID string + ParentID sql.NullString + OwnerID string + Name string + Kind int + Description string + Listed bool + Secret bool +} diff --git a/ports/mysql/mysqlgen/tag.sql.go b/ports/mysql/mysqlgen/tag.sql.go new file mode 100644 index 0000000..d3bfbb1 --- /dev/null +++ b/ports/mysql/mysqlgen/tag.sql.go @@ -0,0 +1,147 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.13.0 +// source: tag.sql + +package mysqlgen + +import ( + "context" + "database/sql" +) + +const deleteTag = `-- name: DeleteTag :exec +DELETE FROM tag WHERE id = ? +` + +func (q *Queries) DeleteTag(ctx context.Context, id string) error { + _, err := q.exec(ctx, q.deleteTagStmt, deleteTag, id) + return err +} + +const findTag = `-- name: FindTag :one +SELECT id, parent_id, owner_id, name, kind, description, listed, secret +FROM tag +WHERE id = ? +` + +func (q *Queries) FindTag(ctx context.Context, id string) (Tag, error) { + row := q.queryRow(ctx, q.findTagStmt, findTag, id) + var i Tag + err := row.Scan( + &i.ID, + &i.ParentID, + &i.OwnerID, + &i.Name, + &i.Kind, + &i.Description, + &i.Listed, + &i.Secret, + ) + return i, err +} + +const listListedTags = `-- name: ListListedTags :many +SELECT id, parent_id, owner_id, name, kind, description, listed, secret +FROM tag +WHERE listed = 1 +` + +func (q *Queries) ListListedTags(ctx context.Context) ([]Tag, error) { + rows, err := q.query(ctx, q.listListedTagsStmt, listListedTags) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Tag{} + for rows.Next() { + var i Tag + if err := rows.Scan( + &i.ID, + &i.ParentID, + &i.OwnerID, + &i.Name, + &i.Kind, + &i.Description, + &i.Listed, + &i.Secret, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTags = `-- name: ListTags :many +SELECT id, parent_id, owner_id, name, kind, description, listed, secret +FROM tag +` + +func (q *Queries) ListTags(ctx context.Context) ([]Tag, error) { + rows, err := q.query(ctx, q.listTagsStmt, listTags) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Tag{} + for rows.Next() { + var i Tag + if err := rows.Scan( + &i.ID, + &i.ParentID, + &i.OwnerID, + &i.Name, + &i.Kind, + &i.Description, + &i.Listed, + &i.Secret, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const replaceTag = `-- name: ReplaceTag :exec +REPLACE INTO tag (id, parent_id, owner_id, name, kind, description, listed, secret) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +` + +type ReplaceTagParams struct { + ID string + ParentID sql.NullString + OwnerID string + Name string + Kind int + Description string + Listed bool + Secret bool +} + +func (q *Queries) ReplaceTag(ctx context.Context, arg ReplaceTagParams) error { + _, err := q.exec(ctx, q.replaceTagStmt, replaceTag, + arg.ID, + arg.ParentID, + arg.OwnerID, + arg.Name, + arg.Kind, + arg.Description, + arg.Listed, + arg.Secret, + ) + return err +} diff --git a/ports/mysql/null.go b/ports/mysql/null.go new file mode 100644 index 0000000..cb7c8ed --- /dev/null +++ b/ports/mysql/null.go @@ -0,0 +1,22 @@ +package mysql + +import ( + "database/sql" + "git.aiterp.net/rpdata2-take2/rpdata2/internal/genutils" +) + +func toNullString(s *string) sql.NullString { + if s != nil { + return sql.NullString{Valid: true, String: *s} + } else { + return sql.NullString{Valid: false} + } +} + +func fromNullString(ns sql.NullString) *string { + if ns.Valid { + return genutils.Ptr(ns.String) + } else { + return nil + } +} diff --git a/ports/mysql/queries/tag.sql b/ports/mysql/queries/tag.sql new file mode 100644 index 0000000..a9b08e2 --- /dev/null +++ b/ports/mysql/queries/tag.sql @@ -0,0 +1,20 @@ +-- name: FindTag :one +SELECT * +FROM tag +WHERE id = ?; + +-- name: ListTags :many +SELECT * +FROM tag; + +-- name: ListListedTags :many +SELECT * +FROM tag +WHERE listed = 1; + +-- name: ReplaceTag :exec +REPLACE INTO tag (id, parent_id, owner_id, name, kind, description, listed, secret) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); + +-- name: DeleteTag :exec +DELETE FROM tag WHERE id = ?; \ No newline at end of file diff --git a/ports/mysql/sqlc.yaml b/ports/mysql/sqlc.yaml new file mode 100644 index 0000000..fe94fe1 --- /dev/null +++ b/ports/mysql/sqlc.yaml @@ -0,0 +1,20 @@ +version: "1" +packages: + - name: "mysqlgen" + path: "./mysqlgen" + queries: "./queries" + schema: "./migrations" + engine: "mysql" + emit_prepared_queries: true + emit_interface: false + emit_exact_table_names: false + emit_empty_slices: true + emit_json_tags: false +overrides: + - go_type: "float64" + db_type: "float" + - go_type: "float64" + db_type: "float" + nullable: true + - go_type: "int" + db_type: "int" \ No newline at end of file diff --git a/ports/mysql/tags.go b/ports/mysql/tags.go new file mode 100644 index 0000000..f0b4958 --- /dev/null +++ b/ports/mysql/tags.go @@ -0,0 +1,125 @@ +package mysql + +import ( + "context" + "database/sql" + "git.aiterp.net/rpdata2-take2/rpdata2" + "git.aiterp.net/rpdata2-take2/rpdata2/ports/mysql/mysqlgen" + "git.aiterp.net/rpdata2-take2/rpdata2/tag" + "github.com/Masterminds/squirrel" +) + +type tagRepository struct { + db *sql.DB + q *mysqlgen.Queries +} + +func (r *tagRepository) FindOne(ctx context.Context, id string) (*tag.Tag, error) { + row, err := r.q.FindTag(ctx, id) + if err != sql.ErrNoRows { + return nil, rpdata2.NotFound("Tag") + } else if err != nil { + return nil, err + } + + return &tag.Tag{ + ID: row.ID, + ParentID: fromNullString(row.ParentID), + OwnerID: row.OwnerID, + Name: row.Name, + Kind: tag.Kind(row.Kind), + Description: row.Description, + Listed: row.Listed, + Secret: row.Secret, + }, nil +} + +func (r *tagRepository) FindRecursive(ctx context.Context, id string) ([]tag.Tag, error) { + return r.findRecursive(ctx, make([]tag.Tag, 0, 32), []string{id}) +} + +func (r *tagRepository) findRecursive(ctx context.Context, tags []tag.Tag, parentIDs []string) ([]tag.Tag, error) { + query, args, err := squirrel.Select("id,parent_id,owner_id,name,kind,description,listed,secret"). + From("tag"). + Where(squirrel.Eq{"parent_id": parentIDs}). + ToSql() + if err != nil { + return nil, err + } + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + defer rows.Close() + + nextParentIDs := make([]string, 0, 8) + for rows.Next() { + var t tag.Tag + var ns sql.NullString + + err = rows.Scan(&t.ID, &ns, &t.OwnerID, &t.Name, &t.Kind, &t.Description, &t.Listed, &t.Secret) + if err != nil { + return nil, err + } + t.ParentID = fromNullString(ns) + nextParentIDs = append(nextParentIDs, t.ID) + + tags = append(tags, t) + } + + if len(nextParentIDs) > 0 { + return r.findRecursive(ctx, tags, nextParentIDs) + } else { + return tags, nil + } +} + +func (r *tagRepository) List(ctx context.Context, full bool) ([]tag.Tag, error) { + var rows []mysqlgen.Tag + var err error + if full { + rows, err = r.q.ListTags(ctx) + } else { + rows, err = r.q.ListListedTags(ctx) + } + if err != nil { + if err == sql.ErrNoRows { + return []tag.Tag{}, nil + } + return nil, err + } + + res := make([]tag.Tag, 0, len(rows)) + for _, row := range rows { + res = append(res, tag.Tag{ + ID: row.ID, + ParentID: fromNullString(row.ParentID), + OwnerID: row.OwnerID, + Name: row.Name, + Kind: tag.Kind(row.Kind), + Description: row.Description, + Listed: row.Listed, + Secret: row.Secret, + }) + } + + return res, nil +} + +func (r *tagRepository) Save(ctx context.Context, tag tag.Tag) error { + return r.q.ReplaceTag(ctx, mysqlgen.ReplaceTagParams{ + ID: tag.ID, + ParentID: toNullString(tag.ParentID), + OwnerID: tag.OwnerID, + Name: tag.Name, + Kind: int(tag.Kind), + Description: tag.Description, + Listed: tag.Listed, + Secret: tag.Secret, + }) +} + +func (r *tagRepository) Delete(ctx context.Context, tag tag.Tag) error { + return r.q.DeleteTag(ctx, tag.ID) +} diff --git a/tag/repository.go b/tag/repository.go new file mode 100644 index 0000000..3882ad7 --- /dev/null +++ b/tag/repository.go @@ -0,0 +1,11 @@ +package tag + +import "context" + +type Repository interface { + FindOne(ctx context.Context, id string) (*Tag, error) + FindRecursive(ctx context.Context, id string) ([]Tag, error) + List(ctx context.Context, full bool) ([]Tag, error) + Save(ctx context.Context, tag Tag) error + Delete(ctx context.Context, tag Tag) error +} diff --git a/tag/service.go b/tag/service.go new file mode 100644 index 0000000..1b48035 --- /dev/null +++ b/tag/service.go @@ -0,0 +1,26 @@ +package tag + +import ( + "context" + "git.aiterp.net/rpdata2-take2/rpdata2" + "git.aiterp.net/rpdata2-take2/rpdata2/auth" +) + +type Service struct { + Repository Repository + Auth auth.Service +} + +func (s *Service) Find(ctx context.Context, id string) (*Node, error) { + tags, err := s.Repository.FindRecursive(ctx, id) + if err != nil { + return nil, err + } + + root := BuildForest(tags)[0].WithoutSecret(s.Auth.GetUser(ctx)) + if root == nil { + return nil, rpdata2.NotFound("Tag") + } + + return root, nil +} diff --git a/tag/tag.go b/tag/tag.go new file mode 100644 index 0000000..a776680 --- /dev/null +++ b/tag/tag.go @@ -0,0 +1,46 @@ +package tag + +type Tag struct { + ID string `json:"id"` + ParentID *string `json:"parentId"` + OwnerID string `json:"ownerId"` // The user who can change the tag + Name string `json:"name"` + Kind Kind `json:"kind"` + Description string `json:"description"` + Listed bool `json:"listed"` // Whether to list it for search + Secret bool `json:"secret"` // Only owners can see it +} + +type Kind int + +const ( + // TKOrganization is an organization, faction or group + TKOrganization = 1 << iota + // TKLocation is a settlement, building, planet, etc.... Icon: Pin + TKLocation + // TKEvent is a specific event + TKEvent + // TKPlot is a larger plot + TKPlot + // TKSeries is a thread of content that's not exactly a plot/event + TKSeries +) + +/* +L Aite + L Freedom Falls + LO Miner's Respite + L Derrai Union + L Derrai + L Aroste + L Aroste +P "The Collectors" + +P "Rellis Stuff" + E Rellis' Contract + E Familial Ties + E Skipping Work + E Sala's Backlog +S Anywhere but Here + +*/ diff --git a/tag/tree.go b/tag/tree.go new file mode 100644 index 0000000..32846f8 --- /dev/null +++ b/tag/tree.go @@ -0,0 +1,110 @@ +package tag + +import ( + "git.aiterp.net/rpdata2-take2/rpdata2/auth" + "git.aiterp.net/rpdata2-take2/rpdata2/internal/genutils" +) + +type Node struct { + Tag + + Children []Node `json:"children"` +} + +func (n Node) WithoutSecret(reqUser *auth.UserInfo) *Node { + return n.filter(func(n Node) bool { + if !n.Secret { + return false + } + + return reqUser != nil && reqUser.HasIDOrPermission(n.OwnerID, "tag", "view_secret") + }) +} + +func (n Node) WithoutUnlisted() *Node { + return n.filter(func(n Node) bool { return n.Listed }) +} + +func (n Node) filter(cb func(n Node) bool) *Node { + if !cb(n) { + return nil + } + + filtered := make([]Node, 0, len(n.Children)) + for _, child := range n.Children { + pruned := child.filter(cb) + if pruned == nil { + continue + } + + filtered = append(filtered, *pruned) + } + + n.Children = filtered + return &n +} + +func (n Node) Less(n2 Node) bool { + if n.Kind < n2.Kind { + return true + } + + return n.Name < n2.Name +} + +func BuildForest(tags []Tag) []Node { + nodes := make(map[string]*Node) + for _, tag := range tags { + nodes[tag.ID] = &Node{Tag: tag, Children: []Node{}} + } + + children := make(map[string][]*Node, len(tags)) + for _, node := range nodes { + if node.ParentID != nil { + children[*node.ParentID] = append(children[*node.ParentID], node) + } + } + + cleared := make(map[string]bool) + for len(cleared) < len(nodes) { + anyCleared := false + for _, node := range nodes { + if cleared[node.ID] { + continue + } + + good := true + for _, child := range children[node.ID] { + if !cleared[child.ID] { + good = false + break + } + } + + if good { + genutils.SortSlice(node.Children) + + cleared[node.ID] = true + anyCleared = true + if node.ParentID != nil { + nodes[*node.ParentID].Children = append(nodes[*node.ParentID].Children, *node) + } + } + } + + if !anyCleared { + panic("deadlock in tag.BuildForest") + } + } + + res := make([]Node, 0, 8) + for _, node := range nodes { + if node.ParentID == nil { + res = append(res, *node) + } + } + + genutils.SortSlice(res) + + return res +} diff --git a/tag/tree_test.go b/tag/tree_test.go new file mode 100644 index 0000000..f11103f --- /dev/null +++ b/tag/tree_test.go @@ -0,0 +1,95 @@ +package tag + +import ( + "git.aiterp.net/rpdata2-take2/rpdata2/internal/genutils" + "github.com/stretchr/testify/assert" + "testing" +) + +func printNode(t *testing.T, prefix string, node Node) { + t.Log(prefix + "[" + node.ID + "] " + node.Name) + for _, child := range node.Children { + printNode(t, prefix+" ", child) + } +} + +func TestNode_Less(t *testing.T) { + n1 := Node{Tag: Tag{Kind: TKLocation | TKOrganization, Name: "Miner's Respite"}} + n2 := Node{Tag: Tag{Kind: TKLocation | TKOrganization, Name: "Litae's Grace"}} + n3 := Node{Tag: Tag{Kind: TKOrganization, Name: "Redrock Agency"}} + n4 := Node{Tag: Tag{Kind: TKLocation, Name: "Redrock HQ"}} + + assert.True(t, n2.Less(n1)) + assert.True(t, !n1.Less(n2)) + assert.True(t, n3.Less(n4)) +} + +func TestNode_WithoutUnlisted(t *testing.T) { + before := Node{Tag{Name: "A", Listed: true}, []Node{ + {Tag{Name: "B", Listed: true}, []Node{ + {Tag{Name: "D", Listed: false}, []Node{ + {Tag{Name: "F", Listed: true}, []Node{}}, + }}, + {Tag{Name: "E", Secret: true, Listed: true}, []Node{ + {Tag{Name: "G", Listed: true}, []Node{}}, + }}, + }}, + {Tag{Name: "C", Listed: true}, []Node{}}, + }} + + after := Node{Tag{Name: "A", Listed: true}, []Node{ + {Tag{Name: "B", Listed: true}, []Node{ + {Tag{Name: "E", Secret: true, Listed: true}, []Node{ + {Tag{Name: "G", Listed: true}, []Node{}}, + }}, + }}, + {Tag{Name: "C", Listed: true}, []Node{}}, + }} + + assert.Equal(t, &after, before.WithoutUnlisted()) +} + +func TestBuildForest(t *testing.T) { + tags := []Tag{ + {ID: "T0", ParentID: genutils.Ptr("T7"), Name: "Root"}, + {ID: "T1", ParentID: genutils.Ptr("T0"), Name: "Stuff"}, + {ID: "T2", ParentID: genutils.Ptr("T0"), Name: "Things"}, + {ID: "T3", Name: "Second Root"}, + {ID: "T4", ParentID: genutils.Ptr("T2"), Name: "Items"}, + {ID: "T5", ParentID: genutils.Ptr("T1"), Name: "Objects"}, + {ID: "T6", ParentID: genutils.Ptr("T4"), Name: "Thingies"}, + {ID: "T7", Name: "Real Root"}, + {ID: "T8", ParentID: genutils.Ptr("T3"), Name: "Another Leaf"}, + {ID: "T9", ParentID: genutils.Ptr("T5"), Name: "Branch"}, + {ID: "TA", ParentID: genutils.Ptr("T9"), Name: "Leafy Leaf"}, + } + + nodes := BuildForest(tags) + for _, node := range nodes { + printNode(t, "", node) + } + + expected := []Node{ + {tags[7], []Node{ + {tags[0], []Node{ + {tags[1], []Node{ + {tags[5], []Node{ + {tags[9], []Node{ + {tags[10], []Node{}}, + }}, + }}, + }}, + {tags[2], []Node{ + {tags[4], []Node{ + {tags[6], []Node{}}, + }}, + }}, + }}, + }}, + {tags[3], []Node{ + {tags[8], []Node{}}, + }}, + } + + assert.Equal(t, expected, nodes) +} diff --git a/tag/update.go b/tag/update.go new file mode 100644 index 0000000..65097b3 --- /dev/null +++ b/tag/update.go @@ -0,0 +1,19 @@ +package tag + +import "git.aiterp.net/rpdata2-take2/rpdata2/internal/genutils" + +type Update struct { + ParentID *string `json:"parentId"` + Name *string `json:"name"` + Kind *Kind `json:"kind"` + Listed *bool `json:"listed"` + Secret *bool `json:"secret"` // Only owners can see it +} + +func (t *Tag) ApplyUpdate(update *Update) { + genutils.ApplyUpdateNilZero(&t.ParentID, update.ParentID) + genutils.ApplyUpdate(&t.Name, update.Name) + genutils.ApplyUpdate(&t.Kind, update.Kind) + genutils.ApplyUpdate(&t.Listed, update.Listed) + genutils.ApplyUpdate(&t.Secret, update.Secret) +}