From 18cb6f14aad03d3207b38752ebf8d09eb3aecca9 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sun, 10 Jun 2018 14:50:06 +0200 Subject: [PATCH] Long overdue initial commit --- .gitignore | 6 + Gopkg.lock | 151 +++++++++++ Gopkg.toml | 45 ++++ LICENSE.md | 7 + README.md | 15 ++ cmd/rpdata-graphiql/.gitignore | 2 + cmd/rpdata-graphiql/main.go | 91 +++++++ cmd/rpdata-lb2charimport/.gitignore | 3 + cmd/rpdata-lb2charimport/data.go | 55 ++++ cmd/rpdata-lb2charimport/main.go | 62 +++++ cmd/rpdata-lb2logimport/.gitignore | 2 + cmd/rpdata-lb2logimport/line.go | 85 +++++++ cmd/rpdata-lb2logimport/main.go | 108 ++++++++ config.example.json | 22 ++ internal/config/config.go | 75 ++++++ internal/session/context.go | 20 ++ internal/session/defaults.go | 33 +++ internal/session/session.go | 182 ++++++++++++++ internal/session/user.go | 44 ++++ internal/store/db.go | 73 ++++++ internal/store/init.go | 37 +++ internal/store/space.go | 78 ++++++ loader/character.go | 133 ++++++++++ loader/loader.go | 48 ++++ makefile | 15 ++ model/change/change.go | 75 ++++++ model/character/character.go | 231 +++++++++++++++++ model/counter/counter.go | 37 +++ model/counter/counter_test.go | 19 ++ model/log/log.go | 374 ++++++++++++++++++++++++++++ model/log/log_test.go | 83 ++++++ model/log/post.go | 198 +++++++++++++++ model/log/unknownnick.go | 42 ++++ model/log/updater.go | 84 +++++++ resolver/character.go | 330 ++++++++++++++++++++++++ resolver/error.go | 15 ++ resolver/log.go | 304 ++++++++++++++++++++++ resolver/post.go | 233 +++++++++++++++++ resolver/root.go | 13 + resolver/session.go | 51 ++++ resolver/user.go | 16 ++ schema/root.graphql | 80 ++++++ schema/schema.go | 21 ++ schema/types/change.graphql | 4 + schema/types/character.graphql | 65 +++++ schema/types/log.graphql | 126 ++++++++++ schema/types/post.graphql | 68 +++++ schema/types/session.graphql | 5 + schema/types/user.graphql | 8 + 49 files changed, 3874 insertions(+) create mode 100644 .gitignore create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 cmd/rpdata-graphiql/.gitignore create mode 100644 cmd/rpdata-graphiql/main.go create mode 100644 cmd/rpdata-lb2charimport/.gitignore create mode 100644 cmd/rpdata-lb2charimport/data.go create mode 100644 cmd/rpdata-lb2charimport/main.go create mode 100644 cmd/rpdata-lb2logimport/.gitignore create mode 100644 cmd/rpdata-lb2logimport/line.go create mode 100644 cmd/rpdata-lb2logimport/main.go create mode 100644 config.example.json create mode 100644 internal/config/config.go create mode 100644 internal/session/context.go create mode 100644 internal/session/defaults.go create mode 100644 internal/session/session.go create mode 100644 internal/session/user.go create mode 100644 internal/store/db.go create mode 100644 internal/store/init.go create mode 100644 internal/store/space.go create mode 100644 loader/character.go create mode 100644 loader/loader.go create mode 100644 makefile create mode 100644 model/change/change.go create mode 100644 model/character/character.go create mode 100644 model/counter/counter.go create mode 100644 model/counter/counter_test.go create mode 100644 model/log/log.go create mode 100644 model/log/log_test.go create mode 100644 model/log/post.go create mode 100644 model/log/unknownnick.go create mode 100644 model/log/updater.go create mode 100644 resolver/character.go create mode 100644 resolver/error.go create mode 100644 resolver/log.go create mode 100644 resolver/post.go create mode 100644 resolver/root.go create mode 100644 resolver/session.go create mode 100644 resolver/user.go create mode 100644 schema/root.graphql create mode 100644 schema/schema.go create mode 100644 schema/types/change.graphql create mode 100644 schema/types/character.graphql create mode 100644 schema/types/log.graphql create mode 100644 schema/types/post.graphql create mode 100644 schema/types/session.graphql create mode 100644 schema/types/user.graphql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d20a34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +vendor +schema/bindata.go +config.json +api +debug +build \ No newline at end of file diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..ef1231b --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,151 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + branch = "master" + name = "git.aiterp.net/aiterp/wikiauth" + packages = ["."] + revision = "d73e1a0802728a5559e6e870ce14bfdac3901ed8" + +[[projects]] + branch = "master" + name = "github.com/dustin/go-humanize" + packages = ["."] + revision = "bb3d318650d48840a39aa21a027c6630e198e626" + +[[projects]] + branch = "master" + name = "github.com/globalsign/mgo" + packages = [ + ".", + "bson", + "internal/json", + "internal/sasl", + "internal/scram" + ] + revision = "f76e4f9da92ecd56e3be26f5ba92580af1ef97b4" + +[[projects]] + name = "github.com/go-ini/ini" + packages = ["."] + revision = "ace140f73450505f33e8b8418216792275ae82a7" + version = "v1.35.0" + +[[projects]] + name = "github.com/graph-gophers/dataloader" + packages = ["."] + revision = "78139374585c29dcb97b8f33089ed11959e4be59" + version = "v5" + +[[projects]] + branch = "master" + name = "github.com/graph-gophers/graphql-go" + packages = [ + ".", + "errors", + "internal/common", + "internal/exec", + "internal/exec/packer", + "internal/exec/resolvable", + "internal/exec/selected", + "internal/query", + "internal/schema", + "internal/validation", + "introspection", + "log", + "relay", + "trace" + ] + revision = "9ebf33af539ab8cb832c7107bc0a978ca8dbc0de" + +[[projects]] + name = "github.com/minio/minio-go" + packages = [ + ".", + "pkg/credentials", + "pkg/encrypt", + "pkg/s3signer", + "pkg/s3utils", + "pkg/set" + ] + revision = "3d2d02921f0510e9d1f66ef77a265b8dddd36992" + version = "6.0.0" + +[[projects]] + branch = "master" + name = "github.com/mitchellh/go-homedir" + packages = ["."] + revision = "b8bc1bf767474819792c23f32d8286a45736f1c6" + +[[projects]] + name = "github.com/opentracing/opentracing-go" + packages = [ + ".", + "ext", + "log" + ] + revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" + version = "v1.0.2" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = [ + "argon2", + "blake2b", + "ssh/terminal" + ] + revision = "d6449816ce06963d9d136eee5a56fca5b0616e7e" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "idna", + "lex/httplex" + ] + revision = "a35a21de978d84ffc92f010a153705b170b2f9d1" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "2f57af4873d00d535c5c9028850aa2152e6a5566" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "0e9f91dc1e710ccd543842b55af8f6e4edbcb528246bb6d1e1e0c10d66328220" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..d44a63c --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,45 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + +[[constraint]] + branch = "master" + name = "github.com/globalsign/mgo" + +[[constraint]] + name = "github.com/minio/minio-go" + version = "6.0.0" + +[[constraint]] + name = "git.aiterp.net/aiterp/wikiauth" + branch = "master" + +[prune] + go-tests = true + unused-packages = true + +[[constraint]] + branch = "master" + name = "github.com/graph-gophers/graphql-go" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b3cc8af --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +# ISC License + +Copyright (c) 2018 Gisle Aune + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0cab1c5 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# RPData API + +Project RPData aims to centralize the AiteRP applications, and partially involve the wiki, into a common API. This will allow for easy linking and inlining between them. The primary consumers of this API will be the Logbot and the upcoming RPData website. + +## Progress + +### Complete +* Session +* Character +* Log +* Post + +### Remaining +* Story +* File \ No newline at end of file diff --git a/cmd/rpdata-graphiql/.gitignore b/cmd/rpdata-graphiql/.gitignore new file mode 100644 index 0000000..f85ae20 --- /dev/null +++ b/cmd/rpdata-graphiql/.gitignore @@ -0,0 +1,2 @@ +config.json +debug \ No newline at end of file diff --git a/cmd/rpdata-graphiql/main.go b/cmd/rpdata-graphiql/main.go new file mode 100644 index 0000000..45fec3c --- /dev/null +++ b/cmd/rpdata-graphiql/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "log" + "net/http" + + "git.aiterp.net/rpdata/api/internal/session" + "git.aiterp.net/rpdata/api/internal/store" + "git.aiterp.net/rpdata/api/loader" + logModel "git.aiterp.net/rpdata/api/model/log" + "git.aiterp.net/rpdata/api/resolver" + "git.aiterp.net/rpdata/api/schema" + graphql "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/relay" +) + +func main() { + err := store.Init() + if err != nil { + log.Fatalln("Failed to init store:", err) + } + + n, err := logModel.UpdateAllCharacters() + if err != nil { + log.Println("Charcter updated stopped:", err) + } + log.Println("Updated characters on", n, "logs") + + schema, err := graphql.ParseSchema(schema.String(), &resolver.RootResolver{}, graphql.MaxParallelism(4)) + if err != nil { + log.Fatalln("Failed to parse schema:", err) + } + + http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Write(page) + })) + + relayHandler := &relay.Handler{Schema: schema} + http.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + r = session.Load(w, r) + + l := loader.New() + r = r.WithContext(l.ToContext(r.Context())) + + relayHandler.ServeHTTP(w, r) + }) + + log.Fatal(http.ListenAndServe(":17000", nil)) +} + +var page = []byte(` + + + + + + + + + + +
Loading...
+ + + + `) diff --git a/cmd/rpdata-lb2charimport/.gitignore b/cmd/rpdata-lb2charimport/.gitignore new file mode 100644 index 0000000..9aaf1e6 --- /dev/null +++ b/cmd/rpdata-lb2charimport/.gitignore @@ -0,0 +1,3 @@ +characters.json +characters.cson +debug \ No newline at end of file diff --git a/cmd/rpdata-lb2charimport/data.go b/cmd/rpdata-lb2charimport/data.go new file mode 100644 index 0000000..cf75a6b --- /dev/null +++ b/cmd/rpdata-lb2charimport/data.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "io" + "strings" +) + +type charInfo struct { + Nicks []string `json:"nicks"` + Name string `json:"name"` + Author string `json:"player"` + ShortName string `json:"first"` +} + +func load(reader io.Reader) ([]charInfo, error) { + data := make(map[string]interface{}) + err := json.NewDecoder(reader).Decode(&data) + if err != nil { + return nil, err + } + + links := make(map[string]string, len(data)) + infos := make([]charInfo, 0, 64) + + for key, value := range data { + if info, ok := value.(map[string]interface{}); ok { + name := info["name"].(string) + author := info["player"].(string) + shortName, ok := info["first"].(string) + if !ok { + shortName = strings.SplitN(name, " ", 2)[0] + } + + infos = append(infos, charInfo{ + Nicks: []string{key}, + Name: name, + Author: author, + ShortName: shortName, + }) + } else if nick, ok := value.(string); ok { + links[key] = nick + } + } + + for key, value := range links { + for i := range infos { + if infos[i].Nicks[0] == value { + infos[i].Nicks = append(infos[i].Nicks, key) + } + } + } + + return infos, nil +} diff --git a/cmd/rpdata-lb2charimport/main.go b/cmd/rpdata-lb2charimport/main.go new file mode 100644 index 0000000..c98fbbb --- /dev/null +++ b/cmd/rpdata-lb2charimport/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "flag" + "log" + "os" + + "git.aiterp.net/rpdata/api/internal/store" + "git.aiterp.net/rpdata/api/model/character" +) + +var fileName = flag.String("file", "./characters.json", "json file to load") + +func main() { + file, err := os.Open(*fileName) + if err != nil { + log.Fatalln("Open:", err) + } + + infos, err := load(file) + if err != nil { + log.Fatalln("Parse:", err) + } + + err = store.Init() + if err != nil { + log.Fatalln("Store:", err) + } + + charsAdded, charsFailed := 0, 0 + nicksAdded, nicksFailed := 0, 0 + + for _, info := range infos { + char, err := character.New(info.Nicks[0], info.Name, info.ShortName, info.Author, "") + if err != nil { + log.Println(info.Nicks[0], "failed to insert:", err) + charsFailed++ + + char, err = character.FindNick(info.Nicks[0]) + if err != nil { + continue + } + } else { + log.Println(info.Nicks[0], "added") + charsAdded++ + } + + for _, alt := range info.Nicks[1:] { + err := char.AddNick(alt) + if err != nil { + log.Println(info.Nicks[0], "failed to add nick", alt, "error:", err) + nicksFailed++ + } else { + log.Println(info.Nicks[0], "addded nick", alt) + nicksAdded++ + } + } + } + + log.Printf("Characters – %d/%d", charsAdded, charsFailed+charsAdded) + log.Printf("Alt. Nicks – %d/%d", nicksAdded, nicksFailed+nicksAdded) +} diff --git a/cmd/rpdata-lb2logimport/.gitignore b/cmd/rpdata-lb2logimport/.gitignore new file mode 100644 index 0000000..2d164fe --- /dev/null +++ b/cmd/rpdata-lb2logimport/.gitignore @@ -0,0 +1,2 @@ +logs +debug \ No newline at end of file diff --git a/cmd/rpdata-lb2logimport/line.go b/cmd/rpdata-lb2logimport/line.go new file mode 100644 index 0000000..f352a4c --- /dev/null +++ b/cmd/rpdata-lb2logimport/line.go @@ -0,0 +1,85 @@ +package main + +import ( + "bufio" + "errors" + "io" + "strconv" + "strings" + "time" +) + +type logLine struct { + Verb string + Args []string + Text string +} + +func parseFile(reader io.Reader) []logLine { + bufReader := bufio.NewReader(reader) + results := make([]logLine, 0, 512) + + for { + line, err := bufReader.ReadString('\n') + if err == io.EOF { + if len(line) < 2 { + break + } + } else if err != nil { + break + } + + if len(line) <= 2 { + continue + } + + line = strings.Replace(line, "\n", "", 1) + line = strings.Replace(line, "\r", "", 1) + + results = append(results, parseLine(line)) + } + + return results +} + +func parseLine(line string) logLine { + textSplit := strings.SplitN(line, " :", 2) + tokens := strings.Split(textSplit[0], " ") + + ll := logLine{ + Verb: tokens[0], + } + + if len(tokens) > 1 { + ll.Args = tokens[1:] + } + + if len(textSplit) > 1 { + ll.Text = textSplit[1] + } + + return ll +} + +func parseFilename(fname string) (date time.Time, channel string, err error) { + date, err = time.ParseInLocation("2006-01-02_150405", fname[:17], time.Local) + if err != nil { + return + } + + ms, err := strconv.Atoi(fname[17:20]) + if err != nil { + return + } + + date = date.Add(time.Duration(ms) * time.Millisecond) + + if len(fname) < 23 { + err = errors.New("filename too short") + return + } + + channel = fname[21:] + + return +} diff --git a/cmd/rpdata-lb2logimport/main.go b/cmd/rpdata-lb2logimport/main.go new file mode 100644 index 0000000..10d5257 --- /dev/null +++ b/cmd/rpdata-lb2logimport/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "log" + "os" + "path" + "strings" + "time" + + "git.aiterp.net/rpdata/api/internal/store" + logModel "git.aiterp.net/rpdata/api/model/log" +) + +var prefixReplacer = strings.NewReplacer("+", "", "@", "", "!", "", "%", "") + +func main() { + err := store.Init() + if err != nil { + log.Fatalln(err) + } + + for _, filepath := range os.Args[1:] { + name := strings.Replace(path.Base(filepath), ".txt", "", 1) + file, err := os.Open(filepath) + if err != nil { + log.Println(filepath, err) + continue + } + logLines := parseFile(file) + file.Close() + + // Get title and event + title := "" + event := "" + for _, line := range logLines { + if line.Verb == "TITLE" { + title = line.Text + } + if line.Verb == "TAG" { + event = line.Text + } + } + + date, channel, err := parseFilename(name) + if err != nil { + log.Fatalln(err) + } + + l, err := logModel.New(date, channel, title, event, "", false) + if err != nil { + log.Println(err) + continue + } + + _, err = l.NewPost(time.Now(), "annotation.info", "rpdata-lb2logimport", "This logfile is imported from aitelogs2 and may contain errors or wrong timestamps.") + if err != nil { + log.Println(err) + } + + for _, line := range logLines { + if line.Verb != "CHARS" { + continue + } + + _, err = l.NewPost(l.Date, "chars", prefixReplacer.Replace(line.Args[0]), line.Text) + if err != nil { + log.Println(err) + } + } + + for _, line := range logLines { + if line.Verb != "SCENE" && line.Verb != "ACTION" && line.Verb != "TEXT" { + continue + } + + postTime, err := time.ParseInLocation("2006-01-02 15:04:05", date.Format("2006-01-02")+" "+line.Args[1], time.Local) + diff := postTime.Sub(date) + if err != nil { + log.Println(err) + continue + } + if diff < 0 { + if diff > -time.Second { + postTime = postTime.Add(diff) + } else { + postTime = postTime.Add(time.Hour * 24) + } + } + + if line.Args[0][0] == '=' { + line.Verb = "SCENE" + } + + _, err = l.NewPost(postTime, strings.ToLower(line.Verb), prefixReplacer.Replace(line.Args[0]), line.Text) + if err != nil { + log.Println(err) + } + } + + err = l.UpdateCharacters() + if err != nil { + log.Println(err) + } + + fmt.Println(l.ID, "completed") + } +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..4267474 --- /dev/null +++ b/config.example.json @@ -0,0 +1,22 @@ +{ + "space": { + "host": "ams3.digitaloceanspaces.com", + "accessKey": "", + "secretKey": "", + "bucket": "aiterp", + "maxSize": 8388113 + }, + + "database": { + "host": "localhost", + "port": 27017, + "db": "rpdata", + "username": "", + "password": "", + "mechanism": "" + }, + + "wiki": { + "url": "https://wiki.aiterp.net/api.php" + } +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0df5ded --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,75 @@ +package config + +import ( + "encoding/json" + "errors" + "log" + "os" + "sync" +) + +var globalMutex sync.Mutex +var global *Config + +// Config is configuration +type Config struct { + Space struct { + Host string `json:"host"` + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` + Bucket string `json:"bucket"` + MaxSize int64 `json:"maxSize"` + Root string `json:"root"` + } `json:"space"` + + Database struct { + Host string `json:"host"` + Port int `json:"port"` + Db string `json:"db"` + Username string `json:"username"` + Password string `json:"password"` + Mechanism string `json:"mechanism"` + } `json:"database"` + + Wiki struct { + URL string `json:"url"` + } `json:"wiki"` +} + +// Load loads config stuff +func (config *Config) Load(filename string) error { + file, err := os.Open(filename) + if err != nil { + return err + } + + return json.NewDecoder(file).Decode(config) +} + +// LoadAny loads the first of these files it can find +func (config *Config) LoadAny(filenames ...string) error { + for _, filename := range filenames { + if err := config.Load(filename); err == nil { + return nil + } + + *config = Config{} + } + + return errors.New("Failed to load configuration files") +} + +// Global gets the global configuration, loading it if this is the first caller +func Global() Config { + globalMutex.Lock() + if global == nil { + global = &Config{} + err := global.LoadAny("/etc/aiterp/rpdata.json", "./config.json") + if err != nil { + log.Fatalln(err) + } + } + globalMutex.Unlock() + + return *global +} diff --git a/internal/session/context.go b/internal/session/context.go new file mode 100644 index 0000000..03bb2b9 --- /dev/null +++ b/internal/session/context.go @@ -0,0 +1,20 @@ +package session + +import "context" + +type contextKeyType struct{ name string } + +func (ck *contextKeyType) String() string { + return ck.name +} + +var contextKey = &contextKeyType{name: "session context key"} + +// FromContext gets a session fron the context. +func FromContext(ctx context.Context) *Session { + return ctx.Value(contextKey).(*Session) +} + +func contextWithSession(parent context.Context, session *Session) context.Context { + return context.WithValue(parent, contextKey, session) +} diff --git a/internal/session/defaults.go b/internal/session/defaults.go new file mode 100644 index 0000000..e089be4 --- /dev/null +++ b/internal/session/defaults.go @@ -0,0 +1,33 @@ +package session + +// DefaultPermissions gets the default permissions +func DefaultPermissions() []string { + return []string{ + "member", + "log.edit", + "log.reorder", + "post.edit", + "post.move", + } +} + +// AllPermissions gets all permissions and their purpose +func AllPermissions() map[string]string { + return map[string]string{ + "member": "Can add/edit/remove own content", + "user.edit": "Can edit any users", + "character.add": "Can add any characters", + "character.edit": "Can edit any characters", + "character.remove": "Can remove any characters", + "log.add": "Can add logs", + "log.edit": "Can edit logs", + "log.remove": "Can remove logs", + "post.add": "Can add posts", + "post.edit": "Can edit posts", + "post.mvoe": "Can mvoe posts", + "post.remove": "Can remove posts", + "story.add": "Can add any stories", + "story.edit": "Can edit any stories", + "story.remove": "Can remove any stories", + } +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..97138c6 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,182 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" + "log" + "net/http" + "strings" + "sync" + "time" + + "git.aiterp.net/aiterp/wikiauth" + + "git.aiterp.net/rpdata/api/internal/config" + "git.aiterp.net/rpdata/api/internal/store" + "github.com/globalsign/mgo" + "github.com/globalsign/mgo/bson" +) + +var sessionCollection *mgo.Collection + +// A Session represents a login session. +type Session struct { + mutex sync.Mutex + + ID string `bson:"_id"` + Time time.Time `bson:"time"` + UserID string `bson:"userId"` + + user *User + w http.ResponseWriter +} + +// Load loads a session from a cookie, returning either `r` or a request +// with the session context. +func Load(w http.ResponseWriter, r *http.Request) *http.Request { + cookie, err := r.Cookie("aiterp_session") + if err != nil { + return r.WithContext(contextWithSession(r.Context(), &Session{w: w})) + } + + id := cookie.Value + + session := Session{} + err = sessionCollection.FindId(id).One(&session) + if err != nil || time.Since(session.Time) > time.Hour*168 { + return r.WithContext(contextWithSession(r.Context(), &Session{w: w})) + } + + if session.ID != "" && time.Since(session.Time) > time.Second*30 { + session.Time = time.Now() + go sessionCollection.UpdateId(id, bson.M{"$set": bson.M{"time": session.Time}}) + } + + cookie.Expires = time.Now().Add(time.Hour * 168) + http.SetCookie(w, cookie) + + session.w = w + + return r.WithContext(contextWithSession(r.Context(), &session)) +} + +// Login logs a user in. +func (session *Session) Login(username, password string) error { + auth := wikiauth.New(config.Global().Wiki.URL) + + err := auth.Login(username, password) + if err != nil { + return err + } + + // Allow bot passwords + username = strings.SplitN(username, "@", 2)[0] + + data := make([]byte, 32) + _, err = rand.Read(data) + if err != nil { + return err + } + + session.ID = hex.EncodeToString(data) + session.UserID = username + session.Time = time.Now() + + err = sessionCollection.Insert(&session) + if err != nil { + return err + } + + http.SetCookie(session.w, &http.Cookie{ + Name: "aiterp_session", + Value: session.ID, + Expires: time.Now().Add(time.Hour * 2160), // 90 days + HttpOnly: true, + }) + + user, err := FindUser(session.UserID) + if err == mgo.ErrNotFound { + user = User{ID: username, Nick: "", Permissions: DefaultPermissions()} + + err := userCollection.Insert(user) + if err != nil { + return err + } + } else if err != nil { + return err + } + + return nil +} + +// Logout logs out the session +func (session *Session) Logout() { + http.SetCookie(session.w, &http.Cookie{ + Name: "aiterp_session", + Value: "", + Expires: time.Unix(0, 0), + HttpOnly: true, + }) + + session.mutex.Lock() + session.user = nil + session.UserID = "" + session.ID = "" + session.mutex.Unlock() + + sessionCollection.RemoveId(session.ID) +} + +// User gets the user information for the session. +func (session *Session) User() *User { + session.mutex.Lock() + defer session.mutex.Unlock() + + if session.user != nil { + return session.user + } + + if session.UserID == "" { + return nil + } + + user, err := FindUser(session.UserID) + if err != nil { + return nil + } + + return &user +} + +// NameOrPermitted is a shorthand for checking the username OR permissions, e.g. to check +// if a logged in user can edit a certain post. +func (session *Session) NameOrPermitted(userid string, permissions ...string) bool { + if session.UserID == userid { + return true + } + + user := session.User() + if user == nil { + return false + } + + return user.Permitted() +} + +func init() { + store.HandleInit(func(db *mgo.Database) { + sessionCollection = db.C("core.sessions") + + sessionCollection.EnsureIndexKey("nick") + sessionCollection.EnsureIndexKey("userId") + + err := sessionCollection.EnsureIndex(mgo.Index{ + Name: "time", + Key: []string{"time"}, + ExpireAfter: time.Hour * 168, + }) + if err != nil { + log.Fatalln(err) + } + }) +} diff --git a/internal/session/user.go b/internal/session/user.go new file mode 100644 index 0000000..dc6eb5c --- /dev/null +++ b/internal/session/user.go @@ -0,0 +1,44 @@ +package session + +import ( + "git.aiterp.net/rpdata/api/internal/store" + "github.com/globalsign/mgo" +) + +var userCollection *mgo.Collection + +// A User represents user information about a user that has logged in. +type User struct { + ID string `bson:"_id" json:"id"` + Nick string `bson:"nick,omitempty" json:"nick,omitempty"` + Permissions []string `bson:"permissions" json:"permissions"` +} + +// Permitted returns true if either of the permissions can be found +// +// `user.ID == page.Author || user.Permitted("story.edit")` +func (user *User) Permitted(permissions ...string) bool { + for i := range permissions { + for j := range user.Permissions { + if permissions[i] == user.Permissions[j] { + return true + } + } + } + + return false +} + +// FindUser finds a user by userid +func FindUser(userid string) (User, error) { + user := User{} + err := userCollection.FindId(userid).One(&user) + + return user, err +} + +func init() { + store.HandleInit(func(db *mgo.Database) { + userCollection = db.C("core.users") + }) +} diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 0000000..2e034f8 --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,73 @@ +package store + +import ( + "fmt" + "time" + + "github.com/globalsign/mgo" +) + +var db *mgo.Database +var dbInits []func(db *mgo.Database) + +// ConnectDB connects to a mongodb database. +func ConnectDB(host string, port int, database, username, password, mechanism string) error { + session, err := mgo.DialWithInfo(&mgo.DialInfo{ + Addrs: []string{fmt.Sprintf("%s:%d", host, port)}, + Timeout: 30 * time.Second, + Database: database, + Username: username, + Password: password, + Mechanism: mechanism, + Source: database, + }) + if err != nil { + return err + } + + db = session.DB(database) + + return setupDB() +} + +// HandleInit handles the initialization of the database +func HandleInit(function func(db *mgo.Database)) { + dbInits = append(dbInits, function) +} + +func setupDB() error { + db.C("common.characters").EnsureIndexKey("name") + db.C("common.characters").EnsureIndexKey("shortName") + db.C("common.characters").EnsureIndexKey("author") + err := db.C("common.characters").EnsureIndex(mgo.Index{ + Key: []string{"nicks"}, + Unique: true, + DropDups: true, + }) + if err != nil { + return err + } + + db.C("logbot3.logs").EnsureIndexKey("date") + db.C("logbot3.logs").EnsureIndexKey("channel") + db.C("logbot3.logs").EnsureIndexKey("channel", "open") + db.C("logbot3.logs").EnsureIndexKey("open") + db.C("logbot3.logs").EnsureIndexKey("oldId") + db.C("logbot3.logs").EnsureIndexKey("characterIds") + db.C("logbot3.logs").EnsureIndexKey("event") + db.C("logbot3.logs").EnsureIndexKey("$text:channel", "$text:title", "$text:event", "$text:description", "$text:posts.nick", "$text:posts.text") + + err = db.C("server.changes").EnsureIndex(mgo.Index{ + Key: []string{"date"}, + ExpireAfter: time.Hour * (24 * 14), + }) + if err != nil { + return err + } + + for _, dbInit := range dbInits { + dbInit(db) + } + + return nil +} diff --git a/internal/store/init.go b/internal/store/init.go new file mode 100644 index 0000000..a76c7ad --- /dev/null +++ b/internal/store/init.go @@ -0,0 +1,37 @@ +package store + +import ( + "sync" + + "git.aiterp.net/rpdata/api/internal/config" +) + +var initMuted sync.Mutex +var hasInitialized bool + +// Init initalizes the store +func Init() error { + initMuted.Lock() + defer initMuted.Unlock() + if hasInitialized { + return nil + } + + conf := config.Global() + + dbconf := conf.Database + err := ConnectDB(dbconf.Host, dbconf.Port, dbconf.Db, dbconf.Username, dbconf.Password, dbconf.Mechanism) + if err != nil { + return err + } + + sconf := conf.Space + err = ConnectSpace(sconf.Host, sconf.AccessKey, sconf.SecretKey, sconf.Bucket, sconf.MaxSize, sconf.Root) + if err != nil { + return err + } + + hasInitialized = true + + return nil +} diff --git a/internal/store/space.go b/internal/store/space.go new file mode 100644 index 0000000..b515272 --- /dev/null +++ b/internal/store/space.go @@ -0,0 +1,78 @@ +package store + +import ( + "context" + "errors" + "fmt" + "io" + + minio "github.com/minio/minio-go" +) + +var spaceBucket string +var spaceURLRoot string +var spaceRoot string +var spaceClient *minio.Client +var spaceMaxSize int64 + +// ConnectSpace connects to a S3 space. +func ConnectSpace(host, accessKey, secretKey, bucket string, maxSize int64, rootDirectory string) error { + client, err := minio.New(host, accessKey, secretKey, true) + if err != nil { + return err + } + + exists, err := client.BucketExists(bucket) + if err != nil { + return err + } + + if !exists { + return errors.New("Bucket not found") + } + + spaceClient = client + spaceBucket = bucket + spaceURLRoot = fmt.Sprintf("https://%s.%s/%s/", bucket, host, rootDirectory) + spaceMaxSize = maxSize + spaceRoot = rootDirectory + + return nil +} + +// UploadFile uploads the file to the space. This does not do any checks on it, so the endpoints should +// ensure that's all okay. +func UploadFile(ctx context.Context, folder string, name string, mimeType string, reader io.Reader, size int64) (string, error) { + path := folder + "/" + name + + if size > spaceMaxSize { + return "", errors.New("File is too big") + } + + _, err := spaceClient.PutObjectWithContext(ctx, spaceBucket, spaceRoot+"/"+path, reader, size, minio.PutObjectOptions{ + ContentType: mimeType, + UserMetadata: map[string]string{ + "x-amz-acl": "public-read", + }, + }) + if err != nil { + return "", err + } + + _, err = spaceClient.StatObject(spaceBucket, path, minio.StatObjectOptions{}) + if err != nil { + return "", err + } + + return path, nil +} + +// DownloadFile opens a file for download, using the same path format as the UploadFile function. Remember to Close it! +func DownloadFile(ctx context.Context, path string) (io.ReadCloser, error) { + return spaceClient.GetObjectWithContext(ctx, spaceBucket, spaceRoot+"/"+path, minio.GetObjectOptions{}) +} + +// URLFromPath gets the URL from the path returned by UploadFile +func URLFromPath(path string) string { + return spaceURLRoot + path +} diff --git a/loader/character.go b/loader/character.go new file mode 100644 index 0000000..f9d4514 --- /dev/null +++ b/loader/character.go @@ -0,0 +1,133 @@ +package loader + +import ( + "context" + "errors" + "log" + "strings" + + "git.aiterp.net/rpdata/api/model/character" + "github.com/graph-gophers/dataloader" +) + +// Character gets a character by key +func (loader *Loader) Character(key, value string) (character.Character, error) { + if !strings.HasPrefix(key, "Character.") { + key = "Character." + key + } + + if loader.loaders[key] == nil { + return character.Character{}, errors.New("unsupported key") + } + + thunk := loader.loaders[key].Load(loader.ctx, dataloader.StringKey(value)) + res, err := thunk() + if err != nil { + return character.Character{}, err + } + + char, ok := res.(character.Character) + if !ok { + return character.Character{}, errors.New("incorrect type") + } + + return char, nil +} + +// Characters gets characters by key +func (loader *Loader) Characters(key string, values ...string) ([]character.Character, error) { + if !strings.HasPrefix(key, "Character.") { + key = "Character." + key + } + + if loader.loaders[key] == nil { + return nil, errors.New("unsupported key") + } + + thunk := loader.loaders[key].LoadMany(loader.ctx, dataloader.NewKeysFromStrings(values)) + res, errs := thunk() + for _, err := range errs { + if err != nil && err != ErrNotFound { + return nil, err + } + } + + chars := make([]character.Character, len(res)) + + for i := range res { + char, ok := res[i].(character.Character) + if !ok { + return nil, errors.New("incorrect type") + } + + chars[i] = char + } + + return chars, nil +} + +func characterIDBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { + results := make([]*dataloader.Result, 0, len(keys)) + ids := keys.Keys() + + log.Println("Loading", len(ids), "characters:", strings.Join(ids, ",")) + + characters, err := character.ListIDs(ids...) + if err != nil { + for range ids { + results = append(results, &dataloader.Result{Error: err}) + } + + return results + } + + for _, id := range ids { + found := false + + for _, character := range characters { + if character.ID == id { + results = append(results, &dataloader.Result{Data: character}) + found = true + break + } + } + + if !found { + results = append(results, &dataloader.Result{Data: character.Character{}, Error: ErrNotFound}) + } + } + + return results +} + +func characterNickBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { + var results []*dataloader.Result + nicks := keys.Keys() + + characters, err := character.ListNicks(nicks...) + if err != nil { + for range nicks { + results = append(results, &dataloader.Result{Error: err}) + } + + return results + } + + for _, nick := range nicks { + found := false + for i := range characters { + if characters[i].HasNick(nick) { + results = append(results, &dataloader.Result{Data: characters[i]}) + + found = true + break + } + } + + if !found { + results = append(results, &dataloader.Result{Data: character.Character{}, Error: err}) + } + } + + return results +} diff --git a/loader/loader.go b/loader/loader.go new file mode 100644 index 0000000..c121692 --- /dev/null +++ b/loader/loader.go @@ -0,0 +1,48 @@ +package loader + +import ( + "context" + "errors" + "time" + + "github.com/graph-gophers/dataloader" +) + +var contextKey struct{} + +// ErrNotFound is returned in batches when one or more things weren't found. Usually harmless. +var ErrNotFound = errors.New("not found") + +// A Loader is a collection of data loaders and functions to act on them. It's supposed to be +// request-scoped, and will thus keep things cached indefinitely. +type Loader struct { + ctx context.Context + loaders map[string]*dataloader.Loader +} + +// New initializes the loader. +func New() *Loader { + return &Loader{ + ctx: context.Background(), + loaders: map[string]*dataloader.Loader{ + "Character.id": dataloader.NewBatchedLoader(characterIDBatch, dataloader.WithWait(time.Millisecond*2)), + "Character.nick": dataloader.NewBatchedLoader(characterNickBatch, dataloader.WithWait(time.Millisecond*2)), + }, + } +} + +// FromContext gets the Loader from context. +func FromContext(ctx context.Context) *Loader { + value := ctx.Value(&contextKey) + if value == nil { + return nil + } + + return value.(*Loader) +} + +// ToContext gets a context with the loader as a value +func (loader *Loader) ToContext(ctx context.Context) context.Context { + loader.ctx = ctx + return context.WithValue(ctx, &contextKey, loader) +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..969bf3a --- /dev/null +++ b/makefile @@ -0,0 +1,15 @@ +INSTALL_PATH ?= ./build + +build: + dep ensure + go generate ./... + go test ./... + mkdir -p $(INSTALL_PATH)/usr/bin + mkdir -p $(INSTALL_PATH)/etc/aiterp + cp ./config.example.json $(INSTALL_PATH)/etc/aiterp/rpdata.json + go build -ldflags="-s -w" -o $(INSTALL_PATH)/usr/bin/rpdata-graphiql ./cmd/rpdata-graphiql + go build -ldflags="-s -w" -o $(INSTALL_PATH)/usr/bin/rpdata-lb2charimport ./cmd/rpdata-lb2charimport + go build -ldflags="-s -w" -o $(INSTALL_PATH)/usr/bin/rpdata-lb2logimport ./cmd/rpdata-lb2logimport + +install: + cp $(INSTALL_PATH)/usr/bin/* /usr/local/bin/ \ No newline at end of file diff --git a/model/change/change.go b/model/change/change.go new file mode 100644 index 0000000..5469d90 --- /dev/null +++ b/model/change/change.go @@ -0,0 +1,75 @@ +package change + +import ( + "log" + "time" + + "git.aiterp.net/rpdata/api/model/counter" + + "git.aiterp.net/rpdata/api/internal/store" + "github.com/globalsign/mgo" +) + +var collection *mgo.Collection + +// A Change represents a change in any other model +type Change struct { + ID int `bson:"_id" json:"id"` + Time time.Time `bson:"time" json:"time"` + Model string `bson:"model" json:"model"` + Op string `bson:"op" json:"op"` + Author string `bson:"author,omitempty" json:"author,omitempty"` + ObjectID string `bson:"objectId,omitempty" json:"objectId,omitempty"` + Data interface{} `bson:"data,omitempty" json:"data,omitempty"` +} + +// PublicModels lists which models can be listed in bulk by anyone. +var PublicModels = []string{ + "Character", + "Log", + "Post", +} + +// Submit submits a change to the history. +func Submit(model, op, author, objectID string, data interface{}) (Change, error) { + index, err := counter.Next("auto_increment", "Change") + if err != nil { + return Change{}, err + } + + change := Change{ + ID: index, + Time: time.Now(), + Model: model, + Op: op, + Author: author, + ObjectID: objectID, + Data: data, + } + + err = collection.Insert(&change) + if err != nil { + return Change{}, err + } + + return change, err +} + +func init() { + store.HandleInit(func(db *mgo.Database) { + collection = db.C("common.history") + + collection.EnsureIndexKey("model") + collection.EnsureIndexKey("author") + collection.EnsureIndexKey("objectId") + + err := collection.EnsureIndex(mgo.Index{ + Name: "expiry", + Key: []string{"time"}, + ExpireAfter: time.Hour * 336, + }) + if err != nil { + log.Fatalln(err) + } + }) +} diff --git a/model/character/character.go b/model/character/character.go new file mode 100644 index 0000000..e342286 --- /dev/null +++ b/model/character/character.go @@ -0,0 +1,231 @@ +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 + +// 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"` +} + +// 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() ([]Character, error) { + return list(bson.M{}) +} + +// 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}}) +} + +// 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) + } + }) +} diff --git a/model/counter/counter.go b/model/counter/counter.go new file mode 100644 index 0000000..3e90823 --- /dev/null +++ b/model/counter/counter.go @@ -0,0 +1,37 @@ +package counter + +import ( + "git.aiterp.net/rpdata/api/internal/store" + "github.com/globalsign/mgo" + "github.com/globalsign/mgo/bson" +) + +var collection *mgo.Collection + +type counter struct { + ID string `bson:"_id"` + Value int `bson:"value"` +} + +// Next gets the next value of a counter, or an error if it hasn't. +func Next(category, name string) (int, error) { + id := category + "." + name + doc := counter{} + + _, err := collection.Find(bson.M{"_id": id}).Apply(mgo.Change{ + Update: bson.M{"$inc": bson.M{"value": 1}}, + Upsert: true, + ReturnNew: true, + }, &doc) + if err != nil { + return -1, err + } + + return doc.Value, nil +} + +func init() { + store.HandleInit(func(db *mgo.Database) { + collection = db.C("core.counters") + }) +} diff --git a/model/counter/counter_test.go b/model/counter/counter_test.go new file mode 100644 index 0000000..50138b8 --- /dev/null +++ b/model/counter/counter_test.go @@ -0,0 +1,19 @@ +package counter_test + +import ( + "testing" + + "git.aiterp.net/rpdata/api/internal/store" + "git.aiterp.net/rpdata/api/model/counter" +) + +func TestCounter(t *testing.T) { + store.Init() + + value, err := counter.Next("test", "times_tested") + if err != nil { + t.Error(err) + } + + t.Log("Value:", value) +} diff --git a/model/log/log.go b/model/log/log.go new file mode 100644 index 0000000..c4c6095 --- /dev/null +++ b/model/log/log.go @@ -0,0 +1,374 @@ +package log + +import ( + "errors" + "fmt" + "log" + "sort" + "strconv" + "strings" + "sync" + "time" + + "git.aiterp.net/rpdata/api/internal/store" + "git.aiterp.net/rpdata/api/model/character" + "git.aiterp.net/rpdata/api/model/counter" + + "github.com/globalsign/mgo/bson" + + "github.com/globalsign/mgo" +) + +var postMutex sync.RWMutex + +var characterUpdateMutex sync.Mutex + +var logsCollection *mgo.Collection + +// Log is the header/session for a log file. +type Log struct { + ID string `bson:"_id"` + ShortID string `bson:"shortId"` + Date time.Time `bson:"date"` + Channel string `bson:"channel"` + Title string `bson:"title,omitempty"` + Event string `bson:"event,omitempty"` + Description string `bson:"description,omitempty"` + Open bool `bson:"open"` + CharacterIDs []string `bson:"characterIds"` +} + +// New creates a new Log +func New(date time.Time, channel, title, event, description string, open bool) (Log, error) { + nextID, err := counter.Next("auto_increment", "Log") + if err != nil { + return Log{}, err + } + + log := Log{ + ID: MakeLogID(date, channel), + ShortID: "L" + strconv.Itoa(nextID), + Date: date, + Channel: channel, + Title: title, + Event: event, + Description: description, + Open: open, + CharacterIDs: nil, + } + + err = logsCollection.Insert(log) + if err != nil { + return Log{}, err + } + + return log, nil +} + +// FindID finds a log either by it's ID or short ID. +func FindID(id string) (Log, error) { + return findLog(bson.M{ + "$or": []bson.M{ + bson.M{"_id": id}, + bson.M{"shortId": id}, + }, + }) +} + +// List lists all logs +func List(limit int) ([]Log, error) { + return listLog(bson.M{}, limit) +} + +// Remove removes the log post with this ID. Both the long and short ID is accepted +func Remove(id string) error { + return logsCollection.Remove(bson.M{ + "$or": []bson.M{ + bson.M{"_id": id}, + bson.M{"shortId": id}, + }, + }) +} + +// ListSearch lists the logs matching the parameters. Empty/zero values means the parameter is ingored when +// building the query. This is the old aitelogs2 way, but with the addition of a text search. +// +// If a text search is specified, it will make two trips to the database. +func ListSearch(textSearch string, channels []string, characterIds []string, events []string, open bool, limit int) ([]Log, error) { + postMutex.RLock() + defer postMutex.RUnlock() + + query := bson.M{} + + // Run a text search + if textSearch != "" { + searchResults := make([]string, 0, 32) + + err := postCollection.Find(bson.M{"$text": bson.M{"$search": textSearch}}).Distinct("logId", &searchResults) + if err != nil { + return nil, err + } + + // Posts always use shortId to refer to the log + query["shortId"] = bson.M{"$in": searchResults} + } + + // Find logs including any of the specified events and channels + if len(channels) > 0 { + query["channel"] = bson.M{"$in": channels} + } + if len(events) > 0 { + query["events"] = bson.M{"$in": channels} + } + + // Find logs including all of the specified character IDs. + if len(characterIds) > 0 { + query["characterIds"] = bson.M{"$all": characterIds} + } + + // Limit to only open logs + if open { + query["open"] = true + } + + return listLog(query, limit) +} + +// Edit sets the metadata +func (log *Log) Edit(title *string, event *string, description *string, open *bool) error { + changes := bson.M{} + + if title != nil && *title != log.Title { + changes["title"] = *title + } + if event != nil && *event != log.Event { + changes["event"] = *event + } + if description != nil && *description != log.Description { + changes["description"] = *description + } + if open != nil && *open != log.Open { + changes["open"] = *open + } + + if len(changes) == 0 { + return nil + } + + err := logsCollection.UpdateId(log.ID, bson.M{"$set": changes}) + if err != nil { + return err + } + + if title != nil { + log.Title = *title + } + if event != nil { + log.Event = *event + } + if description != nil { + log.Description = *description + } + if open != nil { + log.Open = *open + } + + return nil +} + +// Posts gets all the posts under the log. If no kinds are specified, it +// will get all posts +func (log *Log) Posts(kinds ...string) ([]Post, error) { + postMutex.RLock() + defer postMutex.RUnlock() + + query := bson.M{ + "$or": []bson.M{ + bson.M{"logId": log.ID}, + bson.M{"logId": log.ShortID}, + }, + } + + if len(kinds) > 0 { + for i := range kinds { + kinds[i] = strings.ToLower(kinds[i]) + } + + query["kind"] = bson.M{"$in": kinds} + } + + posts, err := listPosts(query) + if err != nil { + return nil, err + } + + sort.SliceStable(posts, func(i, j int) bool { + return posts[i].Index < posts[j].Index + }) + + return posts, nil +} + +// NewPost creates a new post. +func (log *Log) NewPost(time time.Time, kind, nick, text string) (Post, error) { + if kind == "" || nick == "" || text == "" { + return Post{}, errors.New("Missing/empty parameters") + } + + postMutex.RLock() + defer postMutex.RUnlock() + + index, err := counter.Next("next_post_id", log.ShortID) + if err != nil { + return Post{}, err + } + + post := Post{ + ID: MakePostID(time), + Index: index, + LogID: log.ShortID, + Time: time, + Kind: kind, + Nick: nick, + Text: text, + } + + err = postCollection.Insert(post) + if err != nil { + return Post{}, err + } + + return post, nil +} + +// UpdateCharacters updates the character list +func (log *Log) UpdateCharacters() error { + characterUpdateMutex.Lock() + defer characterUpdateMutex.Unlock() + + posts, err := log.Posts() + if err != nil { + return err + } + + added := make(map[string]bool) + removed := make(map[string]bool) + for _, post := range posts { + if post.Kind == "text" || post.Kind == "action" { + if strings.HasPrefix(post.Text, "(") || strings.Contains(post.Nick, "(") || strings.Contains(post.Nick, "[E]") { + continue + } + + // Clean up the nick (remove possessive suffix, comma, formatting stuff) + if strings.HasSuffix(post.Nick, "'s") || strings.HasSuffix(post.Nick, "`s") { + post.Nick = post.Nick[:len(post.Nick)-2] + } else if strings.HasSuffix(post.Nick, "'") || strings.HasSuffix(post.Nick, "`") || strings.HasSuffix(post.Nick, ",") || strings.HasSuffix(post.Nick, "\x0f") { + post.Nick = post.Nick[:len(post.Nick)-1] + } + + added[post.Nick] = true + } + if post.Kind == "chars" { + tokens := strings.Fields(post.Text) + for _, token := range tokens { + if strings.HasPrefix(token, "-") { + removed[token[1:]] = true + } else { + added[strings.Replace(token, "+", "", 1)] = true + } + } + } + } + + nicks := make([]string, 0, len(added)) + for nick := range added { + if added[nick] && !removed[nick] { + nicks = append(nicks, nick) + } + } + + characters, err := character.ListNicks(nicks...) + if err != nil { + return err + } + + characterIDs := make([]string, len(characters)) + for i, char := range characters { + characterIDs[i] = char.ID + } + + err = logsCollection.UpdateId(log.ID, bson.M{"$set": bson.M{"characterIds": characterIDs}}) + if err != nil { + return err + } + + for _, nick := range nicks { + found := false + + for _, character := range characters { + if character.HasNick(nick) { + found = true + break + } + } + + if !found { + addUnknownNick(nick) + } + } + + log.CharacterIDs = characterIDs + + return nil +} + +func findLog(query interface{}) (Log, error) { + log := Log{} + err := logsCollection.Find(query).One(&log) + if err != nil { + return Log{}, err + } + + return log, nil +} + +func listLog(query interface{}, limit int) ([]Log, error) { + logs := make([]Log, 0, 64) + err := logsCollection.Find(query).Limit(limit).Sort("-date").All(&logs) + if err != nil { + return nil, err + } + + return logs, nil +} + +func iterLogs(query interface{}, limit int) *mgo.Iter { + return logsCollection.Find(query).Sort("-date").Limit(limit).Batch(8).Iter() +} + +// MakeLogID generates log IDs that are of the format from logbot2, though it will break compatibility. +func MakeLogID(date time.Time, channel string) string { + return fmt.Sprintf("%s%03d_%s", date.UTC().Format("2006-01-02_150405"), (date.Nanosecond() / int(time.Millisecond/time.Nanosecond)), channel[1:]) +} + +func init() { + store.HandleInit(func(db *mgo.Database) { + logsCollection = db.C("logbot3.logs") + + logsCollection.EnsureIndexKey("date") + logsCollection.EnsureIndexKey("channel") + logsCollection.EnsureIndexKey("characterIds") + logsCollection.EnsureIndexKey("event") + logsCollection.EnsureIndex(mgo.Index{ + Key: []string{"channel", "open"}, + }) + err := logsCollection.EnsureIndex(mgo.Index{ + Key: []string{"shortId"}, + Unique: true, + DropDups: true, + }) + if err != nil { + log.Fatalln("init logbot3.logs:", err) + } + }) +} diff --git a/model/log/log_test.go b/model/log/log_test.go new file mode 100644 index 0000000..016959e --- /dev/null +++ b/model/log/log_test.go @@ -0,0 +1,83 @@ +package log_test + +import ( + "testing" + "time" + + "git.aiterp.net/rpdata/api/internal/store" + "git.aiterp.net/rpdata/api/model/log" +) + +func TestMakeLogID(t *testing.T) { + table := []struct { + Date time.Time + Channel string + Expected string + }{ + { + time.Date(2018, 4, 9, 9, 3, 0, 133000000, time.FixedZone("CEST", 7200)), "#Miner'sRespite", + "2018-04-09_070300133_Miner'sRespite", + }, + { + time.Date(2017, 3, 23, 23, 59, 59, 0, time.UTC), "#RedrockAgency", + "2017-03-23_235959000_RedrockAgency", + }, + } + + for _, row := range table { + t.Run(row.Expected, func(t *testing.T) { + id := log.MakeLogID(row.Date, row.Channel) + + if id != row.Expected { + t.Error("Failed to make ID, result:", id) + } + }) + } +} + +func TestSearch(t *testing.T) { + store.Init() + + logs, err := log.ListSearch("", nil, []string{"C31", "C51"}, nil, false, 0) + if err != nil { + t.Log(err) + t.Skip() + } + + for _, l := range logs { + t.Log(l.ID) + } +} + +func TestMovePost(t *testing.T) { + store.Init() + + l, err := log.FindID("L684") + if err != nil { + t.Log(err) + t.Skip() + } + + posts, err := l.Posts() + if err != nil { + t.Log(err) + t.Skip() + } + + for _, post := range posts { + if post.ID == "blfn5uaxpyf11j4phxo" { + start := time.Now() + + err := post.Move(1) + if err != nil { + t.Error(err) + } + + t.Log(time.Since(start)) + } + } +} + +func TestMakePostID(t *testing.T) { + t.Log(log.MakePostID(time.Now())) +} diff --git a/model/log/post.go b/model/log/post.go new file mode 100644 index 0000000..b9d9a92 --- /dev/null +++ b/model/log/post.go @@ -0,0 +1,198 @@ +package log + +import ( + "crypto/rand" + "encoding/binary" + "errors" + "log" + "strconv" + "time" + + "git.aiterp.net/rpdata/api/internal/store" + "github.com/globalsign/mgo" + "github.com/globalsign/mgo/bson" +) + +var postCollection *mgo.Collection + +// A Post is a part of a log file. +type Post struct { + ID string `bson:"_id"` + LogID string `bson:"logId"` + Time time.Time `bson:"time"` + Kind string `bson:"kind"` + Nick string `bson:"nick"` + Text string `bson:"text"` + Index int `bson:"index"` +} + +// Edit the post +func (post *Post) Edit(time *time.Time, kind *string, nick *string, text *string) error { + changes := bson.M{} + changed := false + postCopy := *post + + if time != nil && !time.IsZero() && !time.Equal(post.Time) { + changes["time"] = *time + changed = true + postCopy.Time = *time + } + if kind != nil && *kind != "" && *kind != post.Kind { + changes["kind"] = *kind + changed = true + postCopy.Kind = *kind + } + if nick != nil && *nick != "" && *nick != post.Nick { + changes["nick"] = *nick + changed = true + postCopy.Nick = *nick + } + if text != nil && *text != "" && *text != post.Text { + changes["text"] = *text + changed = true + postCopy.Text = *text + } + + if !changed { + return nil + } + + err := postCollection.UpdateId(post.ID, bson.M{"$set": changes}) + if err != nil { + return err + } + + *post = postCopy + return nil +} + +// Move the post +func (post *Post) Move(targetIndex int) error { + if targetIndex < 1 { + return errors.New("Invalid index") + } + + postMutex.Lock() + defer postMutex.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 index. + if targetIndex > 1 { + existingPost := Post{} + err := postCollection.Find(bson.M{"logId": post.LogID, "index": targetIndex}).One(&existingPost) + + if err != nil || existingPost.Index != targetIndex { + return errors.New("No post found at the index") + } + } + + query := bson.M{"logId": post.LogID} + operation := bson.M{"$inc": bson.M{"index": 1}} + + if targetIndex < post.Index { + query["$and"] = []bson.M{ + bson.M{"index": bson.M{"$gte": targetIndex}}, + bson.M{"index": bson.M{"$lt": post.Index}}, + } + } else { + query["$and"] = []bson.M{ + bson.M{"index": bson.M{"$gt": post.Index}}, + bson.M{"index": bson.M{"$lte": targetIndex}}, + } + + operation["$inc"] = bson.M{"index": -1} + } + + _, err := postCollection.UpdateAll(query, operation) + if err != nil { + return errors.New("moving others: " + err.Error()) + } + + err = postCollection.UpdateId(post.ID, bson.M{"$set": bson.M{"index": targetIndex}}) + if err != nil { + return errors.New("moving: " + err.Error()) + } + + post.Index = targetIndex + + return nil +} + +// FindPostID finds a log post by ID. +func FindPostID(id string) (Post, error) { + return findPost(bson.M{"_id": id}) +} + +// ListPostIDs lists log posts by ID +func ListPostIDs(ids ...string) ([]Post, error) { + return listPosts(bson.M{"_id": bson.M{"$in": ids}}) +} + +// RemovePost removes a post, moving all subsequent post up one index +func RemovePost(id string) (Post, error) { + postMutex.Lock() + defer postMutex.Unlock() + + post, err := findPost(bson.M{"_id": id}) + if err != nil { + return Post{}, err + } + + err = postCollection.RemoveId(id) + if err != nil { + return Post{}, err + } + + _, err = postCollection.UpdateAll(bson.M{"logId": post.LogID, "index": bson.M{"$gt": post.Index}}, bson.M{"$inc": bson.M{"index": -1}}) + if err != nil { + return Post{}, err + } + + return post, nil +} + +func findPost(query interface{}) (Post, error) { + post := Post{} + err := postCollection.Find(query).One(&post) + if err != nil { + return Post{}, err + } + + return post, nil +} + +func listPosts(query interface{}) ([]Post, error) { + posts := make([]Post, 0, 64) + err := postCollection.Find(query).All(&posts) + if err != nil { + return nil, err + } + + return posts, nil +} + +// MakePostID makes a random post ID +func MakePostID(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) +} + +func init() { + store.HandleInit(func(db *mgo.Database) { + postCollection = db.C("logbot3.posts") + + postCollection.EnsureIndexKey("logId") + postCollection.EnsureIndexKey("time") + postCollection.EnsureIndexKey("kind") + postCollection.EnsureIndexKey("index") + + err := postCollection.EnsureIndex(mgo.Index{ + Key: []string{"$text:text"}, + }) + if err != nil { + log.Fatalln("init logbot3.logs:", err) + } + }) +} diff --git a/model/log/unknownnick.go b/model/log/unknownnick.go new file mode 100644 index 0000000..ef8dfe3 --- /dev/null +++ b/model/log/unknownnick.go @@ -0,0 +1,42 @@ +package log + +import ( + "git.aiterp.net/rpdata/api/internal/store" + "github.com/globalsign/mgo" + "github.com/globalsign/mgo/bson" +) + +var unknownConnection *mgo.Collection + +// An UnknownNick is a nick found by the character list updater that +// does not exist. The score is the number of logs that nick was in, meaning +// nicks with a higher score should be a high priority to be matched with +// a character. +type UnknownNick struct { + Nick string `bson:"_id" json:"nick"` + Score int `bson:"score" json:"score"` +} + +// UnknownNicks gets all the unknown nicks from the last search. +func UnknownNicks() ([]UnknownNick, error) { + nicks := make([]UnknownNick, 0, 256) + err := unknownConnection.Find(bson.M{}).Sort("-score").All(&nicks) + + return nicks, err +} + +func addUnknownNick(nick string) error { + _, err := unknownConnection.UpsertId(nick, bson.M{"$inc": bson.M{"score": 1}}) + return err +} + +func clearUnknownNicks() error { + _, err := unknownConnection.RemoveAll(bson.M{}) + return err +} + +func init() { + store.HandleInit(func(db *mgo.Database) { + unknownConnection = db.C("logbot3.unknown_nicks") + }) +} diff --git a/model/log/updater.go b/model/log/updater.go new file mode 100644 index 0000000..981fa7c --- /dev/null +++ b/model/log/updater.go @@ -0,0 +1,84 @@ +package log + +import ( + "sync" + "time" + + "github.com/globalsign/mgo/bson" +) + +var scheduleCharacterUpdate = func() func() { + var mutex sync.Mutex + var scheduled bool + + return func() { + mutex.Lock() + if !scheduled { + go func() { + time.Sleep(time.Second * 60) + + // If another comes along in the next 2-3 seconds, it should schedule a new + // round to avoid a character only appearing in half their logs. + mutex.Lock() + scheduled = false + mutex.Unlock() + + UpdateAllCharacters() + }() + + scheduled = true + } + mutex.Unlock() + } +}() + +// ScheduleCharacterUpdate schedules a full update within the minute. +// Subsequent calls within that time will not schedule anything. Even +// if the operation takes a few seconds at most, it need not be ran often. +func ScheduleCharacterUpdate() { + scheduleCharacterUpdate() +} + +// UpdateCharacters is a shorthand for getting a log and updaing its characters +func UpdateCharacters(logID string) error { + log, err := FindID(logID) + if err != nil { + return err + } + + return log.UpdateCharacters() +} + +// UpdateAllCharacters updates character list on all logs. This should +// be done if one or more characters failed to be added. +func UpdateAllCharacters() (updated int, err error) { + updated = 0 + + err = clearUnknownNicks() + if err != nil { + return + } + + iter := iterLogs(bson.M{}, 0) + err = iter.Err() + if err != nil { + return + } + + log := Log{} + for iter.Next(&log) { + err = log.UpdateCharacters() + if err != nil { + return + } + + updated++ + } + + err = iter.Err() + if err != nil { + return + } + + return +} diff --git a/resolver/character.go b/resolver/character.go new file mode 100644 index 0000000..d655698 --- /dev/null +++ b/resolver/character.go @@ -0,0 +1,330 @@ +package resolver + +import ( + "context" + "errors" + "strings" + + "git.aiterp.net/rpdata/api/internal/session" + "git.aiterp.net/rpdata/api/loader" + "git.aiterp.net/rpdata/api/model/change" + "git.aiterp.net/rpdata/api/model/character" + "git.aiterp.net/rpdata/api/model/log" +) + +// CharacterResolver for the Character graphql type +type CharacterResolver struct{ C character.Character } + +// CharacterArgs is an arg +type CharacterArgs struct { + ID *string + Nick *string +} + +// Character resolver +func (r *QueryResolver) Character(ctx context.Context, args *CharacterArgs) (*CharacterResolver, error) { + var char character.Character + var err error + + loader := loader.FromContext(ctx) + if loader == nil { + return nil, errors.New("no loader") + } + + switch { + case args.ID != nil: + char, err = character.FindID(*args.ID) + case args.Nick != nil: + char, err = character.FindNick(*args.Nick) + default: + err = ErrCannotResolve + } + + if err != nil { + return nil, err + } + + return &CharacterResolver{C: char}, nil +} + +// CharactersArgs is an arg +type CharactersArgs struct { + IDs *[]string + Nicks *[]string + Author *string +} + +// Characters resolves +func (r *QueryResolver) Characters(ctx context.Context, args *CharactersArgs) ([]*CharacterResolver, error) { + var chars []character.Character + var err error + + loader := loader.FromContext(ctx) + if loader == nil { + return nil, errors.New("no loader") + } + + switch { + case args.IDs != nil: + chars, err = character.ListIDs(*args.IDs...) + case args.Nicks != nil: + chars, err = character.ListNicks(*args.Nicks...) + case args.Author != nil: + chars, err = character.ListAuthor(*args.Author) + default: + chars, err = character.List() + } + + if err != nil { + return nil, err + } + + resolvers := make([]*CharacterResolver, 0, len(chars)) + for i := range chars { + if chars[i].ID == "" { + continue + } + + resolvers = append(resolvers, &CharacterResolver{C: chars[i]}) + } + + return resolvers, nil +} + +// AddCharacterInput is args for mutation addCharacter +type AddCharacterInput struct { + Nick string + Name string + ShortName *string + Author *string + Description *string +} + +// AddCharacter resolves the addCharacter mutation +func (r *MutationResolver) AddCharacter(ctx context.Context, args struct{ Input *AddCharacterInput }) (*CharacterResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("member", "character.add") { + return nil, ErrUnauthorized + } + + nick := args.Input.Nick + name := args.Input.Name + + shortName := "" + if args.Input.ShortName != nil { + shortName = *args.Input.ShortName + } else { + shortName = strings.SplitN(args.Input.Name, " ", 2)[0] + } + + author := user.ID + if args.Input.Author != nil { + author = *args.Input.Author + + if author != user.ID && !user.Permitted("character.add") { + return nil, ErrPermissionDenied + } + } + + description := "" + if args.Input.Description != nil { + description = *args.Input.Description + } + + character, err := character.New(nick, name, shortName, author, description) + if err != nil { + return nil, err + } + + go change.Submit("Character", "add", user.ID, character.ID, map[string]interface{}{ + "name": character.Name, + "nick": character.Nicks[0], + "author": character.Author, + }) + + log.ScheduleCharacterUpdate() + + return &CharacterResolver{C: character}, nil +} + +// CharacterNickInput is args for mutation addCharacterNick/removeCharacterNick +type CharacterNickInput struct { + ID string + Nick string +} + +// AddCharacterNick resolves the addCharacterNick mutation +func (r *MutationResolver) AddCharacterNick(ctx context.Context, args struct{ Input *CharacterNickInput }) (*CharacterResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("member") { + return nil, ErrUnauthorized + } + + character, err := character.FindID(args.Input.ID) + if err != nil { + return nil, err + } + if character.Author != user.ID && !user.Permitted("character.edit") { + return nil, ErrPermissionDenied + } + + err = character.AddNick(args.Input.Nick) + if err != nil { + return nil, err + } + + go change.Submit("Character", "add.nick", user.ID, character.ID, map[string]interface{}{ + "nick": args.Input.Nick, + }) + + log.ScheduleCharacterUpdate() + + return &CharacterResolver{C: character}, nil +} + +// RemoveCharacterNick resolves the removeCharacterNick mutation +func (r *MutationResolver) RemoveCharacterNick(ctx context.Context, args struct{ Input *CharacterNickInput }) (*CharacterResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("member") { + return nil, ErrUnauthorized + } + + character, err := character.FindID(args.Input.ID) + if err != nil { + return nil, err + } + if character.Author != user.ID && !user.Permitted("character.edit") { + return nil, ErrPermissionDenied + } + + err = character.RemoveNick(args.Input.Nick) + if err != nil { + return nil, err + } + + go change.Submit("Character", "remove.nick", user.ID, character.ID, map[string]interface{}{ + "nick": args.Input.Nick, + }) + + log.ScheduleCharacterUpdate() + + return &CharacterResolver{C: character}, nil +} + +// CharacterEditInput is args for mutation addCharacterNick/removeCharacterNick +type CharacterEditInput struct { + ID string + Name *string + ShortName *string + Description *string +} + +// EditCharacter resolves the editCharacter mutation +func (r *MutationResolver) EditCharacter(ctx context.Context, args struct{ Input *CharacterEditInput }) (*CharacterResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("member") { + return nil, ErrUnauthorized + } + + character, err := character.FindID(args.Input.ID) + if err != nil { + return nil, err + } + if character.Author != user.ID && !user.Permitted("character.edit") { + return nil, ErrPermissionDenied + } + + name := "" + if args.Input.Name != nil { + name = *args.Input.Name + } + shortName := "" + if args.Input.ShortName != nil { + shortName = *args.Input.ShortName + } + description := "" + if args.Input.Description != nil { + description = *args.Input.Description + } + + err = character.Edit(name, shortName, description) + if err != nil { + return nil, err + } + + go change.Submit("Character", "edit", user.ID, character.ID, map[string]interface{}{ + "name": character.Name, + "shortName": character.ShortName, + "description": character.Description, + }) + + return &CharacterResolver{C: character}, nil +} + +// RemoveCharacter resolves the removeCharacter mutation +func (r *MutationResolver) RemoveCharacter(ctx context.Context, args struct{ ID string }) (*CharacterResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("member") { + return nil, ErrUnauthorized + } + + character, err := character.FindID(args.ID) + if err != nil { + return nil, err + } + if character.Author != user.ID && !user.Permitted("character.remove") { + return nil, ErrPermissionDenied + } + + err = character.Remove() + if err != nil { + return nil, err + } + + go change.Submit("Character", "remove", user.ID, character.ID, map[string]interface{}{ + "name": character.Name, + "author": character.Author, + "nicks": character.Nicks, + }) + + return &CharacterResolver{C: character}, nil +} + +// ID is a property resolver +func (r *CharacterResolver) ID() string { + return r.C.ID +} + +// Nick is a property resolver +func (r *CharacterResolver) Nick() *string { + if len(r.C.Nicks) == 0 { + return nil + } + + return &r.C.Nicks[0] +} + +// Nicks is a property resolver +func (r *CharacterResolver) Nicks() []string { + return r.C.Nicks +} + +// Name is a property resolver +func (r *CharacterResolver) Name() string { + return r.C.Name +} + +// ShortName is a property resolver +func (r *CharacterResolver) ShortName() string { + return r.C.ShortName +} + +// Author is a property resolver +func (r *CharacterResolver) Author() string { + return r.C.Author +} + +// Description is a property resolver +func (r *CharacterResolver) Description() string { + return r.C.Description +} diff --git a/resolver/error.go b/resolver/error.go new file mode 100644 index 0000000..fe2a538 --- /dev/null +++ b/resolver/error.go @@ -0,0 +1,15 @@ +package resolver + +import "errors" + +// ErrCannotResolve is returned when a resolver constructor is at its wit's end +var ErrCannotResolve = errors.New("Cannot resolve due to invalid arguments") + +// ErrNotImplemented is for TODOs +var ErrNotImplemented = errors.New("Resolver not implemented") + +// ErrUnauthorized is when a guest acts like they own the place +var ErrUnauthorized = errors.New("Unauthorized") + +// ErrPermissionDenied is returned when users act above their station +var ErrPermissionDenied = errors.New("Permission denied") diff --git a/resolver/log.go b/resolver/log.go new file mode 100644 index 0000000..8005fa5 --- /dev/null +++ b/resolver/log.go @@ -0,0 +1,304 @@ +package resolver + +import ( + "context" + "time" + + "git.aiterp.net/rpdata/api/model/change" + + "git.aiterp.net/rpdata/api/internal/session" + "git.aiterp.net/rpdata/api/model/character" + "git.aiterp.net/rpdata/api/model/log" +) + +// LogResolver for the Log graphql type +type LogResolver struct{ L log.Log } + +// LogArgs is an arg +type LogArgs struct { + ID *string +} + +// LogPostArgs is an arg +type LogPostArgs struct { + Kinds *[]string +} + +// Log finds log +func (r *QueryResolver) Log(ctx context.Context, args *LogArgs) (*LogResolver, error) { + var l log.Log + var err error + + switch { + case args.ID != nil: + l, err = log.FindID(*args.ID) + default: + err = ErrCannotResolve + } + + if err != nil { + return nil, err + } + + return &LogResolver{L: l}, nil +} + +// LogQueryInput is an input +type LogQueryInput struct { + Search *string + Characters *[]string + Channels *[]string + Events *[]string + Open *bool + Limit *int32 +} + +// Logs lists logs +func (r *QueryResolver) Logs(ctx context.Context, args *struct{ Input *LogQueryInput }) ([]*LogResolver, error) { + var logs []log.Log + var err error + + input := args.Input + + if input != nil { + // Parse input + limit := 100 + search := "" + if input.Search != nil { + search = *input.Search + limit = 0 + } + channels := []string(nil) + if input.Channels != nil { + channels = *input.Channels + limit = 0 + } + characters := []string(nil) + if input.Characters != nil { + characters = *input.Characters + limit = 0 + } + events := []string(nil) + if input.Events != nil { + events = *input.Events + limit = 0 + } + if input.Limit != nil { + limit = int(*input.Limit) + } + open := input.Open != nil && *input.Open == true + + logs, err = log.ListSearch(search, channels, characters, events, open, limit) + if err != nil { + return nil, err + } + } else { + logs, err = log.List(100) + if err != nil { + return nil, err + } + } + + resolvers := make([]*LogResolver, len(logs)) + for i := range logs { + resolvers[i] = &LogResolver{L: logs[i]} + } + + return resolvers, nil +} + +// LogAddInput is an input +type LogAddInput struct { + Date string + Channel string + Title *string + Open *bool + Event *string + Description *string +} + +// AddLog resolves the addLog mutation +func (r *MutationResolver) AddLog(ctx context.Context, args *struct{ Input LogAddInput }) (*LogResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("log.add") { + return nil, ErrUnauthorized + } + + date, err := time.Parse(time.RFC3339Nano, args.Input.Date) + if err != nil { + return nil, err + } + + title := "" + if args.Input.Title != nil { + title = *args.Input.Title + } + event := "" + if args.Input.Event != nil { + event = *args.Input.Event + } + description := "" + if args.Input.Description != nil { + description = *args.Input.Description + } + open := args.Input.Open != nil && *args.Input.Open == true + + log, err := log.New(date, args.Input.Channel, title, event, description, open) + if err != nil { + return nil, err + } + + change.Submit("Log", "add", user.ID, log.ID, map[string]interface{}{ + "channel": log.Channel, + "title": log.Title, + "event": log.Event, + "description": log.Description, + "open": log.Open, + }) + + return &LogResolver{L: log}, nil +} + +// LogEditInput is an input +type LogEditInput struct { + ID string + Title *string + Event *string + Description *string + Open *bool +} + +// EditLog resolves the addLog mutation +func (r *MutationResolver) EditLog(ctx context.Context, args *struct{ Input LogEditInput }) (*LogResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("log.edit") { + return nil, ErrUnauthorized + } + + l, err := log.FindID(args.Input.ID) + if err != nil { + return nil, err + } + + err = l.Edit(args.Input.Title, args.Input.Event, args.Input.Description, args.Input.Open) + if err != nil { + return nil, err + } + + change.Submit("Log", "edit", user.ID, l.ID, map[string]interface{}{ + "channel": l.Channel, + "title": args.Input.Title, + "event": args.Input.Event, + "description": args.Input.Description, + "open": args.Input.Open, + }) + + return &LogResolver{L: l}, nil +} + +// RemoveLog resolves the removeLog mutation +func (r *MutationResolver) RemoveLog(ctx context.Context, args *struct{ ID string }) (*LogResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("log.remove") { + return nil, ErrUnauthorized + } + + l, err := log.FindID(args.ID) + if err != nil { + return nil, err + } + + err = log.Remove(args.ID) + if err != nil { + return nil, err + } + + change.Submit("Log", "add", user.ID, l.ID, map[string]interface{}{ + "channel": l.Channel, + "title": l.Title, + "event": l.Event, + "description": l.Description, + "open": l.Open, + }) + + return &LogResolver{L: l}, nil +} + +// ID resolves Log.id +func (r *LogResolver) ID() string { + return r.L.ID +} + +// ShortID resolves Log.shortId +func (r *LogResolver) ShortID() string { + return r.L.ShortID +} + +// Date resolves Log.date +func (r *LogResolver) Date() string { + return r.L.Date.Format(time.RFC3339Nano) +} + +// Channel resolves Log.channel +func (r *LogResolver) Channel() string { + return r.L.Channel +} + +// Title resolves Log.title +func (r *LogResolver) Title() string { + return r.L.Title +} + +// Event resolves Log.event +func (r *LogResolver) Event() string { + return r.L.Event +} + +// Description resolves Log.description +func (r *LogResolver) Description() string { + return r.L.Description +} + +// Open resolves Log.open +func (r *LogResolver) Open() bool { + return r.L.Open +} + +// Characters resolves Log.characters +func (r *LogResolver) Characters(ctx context.Context) ([]*CharacterResolver, error) { + chars, err := character.ListIDs(r.L.CharacterIDs...) + if err != nil { + return nil, err + } + + resolvers := make([]*CharacterResolver, 0, len(chars)) + for i := range chars { + if chars[i].ID == "" { + continue + } + + resolvers = append(resolvers, &CharacterResolver{C: chars[i]}) + } + + return resolvers, nil +} + +// Posts resolves Log.posts +func (r *LogResolver) Posts(ctx context.Context, args *LogPostArgs) ([]*PostResolver, error) { + var kinds []string + if args.Kinds != nil { + kinds = *args.Kinds + } + + posts, err := r.L.Posts(kinds...) + if err != nil { + return nil, err + } + + resolvers := make([]*PostResolver, len(posts)) + for i := range posts { + resolvers[i] = &PostResolver{posts[i]} + } + + return resolvers, nil +} diff --git a/resolver/post.go b/resolver/post.go new file mode 100644 index 0000000..5c66981 --- /dev/null +++ b/resolver/post.go @@ -0,0 +1,233 @@ +package resolver + +import ( + "context" + "time" + + "git.aiterp.net/rpdata/api/internal/session" + "git.aiterp.net/rpdata/api/model/change" + "git.aiterp.net/rpdata/api/model/log" +) + +// PostResolver for the Post graphql type +type PostResolver struct{ P log.Post } + +// PostArgs is an arg +type PostArgs struct { + ID string +} + +// Post implements the post query +func (r *QueryResolver) Post(ctx context.Context, args *PostArgs) (*PostResolver, error) { + post, err := log.FindPostID(args.ID) + if err != nil { + return nil, err + } + + return &PostResolver{P: post}, nil +} + +// PostsArgs is an arg +type PostsArgs struct { + IDs []string +} + +// Posts implements the posts query +func (r *QueryResolver) Posts(ctx context.Context, args *PostsArgs) ([]*PostResolver, error) { + posts, err := log.ListPostIDs(args.IDs...) + if err != nil { + return nil, err + } + + resolvers := make([]*PostResolver, len(posts)) + for i := range resolvers { + resolvers[i] = &PostResolver{P: posts[i]} + } + + return resolvers, nil +} + +// PostAddInput is an input +type PostAddInput struct { + LogID string + Time string + Kind string + Nick string + Text string +} + +// AddPost resolves the addPost mutation +func (r *MutationResolver) AddPost(ctx context.Context, args struct{ Input *PostAddInput }) (*PostResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("post.add") { + return nil, ErrUnauthorized + } + + postTime, err := time.Parse(time.RFC3339Nano, args.Input.Time) + if err != nil { + return nil, err + } + + log, err := log.FindID(args.Input.LogID) + if err != nil { + return nil, err + } + + post, err := log.NewPost(postTime, args.Input.Kind, args.Input.Nick, args.Input.Text) + if err != nil { + return nil, err + } + + change.Submit("Post", "add", user.ID, post.ID, map[string]interface{}{ + "logId": post.LogID, + "time": post.Time, + "kind": post.Kind, + "nick": post.Nick, + "text": post.Text, + "index": post.Index, + }) + + go log.UpdateCharacters() + + return &PostResolver{P: post}, nil +} + +// PostEditInput is an input +type PostEditInput struct { + ID string + Time *string + Kind *string + Nick *string + Text *string +} + +// EditPost resolves the editPost mutation +func (r *MutationResolver) EditPost(ctx context.Context, args struct{ Input *PostEditInput }) (*PostResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("post.edit") { + return nil, ErrUnauthorized + } + + postTime := (*time.Time)(nil) + if args.Input.Time != nil { + t, err := time.Parse(time.RFC3339Nano, *args.Input.Time) + if err != nil { + return nil, err + } + + postTime = &t + } + + post, err := log.FindPostID(args.Input.ID) + if err != nil { + return nil, err + } + + err = post.Edit(postTime, args.Input.Kind, args.Input.Nick, args.Input.Text) + if err != nil { + return nil, err + } + + change.Submit("Post", "edit", user.ID, post.ID, map[string]interface{}{ + "time": postTime, + "kind": args.Input.Kind, + "nick": args.Input.Nick, + "text": args.Input.Text, + }) + + go log.UpdateCharacters(post.LogID) + + return &PostResolver{P: post}, nil +} + +// PostMoveInput is an input +type PostMoveInput struct { + ID string + TargetIndex int32 +} + +// MovePost resolves the movePost mutation +func (r *MutationResolver) MovePost(ctx context.Context, args struct{ Input *PostMoveInput }) (*PostResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("post.move") { + return nil, ErrUnauthorized + } + + post, err := log.FindPostID(args.Input.ID) + if err != nil { + return nil, err + } + + err = post.Move(int(args.Input.TargetIndex)) + if err != nil { + return nil, err + } + + change.Submit("Post", "move", user.ID, post.ID, map[string]interface{}{ + "logId": post.LogID, + "targetIndex": args.Input.TargetIndex, + }) + + return &PostResolver{P: post}, nil +} + +// PostRemoveArgs is an arg +type PostRemoveArgs struct { + ID string +} + +// RemovePost resolves the removePost mutation +func (r *MutationResolver) RemovePost(ctx context.Context, args PostRemoveArgs) (*PostResolver, error) { + user := session.FromContext(ctx).User() + if user == nil || !user.Permitted("post.remove") { + return nil, ErrUnauthorized + } + + post, err := log.RemovePost(args.ID) + if err != nil { + return nil, err + } + + change.Submit("Post", "remove", user.ID, post.ID, map[string]interface{}{ + "logId": post.LogID, + }) + + go log.UpdateCharacters(post.LogID) + + return &PostResolver{P: post}, nil +} + +// ID resolves Post.id +func (r *PostResolver) ID() string { + return r.P.ID +} + +// LogID resolves Post.logId +func (r *PostResolver) LogID() string { + return r.P.LogID +} + +// Time resolves Post.time +func (r *PostResolver) Time() string { + return r.P.Time.Format(time.RFC3339Nano) +} + +// Kind resolves Post.logId +func (r *PostResolver) Kind() string { + return r.P.Kind +} + +// Nick resolves Post.nick +func (r *PostResolver) Nick() string { + return r.P.Nick +} + +// Text resolves Post.text +func (r *PostResolver) Text() string { + return r.P.Text +} + +// Index resolves Post.text +func (r *PostResolver) Index() int32 { + return int32(r.P.Index) +} diff --git a/resolver/root.go b/resolver/root.go new file mode 100644 index 0000000..9830c0b --- /dev/null +++ b/resolver/root.go @@ -0,0 +1,13 @@ +package resolver + +// The RootResolver brings queries and mutations together. The rest is just for readability. +type RootResolver struct { + MutationResolver + QueryResolver +} + +// The QueryResolver is the entry point for all top-level read operations. +type QueryResolver struct{} + +// The MutationResolver is the entry point for all top-level mutation operations. +type MutationResolver struct{} diff --git a/resolver/session.go b/resolver/session.go new file mode 100644 index 0000000..08f3a6d --- /dev/null +++ b/resolver/session.go @@ -0,0 +1,51 @@ +package resolver + +import ( + "context" + + "git.aiterp.net/rpdata/api/internal/session" +) + +// LoginArgs is args +type LoginArgs struct { + Username string + Password string +} + +// Session resolves query.session +func (r *QueryResolver) Session(ctx context.Context) (*SessionResolver, error) { + return &SessionResolver{S: session.FromContext(ctx)}, nil +} + +// Login resolves mutation.login +func (r *MutationResolver) Login(ctx context.Context, args *LoginArgs) (*SessionResolver, error) { + session := session.FromContext(ctx) + + err := session.Login(args.Username, args.Password) + if err != nil { + return nil, err + } + + return &SessionResolver{S: session}, nil +} + +// Logout resolves mutation.logout +func (r *MutationResolver) Logout(ctx context.Context) (*SessionResolver, error) { + session := session.FromContext(ctx) + session.Logout() + + return &SessionResolver{S: session}, nil +} + +// SessionResolver resolves Session +type SessionResolver struct{ S *session.Session } + +// User resolves Session.user +func (r *SessionResolver) User() *UserResolver { + user := r.S.User() + if user == nil { + return nil + } + + return &UserResolver{U: user} +} diff --git a/resolver/user.go b/resolver/user.go new file mode 100644 index 0000000..84bcdd0 --- /dev/null +++ b/resolver/user.go @@ -0,0 +1,16 @@ +package resolver + +import "git.aiterp.net/rpdata/api/internal/session" + +// UserResolver resulves the user type +type UserResolver struct{ U *session.User } + +// ID resolves User.id +func (r *UserResolver) ID() string { + return r.U.ID +} + +// Permissions resolves User.permissions +func (r *UserResolver) Permissions() []string { + return r.U.Permissions +} diff --git a/schema/root.graphql b/schema/root.graphql new file mode 100644 index 0000000..2f0f5c6 --- /dev/null +++ b/schema/root.graphql @@ -0,0 +1,80 @@ +# The Query type represents the read entry points into the API. +type Query { + # Find character by either an ID or a nick. + character(id: String, nick: String): Character + + # Find characters by either a list of ids, nicks or an author. Only one parameter at a time + characters(ids: [String!], nicks: [String!], author: String): [Character!]! + + + # Find log by ID + log(id: String): Log + + # Find logs by a list of IDs + logs(input: LogQueryInput): [LogHeader!]! + + + # Find post by ID + post(id: String!): Post + + # Find posts by IDs. It's meant to allow other parts of the UI to link to a cluster of posts, e.g. for a room description for the + # Mapp should it ever become a thing. + posts(ids: [String!]!): [Post!]! + + + # Find current session + session: Session! +} + +# The Mutation type represents write entry points into the API. +type Mutation { + # Add a new character + addCharacter(input: CharacterAddInput!): Character! + + # Add nick to character + addCharacterNick(input: CharacterNickInput!): Character! + + # Remove nick from character + removeCharacterNick(input: CharacterNickInput!): Character! + + # Edit character + editCharacter(input: CharacterEditInput!): Character! + + # Remove a character + removeCharacter(id: String!): Character! + + + # Add a new log + addLog(input: LogAddInput!): Log! + + # Edit a log + editLog(input: LogEditInput!): Log! + + # Remove a log + removeLog(id: String!): Log! + + + # Add a post + addPost(input: AddPostInput!): Post! + + # Edit a post + editPost(input: EditPostInput!): Post! + + # Move a post + movePost(input: MovePostInput!): Post! + + # Remove a post + removePost(id: String!): Post! + + + # Log in + login(username: String!, password: String!): Session! + + # Log out + logout(): Session! +} + +schema { + query: Query + mutation: Mutation +} \ No newline at end of file diff --git a/schema/schema.go b/schema/schema.go new file mode 100644 index 0000000..a521f4c --- /dev/null +++ b/schema/schema.go @@ -0,0 +1,21 @@ +package schema + +//go:generate go-bindata -ignore=\.go -pkg=schema -o=bindata.go ./... + +import "bytes" + +// String gets the schema +func String() string { + buf := bytes.Buffer{} + for _, name := range AssetNames() { + b := MustAsset(name) + buf.Write(b) + + // Add a newline if the file does not end in a newline. + if len(b) > 0 && b[len(b)-1] != '\n' { + buf.WriteByte('\n') + } + } + + return buf.String() +} diff --git a/schema/types/change.graphql b/schema/types/change.graphql new file mode 100644 index 0000000..1fd1633 --- /dev/null +++ b/schema/types/change.graphql @@ -0,0 +1,4 @@ +# A Change is a part of the history that can be used to keep clients up to date. +type Change { + id: Int! +} \ No newline at end of file diff --git a/schema/types/character.graphql b/schema/types/character.graphql new file mode 100644 index 0000000..47cc5f5 --- /dev/null +++ b/schema/types/character.graphql @@ -0,0 +1,65 @@ +# A Character represents an RP character +type Character { + # A unique identifier for the character + id: String! + + # The primary IRC nick belonging to the character + nick: String + + # All IRC nicks associated with this character + nicks: [String!]! + + # The character's author + author: String! + + # The character's name + name: String! + + # The name to display when space is scarce, usually the first/given name + shortName: String! + + # A short description of the character + description: String! +} + +# Input for adding characters +input CharacterAddInput { + # The primary IRC nick name to recognize this character by + nick: String! + + # The character's name + name: String! + + # Optioanl shortened name. By default, it uses the first token in the name + shortName: String + + # Description for a character. + description: String + + # Optioanlly, specify another author. This needs special permissions if it's not + # your own username + author: String +} + +# Input for addNick and removeNick mutation +input CharacterNickInput { + # The ID of the character + id: String! + + # The nick to add or remove + nick: String! +} + +input CharacterEditInput { + # The id for the character to edit + id: String! + + # The full name of the character -- not the salarian full name! + name: String + + # The character's short name that is used in compact lists + shortName: String + + # A short description for the character + description: String +} \ No newline at end of file diff --git a/schema/types/log.graphql b/schema/types/log.graphql new file mode 100644 index 0000000..60d5a62 --- /dev/null +++ b/schema/types/log.graphql @@ -0,0 +1,126 @@ +# A Log is the "file" of an RP session +type Log { + # A unique identifier for the log. + id: String! + + # A secondary unique identifier for the log. This is a lot shorter and more suitable for storage when + # the link doesn't need to be as expressive. + shortId: String! + + # The date for a log. + date: String! + + # The channel of a log. + channel: String! + + # The session's title. + title: String! + + # The log's event, which is the same as the tags in the previous logbot site. + # Empty string means that it's no event. + event: String! + + # The description of a session, which is empty if unset. + description: String! + + # Whether the log session is open. + open: Boolean! + + # The characters involved in the log file. + characters: [Character!]! + + # The posts of the logfile, which can be filtered by kinds. + posts(kinds:[String!]): [Post!]! +} + +# Input for logs query +input LogQueryInput { + # Channels to limit results to (inclusive) + channels: [String!] + + # Events to limit results to (inclusive) + events: [String!] + + # Characters to limit results to (exclusive) + characters: [String!] + + # Search post content + search: String + + # Limit to only open logs + open: Boolean + + # Limit the amount of posts + limit: Int +} + +# Input for addLog mutation +input LogAddInput { + # The date of the log, in RFC3339 format with up to nanosecond precision + date: String! + + # The channel where the log is set + channel: String! + + # Optional: The log title + title: String + + # Optional: Whether the log is open + open: Boolean + + # Optional: The log event name + event: String + + # Optional: A short description of the log + description: String +} + +# Input for addLog mutation +input LogEditInput { + # The id of the log + id: String! + + # The log title + title: String + + # The log event name + event: String + + # A short description of the log + description: String + + # Open/close the log + open: Boolean +} + +# A LogHeader is a cut-down version of Log that does not allow getting the actual posts. +type LogHeader { + # A unique identifier for the log. + id: String! + + # A secondary unique identifier for the log. This is a lot shorter and more suitable for storage when + # the link doesn't need to be as expressive. + shortId: String! + + # The date for a log. + date: String! + + # The channel of a log. + channel: String! + + # The session's title. + title: String! + + # The log's event, which is the same as the tags in the previous logbot site. + # Empty string means that it's no event. + event: String! + + # The description of a session, which is empty if unset. + description: String! + + # Whether the log session is open. + open: Boolean! + + # The characters involved in the log file. + characters: [Character!]! +} diff --git a/schema/types/post.graphql b/schema/types/post.graphql new file mode 100644 index 0000000..c6fa188 --- /dev/null +++ b/schema/types/post.graphql @@ -0,0 +1,68 @@ +# A Post is a part of a log +type Post { + # The post's ID + id: String! + + # The post's Log ID. This is the closest thing to a link back since this API graph doesn't have any cycles. + logId: String! + + # The date and time of posting + time: String! + + # The kind of post this is. Only "text", "scene" and "action" are RP, while others are annotations and 'commands'. + kind: String! + + # The character nick + nick: String! + + # The post's text, which purpose depends on the kind + text: String! + + # The post's index, which is used to sort posts + index: Int! +} + +# Input for the addPost mutation +input AddPostInput { + # The log's ID that this post should be a part of + logId: String! + + # The date and time of posting, in a RFC3339 format with up to a nanosecond's precision + time: String! + + # The kind of post this is. Only "text", "scene" and "action" are RP, while others are annotations and 'commands'. + kind: String! + + # The character nick, or command invoker for non-RP stuff + nick: String! + + # The post's text, which purpose depends on the kind + text: String! +} + +# Input for the editPost mutation +input EditPostInput { + # The Post ID + id: String! + + # The date and time of posting, in a RFC3339 format with up to a nanosecond's precision + time: String + + # The kind of post this is. Only "text", "scene" and "action" are RP, while others are annotations and 'commands'. + kind: String + + # The character nick, or command invoker for non-RP stuff + nick: String + + # The post's text, which purpose depends on the kind + text: String +} + +# Input for the movePost mutation +input MovePostInput { + # The Post ID + id: String! + + # Target index + targetIndex: Int! +} \ No newline at end of file diff --git a/schema/types/session.graphql b/schema/types/session.graphql new file mode 100644 index 0000000..ea2355e --- /dev/null +++ b/schema/types/session.graphql @@ -0,0 +1,5 @@ +# The session represents the current login state +type Session { + # The user that is logged in, null if not logged in + user: User +} \ No newline at end of file diff --git a/schema/types/user.graphql b/schema/types/user.graphql new file mode 100644 index 0000000..ad5c286 --- /dev/null +++ b/schema/types/user.graphql @@ -0,0 +1,8 @@ +# The User type is for interacting with user options and settings +type User { + # Their username + id: String! + + # Their permission + permissions: [String!]! +} \ No newline at end of file