diff --git a/cmd/rpdata-server/main.go b/cmd/rpdata-server/main.go index b0c1474..ad20cb4 100644 --- a/cmd/rpdata-server/main.go +++ b/cmd/rpdata-server/main.go @@ -11,7 +11,6 @@ import ( "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/internal/loader" "git.aiterp.net/rpdata/api/internal/store" - logModel "git.aiterp.net/rpdata/api/model/log" "github.com/99designs/gqlgen/handler" ) @@ -24,8 +23,6 @@ func main() { http.Handle("/", handler.Playground("RPData API", "/graphql")) http.Handle("/graphql", queryHandler()) - go updateCharacters() - log.Fatal(http.ListenAndServe(":8081", nil)) } @@ -50,12 +47,3 @@ func queryHandler() http.HandlerFunc { handler.ServeHTTP(w, r) } } - -func updateCharacters() { - n, err := logModel.UpdateAllCharacters() - if err != nil { - log.Println("Charcter updated stopped:", err) - } - - log.Println("Updated characters on", n, "logs") -} diff --git a/graph2/queries/post.go b/graph2/queries/post.go index 33fa904..570cc07 100644 --- a/graph2/queries/post.go +++ b/graph2/queries/post.go @@ -4,10 +4,16 @@ import ( "context" "errors" + "git.aiterp.net/rpdata/api/models/logs" + + "git.aiterp.net/rpdata/api/graph2/input" + "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/models/posts" ) +// Queries + func (r *resolver) Post(ctx context.Context, id string) (models.Post, error) { return posts.FindID(id) } @@ -32,3 +38,61 @@ func (r *resolver) Posts(ctx context.Context, filter *posts.Filter) ([]models.Po return posts.List(filter) } + +// Mutation + +func (r *mutationResolver) AddPost(ctx context.Context, input input.PostAddInput) (models.Post, error) { + token := auth.TokenFromContext(ctx) + if !token.Authenticated() || !token.Permitted("post.add") { + return models.Post{}, errors.New("You are not permitted to edit logs") + } + + log, err := logs.FindID(input.LogID) + if err != nil { + return models.Post{}, err + } + + return posts.Add(log, input.Time, input.Kind, input.Nick, input.Text) +} + +func (r *mutationResolver) EditPost(ctx context.Context, input input.PostEditInput) (models.Post, error) { + token := auth.TokenFromContext(ctx) + if !token.Authenticated() || !token.Permitted("post.edit") { + return models.Post{}, errors.New("You are not permitted to edit logs") + } + + post, err := posts.FindID(input.ID) + if err != nil { + return models.Post{}, errors.New("Post not found") + } + + return posts.Edit(post, input.Time, input.Kind, input.Nick, input.Text) +} + +func (r *mutationResolver) MovePost(ctx context.Context, input input.PostMoveInput) ([]models.Post, error) { + token := auth.TokenFromContext(ctx) + if !token.Authenticated() || !token.Permitted("post.move") { + return nil, errors.New("You are not permitted to edit logs") + } + + post, err := posts.FindID(input.ID) + if err != nil { + return nil, errors.New("Post not found") + } + + return posts.Move(post, input.ToPosition) +} + +func (r *mutationResolver) RemovePost(ctx context.Context, input input.PostRemoveInput) (models.Post, error) { + token := auth.TokenFromContext(ctx) + if !token.Authenticated() || !token.Permitted("post.remove") { + return models.Post{}, errors.New("You are not permitted to edit logs") + } + + post, err := posts.FindID(input.ID) + if err != nil { + return models.Post{}, errors.New("Post not found (before removing, of course)") + } + + return posts.Remove(post) +} diff --git a/graph2/schema/root.gql b/graph2/schema/root.gql index 9de5b22..05ede87 100644 --- a/graph2/schema/root.gql +++ b/graph2/schema/root.gql @@ -83,6 +83,19 @@ type Mutation { # Remove a chapter removeChapter(input: ChapterRemoveInput!): Chapter! + + + # Add a post + addPost(input: PostAddInput!): Post! + + # Edit a post + editPost(input: PostEditInput!): Post! + + # Move a post. All affected posts will be returned. + movePost(input: PostMoveInput!): [Post!]! + + # Remove a post + removePost(input: PostRemoveInput!): Post! } # A Date represents a RFC3339 encoded date with up to millisecond precision. diff --git a/graph2/schema/types/Post.gql b/graph2/schema/types/Post.gql index bcc4908..166f24c 100644 --- a/graph2/schema/types/Post.gql +++ b/graph2/schema/types/Post.gql @@ -23,7 +23,7 @@ type Post { } # Input for the addPost mutation -input AddPostInput { +input PostAddInput { # The log's ID that this post should be a part of logId: String! @@ -41,7 +41,7 @@ input AddPostInput { } # Input for the editPost mutation -input EditPostInput { +input PostEditInput { # The Post ID id: String! @@ -59,7 +59,7 @@ input EditPostInput { } # Input for the movePost mutation -input MovePostInput { +input PostMoveInput { # The Post ID id: String! @@ -67,6 +67,12 @@ input MovePostInput { toPosition: Int! } +# Input for the removePost mutation +input PostRemoveInput { + # The Post ID + id: String! +} + # Filter for posts query input PostsFilter { id: [String!] diff --git a/model/counter/counter.go b/internal/counter/counter.go similarity index 100% rename from model/counter/counter.go rename to internal/counter/counter.go diff --git a/internal/loader/character.go b/internal/loader/character.go index 1150163..151ab85 100644 --- a/internal/loader/character.go +++ b/internal/loader/character.go @@ -5,7 +5,6 @@ import ( "errors" "strings" - "git.aiterp.net/rpdata/api/model/character" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/models/characters" "github.com/graph-gophers/dataloader" @@ -118,7 +117,7 @@ func characterNickBatch(ctx context.Context, keys dataloader.Keys) []*dataloader var results []*dataloader.Result nicks := keys.Keys() - characters, err := character.ListNicks(nicks...) + characters, err := characters.List(&characters.Filter{Nicks: nicks}) if err != nil { for range nicks { results = append(results, &dataloader.Result{Error: err}) diff --git a/model/character/character.go b/model/character/character.go deleted file mode 100644 index d6cc3c8..0000000 --- a/model/character/character.go +++ /dev/null @@ -1,345 +0,0 @@ -package character - -import ( - "errors" - "log" - "strconv" - "strings" - - "git.aiterp.net/rpdata/api/internal/store" - "git.aiterp.net/rpdata/api/model/counter" - "github.com/globalsign/mgo" - "github.com/globalsign/mgo/bson" -) - -var collection *mgo.Collection - -var logsCollection *mgo.Collection - -// Character is a common data model representing an RP character or NPC. -type Character struct { - ID string `json:"id" bson:"_id"` - Nicks []string `json:"nicks" bson:"nicks"` - Name string `json:"name" bson:"name"` - ShortName string `json:"shortName" bson:"shortName"` - Author string `json:"author" bson:"author"` - Description string `json:"description" bson:"description"` -} - -// Filter is used to filter the list of characters -type Filter struct { - IDs []string `json:"ids"` - Nicks []string `json:"nicks"` - Names []string `json:"names"` - Author *string `json:"author"` - Search *string `json:"search"` - Logged *bool `json:"logged"` -} - -// Nick gets the character's nick. -func (character *Character) Nick() *string { - if len(character.Nicks[0]) == 0 { - return nil - } - - return &character.Nicks[0] -} - -// HasNick returns true if the character has that nick -func (character *Character) HasNick(nick string) bool { - for i := range character.Nicks { - if strings.EqualFold(character.Nicks[i], nick) { - return true - } - } - - return false -} - -// AddNick adds a nick to the character. It will return an error -// if the nick already exists. -func (character *Character) AddNick(nick string) error { - for i := range character.Nicks { - if strings.EqualFold(character.Nicks[i], nick) { - return errors.New("Nick already exists") - } - } - - err := collection.UpdateId(character.ID, bson.M{"$push": bson.M{"nicks": nick}}) - if err != nil { - return err - } - - character.Nicks = append(character.Nicks, nick) - - return nil -} - -// RemoveNick removes the nick from the character. It will raise -// an error if the nick does not exist; even if that kind of is -// the end goal. -func (character *Character) RemoveNick(nick string) error { - index := -1 - for i := range character.Nicks { - if strings.EqualFold(character.Nicks[i], nick) { - index = i - break - } - } - if index == -1 { - return errors.New("Nick does not exist") - } - - err := collection.UpdateId(character.ID, bson.M{"$pull": bson.M{"nicks": nick}}) - if err != nil { - return err - } - - character.Nicks = append(character.Nicks[:index], character.Nicks[index+1:]...) - - return nil -} - -// Edit sets the fields of metadata. Only non-empty and different fields will be set in the -// database, preventing out of order edits to two fields from conflicting -func (character *Character) Edit(name, shortName, description string) error { - changes := bson.M{} - if len(name) > 0 && name != character.Name { - changes["name"] = name - } - if len(shortName) > 0 && shortName != character.ShortName { - changes["shortName"] = shortName - } - if len(description) > 0 && description != character.Description { - changes["description"] = description - } - - err := collection.UpdateId(character.ID, changes) - if err != nil { - return err - } - - if changes["name"] != nil { - character.Name = name - } - if changes["shortName"] != nil { - character.ShortName = shortName - } - if changes["description"] != nil { - character.Description = description - } - - return nil -} - -// Remove removes the character from the database. The reason this is an instance method -// is that it should only be done after an authorization check. -func (character *Character) Remove() error { - return collection.RemoveId(character.ID) -} - -// FindID finds Character by ID -func FindID(id string) (Character, error) { - return find(bson.M{"_id": id}) -} - -// FindNick finds Character by nick -func FindNick(nick string) (Character, error) { - return find(bson.M{"nicks": nick}) -} - -// FindName finds Character by either full name or -// short name. -func FindName(name string) (Character, error) { - return find(bson.M{"$or": []bson.M{bson.M{"name": name}, bson.M{"shortName": name}}}) -} - -// List lists all characters -func List(filter *Filter) ([]Character, error) { - query := bson.M{} - - if filter != nil { - if len(filter.IDs) > 1 { - query["id"] = bson.M{"$in": filter.IDs} - } else if len(filter.IDs) == 1 { - query["id"] = filter.IDs[0] - } - - if len(filter.Nicks) > 1 { - query["nicks"] = bson.M{"$in": filter.Nicks} - } else if len(filter.Nicks) == 1 { - query["nicks"] = filter.Nicks[0] - } - - if len(filter.Names) > 1 { - query["$or"] = bson.M{ - "name": bson.M{"$in": filter.Names}, - "shortName": bson.M{"$in": filter.Names}, - } - } else if len(filter.Names) == 1 { - query["$or"] = bson.M{ - "name": filter.Names[0], - "shortName": filter.Names[0], - } - } - - if filter.Logged != nil { - query["logged"] = *filter.Logged - } - - if filter.Author != nil { - query["author"] = *filter.Author - } - - if filter.Search != nil { - query["$text"] = bson.M{"$search": *filter.Search} - } - } - - return list(query) -} - -// ListAuthor lists all characters by author -func ListAuthor(author string) ([]Character, error) { - return list(bson.M{"author": author}) -} - -// ListNicks lists all characters with either of these nicks. This was made with -// the logbot in mind, to batch an order for characters. -func ListNicks(nicks ...string) ([]Character, error) { - return list(bson.M{"nicks": bson.M{"$in": nicks}}) -} - -// ListIDs lists all characters with either of these IDs. -func ListIDs(ids ...string) ([]Character, error) { - return list(bson.M{"_id": bson.M{"$in": ids}}) -} - -// ListFilter lists all logs matching the filters. -func ListFilter(ids []string, nicks []string, names []string, author *string, search *string, logged *bool) ([]Character, error) { - query := bson.M{} - - if logged != nil { - loggedIDs := make([]string, 0, 64) - err := logsCollection.Find(bson.M{"characterIds": bson.M{"$ne": nil}}).Distinct("characterIds", &loggedIDs) - if err != nil { - return nil, err - } - - if len(ids) > 0 { - newIds := make([]string, 0, len(ids)) - for _, id := range ids { - for _, loggedID := range loggedIDs { - if id == loggedID { - newIds = append(newIds, id) - break - } - } - } - ids = newIds - } else { - ids = loggedIDs - } - } - if len(ids) > 0 { - query["_id"] = bson.M{"$in": ids} - } - if len(nicks) > 0 { - query["nicks"] = bson.M{"$in": nicks} - } - if len(names) > 0 { - query["name"] = bson.M{"$in": names} - } - if author != nil { - query["author"] = *author - } - if search != nil { - query["$text"] = bson.M{"$search": *search} - } - - return list(query) -} - -// New creates a Character and pushes it to the database. It does some validation -// on nick, name, shortName and author. Leave the shortname blank to have it be the -// first name. -func New(nick, name, shortName, author, description string) (Character, error) { - if len(nick) < 1 || len(name) < 1 || len(author) < 1 { - return Character{}, errors.New("Nick, name, or author name too short or empty") - } - if shortName == "" { - shortName = strings.SplitN(name, " ", 2)[0] - } - - char, err := FindNick(nick) - if err == nil && char.ID != "" { - return Character{}, errors.New("Nick is occupied") - } - - nextID, err := counter.Next("auto_increment", "Character") - if err != nil { - return Character{}, err - } - - character := Character{ - ID: "C" + strconv.Itoa(nextID), - Nicks: []string{nick}, - Name: name, - ShortName: shortName, - Author: author, - Description: description, - } - - err = collection.Insert(character) - if err != nil { - return Character{}, err - } - - return character, nil -} - -func find(query interface{}) (Character, error) { - character := Character{} - err := collection.Find(query).One(&character) - if err != nil { - return Character{}, err - } - - return character, nil -} - -func list(query interface{}) ([]Character, error) { - characters := make([]Character, 0, 64) - err := collection.Find(query).All(&characters) - if err != nil { - return nil, err - } - - return characters, nil -} - -func init() { - store.HandleInit(func(db *mgo.Database) { - collection = db.C("common.characters") - - collection.EnsureIndexKey("name") - collection.EnsureIndexKey("shortName") - collection.EnsureIndexKey("author") - err := collection.EnsureIndex(mgo.Index{ - Key: []string{"nicks"}, - Unique: true, - DropDups: true, - }) - if err != nil { - log.Fatalln("init common.characters:", err) - } - err = collection.EnsureIndex(mgo.Index{ - Key: []string{"$text:description"}, - }) - if err != nil { - log.Fatalln("init common.characters:", err) - } - - logsCollection = db.C("logbot3.logs") - }) -} diff --git a/models/character.go b/models/character.go index 13b1357..37e819a 100644 --- a/models/character.go +++ b/models/character.go @@ -18,3 +18,14 @@ func (character *Character) Nick() *string { return &character.Nicks[0] } + +// HasNick gets whether the character has the nick. +func (character *Character) HasNick(nick string) bool { + for i := range character.Nicks { + if nick == character.Nicks[i] { + return true + } + } + + return false +} diff --git a/models/characters/add.go b/models/characters/add.go index 2abf75f..3f261e2 100644 --- a/models/characters/add.go +++ b/models/characters/add.go @@ -5,7 +5,7 @@ import ( "strconv" "strings" - "git.aiterp.net/rpdata/api/model/counter" + "git.aiterp.net/rpdata/api/internal/counter" "git.aiterp.net/rpdata/api/models" ) diff --git a/models/posts/add.go b/models/posts/add.go new file mode 100644 index 0000000..f59a3db --- /dev/null +++ b/models/posts/add.go @@ -0,0 +1,51 @@ +package posts + +import ( + "crypto/rand" + "encoding/binary" + "errors" + "strconv" + "time" + + "git.aiterp.net/rpdata/api/internal/counter" + "git.aiterp.net/rpdata/api/models" +) + +// Add creates a new post. +func Add(log models.Log, time time.Time, kind, nick, text string) (models.Post, error) { + if kind == "" || nick == "" || text == "" { + return models.Post{}, errors.New("Missing/empty parameters") + } + + mutex.RLock() + defer mutex.RUnlock() + + position, err := counter.Next("next_post_id", log.ShortID) + if err != nil { + return models.Post{}, err + } + + post := models.Post{ + ID: generateID(time), + Position: position, + LogID: log.ShortID, + Time: time, + Kind: kind, + Nick: nick, + Text: text, + } + + err = collection.Insert(post) + if err != nil { + return models.Post{}, err + } + + return post, nil +} + +func generateID(time time.Time) string { + data := make([]byte, 4) + rand.Read(data) + + return "P" + strconv.FormatInt(time.UnixNano(), 36) + strconv.FormatInt(int64(binary.LittleEndian.Uint32(data)), 36) +} diff --git a/models/posts/edit.go b/models/posts/edit.go new file mode 100644 index 0000000..64307c8 --- /dev/null +++ b/models/posts/edit.go @@ -0,0 +1,44 @@ +package posts + +import ( + "time" + + "git.aiterp.net/rpdata/api/models" + "github.com/globalsign/mgo/bson" +) + +// Edit edits a post and returns the result if the edit succeeded. +func Edit(post models.Post, time *time.Time, kind *string, nick *string, text *string) (models.Post, error) { + mutex.RLock() + defer mutex.RUnlock() + + changes := bson.M{} + + if time != nil && !time.IsZero() && !time.Equal(post.Time) { + changes["time"] = *time + post.Time = *time + } + if kind != nil && *kind != "" && *kind != post.Kind { + changes["kind"] = *kind + post.Kind = *kind + } + if nick != nil && *nick != "" && *nick != post.Nick { + changes["nick"] = *nick + post.Nick = *nick + } + if text != nil && *text != "" && *text != post.Text { + changes["text"] = *text + post.Text = *text + } + + if len(changes) == 0 { + return post, nil + } + + err := collection.UpdateId(post.ID, bson.M{"$set": changes}) + if err != nil { + return models.Post{}, err + } + + return post, nil +} diff --git a/models/posts/move.go b/models/posts/move.go new file mode 100644 index 0000000..82a8f3f --- /dev/null +++ b/models/posts/move.go @@ -0,0 +1,69 @@ +package posts + +import ( + "errors" + + "git.aiterp.net/rpdata/api/models" + "github.com/globalsign/mgo/bson" +) + +// Move the post +func Move(post models.Post, toPosition int) ([]models.Post, error) { + if toPosition < 1 { + return nil, errors.New("Invalid position") + } + + mutex.Lock() + defer mutex.Unlock() + + // To avoid problems, only allow target indices that are allowed. If it's 1, then there is bound to + // be a post at the position. + if toPosition > 1 { + existing := models.Post{} + err := collection.Find(bson.M{"logId": post.LogID, "position": toPosition}).One(&existing) + + if err != nil || existing.Position != toPosition { + return nil, errors.New("No post found at the position") + } + } + + query := bson.M{"logId": post.LogID} + operation := bson.M{"$inc": bson.M{"position": 1}} + + if toPosition < post.Position { + query["$and"] = []bson.M{ + bson.M{"position": bson.M{"$gte": toPosition}}, + bson.M{"position": bson.M{"$lt": post.Position}}, + } + } else { + query["$and"] = []bson.M{ + bson.M{"position": bson.M{"$gt": post.Position}}, + bson.M{"position": bson.M{"$lte": toPosition}}, + } + + operation["$inc"] = bson.M{"position": -1} + } + + _, err := collection.UpdateAll(query, operation) + if err != nil { + return nil, errors.New("Moving others failed: " + err.Error()) + } + + err = collection.UpdateId(post.ID, bson.M{"$set": bson.M{"position": toPosition}}) + if err != nil { + return nil, errors.New("Moving failed: " + err.Error() + " (If you see this on the page, please let me know ASAP)") + } + + from, to := post.Position, toPosition + if to < from { + from, to = to, from + } + + posts := make([]models.Post, 0, (to-from)+1) + err = collection.Find(bson.M{"logId": post.LogID, "position": bson.M{"$gte": from, "$lte": to}}).Sort("position").All(&posts) + if err != nil { + return nil, errors.New("The move completed successfully, but finding the moved posts failed: " + err.Error()) + } + + return posts, nil +} diff --git a/models/posts/remove.go b/models/posts/remove.go new file mode 100644 index 0000000..f79a15b --- /dev/null +++ b/models/posts/remove.go @@ -0,0 +1,24 @@ +package posts + +import ( + "git.aiterp.net/rpdata/api/models" + "github.com/globalsign/mgo/bson" +) + +// Remove removes a post, moving all subsequent post up one position +func Remove(post models.Post) (models.Post, error) { + mutex.Lock() + defer mutex.Unlock() + + err := collection.RemoveId(post.ID) + if err != nil { + return models.Post{}, err + } + + _, err = collection.UpdateAll(bson.M{"logId": post.LogID, "position": bson.M{"$gt": post.Position}}, bson.M{"$inc": bson.M{"position": -1}}) + if err != nil { + return models.Post{}, err + } + + return post, nil +} diff --git a/models/posts/search.go b/models/posts/search.go deleted file mode 100644 index c427a4f..0000000 --- a/models/posts/search.go +++ /dev/null @@ -1 +0,0 @@ -package posts