Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947765884e | ||
|
|
ffec008820 | ||
|
|
138b3e3eab | ||
|
|
929abfdbe4 | ||
|
|
a8cd595876 | ||
|
|
9ed3b32b42 | ||
|
|
6bae52d652 | ||
|
|
cee370c2ac | ||
|
|
9affdbd20e | ||
|
|
cf545cde3b | ||
|
|
b414cf118f | ||
|
|
4979b02104 | ||
|
|
b7d5059d60 | ||
|
|
9fbdc767ad | ||
|
|
daeaaf5fa9 | ||
|
|
d3277c7476 | ||
|
|
90681c67b0 | ||
|
|
bebd99833b | ||
|
|
34f86d5037 | ||
|
|
8a85313762 | ||
|
|
017e68eae7 | ||
|
|
1955ad0517 | ||
|
|
34ba5d5d3a | ||
|
|
8c8a251080 | ||
|
|
73fe125870 | ||
|
|
04ec85aadf | ||
|
|
59233ee549 | ||
|
|
7aa0de9135 | ||
|
|
6dfbee7ff2 | ||
|
|
8a07c2ffdb | ||
|
|
0e9536b0a8 | ||
|
|
b8e5ed5813 | ||
|
|
2317f66920 | ||
|
|
fc488b938d | ||
|
|
ceb2617571 | ||
|
|
aeb5b26627 | ||
|
|
6aafc67790 | ||
|
|
c6f8fd9faa | ||
|
|
efa98b80cc | ||
|
|
707dcb43eb | ||
|
|
9bcc28a0ea | ||
|
|
2fceaa62ed | ||
|
|
86125c7ce9 | ||
|
|
bb28795cd5 | ||
|
|
815f30597e | ||
|
|
cd1f122502 | ||
|
|
0e0d802a32 | ||
|
|
3c42043ab6 | ||
|
|
8388ee2446 | ||
|
|
260c2e7f76 | ||
|
|
a4034b0faa | ||
|
|
c3a6798050 | ||
|
|
2ab933149e | ||
|
|
07b94b3cab | ||
|
|
79171bf9a2 | ||
|
|
bac57bc378 | ||
|
|
875dac9ecb | ||
|
|
0768cfd0bd | ||
|
|
2b05d90f4a | ||
|
|
0444911e73 | ||
|
|
1be4088a8f | ||
|
|
9978d7380b | ||
|
|
2e5162a29b | ||
|
|
dac0060155 | ||
|
|
9d7fd498a9 | ||
|
|
e0af2685ee | ||
|
|
3184b60310 | ||
|
|
675c09ef04 | ||
|
|
9707320960 | ||
|
|
20e53b4f5e | ||
|
|
8bb4dea358 | ||
|
|
75b2382f09 | ||
|
|
1ca25eff8e | ||
|
|
bf10ed1fe3 | ||
|
|
54136be912 | ||
|
|
228c2bfeb4 | ||
|
|
2f98be5747 | ||
|
|
2a40f97282 | ||
|
|
e5b4184106 | ||
|
|
c348a90b70 | ||
|
|
132cf28f0f | ||
|
|
d5df624188 | ||
|
|
ca98648274 | ||
|
|
4642a8abec | ||
|
|
0ec923d686 | ||
|
|
388d5378e7 | ||
|
|
67e9e8f1e6 | ||
|
|
06bc7e5a1b | ||
|
|
237190e498 | ||
|
|
b9f0fef966 | ||
|
|
ba1c1e0a4b | ||
|
|
18e77b8205 | ||
|
|
d8871259a1 | ||
|
|
26d7e36631 | ||
|
|
51489ed901 | ||
|
|
ed3e384d55 | ||
|
|
9455fd960f | ||
|
|
36939ffc59 | ||
|
|
5fedce5899 | ||
|
|
dced5282b1 | ||
|
|
d05f3a8ca8 | ||
|
|
53e2dfc6b6 | ||
|
|
fba499024b | ||
|
|
cc765e220a | ||
|
|
ba49b1fd30 | ||
|
|
8c290c8dcd | ||
|
|
576e1df001 | ||
|
|
7adcdfe1e0 | ||
|
|
6f04da49b8 | ||
|
|
9b705e6514 | ||
|
|
155309693a | ||
|
|
5895e09f37 | ||
|
|
223d9922e9 | ||
|
|
429387c9ac | ||
|
|
a7113d5efe | ||
|
|
a0cb8d7fa8 | ||
|
|
5189d486a5 | ||
|
|
011cb00001 | ||
|
|
7a52504381 | ||
|
|
866a5056be | ||
|
|
e0409a6635 | ||
|
|
bae15de3e8 | ||
|
|
3f5af44dd6 | ||
|
|
4bbb8bb9ae | ||
|
|
87cd5e8e3a | ||
|
|
136302084d | ||
|
|
0122f64fbb | ||
|
|
6bfb9cbe20 | ||
|
|
fca5176579 | ||
|
|
c4f7846269 |
+4
-4
@@ -6,16 +6,16 @@ pipeline:
|
||||
docker-release:
|
||||
image: plugins/docker
|
||||
repo: vmaple/rpdata
|
||||
tags: latest
|
||||
auto_tag: true
|
||||
secrets: [ docker_username, docker_password ]
|
||||
when:
|
||||
event: tag
|
||||
ref: refs/tags/*
|
||||
docker-dev:
|
||||
docker-release-latest:
|
||||
image: plugins/docker
|
||||
repo: vmaple/rpdata
|
||||
tags: trunk
|
||||
tags: latest
|
||||
secrets: [ docker_username, docker_password ]
|
||||
when:
|
||||
event: push
|
||||
event: tag
|
||||
ref: refs/tags/*
|
||||
+9
-2
@@ -1,6 +1,13 @@
|
||||
vendor
|
||||
schema/bindata.go
|
||||
graphql/schema/bindata.go
|
||||
config.json
|
||||
api
|
||||
debug
|
||||
build
|
||||
build
|
||||
rpdata-graphiql
|
||||
rpdata-*
|
||||
!rpdata-*/
|
||||
.vscode
|
||||
|
||||
generated.gql
|
||||
generated.go
|
||||
+17
-7
@@ -1,27 +1,37 @@
|
||||
## 1. Build Server
|
||||
# Use Go 1.10
|
||||
FROM golang:1.10
|
||||
FROM golang:1.11 AS build-server
|
||||
|
||||
# Load repository into docker
|
||||
WORKDIR /go/src/git.aiterp.net/rpdata/api/
|
||||
COPY . .
|
||||
RUN rm -rf ./vendor
|
||||
|
||||
# Load build tools and dependencies
|
||||
RUN go get -u github.com/golang/dep/cmd/dep
|
||||
RUN go get -u github.com/vektah/gorunpkg
|
||||
RUN go get -u github.com/jteeuwen/go-bindata/...
|
||||
RUN dep ensure
|
||||
RUN go generate ./...
|
||||
|
||||
# Build server and tools (CGO disabled to make them 100% static)
|
||||
ENV CGO_ENABLED 0
|
||||
RUN go build -a -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-graphiql ./cmd/rpdata-graphiql
|
||||
RUN go build -a -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-server ./cmd/rpdata-server
|
||||
RUN go build -a -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-lb2charimport ./cmd/rpdata-lb2charimport
|
||||
RUN go build -a -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-lb2logimport ./cmd/rpdata-lb2logimport
|
||||
RUN go build -a -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-wikifileimport ./cmd/rpdata-wikifileimport
|
||||
# TODO: RUN go build -a -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-wikifileimport ./cmd/rpdata-wikifileimport
|
||||
RUN go build -a -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-ensurechannels ./cmd/rpdata-ensurechannels
|
||||
RUN go build -a -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-as2storyimport ./cmd/rpdata-as2storyimport
|
||||
|
||||
# Move artifacts into final container
|
||||
FROM alpine:latest
|
||||
## 2. Distribute
|
||||
# Use alpine linux
|
||||
FROM alpine:3.7
|
||||
|
||||
# Install certificates to enable HTTPS GET requests
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=0 /binaries/* /usr/local/bin/
|
||||
CMD ["/usr/local/bin/rpdata-graphiql"]
|
||||
|
||||
# Copy build files into final container
|
||||
COPY --from=build-server /binaries/* /usr/local/bin/
|
||||
|
||||
# The server is the main entry point
|
||||
CMD ["/usr/local/bin/rpdata-server"]
|
||||
|
||||
Generated
+83
-7
@@ -2,10 +2,32 @@
|
||||
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "git.aiterp.net/aiterp/wikiauth"
|
||||
name = "github.com/99designs/gqlgen"
|
||||
packages = [
|
||||
".",
|
||||
"cmd",
|
||||
"codegen",
|
||||
"codegen/templates",
|
||||
"complexity",
|
||||
"graphql",
|
||||
"graphql/introspection",
|
||||
"handler",
|
||||
"internal/gopath"
|
||||
]
|
||||
revision = "636435b68700211441303f1a5ed92f3768ba5774"
|
||||
version = "v0.5.1"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/agnivade/levenshtein"
|
||||
packages = ["."]
|
||||
revision = "33860de804ddcec4fc17998745233b4ec477e7c2"
|
||||
revision = "3d21ba515fe27b856f230847e856431ae1724adc"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/dgrijalva/jwt-go"
|
||||
packages = ["."]
|
||||
revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e"
|
||||
version = "v3.2.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
@@ -37,6 +59,12 @@
|
||||
revision = "d523deb1b23d913de5bdada721a6071e71283618"
|
||||
version = "v1.4.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/gorilla/websocket"
|
||||
packages = ["."]
|
||||
revision = "66b9c49e59c6c48f0ffce28c2d8b8a5678502c6d"
|
||||
version = "v1.4.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/graph-gophers/dataloader"
|
||||
packages = ["."]
|
||||
@@ -64,6 +92,15 @@
|
||||
]
|
||||
revision = "9ebf33af539ab8cb832c7107bc0a978ca8dbc0de"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/hashicorp/golang-lru"
|
||||
packages = [
|
||||
".",
|
||||
"simplelru"
|
||||
]
|
||||
revision = "20f1fb78b0740ba8c3cb143a61e86ba5c8669768"
|
||||
version = "v0.5.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/jmoiron/sqlx"
|
||||
@@ -103,10 +140,10 @@
|
||||
version = "v1.0.2"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/sadbox/mediawiki"
|
||||
name = "github.com/pkg/errors"
|
||||
packages = ["."]
|
||||
revision = "39fea8a1336076a961a300d1d95765dcd17e8a3c"
|
||||
version = "v0.1"
|
||||
revision = "645ef00459ed84a119197bfb8d8205042c6df63d"
|
||||
version = "v0.8.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/sirupsen/logrus"
|
||||
@@ -114,6 +151,26 @@
|
||||
revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc"
|
||||
version = "v1.0.5"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/urfave/cli"
|
||||
packages = ["."]
|
||||
revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1"
|
||||
version = "v1.20.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/vektah/gqlparser"
|
||||
packages = [
|
||||
".",
|
||||
"ast",
|
||||
"gqlerror",
|
||||
"lexer",
|
||||
"parser",
|
||||
"validator",
|
||||
"validator/rules"
|
||||
]
|
||||
revision = "14e83ae06ec152e6d0afb9766a00e0c0918aa8fc"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/crypto"
|
||||
@@ -164,15 +221,34 @@
|
||||
revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0"
|
||||
version = "v0.3.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/tools"
|
||||
packages = [
|
||||
"go/ast/astutil",
|
||||
"go/buildutil",
|
||||
"go/internal/cgo",
|
||||
"go/loader",
|
||||
"imports",
|
||||
"internal/fastwalk"
|
||||
]
|
||||
revision = "677d2ff680c188ddb7dcd2bfa6bc7d3f2f2f75b2"
|
||||
|
||||
[[projects]]
|
||||
name = "google.golang.org/appengine"
|
||||
packages = ["cloudsql"]
|
||||
revision = "b1f26356af11148e710935ed1ac8a7f5702c7612"
|
||||
version = "v1.1.0"
|
||||
|
||||
[[projects]]
|
||||
name = "gopkg.in/yaml.v2"
|
||||
packages = ["."]
|
||||
revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183"
|
||||
version = "v2.2.1"
|
||||
|
||||
[solve-meta]
|
||||
analyzer-name = "dep"
|
||||
analyzer-version = 1
|
||||
inputs-digest = "16fd84401bc5796ffeaa895f31d099e1a23b363b15b87386877de0829ac9575b"
|
||||
inputs-digest = "3c9430afa8b9260026925c8f496af934e2e573a2bd4587bb634fd12a20ce6c2c"
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
|
||||
+3
-1
@@ -24,6 +24,8 @@
|
||||
# go-tests = true
|
||||
# unused-packages = true
|
||||
|
||||
required = [ "github.com/99designs/gqlgen" ]
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/globalsign/mgo"
|
||||
@@ -38,4 +40,4 @@
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/graph-gophers/graphql-go"
|
||||
name = "github.com/graph-gophers/graphql-go"
|
||||
@@ -1,18 +1,5 @@
|
||||
# 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.
|
||||
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
|
||||
* File
|
||||
|
||||
### Remaining
|
||||
* Story
|
||||
|
||||
### Future
|
||||
* User (manual DB edits will suffice for now)
|
||||
There is some renovation going on, and the packages `model` and `graphql` are going to be superceded by `models` and `graph2`
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/model/story"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/chapters"
|
||||
"git.aiterp.net/rpdata/api/models/stories"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
@@ -71,26 +73,38 @@ func main() {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
tags := make([]story.Tag, 0, 8)
|
||||
tags := make([]models.Tag, 0, 8)
|
||||
for _, tagResult := range tagResults {
|
||||
if tagResult.PageID == result.ID {
|
||||
tags = append(tags, story.Tag{Kind: tagResult.Type, Name: tagResult.Name})
|
||||
tags = append(tags, models.Tag{Kind: models.TagKind(tagResult.Type), Name: tagResult.Name})
|
||||
}
|
||||
}
|
||||
|
||||
story, err := story.New(result.Name, result.Author, result.Category, false, false, tags, publishDate, fictionalDate)
|
||||
category := models.StoryCategory(result.Category)
|
||||
if !category.IsValid() {
|
||||
log.Println(result.Name, "does not have a valid category:", result.Category)
|
||||
continue
|
||||
}
|
||||
|
||||
story, err := stories.Add(result.Name, result.Author[5:], category, true, false, tags, publishDate, fictionalDate)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
// Change the story title
|
||||
title := result.Name
|
||||
if strings.HasPrefix(result.Source, "#") {
|
||||
firstNewline := strings.Index(result.Source, "\n")
|
||||
title = result.Source[1:firstNewline]
|
||||
result.Name = strings.Replace(result.Source[1:firstNewline], "\r", "", -1)
|
||||
result.Source = result.Source[firstNewline+1:]
|
||||
|
||||
result.Source = strings.Replace(result.Source, "\r\n", "\n", -1)
|
||||
if strings.HasPrefix(result.Source, "\r") || strings.HasPrefix(result.Source, "\n") {
|
||||
result.Source = result.Source[1:]
|
||||
}
|
||||
}
|
||||
|
||||
chapter, err := story.AddChapter(title, result.Author, result.Source, publishDate, fictionalDate)
|
||||
chapter, err := chapters.Add(story, title, result.Author[5:], result.Source, publishDate, &fictionalDate)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.aiterp.net/rpdata/api/model/channel"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/model/log"
|
||||
"git.aiterp.net/rpdata/api/models/channels"
|
||||
"git.aiterp.net/rpdata/api/models/logs"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -17,7 +16,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := log.List(32768)
|
||||
logs, err := logs.List(nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
@@ -25,18 +24,18 @@ func main() {
|
||||
|
||||
added := make(map[string]bool, 1024)
|
||||
for _, log := range logs {
|
||||
if added[log.Channel] {
|
||||
if added[log.ChannelName] {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := channel.Ensure(log.Channel, false)
|
||||
_, err := channels.Ensure(log.ChannelName, false)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, log.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
added[log.Channel] = true
|
||||
added[log.ChannelName] = true
|
||||
|
||||
fmt.Println(log.Channel, "ensured")
|
||||
fmt.Println(log.ChannelName, "ensured")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
config.json
|
||||
debug
|
||||
@@ -1,130 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/session"
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/loader"
|
||||
"git.aiterp.net/rpdata/api/model/file"
|
||||
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(48))
|
||||
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)
|
||||
})
|
||||
|
||||
http.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
|
||||
r = session.Load(w, r)
|
||||
sess := session.FromContext(r.Context())
|
||||
user := sess.User()
|
||||
if user == nil || !user.Permitted("file.upload") {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(16384); err != nil {
|
||||
http.Error(w, "Internal (1): "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
formFile, header, err := r.FormFile("file")
|
||||
if err != nil || header == nil || formFile == nil {
|
||||
http.Error(w, "No file provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := file.Upload(r.Context(), header.Filename, header.Header.Get("Content-Type"), user.ID, header.Size, formFile)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal (2): "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json, err := json.Marshal(file)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal (3): "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(json)
|
||||
})
|
||||
|
||||
log.Fatal(http.ListenAndServe(":17000", nil))
|
||||
}
|
||||
|
||||
var page = []byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/graphiql/0.10.2/graphiql.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/fetch/1.1.0/fetch.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.5.4/react.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.5.4/react-dom.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/graphiql/0.10.2/graphiql.js"></script>
|
||||
</head>
|
||||
<body style="width: 100%; height: 100%; margin: 0; overflow: hidden;">
|
||||
<div id="graphiql" style="height: 100vh;">Loading...</div>
|
||||
<script>
|
||||
function graphQLFetcher(graphQLParams) {
|
||||
return fetch("/graphql", {
|
||||
method: "post",
|
||||
body: JSON.stringify(graphQLParams),
|
||||
credentials: "include",
|
||||
}).then(function (response) {
|
||||
return response.text();
|
||||
}).then(function (responseBody) {
|
||||
try {
|
||||
return JSON.parse(responseBody);
|
||||
} catch (error) {
|
||||
return responseBody;
|
||||
}
|
||||
});
|
||||
}
|
||||
ReactDOM.render(
|
||||
React.createElement(GraphiQL, {fetcher: graphQLFetcher}),
|
||||
document.getElementById("graphiql")
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/model/character"
|
||||
"git.aiterp.net/rpdata/api/models/characters"
|
||||
)
|
||||
|
||||
var fileName = flag.String("file", "./characters.json", "json file to load")
|
||||
@@ -31,12 +31,12 @@ func main() {
|
||||
nicksAdded, nicksFailed := 0, 0
|
||||
|
||||
for _, info := range infos {
|
||||
char, err := character.New(info.Nicks[0], info.Name, info.ShortName, info.Author, "")
|
||||
char, err := characters.Add(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])
|
||||
char, err = characters.FindNick(info.Nicks[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func main() {
|
||||
}
|
||||
|
||||
for _, alt := range info.Nicks[1:] {
|
||||
err := char.AddNick(alt)
|
||||
char, err = characters.AddNick(char, alt)
|
||||
if err != nil {
|
||||
log.Println(info.Nicks[0], "failed to add nick", alt, "error:", err)
|
||||
nicksFailed++
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models/posts"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
logModel "git.aiterp.net/rpdata/api/model/log"
|
||||
"git.aiterp.net/rpdata/api/models/logs"
|
||||
)
|
||||
|
||||
var prefixReplacer = strings.NewReplacer("+", "", "@", "", "!", "", "%", "")
|
||||
@@ -47,13 +49,13 @@ func main() {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
l, err := logModel.New(date, channel, title, event, "", false)
|
||||
l, err := logs.Add(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.")
|
||||
_, err = posts.Add(l, 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)
|
||||
}
|
||||
@@ -63,7 +65,7 @@ func main() {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = l.NewPost(l.Date, "chars", prefixReplacer.Replace(line.Args[0]), line.Text)
|
||||
_, err = posts.Add(l, l.Date, "chars", prefixReplacer.Replace(line.Args[0]), line.Text)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
@@ -92,13 +94,13 @@ func main() {
|
||||
line.Verb = "SCENE"
|
||||
}
|
||||
|
||||
_, err = l.NewPost(postTime, strings.ToLower(line.Verb), prefixReplacer.Replace(line.Args[0]), line.Text)
|
||||
_, err = posts.Add(l, postTime, strings.ToLower(line.Verb), prefixReplacer.Replace(line.Args[0]), line.Text)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = l.UpdateCharacters()
|
||||
l, err = logs.UpdateCharacters(l)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models/logs"
|
||||
|
||||
"git.aiterp.net/rpdata/api/graph2"
|
||||
"git.aiterp.net/rpdata/api/internal/auth"
|
||||
"git.aiterp.net/rpdata/api/internal/loader"
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"github.com/99designs/gqlgen/handler"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := store.Init()
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to init store:", err)
|
||||
}
|
||||
|
||||
http.Handle("/", handler.Playground("RPData API", "/graphql"))
|
||||
http.Handle("/graphql", queryHandler())
|
||||
|
||||
go func() {
|
||||
err := logs.RunFullUpdate()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
log.Println("Characters updated")
|
||||
}()
|
||||
|
||||
log.Fatal(http.ListenAndServe(":8081", nil))
|
||||
}
|
||||
|
||||
func queryHandler() http.HandlerFunc {
|
||||
handler := handler.GraphQL(
|
||||
graph2.New(),
|
||||
handler.RecoverFunc(func(ctx context.Context, err interface{}) error {
|
||||
// send this panic somewhere
|
||||
log.Println(err)
|
||||
log.Println(string(debug.Stack()))
|
||||
|
||||
return fmt.Errorf("shit")
|
||||
}),
|
||||
)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
l := loader.New()
|
||||
r = r.WithContext(l.ToContext(r.Context()))
|
||||
|
||||
// >_>
|
||||
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer of the curse") {
|
||||
w.Header().Set("X-Emerald-Herald", "Seek souls. Larger, more powerful souls.")
|
||||
w.Header().Add("X-Emerald-Herald", "Seek the king, that is the only way.")
|
||||
w.Header().Add("X-Emerald-Herald", "Lest this land swallow you whole... As it has so many others.")
|
||||
}
|
||||
|
||||
r = auth.RequestWithToken(r)
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/config"
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/model/file"
|
||||
"git.aiterp.net/rpdata/api/models/files"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -33,7 +33,7 @@ func main() {
|
||||
store.Init()
|
||||
|
||||
for _, info := range data.Query.Allimages {
|
||||
existing, err := file.FindName("wiki", info.Name, info.User)
|
||||
existing, err := files.FindName("wiki", info.Name, info.User)
|
||||
if err == nil && existing.ID != "" {
|
||||
log.Println("Skipping", info.Name, "because it already exists")
|
||||
continue
|
||||
@@ -45,7 +45,7 @@ func main() {
|
||||
continue
|
||||
}
|
||||
|
||||
file, err := file.Insert(info.Name, "wiki", info.Mime, info.User, ts, info.Size, info.URL)
|
||||
file, err := files.Insert(info.Name, "wiki", info.Mime, info.User, ts, info.Size, info.URL)
|
||||
if err != nil {
|
||||
log.Println("Skipping", info.Name, "error:", err)
|
||||
continue
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "# Generated by stiching together the files under schema/ – DO NOT EDIT" >generated.gql
|
||||
cat schema/root.gql schema/**/*.gql >>generated.gql
|
||||
@@ -0,0 +1,67 @@
|
||||
schema: generated.gql
|
||||
|
||||
exec:
|
||||
filename: generated.go
|
||||
package: graph2
|
||||
|
||||
model:
|
||||
filename: input/generated.go
|
||||
package: input
|
||||
|
||||
models:
|
||||
Tag:
|
||||
model: git.aiterp.net/rpdata/api/models.Tag
|
||||
TagInput: #It's the same as tags
|
||||
model: git.aiterp.net/rpdata/api/models.Tag
|
||||
TagKind:
|
||||
model: git.aiterp.net/rpdata/api/models.TagKind
|
||||
Character:
|
||||
model: git.aiterp.net/rpdata/api/models.Character
|
||||
CharactersFilter:
|
||||
model: git.aiterp.net/rpdata/api/models/characters.Filter
|
||||
Channel:
|
||||
model: git.aiterp.net/rpdata/api/models.Channel
|
||||
ChannelsFilter:
|
||||
model: git.aiterp.net/rpdata/api/models/channels.Filter
|
||||
Post:
|
||||
model: git.aiterp.net/rpdata/api/models.Post
|
||||
PostsFilter:
|
||||
model: git.aiterp.net/rpdata/api/models/posts.Filter
|
||||
Log:
|
||||
model: git.aiterp.net/rpdata/api/models.Log
|
||||
LogsFilter:
|
||||
model: git.aiterp.net/rpdata/api/models/logs.Filter
|
||||
Chapter:
|
||||
model: git.aiterp.net/rpdata/api/models.Chapter
|
||||
fields:
|
||||
fictionalDate:
|
||||
resolver: true
|
||||
Story:
|
||||
model: git.aiterp.net/rpdata/api/models.Story
|
||||
fields:
|
||||
fictionalDate:
|
||||
resolver: true
|
||||
StoryCategory:
|
||||
model: git.aiterp.net/rpdata/api/models.StoryCategory
|
||||
StoriesFilter:
|
||||
model: git.aiterp.net/rpdata/api/models/stories.Filter
|
||||
File:
|
||||
model: git.aiterp.net/rpdata/api/models.File
|
||||
FilesFilter:
|
||||
model: git.aiterp.net/rpdata/api/models/files.Filter
|
||||
Change:
|
||||
model: git.aiterp.net/rpdata/api/models.Change
|
||||
ChangeModel:
|
||||
model: git.aiterp.net/rpdata/api/models.ChangeModel
|
||||
ChangeKey:
|
||||
model: git.aiterp.net/rpdata/api/models.ChangeKey
|
||||
ChangeKeyInput: # It's the same as ChangeKey
|
||||
model: git.aiterp.net/rpdata/api/models.ChangeKey
|
||||
ChangesFilter:
|
||||
model: git.aiterp.net/rpdata/api/models/changes.Filter
|
||||
Token:
|
||||
model: git.aiterp.net/rpdata/api/models.Token
|
||||
User:
|
||||
model: git.aiterp.net/rpdata/api/models.User
|
||||
Date:
|
||||
model: git.aiterp.net/rpdata/api/models/scalars.Date
|
||||
@@ -0,0 +1,51 @@
|
||||
package graph2
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/graph2/queries"
|
||||
"git.aiterp.net/rpdata/api/graph2/types"
|
||||
graphql "github.com/99designs/gqlgen/graphql"
|
||||
)
|
||||
|
||||
//go:generate ./combine.sh
|
||||
//go:generate gorunpkg github.com/99designs/gqlgen -v
|
||||
|
||||
// New creates a new GraphQL schema.
|
||||
func New() graphql.ExecutableSchema {
|
||||
return NewExecutableSchema(Config{
|
||||
Resolvers: &rootResolver{},
|
||||
})
|
||||
}
|
||||
|
||||
type rootResolver struct{}
|
||||
|
||||
func (r *rootResolver) Query() QueryResolver {
|
||||
return &queries.Resolver
|
||||
}
|
||||
|
||||
func (r *rootResolver) Mutation() MutationResolver {
|
||||
return queries.MutationResolver
|
||||
}
|
||||
|
||||
func (r *rootResolver) Log() LogResolver {
|
||||
return &types.LogResolver
|
||||
}
|
||||
|
||||
func (r *rootResolver) Chapter() ChapterResolver {
|
||||
return &types.ChapterResolver
|
||||
}
|
||||
|
||||
func (r *rootResolver) Story() StoryResolver {
|
||||
return &types.StoryResolver
|
||||
}
|
||||
|
||||
func (r *rootResolver) File() FileResolver {
|
||||
return &types.FileResolver
|
||||
}
|
||||
|
||||
func (r *rootResolver) Change() ChangeResolver {
|
||||
return &types.ChangeResolver
|
||||
}
|
||||
|
||||
func (r *rootResolver) Token() TokenResolver {
|
||||
return &types.TokenResolver
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/changes"
|
||||
)
|
||||
|
||||
/// Queries
|
||||
|
||||
func (r *resolver) Changes(ctx context.Context, filter *changes.Filter) ([]models.Change, error) {
|
||||
return changes.List(filter)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.aiterp.net/rpdata/api/graph2/input"
|
||||
"git.aiterp.net/rpdata/api/internal/auth"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/changekeys"
|
||||
"git.aiterp.net/rpdata/api/models/changes"
|
||||
"git.aiterp.net/rpdata/api/models/channels"
|
||||
)
|
||||
|
||||
// Queries
|
||||
|
||||
func (r *resolver) Channel(ctx context.Context, name string) (models.Channel, error) {
|
||||
return channels.FindName(name)
|
||||
}
|
||||
|
||||
func (r *resolver) Channels(ctx context.Context, filter *channels.Filter) ([]models.Channel, error) {
|
||||
return channels.List(filter)
|
||||
}
|
||||
|
||||
// Mutations
|
||||
|
||||
func (r *mutationResolver) AddChannel(ctx context.Context, input input.ChannelAddInput) (models.Channel, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.Permitted("channel.add") {
|
||||
return models.Channel{}, errors.New("You are not permitted to add channels")
|
||||
}
|
||||
|
||||
logged := false
|
||||
if input.Logged != nil {
|
||||
logged = *input.Logged
|
||||
}
|
||||
hub := false
|
||||
if input.Hub != nil {
|
||||
hub = *input.Hub
|
||||
}
|
||||
eventName := ""
|
||||
if input.EventName != nil {
|
||||
eventName = *input.EventName
|
||||
}
|
||||
locationName := ""
|
||||
if input.LocationName != nil {
|
||||
locationName = *input.LocationName
|
||||
}
|
||||
|
||||
channel, err := channels.Add(input.Name, logged, hub, eventName, locationName)
|
||||
if err != nil {
|
||||
return models.Channel{}, errors.New("Failed to add channel: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Channel", "add", token.UserID, true, changekeys.Listed(channel), channel)
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) EditChannel(ctx context.Context, input input.ChannelEditInput) (models.Channel, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.Permitted("channel.edit") {
|
||||
return models.Channel{}, errors.New("You are not permitted to edit channels")
|
||||
}
|
||||
|
||||
channel, err := channels.FindName(input.Name)
|
||||
if err != nil {
|
||||
return models.Channel{}, errors.New("Channel not found")
|
||||
}
|
||||
|
||||
channel, err = channels.Edit(channel, input.Logged, input.Hub, input.EventName, input.LocationName)
|
||||
if err != nil {
|
||||
return models.Channel{}, errors.New("Failed to edit channel: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Channel", "edit", token.UserID, true, changekeys.Listed(channel), channel)
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models/changekeys"
|
||||
"git.aiterp.net/rpdata/api/models/changes"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/auth"
|
||||
"git.aiterp.net/rpdata/api/models/stories"
|
||||
|
||||
"git.aiterp.net/rpdata/api/graph2/input"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/chapters"
|
||||
)
|
||||
|
||||
// Queries
|
||||
|
||||
func (r *resolver) Chapter(ctx context.Context, id string) (models.Chapter, error) {
|
||||
return chapters.FindID(id)
|
||||
}
|
||||
|
||||
// Mutations
|
||||
|
||||
func (r *mutationResolver) AddChapter(ctx context.Context, input input.ChapterAddInput) (models.Chapter, error) {
|
||||
story, err := stories.FindID(input.StoryID)
|
||||
if err != nil {
|
||||
return models.Chapter{}, errors.New("Story not found")
|
||||
}
|
||||
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Permitted("member", "story.add") {
|
||||
return models.Chapter{}, errors.New("Unauthorized")
|
||||
}
|
||||
|
||||
author := token.UserID
|
||||
if input.Author != nil && *input.Author != author {
|
||||
if !token.Permitted("story.add") {
|
||||
return models.Chapter{}, errors.New("False pretender")
|
||||
}
|
||||
|
||||
author = *input.Author
|
||||
}
|
||||
|
||||
if !story.Open && story.Author != author {
|
||||
return models.Chapter{}, errors.New("Story is not open")
|
||||
}
|
||||
|
||||
chapter, err := chapters.Add(story, input.Title, author, input.Source, time.Now(), input.FictionalDate)
|
||||
if err != nil {
|
||||
return models.Chapter{}, errors.New("Failed to create chapter: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Chapter", "add", token.UserID, story.Listed, changekeys.Listed(story, chapter), story, chapter)
|
||||
|
||||
return chapter, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) MoveChapter(ctx context.Context, input input.ChapterMoveInput) (models.Chapter, error) {
|
||||
chapter, err := chapters.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Chapter{}, errors.New("Chapter not found")
|
||||
}
|
||||
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.PermittedUser(chapter.Author, "member", "chapter.move") {
|
||||
return models.Chapter{}, errors.New("You are not allowed to move this chapter")
|
||||
}
|
||||
|
||||
target, err := stories.FindID(input.StoryID)
|
||||
if err != nil {
|
||||
return models.Chapter{}, errors.New("Target story not found")
|
||||
}
|
||||
|
||||
if !target.Open && !token.PermittedUser(target.Author, "member", "chapter.move") {
|
||||
return models.Chapter{}, errors.New("You are not permitted to move chapters to this story")
|
||||
}
|
||||
|
||||
oldStoryID := chapter.StoryID
|
||||
chapter, err = chapters.Move(chapter, target)
|
||||
if err != nil {
|
||||
return models.Chapter{}, errors.New("Failed to move chapter: " + err.Error())
|
||||
}
|
||||
|
||||
go func() {
|
||||
story, err := stories.FindID(chapter.StoryID)
|
||||
if err != nil {
|
||||
story.ID = chapter.StoryID
|
||||
}
|
||||
|
||||
oldStory, err := stories.FindID(oldStoryID)
|
||||
if err != nil {
|
||||
oldStory.ID = oldStoryID
|
||||
}
|
||||
|
||||
changes.Submit("Chapter", "move-out", chapter.Author, oldStory.Listed, changekeys.Many(oldStory, chapter), chapter)
|
||||
changes.Submit("Chapter", "move-in", token.UserID, story.Listed, changekeys.Listed(story, chapter), story, chapter)
|
||||
}()
|
||||
|
||||
return chapter, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) EditChapter(ctx context.Context, input input.ChapterEditInput) (models.Chapter, error) {
|
||||
chapter, err := chapters.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Chapter{}, errors.New("Chapter not found")
|
||||
}
|
||||
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.PermittedUser(chapter.Author, "member", "chapter.edit") {
|
||||
return models.Chapter{}, errors.New("Unauthorized")
|
||||
}
|
||||
|
||||
if input.ClearFictionalDate != nil && *input.ClearFictionalDate == true {
|
||||
input.FictionalDate = &time.Time{}
|
||||
}
|
||||
|
||||
chapter, err = chapters.Edit(chapter, input.Title, input.Source, input.FictionalDate)
|
||||
if err != nil {
|
||||
return models.Chapter{}, errors.New("Failed to edit chapter: " + err.Error())
|
||||
}
|
||||
|
||||
go func() {
|
||||
story, err := stories.FindID(chapter.StoryID)
|
||||
if err != nil {
|
||||
story.ID = chapter.StoryID
|
||||
}
|
||||
|
||||
changes.Submit("Chapter", "edit", token.UserID, story.Listed, changekeys.Many(story, chapter), chapter)
|
||||
}()
|
||||
|
||||
return chapter, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) RemoveChapter(ctx context.Context, input input.ChapterRemoveInput) (models.Chapter, error) {
|
||||
chapter, err := chapters.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Chapter{}, errors.New("Chapter not found")
|
||||
}
|
||||
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.PermittedUser(chapter.Author, "member", "chapter.remove") {
|
||||
return models.Chapter{}, errors.New("Unauthorized")
|
||||
}
|
||||
|
||||
chapter, err = chapters.Remove(chapter)
|
||||
if err != nil {
|
||||
return models.Chapter{}, errors.New("Failed to remove chapter: " + err.Error())
|
||||
}
|
||||
|
||||
go func() {
|
||||
story, err := stories.FindID(chapter.StoryID)
|
||||
if err != nil {
|
||||
story.ID = chapter.StoryID
|
||||
}
|
||||
|
||||
changes.Submit("Chapter", "remove", token.UserID, story.Listed, changekeys.Many(story, chapter), chapter)
|
||||
}()
|
||||
|
||||
return chapter, nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/graph2/input"
|
||||
"git.aiterp.net/rpdata/api/internal/auth"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/changekeys"
|
||||
"git.aiterp.net/rpdata/api/models/changes"
|
||||
"git.aiterp.net/rpdata/api/models/characters"
|
||||
"git.aiterp.net/rpdata/api/models/logs"
|
||||
)
|
||||
|
||||
// Queries
|
||||
|
||||
func (r *resolver) Character(ctx context.Context, id *string, nick *string) (models.Character, error) {
|
||||
if id != nil {
|
||||
return characters.FindID(*id)
|
||||
} else if nick != nil {
|
||||
return characters.FindNick(*nick)
|
||||
} else {
|
||||
return models.Character{}, errors.New("You must specify either an ID or a nick")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *resolver) Characters(ctx context.Context, filter *characters.Filter) ([]models.Character, error) {
|
||||
return characters.List(filter)
|
||||
}
|
||||
|
||||
// Mutations
|
||||
|
||||
func (r *mutationResolver) AddCharacter(ctx context.Context, input input.CharacterAddInput) (models.Character, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Permitted("member", "character.add") {
|
||||
return models.Character{}, errors.New("You are not permitted to add characters")
|
||||
}
|
||||
if len(input.Name) < 2 || len(input.Nick) < 2 {
|
||||
return models.Character{}, errors.New("You need to provide a name and a nick (min length: 2)")
|
||||
}
|
||||
|
||||
shortName := ""
|
||||
if input.ShortName != nil {
|
||||
shortName = *input.ShortName
|
||||
} else {
|
||||
shortName = strings.SplitN(input.Name, " ", 2)[0]
|
||||
}
|
||||
|
||||
description := ""
|
||||
if input.Description != nil {
|
||||
description = *input.Description
|
||||
}
|
||||
|
||||
author := token.UserID
|
||||
if input.Author != nil && *input.Author != author {
|
||||
if !token.Permitted("character.add") {
|
||||
return models.Character{}, errors.New("You are only permitted to add your own characters")
|
||||
}
|
||||
|
||||
author = *input.Author
|
||||
}
|
||||
|
||||
logs.ScheduleFullUpdate()
|
||||
|
||||
character, err := characters.Add(input.Nick, input.Name, shortName, author, description)
|
||||
if err != nil {
|
||||
return models.Character{}, errors.New("Adding character failed: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Character", "add", token.UserID, true, changekeys.Listed(character), character)
|
||||
|
||||
return character, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) AddCharacterNick(ctx context.Context, input input.CharacterNickInput) (models.Character, error) {
|
||||
character, err := characters.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Character{}, errors.New("Character not found")
|
||||
}
|
||||
|
||||
if len(input.Nick) < 2 {
|
||||
return models.Character{}, errors.New("You need to provide a valid nick (min length: 2)")
|
||||
}
|
||||
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.PermittedUser(character.Author, "member", "character.edit") {
|
||||
return models.Character{}, errors.New("You are not permitted to edit this character")
|
||||
}
|
||||
|
||||
logs.ScheduleFullUpdate()
|
||||
|
||||
character, err = characters.AddNick(character, input.Nick)
|
||||
if err != nil {
|
||||
return models.Character{}, errors.New("Failed to add nick: " + err.Error())
|
||||
}
|
||||
|
||||
go logs.ScheduleFullUpdate()
|
||||
go changes.Submit("Character", "edit", token.UserID, true, changekeys.Listed(character), character)
|
||||
|
||||
return character, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) RemoveCharacterNick(ctx context.Context, input input.CharacterNickInput) (models.Character, error) {
|
||||
character, err := characters.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Character{}, errors.New("Character not found")
|
||||
}
|
||||
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.PermittedUser(character.Author, "member", "character.edit") {
|
||||
return models.Character{}, errors.New("You are not permitted to edit this character")
|
||||
}
|
||||
|
||||
character, err = characters.RemoveNick(character, input.Nick)
|
||||
if err != nil {
|
||||
return models.Character{}, errors.New("Failed to remove nick: " + err.Error())
|
||||
}
|
||||
|
||||
go logs.ScheduleFullUpdate()
|
||||
go changes.Submit("Character", "edit", token.UserID, true, changekeys.Listed(character), character)
|
||||
|
||||
return character, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) EditCharacter(ctx context.Context, input input.CharacterEditInput) (models.Character, error) {
|
||||
character, err := characters.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Character{}, errors.New("Character not found")
|
||||
}
|
||||
|
||||
if input.Name != nil && len(*input.Name) < 2 {
|
||||
return models.Character{}, errors.New("You need to provide a valid name (min length: 2)")
|
||||
}
|
||||
if input.ShortName != nil && len(*input.ShortName) < 2 {
|
||||
return models.Character{}, errors.New("You need to provide a valid short name (min length: 2)")
|
||||
}
|
||||
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.PermittedUser(character.Author, "member", "character.edit") {
|
||||
return models.Character{}, errors.New("You are not permitted to edit this character")
|
||||
}
|
||||
|
||||
character, err = characters.Edit(character, input.Name, input.ShortName, input.Description)
|
||||
if err != nil {
|
||||
return models.Character{}, errors.New("Failed to edit character: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Character", "edit", token.UserID, true, changekeys.Listed(character), character)
|
||||
|
||||
return character, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) RemoveCharacter(ctx context.Context, input input.CharacterRemoveInput) (models.Character, error) {
|
||||
character, err := characters.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Character{}, errors.New("Character not found")
|
||||
}
|
||||
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.PermittedUser(character.Author, "member", "character.remove") {
|
||||
return models.Character{}, errors.New("You are not permitted to remove this character")
|
||||
}
|
||||
|
||||
character, err = characters.Remove(character)
|
||||
if err != nil {
|
||||
return models.Character{}, errors.New("Failed to remove character: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Character", "remove", token.UserID, true, changekeys.Listed(character), character)
|
||||
|
||||
return character, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/auth"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/files"
|
||||
)
|
||||
|
||||
func (r *resolver) File(ctx context.Context, id string) (models.File, error) {
|
||||
return files.FindID(id)
|
||||
}
|
||||
|
||||
func (r *resolver) Files(ctx context.Context, filter *files.Filter) ([]models.File, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
|
||||
if filter == nil {
|
||||
filter = &files.Filter{}
|
||||
}
|
||||
|
||||
// Only allow users to view public files that are not their own.
|
||||
if token != nil {
|
||||
if filter.Public == nil || *filter.Public == false {
|
||||
filter.Author = &token.UserID
|
||||
}
|
||||
} else {
|
||||
filter.Public = &trueValue
|
||||
}
|
||||
|
||||
return files.List(filter)
|
||||
}
|
||||
|
||||
var trueValue = true
|
||||
@@ -0,0 +1,139 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models/channels"
|
||||
|
||||
"git.aiterp.net/rpdata/api/graph2/input"
|
||||
"git.aiterp.net/rpdata/api/internal/auth"
|
||||
"git.aiterp.net/rpdata/api/internal/loader"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/changekeys"
|
||||
"git.aiterp.net/rpdata/api/models/changes"
|
||||
"git.aiterp.net/rpdata/api/models/logs"
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
)
|
||||
|
||||
// Queries
|
||||
|
||||
func (r *resolver) Log(ctx context.Context, id string) (models.Log, error) {
|
||||
return logs.FindID(id)
|
||||
}
|
||||
|
||||
func (r *resolver) Logs(ctx context.Context, filter *logs.Filter) ([]models.Log, error) {
|
||||
logs, err := logs.List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqCtx := graphql.GetRequestContext(ctx)
|
||||
maybeCharacters := strings.Contains(reqCtx.RawQuery, "characters")
|
||||
maybeChannels := strings.Contains(reqCtx.RawQuery, "channels")
|
||||
|
||||
if len(logs) >= 100 && (maybeCharacters || maybeChannels) {
|
||||
loader := loader.FromContext(ctx)
|
||||
if loader == nil {
|
||||
return nil, errors.New("no loader")
|
||||
}
|
||||
|
||||
for _, log := range logs {
|
||||
if maybeChannels {
|
||||
loader.PrimeChannels("name", log.ChannelName)
|
||||
}
|
||||
|
||||
if maybeCharacters {
|
||||
loader.PrimeCharacters("id", log.CharacterIDs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// Mutations
|
||||
|
||||
func (r *mutationResolver) AddLog(ctx context.Context, input input.LogAddInput) (models.Log, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.Permitted("log.add") {
|
||||
return models.Log{}, errors.New("You are not permitted to add logs")
|
||||
}
|
||||
|
||||
open := input.Open != nil && *input.Open == true
|
||||
title := ""
|
||||
if input.Title != nil {
|
||||
title = *input.Title
|
||||
}
|
||||
event := ""
|
||||
if input.Event != nil {
|
||||
event = *input.Event
|
||||
}
|
||||
description := ""
|
||||
if input.Description != nil {
|
||||
description = *input.Description
|
||||
}
|
||||
|
||||
existingChannel, _ := channels.FindName(input.Channel)
|
||||
|
||||
log, err := logs.Add(input.Date, input.Channel, title, event, description, open)
|
||||
if !token.Authenticated() || !token.Permitted("log.add") {
|
||||
return models.Log{}, errors.New("Failed to create log: " + err.Error())
|
||||
}
|
||||
|
||||
go func() {
|
||||
if existingChannel.Name == "" {
|
||||
channel, err := channels.FindName(input.Channel)
|
||||
if err == nil {
|
||||
changes.Submit("Channel", "add", token.UserID, true, changekeys.Listed(channel), channel)
|
||||
}
|
||||
}
|
||||
|
||||
changes.Submit("Log", "add", token.UserID, true, changekeys.Listed(log), log)
|
||||
}()
|
||||
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) EditLog(ctx context.Context, input input.LogEditInput) (models.Log, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.Permitted("log.edit") {
|
||||
return models.Log{}, errors.New("You are not permitted to edit logs")
|
||||
}
|
||||
|
||||
log, err := logs.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Log{}, errors.New("Log not found")
|
||||
}
|
||||
|
||||
log, err = logs.Edit(log, input.Title, input.Event, input.Description, input.Open)
|
||||
if err != nil {
|
||||
return models.Log{}, errors.New("Failed to edit log: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Log", "edit", token.UserID, true, changekeys.Listed(log), log)
|
||||
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) RemoveLog(ctx context.Context, input input.LogRemoveInput) (models.Log, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.Permitted("log.remove") {
|
||||
return models.Log{}, errors.New("You are not permitted to remove logs")
|
||||
}
|
||||
|
||||
log, err := logs.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Log{}, errors.New("Log not found")
|
||||
}
|
||||
|
||||
log, err = logs.Remove(log)
|
||||
if err != nil {
|
||||
return models.Log{}, errors.New("Failed to remove log: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Log", "remove", token.UserID, true, changekeys.Listed(log), log)
|
||||
|
||||
return log, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models/changekeys"
|
||||
"git.aiterp.net/rpdata/api/models/changes"
|
||||
"git.aiterp.net/rpdata/api/models/logs"
|
||||
|
||||
"git.aiterp.net/rpdata/api/graph2/input"
|
||||
"git.aiterp.net/rpdata/api/internal/auth"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/posts"
|
||||
)
|
||||
|
||||
// Queries
|
||||
|
||||
func (r *resolver) Post(ctx context.Context, id string) (models.Post, error) {
|
||||
return posts.FindID(id)
|
||||
}
|
||||
|
||||
func (r *resolver) Posts(ctx context.Context, filter *posts.Filter) ([]models.Post, error) {
|
||||
// Some sanity checks to avoid querying an insame amount of posts.
|
||||
if filter == nil {
|
||||
filter = &posts.Filter{Limit: 256}
|
||||
} else {
|
||||
if (filter.Limit <= 0 || filter.Limit > 256) && filter.LogID == nil {
|
||||
return nil, errors.New("a limit of 0 (no limit) or >256 without a logId is not allowed")
|
||||
}
|
||||
|
||||
if len(filter.Kind) > 32 {
|
||||
return nil, errors.New("You cannot specify more than 32 kinds")
|
||||
}
|
||||
|
||||
if len(filter.ID) > 32 {
|
||||
return nil, errors.New("You cannot specify more than 32 IDs")
|
||||
}
|
||||
}
|
||||
|
||||
return posts.List(filter)
|
||||
}
|
||||
|
||||
// Mutation
|
||||
|
||||
func (r *mutationResolver) AddPost(ctx context.Context, input input.PostAddInput) (models.Post, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.Permitted("post.add") {
|
||||
return models.Post{}, errors.New("You are not permitted to edit logs")
|
||||
}
|
||||
|
||||
log, err := logs.FindID(input.LogID)
|
||||
if err != nil {
|
||||
return models.Post{}, err
|
||||
}
|
||||
|
||||
post, err := posts.Add(log, input.Time, input.Kind, input.Nick, input.Text)
|
||||
if err != nil {
|
||||
return models.Post{}, err
|
||||
}
|
||||
|
||||
go logs.UpdateCharacters(log)
|
||||
go changes.Submit("Post", "add", token.UserID, true, changekeys.Many(log, post), post)
|
||||
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) EditPost(ctx context.Context, input input.PostEditInput) (models.Post, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.Permitted("post.edit") {
|
||||
return models.Post{}, errors.New("You are not permitted to edit logs")
|
||||
}
|
||||
|
||||
post, err := posts.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Post{}, errors.New("Post not found")
|
||||
}
|
||||
|
||||
if input.Nick != nil {
|
||||
go func() {
|
||||
log, err := logs.FindID(post.LogID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
logs.UpdateCharacters(log)
|
||||
}()
|
||||
}
|
||||
|
||||
post, err = posts.Edit(post, input.Time, input.Kind, input.Nick, input.Text)
|
||||
if err != nil {
|
||||
return models.Post{}, errors.New("Adding post failed: " + err.Error())
|
||||
}
|
||||
|
||||
go func() {
|
||||
log, err := logs.FindID(post.LogID)
|
||||
if err != nil {
|
||||
log = models.Log{ID: post.LogID}
|
||||
}
|
||||
|
||||
changes.Submit("Post", "edit", token.UserID, true, changekeys.Many(log, post), post)
|
||||
}()
|
||||
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) MovePost(ctx context.Context, input input.PostMoveInput) ([]models.Post, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.Permitted("post.move") {
|
||||
return nil, errors.New("You are not permitted to edit logs")
|
||||
}
|
||||
|
||||
post, err := posts.FindID(input.ID)
|
||||
if err != nil {
|
||||
return nil, errors.New("Post not found")
|
||||
}
|
||||
|
||||
posts, err := posts.Move(post, input.ToPosition)
|
||||
if err != nil {
|
||||
return nil, errors.New("Moving posts failed: " + err.Error())
|
||||
}
|
||||
|
||||
go func() {
|
||||
log, err := logs.FindID(post.LogID)
|
||||
if err != nil {
|
||||
log = models.Log{ID: post.LogID}
|
||||
}
|
||||
|
||||
changes.Submit("Post", "move", token.UserID, true, changekeys.Many(log, posts), posts)
|
||||
}()
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) RemovePost(ctx context.Context, input input.PostRemoveInput) (models.Post, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() || !token.Permitted("post.remove") {
|
||||
return models.Post{}, errors.New("You are not permitted to edit logs")
|
||||
}
|
||||
|
||||
post, err := posts.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Post{}, errors.New("Post not found (before removing, of course)")
|
||||
}
|
||||
|
||||
post, err = posts.Remove(post)
|
||||
if err != nil {
|
||||
return models.Post{}, errors.New("Could not remove post: " + err.Error())
|
||||
}
|
||||
|
||||
go func() {
|
||||
log, err := logs.FindID(post.LogID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
logs.UpdateCharacters(log)
|
||||
changes.Submit("Post", "remove", token.UserID, true, changekeys.Many(log, post), post)
|
||||
}()
|
||||
|
||||
return post, nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package queries
|
||||
|
||||
type resolver struct{}
|
||||
type mutationResolver struct{}
|
||||
|
||||
// Resolver has all the queries
|
||||
var Resolver resolver
|
||||
|
||||
// MutationResolver brings the mutagens.
|
||||
var MutationResolver *mutationResolver
|
||||
@@ -0,0 +1,169 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models/changekeys"
|
||||
"git.aiterp.net/rpdata/api/models/changes"
|
||||
|
||||
"git.aiterp.net/rpdata/api/graph2/input"
|
||||
"git.aiterp.net/rpdata/api/internal/auth"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/chapters"
|
||||
"git.aiterp.net/rpdata/api/models/stories"
|
||||
)
|
||||
|
||||
func (r *resolver) Story(ctx context.Context, id string) (models.Story, error) {
|
||||
return stories.FindID(id)
|
||||
}
|
||||
|
||||
func (r *resolver) Stories(ctx context.Context, filter *stories.Filter) ([]models.Story, error) {
|
||||
if filter != nil {
|
||||
if filter.Unlisted != nil && *filter.Unlisted == true {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() {
|
||||
return nil, errors.New("You are not permitted to view unlisted stories")
|
||||
}
|
||||
|
||||
if !token.Permitted("story.unlisted") {
|
||||
filter.Author = &token.UserID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stories.List(filter)
|
||||
}
|
||||
|
||||
// Mutations
|
||||
|
||||
func (r *mutationResolver) AddStory(ctx context.Context, input input.StoryAddInput) (models.Story, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if token == nil || !token.Permitted("member", "story.add") {
|
||||
return models.Story{}, errors.New("Permission denied")
|
||||
}
|
||||
|
||||
author := token.UserID
|
||||
if input.Author != nil && *input.Author != author {
|
||||
if !token.Permitted("story.add") {
|
||||
return models.Story{}, errors.New("You are not permitted to add a story in another author's name")
|
||||
}
|
||||
|
||||
author = *input.Author
|
||||
}
|
||||
|
||||
fictionalDate := time.Time{}
|
||||
if input.FictionalDate != nil {
|
||||
fictionalDate = *input.FictionalDate
|
||||
}
|
||||
|
||||
listed := input.Listed != nil && *input.Listed
|
||||
open := input.Open != nil && *input.Open
|
||||
|
||||
story, err := stories.Add(input.Name, author, input.Category, listed, open, input.Tags, time.Now(), fictionalDate)
|
||||
if err != nil {
|
||||
return models.Story{}, errors.New("Failed to add story: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Story", "add", token.UserID, story.Listed, changekeys.Listed(story), story)
|
||||
|
||||
return story, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) AddStoryTag(ctx context.Context, input input.StoryTagAddInput) (models.Story, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
|
||||
story, err := stories.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Story{}, errors.New("Story not found")
|
||||
}
|
||||
|
||||
if !token.PermittedUser(story.Author, "member", "story.edit") {
|
||||
return models.Story{}, errors.New("You are not permitted to edit this story")
|
||||
}
|
||||
|
||||
story, err = stories.AddTag(story, input.Tag)
|
||||
if err != nil {
|
||||
return models.Story{}, errors.New("Failed to add story: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Story", "tag", token.UserID, story.Listed, changekeys.Listed(story), story, input.Tag)
|
||||
|
||||
return story, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) RemoveStoryTag(ctx context.Context, input input.StoryTagRemoveInput) (models.Story, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
|
||||
story, err := stories.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Story{}, errors.New("Story not found")
|
||||
}
|
||||
|
||||
if !token.PermittedUser(story.Author, "member", "story.edit") {
|
||||
return models.Story{}, errors.New("You are not permitted to edit this story")
|
||||
}
|
||||
|
||||
story, err = stories.RemoveTag(story, input.Tag)
|
||||
if err != nil {
|
||||
return models.Story{}, errors.New("Failed to add story: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Story", "untag", token.UserID, story.Listed, changekeys.Listed(story), story, input.Tag)
|
||||
|
||||
return story, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) EditStory(ctx context.Context, input input.StoryEditInput) (models.Story, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
|
||||
story, err := stories.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Story{}, errors.New("Story not found")
|
||||
}
|
||||
|
||||
if !token.PermittedUser(story.Author, "member", "story.edit") {
|
||||
return models.Story{}, errors.New("You are not permitted to remove this story")
|
||||
}
|
||||
|
||||
if input.ClearFictionalDate != nil && *input.ClearFictionalDate {
|
||||
input.FictionalDate = &time.Time{}
|
||||
}
|
||||
|
||||
story, err = stories.Edit(story, input.Name, input.Category, input.Listed, input.Open, input.FictionalDate)
|
||||
if err != nil {
|
||||
return models.Story{}, errors.New("Failed to add story: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Story", "edit", token.UserID, story.Listed, changekeys.Listed(story), story)
|
||||
|
||||
return story, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) RemoveStory(ctx context.Context, input input.StoryRemoveInput) (models.Story, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
|
||||
story, err := stories.FindID(input.ID)
|
||||
if err != nil {
|
||||
return models.Story{}, errors.New("Story not found")
|
||||
}
|
||||
|
||||
if !token.PermittedUser(story.Author, "member", "story.remove") {
|
||||
return models.Story{}, errors.New("You are not permitted to remove this story")
|
||||
}
|
||||
|
||||
story, err = stories.Remove(story)
|
||||
if err != nil {
|
||||
return models.Story{}, err
|
||||
}
|
||||
|
||||
err = chapters.RemoveStory(story)
|
||||
if err != nil {
|
||||
return models.Story{}, errors.New("Failed to remove chapters, but story is removed: " + err.Error())
|
||||
}
|
||||
|
||||
go changes.Submit("Story", "remove", token.UserID, story.Listed, changekeys.Listed(story), story)
|
||||
|
||||
return story, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/tags"
|
||||
)
|
||||
|
||||
func (r *resolver) Tags(ctx context.Context) ([]models.Tag, error) {
|
||||
return tags.List()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/auth"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
func (r *resolver) Token(ctx context.Context) (models.Token, error) {
|
||||
token := auth.TokenFromContext(ctx)
|
||||
if !token.Authenticated() {
|
||||
return models.Token{}, errors.New("No (valid) token")
|
||||
}
|
||||
|
||||
return *token, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
}
|
||||
|
||||
type Query {
|
||||
# Find character by either an ID or a nick.
|
||||
character(id: String, nick: String): Character!
|
||||
|
||||
# Find characters
|
||||
characters(filter: CharactersFilter): [Character!]!
|
||||
|
||||
|
||||
# Find channel by name
|
||||
channel(name: String!): Channel!
|
||||
|
||||
# Find channels
|
||||
channels(filter: ChannelsFilter): [Channel!]!
|
||||
|
||||
|
||||
# Find post by ID.
|
||||
post(id: String!): Post!
|
||||
|
||||
# Find posts
|
||||
posts(filter: PostsFilter): [Post!]!
|
||||
|
||||
|
||||
# Find log by ID
|
||||
log(id: String!): Log!
|
||||
|
||||
# Find logs
|
||||
logs(filter: LogsFilter): [Log!]!
|
||||
|
||||
|
||||
# Find story chapter by ID
|
||||
chapter(id: String!): Chapter!
|
||||
|
||||
|
||||
# Find all distinct tags used in stories
|
||||
tags: [Tag!]!
|
||||
|
||||
|
||||
# Find story by ID
|
||||
story(id: String!): Story!
|
||||
|
||||
# Find stories
|
||||
stories(filter: StoriesFilter): [Story!]!
|
||||
|
||||
|
||||
# Find file by ID
|
||||
file(id: String!): File!
|
||||
|
||||
# Find files
|
||||
files(filter: FilesFilter): [File!]!
|
||||
|
||||
|
||||
# Find changes
|
||||
changes(filter: ChangesFilter): [Change!]!
|
||||
|
||||
|
||||
# Get information about the token, useful for debugging.
|
||||
token: Token!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
# Add a story
|
||||
addStory(input: StoryAddInput!): Story!
|
||||
|
||||
# Add a story tag
|
||||
addStoryTag(input: StoryTagAddInput!): Story!
|
||||
|
||||
# Remove a story tag
|
||||
removeStoryTag(input: StoryTagRemoveInput!): Story!
|
||||
|
||||
# Edit a story
|
||||
editStory(input: StoryEditInput!): Story!
|
||||
|
||||
# Remove a story
|
||||
removeStory(input: StoryRemoveInput!): Story!
|
||||
|
||||
|
||||
# Add a chapter to a story
|
||||
addChapter(input: ChapterAddInput!): Chapter!
|
||||
|
||||
# Edit a chapter
|
||||
editChapter(input: ChapterEditInput!): Chapter!
|
||||
|
||||
# Move a chapter
|
||||
moveChapter(input: ChapterMoveInput!): Chapter!
|
||||
|
||||
# Remove a chapter
|
||||
removeChapter(input: ChapterRemoveInput!): Chapter!
|
||||
|
||||
|
||||
# Add a new log
|
||||
addLog(input: LogAddInput!): Log!
|
||||
|
||||
# Edit a log
|
||||
editLog(input: LogEditInput!): Log!
|
||||
|
||||
# Remove a log
|
||||
removeLog(input: LogRemoveInput!): Log!
|
||||
|
||||
|
||||
# Add a post
|
||||
addPost(input: PostAddInput!): Post!
|
||||
|
||||
# Edit a post
|
||||
editPost(input: PostEditInput!): Post!
|
||||
|
||||
# Move a post. All affected posts will be returned.
|
||||
movePost(input: PostMoveInput!): [Post!]!
|
||||
|
||||
# Remove a post
|
||||
removePost(input: PostRemoveInput!): Post!
|
||||
|
||||
|
||||
# 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(input: CharacterRemoveInput!): Character!
|
||||
|
||||
|
||||
# Add a channel
|
||||
addChannel(input: ChannelAddInput!): Channel!
|
||||
|
||||
# Edit a channel
|
||||
editChannel(input: ChannelEditInput!): Channel!
|
||||
}
|
||||
|
||||
# A Date represents a RFC3339 encoded date with up to millisecond precision.
|
||||
scalar Date
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
A change represents a a change in the history.
|
||||
"""
|
||||
type Change {
|
||||
"A unique ID of the change."
|
||||
id: String!
|
||||
|
||||
"The model of the changed object."
|
||||
model: ChangeModel!
|
||||
|
||||
"The operation performed."
|
||||
op: String!
|
||||
|
||||
"The author that performed the change."
|
||||
author: String!
|
||||
|
||||
"Whether the change will be listed without a matching key."
|
||||
listed: Boolean!
|
||||
|
||||
"The keys indexing this change."
|
||||
keys: [ChangeKey!]!
|
||||
|
||||
"The date when the change happened."
|
||||
date: Date
|
||||
|
||||
"The changed objects."
|
||||
objects: [ChangeObject!]!
|
||||
}
|
||||
|
||||
union ChangeObject = Character | Channel | Log | Post | Story | Tag | Chapter
|
||||
|
||||
"""
|
||||
A change key is a key associated with a change, used for filtering
|
||||
and subscription. A log post edit has keys for both the log and post,
|
||||
making it suitable to use it to patch an active log file.
|
||||
"""
|
||||
type ChangeKey {
|
||||
"The model the key is for."
|
||||
model: ChangeModel!
|
||||
|
||||
"The model's id, or * if it's a key indicating a model list should be updated."
|
||||
id: String!
|
||||
}
|
||||
|
||||
"""
|
||||
See ChangeKey
|
||||
"""
|
||||
input ChangeKeyInput {
|
||||
"The model the key is for."
|
||||
model: ChangeModel!
|
||||
|
||||
"The model's id, or * if it's a key indicating a model list should be updated."
|
||||
id: String!
|
||||
}
|
||||
|
||||
"""
|
||||
A model related to the change.
|
||||
"""
|
||||
enum ChangeModel {
|
||||
Character
|
||||
Channel
|
||||
Log
|
||||
Post
|
||||
Story
|
||||
Tag
|
||||
Chapter
|
||||
}
|
||||
|
||||
"""
|
||||
Optional filter for the changes query.
|
||||
"""
|
||||
input ChangesFilter {
|
||||
"The keys to query for."
|
||||
keys: [ChangeKeyInput!]
|
||||
|
||||
"Only show changes more recent than this date."
|
||||
earliestDate: Date
|
||||
|
||||
"Limit the amount of results."
|
||||
limit: Int
|
||||
}
|
||||
@@ -16,6 +16,18 @@ type Channel {
|
||||
locationName: String
|
||||
}
|
||||
|
||||
# Filters for the channels query
|
||||
input ChannelsFilter {
|
||||
# Filter to either logged or unlogged channels
|
||||
logged: Boolean
|
||||
|
||||
# Filter by event name
|
||||
eventName: String
|
||||
|
||||
# Filter by location name
|
||||
locationName: String
|
||||
}
|
||||
|
||||
input ChannelAddInput {
|
||||
# The channel's name
|
||||
name: String!
|
||||
@@ -0,0 +1,77 @@
|
||||
# A Chapter is the main content body of a story.
|
||||
type Chapter {
|
||||
# The chapter's ID
|
||||
id: String!
|
||||
|
||||
# The chapter's title
|
||||
title: String!
|
||||
|
||||
# The chapter's author
|
||||
author: String!
|
||||
|
||||
# The chapter's source
|
||||
source: String!
|
||||
|
||||
# When the chapter was posted initialy
|
||||
createdDate: Date!
|
||||
|
||||
# The in-universe date and time of the chapter
|
||||
fictionalDate: Date
|
||||
|
||||
# The date of edit.
|
||||
editedDate: Date!
|
||||
}
|
||||
|
||||
# Input for addChapter mutation
|
||||
input ChapterAddInput {
|
||||
# The story to add a chapter to
|
||||
storyId: String!
|
||||
|
||||
# The title to give the chapter. It can not be left empty
|
||||
title: String!
|
||||
|
||||
# The author to set for the chapter. It will use the logged in user ID, and only
|
||||
# users with appropriate permissions can post as another author. Even then, it's only
|
||||
# for importing/moving content. This author name will not be used when logging the
|
||||
# submission
|
||||
author: String
|
||||
|
||||
# The markdown source for the content
|
||||
source: String!
|
||||
|
||||
# Optionally, assign a fictional date for the chapter
|
||||
fictionalDate: Date
|
||||
}
|
||||
|
||||
# Input for editChapter mutation
|
||||
input ChapterEditInput {
|
||||
# The chapter to edit
|
||||
id: String!
|
||||
|
||||
# Change the chapter's title
|
||||
title: String
|
||||
|
||||
# Change the source
|
||||
source: String
|
||||
|
||||
# Set the fictional date for a chapter
|
||||
fictionalDate: Date
|
||||
|
||||
# Remove the fictional date for a chapter
|
||||
clearFictionalDate: Boolean
|
||||
}
|
||||
|
||||
# Input for moveChapter mutation
|
||||
input ChapterMoveInput {
|
||||
# The chapter to move
|
||||
id: String!
|
||||
|
||||
# The story to move it to
|
||||
storyId: String!
|
||||
}
|
||||
|
||||
# Input for removeChapter mutation
|
||||
input ChapterRemoveInput {
|
||||
# The chapter to remove
|
||||
id: String!
|
||||
}
|
||||
@@ -22,6 +22,24 @@ type Character {
|
||||
description: String!
|
||||
}
|
||||
|
||||
# Filter for characters query
|
||||
input CharactersFilter {
|
||||
# Filter by character IDs
|
||||
ids: [String!]
|
||||
|
||||
# Filter by nicks
|
||||
nicks: [String!]
|
||||
|
||||
# Filter by names
|
||||
names: [String!]
|
||||
|
||||
# Filter by author
|
||||
author: String
|
||||
|
||||
# Filter by text search matching against the character's description
|
||||
search: String
|
||||
}
|
||||
|
||||
# Input for adding characters
|
||||
input CharacterAddInput {
|
||||
# The primary IRC nick name to recognize this character by
|
||||
@@ -50,6 +68,7 @@ input CharacterNickInput {
|
||||
nick: String!
|
||||
}
|
||||
|
||||
# Inpuit for editCharacter mutation
|
||||
input CharacterEditInput {
|
||||
# The id for the character to edit
|
||||
id: String!
|
||||
@@ -62,4 +81,11 @@ input CharacterEditInput {
|
||||
|
||||
# A short description for the character
|
||||
description: String
|
||||
}
|
||||
}
|
||||
|
||||
# Input for removeCharacter mutation
|
||||
input CharacterRemoveInput {
|
||||
# The id for the character to edit
|
||||
id: String!
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ type File {
|
||||
kind: String!
|
||||
|
||||
# The time of uploading
|
||||
time: String!
|
||||
time: Date!
|
||||
|
||||
# Whether the file is publicly listable. Someone with knowledge of the ID
|
||||
# will still be able to view it, however.
|
||||
@@ -29,6 +29,15 @@ type File {
|
||||
url: String
|
||||
}
|
||||
|
||||
# Filter for the files quiery.
|
||||
input FilesFilter {
|
||||
# If set, this will limit the results to either public or private files.
|
||||
public: Boolean
|
||||
|
||||
# Limit the MIME types of the files.
|
||||
mimeType: [String!]
|
||||
}
|
||||
|
||||
# Input for editFile mutation
|
||||
input EditFileInput {
|
||||
# The file's unique ID
|
||||
@@ -8,7 +8,7 @@ type Log {
|
||||
shortId: String!
|
||||
|
||||
# The date for a log.
|
||||
date: String!
|
||||
date: Date!
|
||||
|
||||
# The channel of a log.
|
||||
channelName: String!
|
||||
@@ -21,7 +21,7 @@ type Log {
|
||||
|
||||
# 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!
|
||||
eventName: String!
|
||||
|
||||
# The description of a session, which is empty if unset.
|
||||
description: String!
|
||||
@@ -36,8 +36,8 @@ type Log {
|
||||
posts(kinds:[String!]): [Post!]!
|
||||
}
|
||||
|
||||
# Input for logs query
|
||||
input LogQueryInput {
|
||||
# Filter for logs query
|
||||
input LogsFilter {
|
||||
# Channels to limit results to (inclusive)
|
||||
channels: [String!]
|
||||
|
||||
@@ -50,17 +50,17 @@ input LogQueryInput {
|
||||
# Search post content
|
||||
search: String
|
||||
|
||||
# Limit to only open logs
|
||||
# Limit by whether it's open or not
|
||||
open: Boolean
|
||||
|
||||
# Limit the amount of posts
|
||||
# Limit the amount of results you get back
|
||||
limit: Int
|
||||
}
|
||||
|
||||
# Input for addLog mutation
|
||||
input LogAddInput {
|
||||
# The date of the log, in RFC3339 format with up to nanosecond precision
|
||||
date: String!
|
||||
# The date of the log.
|
||||
date: Date!
|
||||
|
||||
# The channel where the log is set
|
||||
channel: String!
|
||||
@@ -96,37 +96,8 @@ input LogEditInput {
|
||||
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.
|
||||
# Input for removeLog mutation
|
||||
input LogRemoveInput {
|
||||
# The id of the log to remove
|
||||
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 name of a log.
|
||||
channelName: String!
|
||||
|
||||
# Information about the channel this log is in.
|
||||
channel: Channel!
|
||||
|
||||
# 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!]!
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ type Post {
|
||||
logId: String!
|
||||
|
||||
# The date and time of posting
|
||||
time: String!
|
||||
time: Date!
|
||||
|
||||
# The kind of post this is. Only "text", "scene" and "action" are RP, while others are annotations and 'commands'.
|
||||
kind: String!
|
||||
@@ -23,12 +23,12 @@ type Post {
|
||||
}
|
||||
|
||||
# Input for the addPost mutation
|
||||
input AddPostInput {
|
||||
input PostAddInput {
|
||||
# The log's ID that this post should be a part of
|
||||
logId: String!
|
||||
|
||||
# The date and time of posting, in a RFC3339 format with up to a nanosecond's precision
|
||||
time: String!
|
||||
time: Date!
|
||||
|
||||
# The kind of post this is. Only "text", "scene" and "action" are RP, while others are annotations and 'commands'.
|
||||
kind: String!
|
||||
@@ -41,12 +41,12 @@ input AddPostInput {
|
||||
}
|
||||
|
||||
# Input for the editPost mutation
|
||||
input EditPostInput {
|
||||
input PostEditInput {
|
||||
# The Post ID
|
||||
id: String!
|
||||
|
||||
# The date and time of posting, in a RFC3339 format with up to a nanosecond's precision
|
||||
time: String
|
||||
time: Date
|
||||
|
||||
# The kind of post this is. Only "text", "scene" and "action" are RP, while others are annotations and 'commands'.
|
||||
kind: String
|
||||
@@ -59,10 +59,24 @@ input EditPostInput {
|
||||
}
|
||||
|
||||
# Input for the movePost mutation
|
||||
input MovePostInput {
|
||||
input PostMoveInput {
|
||||
# The Post ID
|
||||
id: String!
|
||||
|
||||
# Target index
|
||||
toPosition: Int!
|
||||
}
|
||||
|
||||
# Input for the removePost mutation
|
||||
input PostRemoveInput {
|
||||
# The Post ID
|
||||
id: String!
|
||||
}
|
||||
|
||||
# Filter for posts query
|
||||
input PostsFilter {
|
||||
id: [String!]
|
||||
kind: [String!]
|
||||
logId: String
|
||||
limit: Int
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
# A Story is a piece of content that, unlike wiki articles, are not something that should be ordered by time instead of title. It can
|
||||
# contain multiple chapters by one or more autohrs
|
||||
type Story {
|
||||
# The story's ID
|
||||
id: String!
|
||||
|
||||
# The story's name, which will show up in the lists.
|
||||
name: String!
|
||||
|
||||
# The story's author
|
||||
author: String!
|
||||
|
||||
# Whether other users may add/edit chapters to the story.
|
||||
open: Boolean!
|
||||
|
||||
# Whether users without a direct link can find this story.
|
||||
listed: Boolean!
|
||||
|
||||
# The category of the story
|
||||
category: StoryCategory!
|
||||
|
||||
# The tags for this tory
|
||||
tags: [Tag!]!
|
||||
|
||||
# The chapters of this story
|
||||
chapters: [Chapter!]!
|
||||
|
||||
# The date the story was created.
|
||||
createdDate: Date!
|
||||
|
||||
# The date the story is set in.
|
||||
fictionalDate: Date
|
||||
|
||||
# The date of the last major update to the story. This being bumped means a new chapter has been added.
|
||||
updatedDate: Date!
|
||||
}
|
||||
|
||||
# Filter for stories query.
|
||||
input StoriesFilter {
|
||||
# What author to query for
|
||||
author: String
|
||||
|
||||
# What category to query for
|
||||
category: StoryCategory
|
||||
|
||||
# What tags to query for
|
||||
tags: [TagInput!]
|
||||
|
||||
# If true, it will only show unlisted page you have access to
|
||||
unlisted: Boolean
|
||||
|
||||
# Whether the page is open for additions by other users
|
||||
open: Boolean
|
||||
|
||||
# The earliest fictionalDate
|
||||
earliestFictionalDate: Date
|
||||
|
||||
# The latest fictionalDate
|
||||
latestFictionalDate: Date
|
||||
|
||||
# The max amount of stories to get (default: 30)
|
||||
limit: Int
|
||||
}
|
||||
|
||||
# Input for the addStory mutation
|
||||
input StoryAddInput {
|
||||
# Set the name of the story, which will show up as the title, and as a suggestion for
|
||||
# the first chapter being added.
|
||||
name: String!
|
||||
|
||||
# Set the category for the new story.
|
||||
category: StoryCategory!
|
||||
|
||||
# Add the story under another name. This requires the story.add permission, and should not be
|
||||
# abused. The action will be logged with the actual creator's user ID.
|
||||
author: String
|
||||
|
||||
# Allow other users to post chapters to the story.
|
||||
open: Boolean
|
||||
|
||||
# Allow other users to see this page in any lists or search results.
|
||||
listed: Boolean
|
||||
|
||||
# Set which tags the story should have.
|
||||
tags: [TagInput!]
|
||||
|
||||
# Set the fictional date of the story.
|
||||
fictionalDate: Date
|
||||
}
|
||||
|
||||
# Input for the editStory mutation
|
||||
input StoryEditInput {
|
||||
# What story to edit
|
||||
id: String!
|
||||
|
||||
# Set the name of the story, which will show up as the title, and as a suggestion for
|
||||
# the first chapter being added.
|
||||
name: String
|
||||
|
||||
# Set the category for the new story.
|
||||
category: StoryCategory
|
||||
|
||||
# Add the story under another name. This requires the story.add permission, and should not be
|
||||
# abused. The action will be logged with the actual creator's user ID.
|
||||
author: String
|
||||
|
||||
# Change whether to allow others to add chapters
|
||||
open: Boolean
|
||||
|
||||
# Change whether to show this story in the list and search results
|
||||
listed: Boolean
|
||||
|
||||
# Set the fictional date of the story.
|
||||
fictionalDate: Date
|
||||
|
||||
# Clear the fictional date of the story.
|
||||
clearFictionalDate: Boolean
|
||||
}
|
||||
|
||||
# Input for the addStoryTag mutation
|
||||
input StoryTagAddInput {
|
||||
# What story to add the tag to.
|
||||
id: String!
|
||||
|
||||
# The tag to add.
|
||||
tag: TagInput!
|
||||
}
|
||||
|
||||
# Input for the removeStoryTag mutation
|
||||
input StoryTagRemoveInput {
|
||||
# What story to remove the tag from.
|
||||
id: String!
|
||||
|
||||
# The tag to remove.
|
||||
tag: TagInput!
|
||||
}
|
||||
|
||||
# Input for the removeStory mutation
|
||||
input StoryRemoveInput {
|
||||
# What story to remove.
|
||||
id: String!
|
||||
}
|
||||
|
||||
# Possible values for Story.category
|
||||
enum StoryCategory {
|
||||
# General information
|
||||
Info
|
||||
|
||||
# News stories
|
||||
News
|
||||
|
||||
# Description and content of a document or item
|
||||
Document
|
||||
|
||||
# Information about something going on in the background that may or may not inform RP
|
||||
Background
|
||||
|
||||
# A short story
|
||||
Story
|
||||
}
|
||||
@@ -7,6 +7,15 @@ type Tag {
|
||||
name: String!
|
||||
}
|
||||
|
||||
# A Tag is a means of associating stories that have details in common with one another.
|
||||
input TagInput {
|
||||
# The tag's kind
|
||||
kind: TagKind!
|
||||
|
||||
# The tag's name
|
||||
name: String!
|
||||
}
|
||||
|
||||
# Allowed values for Tag.kind
|
||||
enum TagKind {
|
||||
# An organization is a catch all term for in-universe corporations, teams, groups, cults, forces, etc...
|
||||
@@ -24,4 +33,5 @@ enum TagKind {
|
||||
|
||||
# None of the above, but it does still tie multiple stories together. The new story/chapter format may obsolete this tag kind.
|
||||
Series
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Token represents the data parsed and validated from the bearer token.
|
||||
type Token {
|
||||
# The user represented in the token.
|
||||
user: User!
|
||||
|
||||
# The permissions for the token.
|
||||
permissions: [String!]!
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# A user represents a member on the RPData platform.
|
||||
type User {
|
||||
# The user's unique ID / username
|
||||
id: String!
|
||||
|
||||
# What the user is permitted to do.
|
||||
permissions: [String!]!
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.aiterp.net/rpdata/api/graph2/input"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
type changeResolver struct{}
|
||||
|
||||
func (r *changeResolver) Objects(ctx context.Context, obj *models.Change) ([]input.ChangeObject, error) {
|
||||
objects := obj.Objects()
|
||||
|
||||
results := make([]input.ChangeObject, len(objects))
|
||||
for i := range objects {
|
||||
results[i] = objects[i]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ChangeResolver is a resolver
|
||||
var ChangeResolver changeResolver
|
||||
@@ -0,0 +1,21 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
type chapterResolver struct{}
|
||||
|
||||
func (r *chapterResolver) FictionalDate(ctx context.Context, chapter *models.Chapter) (*time.Time, error) {
|
||||
if chapter.FictionalDate.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &chapter.FictionalDate, nil
|
||||
}
|
||||
|
||||
// ChapterResolver is a resolver
|
||||
var ChapterResolver chapterResolver
|
||||
@@ -0,0 +1,16 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
type fileResolver struct{}
|
||||
|
||||
func (r *fileResolver) Size(ctx context.Context, file *models.File) (int, error) {
|
||||
return int(file.Size), nil
|
||||
}
|
||||
|
||||
// FileResolver is a resolver
|
||||
var FileResolver fileResolver
|
||||
@@ -0,0 +1,41 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/loader"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/posts"
|
||||
)
|
||||
|
||||
type logResolver struct{}
|
||||
|
||||
func (r *logResolver) Channel(ctx context.Context, log *models.Log) (models.Channel, error) {
|
||||
loader := loader.FromContext(ctx)
|
||||
if loader == nil {
|
||||
return models.Channel{}, errors.New("no loader")
|
||||
}
|
||||
|
||||
return loader.Channel("name", log.ChannelName)
|
||||
}
|
||||
|
||||
func (r *logResolver) Characters(ctx context.Context, log *models.Log) ([]models.Character, error) {
|
||||
loader := loader.FromContext(ctx)
|
||||
if loader == nil {
|
||||
return nil, errors.New("no loader")
|
||||
}
|
||||
|
||||
return loader.Characters("id", log.CharacterIDs...)
|
||||
}
|
||||
|
||||
func (r *logResolver) Posts(ctx context.Context, log *models.Log, kinds []string) ([]models.Post, error) {
|
||||
return posts.List(&posts.Filter{
|
||||
LogID: &log.ShortID,
|
||||
Kind: kinds,
|
||||
Limit: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// LogResolver is a resolver
|
||||
var LogResolver logResolver
|
||||
@@ -0,0 +1,27 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models/chapters"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
type storyResolver struct{}
|
||||
|
||||
func (r *storyResolver) FictionalDate(ctx context.Context, story *models.Story) (*time.Time, error) {
|
||||
if story.FictionalDate.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &story.FictionalDate, nil
|
||||
}
|
||||
|
||||
func (r *storyResolver) Chapters(ctx context.Context, story *models.Story) ([]models.Chapter, error) {
|
||||
return chapters.ListStoryID(story.ID)
|
||||
}
|
||||
|
||||
// StoryResolver is a resolver
|
||||
var StoryResolver storyResolver
|
||||
@@ -0,0 +1,17 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/users"
|
||||
)
|
||||
|
||||
type tokenResolver struct{}
|
||||
|
||||
func (r *tokenResolver) User(ctx context.Context, token *models.Token) (models.User, error) {
|
||||
return users.Find(token.UserID)
|
||||
}
|
||||
|
||||
// TokenResolver is a resolver
|
||||
var TokenResolver tokenResolver
|
||||
@@ -0,0 +1,109 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"github.com/globalsign/mgo"
|
||||
)
|
||||
|
||||
var keyCollection *mgo.Collection
|
||||
|
||||
// A Key contains a JWT secret and the limitations of it. There are two types of
|
||||
// keys, single-user keys and wildcard keys. The former is used to authenticate
|
||||
// a single user (e.g. the logbot) through an API while the latter is only for
|
||||
// services that can be trusted to perform its own authentication (a frontend).
|
||||
type Key struct {
|
||||
ID string `bson:"_id"`
|
||||
Name string `bson:"name"`
|
||||
User string `bson:"user"`
|
||||
Secret string `bson:"secret"`
|
||||
}
|
||||
|
||||
// ValidForUser returns true if the key's user is the same as
|
||||
// the user, or it's a wildcard key.
|
||||
func (key *Key) ValidForUser(user string) bool {
|
||||
return key.User == user || key.User == "*"
|
||||
}
|
||||
|
||||
// FindKey finds a key by kid (key ID)
|
||||
func FindKey(kid string) (Key, error) {
|
||||
key := Key{}
|
||||
err := keyCollection.FindId(kid).One(&key)
|
||||
|
||||
return key, err
|
||||
}
|
||||
|
||||
// NewKey generates a new key for the user and name. This
|
||||
// does not allow generating wildcard keys, they have to be
|
||||
// manually inserted into the DB.
|
||||
func NewKey(name, user string) (*Key, error) {
|
||||
if user == "*" {
|
||||
return nil, errors.New("auth: wildcard keys not allowed")
|
||||
}
|
||||
|
||||
secret, err := makeKeySecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := &Key{
|
||||
ID: makeKeyID(),
|
||||
Name: name,
|
||||
User: user,
|
||||
Secret: secret,
|
||||
}
|
||||
|
||||
if err := keyCollection.Insert(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
keyCollection = db.C("auth.keys")
|
||||
|
||||
keyCollection.EnsureIndexKey("user")
|
||||
})
|
||||
}
|
||||
|
||||
// makeKeyID makes a random story ID that's 16 characters long
|
||||
func makeKeyID() string {
|
||||
result := "K"
|
||||
offset := 0
|
||||
data := make([]byte, 32)
|
||||
|
||||
rand.Read(data)
|
||||
for len(result) < 16 {
|
||||
result += strconv.FormatUint(binary.LittleEndian.Uint64(data[offset:]), 36)
|
||||
offset += 8
|
||||
|
||||
if offset >= 32 {
|
||||
rand.Read(data)
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
return result[:16]
|
||||
}
|
||||
|
||||
func makeKeySecret() (string, error) {
|
||||
data := make([]byte, 64)
|
||||
alphabet := "0123456789abcdefghjiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSUTVWXYZ-_"
|
||||
|
||||
_, err := rand.Read(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for i := range data {
|
||||
data[i] = alphabet[data[i]%64]
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -1,16 +1,4 @@
|
||||
package session
|
||||
|
||||
// DefaultPermissions gets the default permissions
|
||||
func DefaultPermissions() []string {
|
||||
return []string{
|
||||
"member",
|
||||
"log.edit",
|
||||
"log.reorder",
|
||||
"post.edit",
|
||||
"post.move",
|
||||
"file.upload",
|
||||
}
|
||||
}
|
||||
package auth
|
||||
|
||||
// AllPermissions gets all permissions and their purpose
|
||||
func AllPermissions() map[string]string {
|
||||
@@ -30,8 +18,10 @@ func AllPermissions() map[string]string {
|
||||
"post.edit": "Can edit posts",
|
||||
"post.move": "Can move posts",
|
||||
"post.remove": "Can remove posts",
|
||||
"story.add": "Can add non-owned stories",
|
||||
"story.edit": "Can edit non-owned stories",
|
||||
"story.remove": "Can remove non-owned stories",
|
||||
"story.unlisted": "Can view non-owned unlisted stories",
|
||||
"file.upload": "Can upload files",
|
||||
"file.edit": "Can edit non-owned files",
|
||||
"file.remove": "Can remove non-owned files",
|
||||
@@ -0,0 +1,154 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/users"
|
||||
jwt "github.com/dgrijalva/jwt-go"
|
||||
)
|
||||
|
||||
var contextKey = &struct{ data string }{"Token Context Key"}
|
||||
|
||||
// ErrNoKid is returned if the key id is missing from the jwt token header,
|
||||
var ErrNoKid = errors.New("Missing \"kid\" field in token")
|
||||
|
||||
// ErrKeyNotFound is returned if the key wasn't found.
|
||||
var ErrKeyNotFound = errors.New("Key not found")
|
||||
|
||||
// ErrInvalidClaims is returned by parseClaims if the claims cannot be parsed
|
||||
var ErrInvalidClaims = errors.New("Invalid claims in token")
|
||||
|
||||
// ErrExpired is returned by parseClaims if the expiry date is in the past
|
||||
var ErrExpired = errors.New("Claims have already expired")
|
||||
|
||||
// ErrWrongUser is returned by CheckToken if the key cannot represent this user
|
||||
var ErrWrongUser = errors.New("Key is not valid for this user")
|
||||
|
||||
// ErrWrongPermissions is returned by CheckToken if the key cannot claim one or more of its permissions
|
||||
var ErrWrongPermissions = errors.New("User does not have these permissions")
|
||||
|
||||
// ErrDeletedUser is returned by CheckToken if the key can represent this user, but the user doesn't exist.
|
||||
var ErrDeletedUser = errors.New("User was not found")
|
||||
|
||||
// TokenFromContext gets the token from context.
|
||||
func TokenFromContext(ctx context.Context) *models.Token {
|
||||
token, ok := ctx.Value(contextKey).(*models.Token)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// RequestWithToken either returns the request, or the request with a new context that
|
||||
// has the token.
|
||||
func RequestWithToken(r *http.Request) *http.Request {
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
return r
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
return r
|
||||
}
|
||||
|
||||
token, err := CheckToken(header[7:])
|
||||
if err != nil {
|
||||
return r
|
||||
}
|
||||
|
||||
return r.WithContext(context.WithValue(r.Context(), contextKey, &token))
|
||||
}
|
||||
|
||||
// CheckToken reads the token string and returns a token if everything is kosher.
|
||||
func CheckToken(tokenString string) (token models.Token, err error) {
|
||||
var key Key
|
||||
|
||||
jwtToken, err := jwt.Parse(tokenString, func(jwtToken *jwt.Token) (interface{}, error) {
|
||||
if _, ok := jwtToken.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method: %v", jwtToken.Header["alg"])
|
||||
}
|
||||
|
||||
kid, ok := jwtToken.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, ErrNoKid
|
||||
}
|
||||
|
||||
key, err = FindKey(kid)
|
||||
if err != nil || key.ID == "" {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
|
||||
return []byte(key.Secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return models.Token{}, err
|
||||
}
|
||||
|
||||
userid, permissions, err := parseClaims(jwtToken.Claims)
|
||||
if err != nil {
|
||||
return models.Token{}, err
|
||||
}
|
||||
|
||||
if !key.ValidForUser(userid) {
|
||||
return models.Token{}, ErrWrongUser
|
||||
}
|
||||
|
||||
user, err := users.Ensure(userid)
|
||||
if err != nil {
|
||||
return models.Token{}, ErrDeletedUser
|
||||
}
|
||||
|
||||
acceptedPermissions := make([]string, 0, 8)
|
||||
for _, permission := range permissions {
|
||||
found := false
|
||||
|
||||
for _, userPermission := range user.Permissions {
|
||||
if permission == userPermission {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
acceptedPermissions = append(acceptedPermissions, permission)
|
||||
}
|
||||
}
|
||||
|
||||
return models.Token{UserID: user.ID, Permissions: acceptedPermissions}, nil
|
||||
}
|
||||
|
||||
func parseClaims(jwtClaims jwt.Claims) (userid string, permissions []string, err error) {
|
||||
mapClaims, ok := jwtClaims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return "", nil, ErrInvalidClaims
|
||||
}
|
||||
|
||||
if !mapClaims.VerifyExpiresAt(time.Now().Unix(), true) {
|
||||
return "", nil, ErrExpired
|
||||
}
|
||||
|
||||
if userid, ok = mapClaims["user"].(string); !ok {
|
||||
return "", nil, ErrInvalidClaims
|
||||
}
|
||||
|
||||
if claimedPermissions, ok := mapClaims["permissions"].([]interface{}); ok {
|
||||
for _, permission := range claimedPermissions {
|
||||
if permission, ok := permission.(string); ok {
|
||||
permissions = append(permissions, permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(permissions) == 0 {
|
||||
return "", nil, ErrInvalidClaims
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -5,27 +5,30 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/model/channel"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/channels"
|
||||
"github.com/graph-gophers/dataloader"
|
||||
)
|
||||
|
||||
// Channel gets a character by key
|
||||
func (loader *Loader) Channel(key, value string) (channel.Channel, error) {
|
||||
func (loader *Loader) Channel(key, value string) (models.Channel, error) {
|
||||
if !strings.HasPrefix(key, "Channel.") {
|
||||
key = "Channel." + key
|
||||
}
|
||||
|
||||
if loader.loaders[key] == nil {
|
||||
return channel.Channel{}, errors.New("unsupported key")
|
||||
return models.Channel{}, errors.New("unsupported key")
|
||||
}
|
||||
|
||||
loader.loadPrimed(key)
|
||||
|
||||
thunk := loader.loaders[key].Load(loader.ctx, dataloader.StringKey(value))
|
||||
res, err := thunk()
|
||||
if err != nil {
|
||||
return channel.Channel{}, err
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
channel, ok := res.(channel.Channel)
|
||||
channel, ok := res.(models.Channel)
|
||||
if !ok {
|
||||
return channel, errors.New("incorrect type")
|
||||
}
|
||||
@@ -33,14 +36,23 @@ func (loader *Loader) Channel(key, value string) (channel.Channel, error) {
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// PrimeChannels primes channels for loading along with the first one.
|
||||
func (loader *Loader) PrimeChannels(key string, values ...string) {
|
||||
if !strings.HasPrefix(key, "Channel.") {
|
||||
key = "Channel." + key
|
||||
}
|
||||
|
||||
loader.prime(key, values)
|
||||
}
|
||||
|
||||
func channelNameBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
|
||||
var results []*dataloader.Result
|
||||
names := keys.Keys()
|
||||
|
||||
channels, err := channel.ListNames(names...)
|
||||
channels, err := channels.ListNames(names...)
|
||||
if err != nil {
|
||||
for range names {
|
||||
results = append(results, &dataloader.Result{Data: channel.Channel{}, Error: err})
|
||||
results = append(results, &dataloader.Result{Data: models.Channel{}, Error: err})
|
||||
}
|
||||
|
||||
return results
|
||||
@@ -58,7 +70,7 @@ func channelNameBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.R
|
||||
}
|
||||
|
||||
if !found {
|
||||
results = append(results, &dataloader.Result{Data: channel.Channel{}, Error: err})
|
||||
results = append(results, &dataloader.Result{Data: models.Channel{}, Error: err})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,36 +5,39 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/model/character"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/characters"
|
||||
"github.com/graph-gophers/dataloader"
|
||||
)
|
||||
|
||||
// Character gets a character by key
|
||||
func (loader *Loader) Character(key, value string) (character.Character, error) {
|
||||
func (loader *Loader) Character(key, value string) (models.Character, error) {
|
||||
if !strings.HasPrefix(key, "Character.") {
|
||||
key = "Character." + key
|
||||
}
|
||||
|
||||
loader.loadPrimed(key)
|
||||
|
||||
if loader.loaders[key] == nil {
|
||||
return character.Character{}, errors.New("unsupported key")
|
||||
return models.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
|
||||
return models.Character{}, err
|
||||
}
|
||||
|
||||
char, ok := res.(character.Character)
|
||||
char, ok := res.(models.Character)
|
||||
if !ok {
|
||||
return character.Character{}, errors.New("incorrect type")
|
||||
return models.Character{}, errors.New("incorrect type")
|
||||
}
|
||||
|
||||
return char, nil
|
||||
}
|
||||
|
||||
// Characters gets characters by key
|
||||
func (loader *Loader) Characters(key string, values ...string) ([]character.Character, error) {
|
||||
func (loader *Loader) Characters(key string, values ...string) ([]models.Character, error) {
|
||||
if !strings.HasPrefix(key, "Character.") {
|
||||
key = "Character." + key
|
||||
}
|
||||
@@ -43,6 +46,8 @@ func (loader *Loader) Characters(key string, values ...string) ([]character.Char
|
||||
return nil, errors.New("unsupported key")
|
||||
}
|
||||
|
||||
loader.loadPrimed(key)
|
||||
|
||||
thunk := loader.loaders[key].LoadMany(loader.ctx, dataloader.NewKeysFromStrings(values))
|
||||
res, errs := thunk()
|
||||
for _, err := range errs {
|
||||
@@ -51,10 +56,10 @@ func (loader *Loader) Characters(key string, values ...string) ([]character.Char
|
||||
}
|
||||
}
|
||||
|
||||
chars := make([]character.Character, len(res))
|
||||
chars := make([]models.Character, len(res))
|
||||
|
||||
for i := range res {
|
||||
char, ok := res[i].(character.Character)
|
||||
char, ok := res[i].(models.Character)
|
||||
if !ok {
|
||||
return nil, errors.New("incorrect type")
|
||||
}
|
||||
@@ -65,11 +70,22 @@ func (loader *Loader) Characters(key string, values ...string) ([]character.Char
|
||||
return chars, nil
|
||||
}
|
||||
|
||||
// PrimeCharacters adds a set of characters to be loaded if, and only if, characters
|
||||
// are going to be loaded. This will fill up the cache and speed up subsequent dataloader
|
||||
// runs.
|
||||
func (loader *Loader) PrimeCharacters(key string, values ...string) {
|
||||
if !strings.HasPrefix(key, "Character.") {
|
||||
key = "Character." + key
|
||||
}
|
||||
|
||||
loader.prime(key, values)
|
||||
}
|
||||
|
||||
func characterIDBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
|
||||
results := make([]*dataloader.Result, 0, len(keys))
|
||||
ids := keys.Keys()
|
||||
|
||||
characters, err := character.ListIDs(ids...)
|
||||
characters, err := characters.List(&characters.Filter{IDs: ids})
|
||||
if err != nil {
|
||||
for range ids {
|
||||
results = append(results, &dataloader.Result{Error: err})
|
||||
@@ -90,7 +106,7 @@ func characterIDBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.R
|
||||
}
|
||||
|
||||
if !found {
|
||||
results = append(results, &dataloader.Result{Data: character.Character{}, Error: ErrNotFound})
|
||||
results = append(results, &dataloader.Result{Data: models.Character{}, Error: ErrNotFound})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +117,7 @@ func characterNickBatch(ctx context.Context, keys dataloader.Keys) []*dataloader
|
||||
var results []*dataloader.Result
|
||||
nicks := keys.Keys()
|
||||
|
||||
characters, err := character.ListNicks(nicks...)
|
||||
characters, err := characters.List(&characters.Filter{Nicks: nicks})
|
||||
if err != nil {
|
||||
for range nicks {
|
||||
results = append(results, &dataloader.Result{Error: err})
|
||||
@@ -122,7 +138,7 @@ func characterNickBatch(ctx context.Context, keys dataloader.Keys) []*dataloader
|
||||
}
|
||||
|
||||
if !found {
|
||||
results = append(results, &dataloader.Result{Data: character.Character{}, Error: err})
|
||||
results = append(results, &dataloader.Result{Data: models.Character{}, Error: err})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package loader
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/graph-gophers/dataloader"
|
||||
@@ -16,8 +17,11 @@ 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 {
|
||||
mutex sync.Mutex
|
||||
ctx context.Context
|
||||
loaders map[string]*dataloader.Loader
|
||||
|
||||
primedKeys map[string]map[string]bool
|
||||
}
|
||||
|
||||
// New initializes the loader.
|
||||
@@ -25,10 +29,11 @@ func New() *Loader {
|
||||
return &Loader{
|
||||
ctx: context.Background(),
|
||||
loaders: map[string]*dataloader.Loader{
|
||||
"Character.id": dataloader.NewBatchedLoader(characterIDBatch, dataloader.WithWait(time.Millisecond)),
|
||||
"Character.nick": dataloader.NewBatchedLoader(characterNickBatch, dataloader.WithWait(time.Millisecond)),
|
||||
"Channel.name": dataloader.NewBatchedLoader(channelNameBatch, dataloader.WithWait(time.Millisecond)),
|
||||
"Character.id": dataloader.NewBatchedLoader(characterIDBatch, dataloader.WithWait(time.Millisecond*2)),
|
||||
"Character.nick": dataloader.NewBatchedLoader(characterNickBatch, dataloader.WithWait(time.Millisecond*2)),
|
||||
"Channel.name": dataloader.NewBatchedLoader(channelNameBatch, dataloader.WithWait(time.Millisecond*2)),
|
||||
},
|
||||
primedKeys: make(map[string]map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +52,29 @@ func (loader *Loader) ToContext(ctx context.Context) context.Context {
|
||||
loader.ctx = ctx
|
||||
return context.WithValue(ctx, &contextKey, loader)
|
||||
}
|
||||
|
||||
func (loader *Loader) prime(key string, values []string) {
|
||||
loader.mutex.Lock()
|
||||
if loader.primedKeys[key] == nil {
|
||||
loader.primedKeys[key] = make(map[string]bool)
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
loader.primedKeys[key][value] = true
|
||||
}
|
||||
loader.mutex.Unlock()
|
||||
}
|
||||
|
||||
func (loader *Loader) loadPrimed(key string) {
|
||||
loader.mutex.Lock()
|
||||
if len(loader.primedKeys[key]) > 0 {
|
||||
primedKeys := make([]string, 0, len(loader.primedKeys[key]))
|
||||
for key := range loader.primedKeys[key] {
|
||||
primedKeys = append(primedKeys, key)
|
||||
}
|
||||
|
||||
loader.loaders[key].LoadMany(loader.ctx, dataloader.NewKeysFromStrings(primedKeys))
|
||||
loader.primedKeys[key] = nil
|
||||
}
|
||||
loader.mutex.Unlock()
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var globalCtx, globalCancel = context.WithCancel(context.Background())
|
||||
|
||||
// A Task is a wrapper around a function that offers scheduling
|
||||
// and syncronization.
|
||||
type Task struct {
|
||||
mutex sync.Mutex
|
||||
ctx context.Context
|
||||
ctxCancel context.CancelFunc
|
||||
waitDuration time.Duration
|
||||
scheduled bool
|
||||
callback func() error
|
||||
}
|
||||
|
||||
// Context gets the task's context.
|
||||
func (task *Task) Context() context.Context {
|
||||
return task.ctx
|
||||
}
|
||||
|
||||
// Stop stops a task. This should not be done to stop a single run,
|
||||
// but as a way to cancel it forever.
|
||||
func (task *Task) Stop() {
|
||||
task.ctxCancel()
|
||||
}
|
||||
|
||||
// Schedule schedules a task for later execution.
|
||||
func (task *Task) Schedule() {
|
||||
// Don't if the context is closed.
|
||||
select {
|
||||
case <-task.Context().Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
task.mutex.Lock()
|
||||
if task.scheduled {
|
||||
task.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
task.scheduled = true
|
||||
task.mutex.Unlock()
|
||||
|
||||
go func() {
|
||||
<-time.After(task.waitDuration)
|
||||
|
||||
// Mark as no longer scheduled
|
||||
task.mutex.Lock()
|
||||
task.scheduled = false
|
||||
task.mutex.Unlock()
|
||||
|
||||
// Don't if the task is cancelled
|
||||
select {
|
||||
case <-task.Context().Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
task.callback()
|
||||
}()
|
||||
}
|
||||
|
||||
// New makes a new task with the callback.
|
||||
func New(waitDuration time.Duration, callback func() error) *Task {
|
||||
ctx, ctxCancel := context.WithCancel(globalCtx)
|
||||
|
||||
return &Task{
|
||||
callback: callback,
|
||||
ctx: ctx,
|
||||
ctxCancel: ctxCancel,
|
||||
waitDuration: waitDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// StopAll stops all tasks.
|
||||
func StopAll() {
|
||||
globalCancel()
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"github.com/globalsign/mgo"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
var collection *mgo.Collection
|
||||
|
||||
// ErrInvalidName is an error for an invalid channel name
|
||||
var ErrInvalidName = errors.New("Invalid channel name")
|
||||
|
||||
// A Channel represents information abount an IRC RP channel, and whether it should be logged
|
||||
type Channel struct {
|
||||
Name string `bson:"_id"`
|
||||
Logged bool `bson:"logged"`
|
||||
Hub bool `bson:"hub"`
|
||||
Event string `bson:"event,omitempty"`
|
||||
Location string `bson:"location,omitempty"`
|
||||
}
|
||||
|
||||
// Edit edits the channel
|
||||
func (channel *Channel) Edit(logged, hub *bool, event, location *string) error {
|
||||
changes := bson.M{}
|
||||
changed := *channel
|
||||
|
||||
if logged != nil && channel.Logged != *logged {
|
||||
changes["logged"] = *logged
|
||||
changed.Logged = *logged
|
||||
}
|
||||
if hub != nil && channel.Hub != *hub {
|
||||
changes["hub"] = *hub
|
||||
changed.Hub = *hub
|
||||
}
|
||||
if event != nil && channel.Event != *event {
|
||||
changes["event"] = *event
|
||||
changed.Event = *event
|
||||
}
|
||||
if location != nil && channel.Event != *location {
|
||||
changes["location"] = *location
|
||||
changed.Location = *location
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := collection.UpdateId(channel.Name, bson.M{"$set": changes})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*channel = changed
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes the channel information from the database.
|
||||
func (channel *Channel) Remove() error {
|
||||
return collection.RemoveId(channel.Name)
|
||||
}
|
||||
|
||||
// Ensure ensures a channel's existence. It does not change `logged` if there is
|
||||
// an existing channel.
|
||||
func Ensure(name string, logged bool) (Channel, error) {
|
||||
channel, err := FindName(name)
|
||||
if err == mgo.ErrNotFound {
|
||||
return New(name, logged, false, "", "")
|
||||
} else if err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// New creates a new channel
|
||||
func New(name string, logged, hub bool, event, location string) (Channel, error) {
|
||||
if len(name) < 3 && !strings.HasPrefix(name, "#") {
|
||||
return Channel{}, ErrInvalidName
|
||||
}
|
||||
|
||||
channel := Channel{
|
||||
Name: name,
|
||||
Logged: logged,
|
||||
Hub: hub,
|
||||
Event: event,
|
||||
Location: location,
|
||||
}
|
||||
|
||||
err := collection.Insert(channel)
|
||||
if err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// FindName finds a channel by its id (its name).
|
||||
func FindName(name string) (Channel, error) {
|
||||
channel := Channel{}
|
||||
err := collection.FindId(name).One(&channel)
|
||||
|
||||
return channel, err
|
||||
}
|
||||
|
||||
// List finds channels, if logged is true it will be limited to logged
|
||||
// channels
|
||||
func List(logged bool) ([]Channel, error) {
|
||||
query := bson.M{}
|
||||
if logged {
|
||||
query["logged"] = true
|
||||
}
|
||||
|
||||
channels := make([]Channel, 0, 32)
|
||||
err := collection.Find(query).All(&channels)
|
||||
|
||||
return channels, err
|
||||
}
|
||||
|
||||
// ListNames finds channels by the names provided
|
||||
func ListNames(names ...string) ([]Channel, error) {
|
||||
query := bson.M{"_id": bson.M{"$in": names}}
|
||||
|
||||
channels := make([]Channel, 0, 32)
|
||||
err := collection.Find(query).All(&channels)
|
||||
|
||||
return channels, err
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
collection = db.C("common.channels")
|
||||
|
||||
collection.EnsureIndexKey("logged")
|
||||
collection.EnsureIndexKey("hub")
|
||||
collection.EnsureIndexKey("event")
|
||||
collection.EnsureIndexKey("location")
|
||||
})
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
package character
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/model/counter"
|
||||
"github.com/globalsign/mgo"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
var collection *mgo.Collection
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"github.com/globalsign/mgo"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
var fileCollection *mgo.Collection
|
||||
|
||||
// A File is a record of a file stored in the Space.
|
||||
type File struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
Time time.Time `bson:"time" json:"time"`
|
||||
Kind string `bson:"kind" json:"kind"`
|
||||
Public bool `bson:"public" json:"public"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
MimeType string `bson:"mimeType" json:"mimeType"`
|
||||
Size int64 `bson:"size" json:"size"`
|
||||
Author string `bson:"author" json:"author"`
|
||||
URL string `bson:"url,omitempty" json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// Edit edits the file, changing up to both the two mutable properties
|
||||
func (file *File) Edit(name *string, public *bool) error {
|
||||
changes := bson.M{}
|
||||
changedFile := *file
|
||||
|
||||
if name != nil && *name != file.Name {
|
||||
changes["name"] = *name
|
||||
changedFile.Name = *name
|
||||
}
|
||||
if public != nil && *public != file.Public {
|
||||
changes["public"] = *public
|
||||
changedFile.Public = *public
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := fileCollection.UpdateId(file.ID, bson.M{"$set": changes})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*file = changedFile
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the file information from the database, and deletes the file.
|
||||
func (file *File) Delete() error {
|
||||
err := fileCollection.RemoveId(file.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if file.Kind == "upload" {
|
||||
err = store.RemoveFile("files", file.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
file.URL = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Insert manually inserts file information into the database. This should never, ever be API accessible
|
||||
func Insert(name, kind, mimeType, author string, time time.Time, size int64, url string) (File, error) {
|
||||
file := File{
|
||||
ID: makeFileID(),
|
||||
Kind: kind,
|
||||
Time: time,
|
||||
Public: false,
|
||||
Author: author,
|
||||
Name: name,
|
||||
MimeType: mimeType,
|
||||
Size: size,
|
||||
URL: url,
|
||||
}
|
||||
|
||||
err := fileCollection.Insert(file)
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// Upload adds a file to the space.
|
||||
func Upload(ctx context.Context, name, mimeType, author string, size int64, input io.Reader) (File, error) {
|
||||
if !allowdMimeTypes[mimeType] {
|
||||
return File{}, errors.New("File type not allowed:" + mimeType)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
date := time.Now().UTC().Format("Jan 02 2006 15:04:05 MST")
|
||||
name = "Unnamed file (" + date + ")"
|
||||
}
|
||||
|
||||
if mimeType == "" {
|
||||
mimeType = "binary/octet-stream"
|
||||
}
|
||||
|
||||
id := makeFileID()
|
||||
|
||||
path, err := store.UploadFile(ctx, "files", id, mimeType, input, size)
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
|
||||
file := File{
|
||||
ID: id,
|
||||
Kind: "upload",
|
||||
Time: time.Now(),
|
||||
Public: false,
|
||||
Author: author,
|
||||
Name: name,
|
||||
MimeType: mimeType,
|
||||
Size: size,
|
||||
URL: store.URLFromPath(path),
|
||||
}
|
||||
|
||||
err = fileCollection.Insert(file)
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// FindID finds a file by ID
|
||||
func FindID(id string) (File, error) {
|
||||
file := File{}
|
||||
|
||||
err := fileCollection.FindId(id).One(&file)
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// FindName finds a file by kind, name and author
|
||||
func FindName(kind string, name string, author string) (File, error) {
|
||||
query := bson.M{"kind": kind, "name": name, "author": author}
|
||||
file := File{}
|
||||
|
||||
err := fileCollection.Find(query).One(&file)
|
||||
if err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// List lists files according to the standard lookup. By default it's just the author's own files,
|
||||
// but if `public` is true it will alos include files made public by other authors. If `mimeTypes` contains
|
||||
// any, it will limit the results to that. If `author` is empty, it will only list public files
|
||||
func List(author string, public bool, mimeTypes []string) ([]File, error) {
|
||||
query := bson.M{}
|
||||
|
||||
if author != "" {
|
||||
if public {
|
||||
query["$or"] = []bson.M{
|
||||
bson.M{"author": author},
|
||||
bson.M{"public": true},
|
||||
}
|
||||
} else {
|
||||
query["author"] = author
|
||||
}
|
||||
} else {
|
||||
if !public {
|
||||
return nil, errors.New("No author specified, and public is unset")
|
||||
}
|
||||
|
||||
query["public"] = true
|
||||
}
|
||||
|
||||
if len(mimeTypes) > 0 {
|
||||
query["mimeTypes"] = bson.M{"$in": mimeTypes}
|
||||
}
|
||||
|
||||
return listFiles(query)
|
||||
}
|
||||
|
||||
func listFiles(query interface{}) ([]File, error) {
|
||||
list := make([]File, 0, 32)
|
||||
|
||||
err := fileCollection.Find(query).Sort("-time").All(&list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// makeFileID makes a random file ID that's 32 characters long
|
||||
func makeFileID() string {
|
||||
result := "F" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
offset := 0
|
||||
data := make([]byte, 32)
|
||||
|
||||
rand.Read(data)
|
||||
for len(result) < 32 {
|
||||
result += strconv.FormatUint(binary.LittleEndian.Uint64(data[offset:]), 36)
|
||||
offset += 8
|
||||
|
||||
if offset >= 32 {
|
||||
rand.Read(data)
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
return result[:32]
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
fileCollection = db.C("file.headers")
|
||||
|
||||
fileCollection.EnsureIndexKey("author")
|
||||
fileCollection.EnsureIndexKey("public")
|
||||
fileCollection.EnsureIndexKey("kind", "name", "author")
|
||||
fileCollection.EnsureIndexKey("author", "public")
|
||||
fileCollection.EnsureIndexKey("kind")
|
||||
})
|
||||
}
|
||||
|
||||
var allowdMimeTypes = map[string]bool{
|
||||
"": false,
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"text/plain": true,
|
||||
"application/json": true,
|
||||
"application/pdf": false,
|
||||
"binary/octet-stream": false,
|
||||
"video/mp4": false,
|
||||
"audio/mp3": false,
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/model/channel"
|
||||
"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, channelName, title, event, description string, open bool) (Log, error) {
|
||||
nextID, err := counter.Next("auto_increment", "Log")
|
||||
if err != nil {
|
||||
return Log{}, err
|
||||
}
|
||||
|
||||
_, err = channel.Ensure(channelName, open)
|
||||
if err != nil {
|
||||
return Log{}, err
|
||||
}
|
||||
|
||||
log := Log{
|
||||
ID: MakeLogID(date, channelName),
|
||||
ShortID: "L" + strconv.Itoa(nextID),
|
||||
Date: date,
|
||||
Channel: channelName,
|
||||
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].Position < posts[j].Position
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
position, err := counter.Next("next_post_id", log.ShortID)
|
||||
if err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
|
||||
post := Post{
|
||||
ID: MakePostID(time),
|
||||
Position: position,
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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"`
|
||||
Position int `bson:"position"`
|
||||
}
|
||||
|
||||
// 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(toPosition int) error {
|
||||
if toPosition < 1 {
|
||||
return errors.New("Invalid position")
|
||||
}
|
||||
|
||||
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 position.
|
||||
if toPosition > 1 {
|
||||
existingPost := Post{}
|
||||
err := postCollection.Find(bson.M{"logId": post.LogID, "position": toPosition}).One(&existingPost)
|
||||
|
||||
if err != nil || existingPost.Position != toPosition {
|
||||
return errors.New("No post found at the position")
|
||||
}
|
||||
}
|
||||
|
||||
query := bson.M{"logId": post.LogID}
|
||||
operation := bson.M{"$inc": bson.M{"position": 1}}
|
||||
|
||||
if toPosition < post.Position {
|
||||
query["$and"] = []bson.M{
|
||||
bson.M{"position": bson.M{"$gte": toPosition}},
|
||||
bson.M{"position": bson.M{"$lt": post.Position}},
|
||||
}
|
||||
} else {
|
||||
query["$and"] = []bson.M{
|
||||
bson.M{"position": bson.M{"$gt": post.Position}},
|
||||
bson.M{"position": bson.M{"$lte": toPosition}},
|
||||
}
|
||||
|
||||
operation["$inc"] = bson.M{"position": -1}
|
||||
}
|
||||
|
||||
_, err := postCollection.UpdateAll(query, operation)
|
||||
if err != nil {
|
||||
return errors.New("moving others: " + err.Error())
|
||||
}
|
||||
|
||||
err = postCollection.UpdateId(post.ID, bson.M{"$set": bson.M{"position": toPosition}})
|
||||
if err != nil {
|
||||
return errors.New("moving: " + err.Error())
|
||||
}
|
||||
|
||||
post.Position = toPosition
|
||||
|
||||
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 position
|
||||
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, "position": bson.M{"$gt": post.Position}}, bson.M{"$inc": bson.M{"position": -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("position")
|
||||
|
||||
err := postCollection.EnsureIndex(mgo.Index{
|
||||
Key: []string{"$text:text"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln("init logbot3.logs:", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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")
|
||||
})
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package story
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"github.com/globalsign/mgo"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
var chapterCollection *mgo.Collection
|
||||
|
||||
// A Chapter is a part of a story.
|
||||
type Chapter struct {
|
||||
ID string `bson:"_id"`
|
||||
StoryID string `bson:"storyId"`
|
||||
Title string `bson:"title"`
|
||||
Author string `bson:"author"`
|
||||
Source string `bson:"source"`
|
||||
CreatedDate time.Time `bson:"createdDate"`
|
||||
FictionalDate time.Time `bson:"fictionalDate,omitempty"`
|
||||
EditedDate time.Time `bson:"editedDate"`
|
||||
}
|
||||
|
||||
// Edit edits a chapter, and updates EditedDate. While many Edit functions cheat if there's nothing to
|
||||
// change, this functill will due to EditedDate.
|
||||
func (chapter *Chapter) Edit(title, source *string, fictionalDate *time.Time) error {
|
||||
now := time.Now()
|
||||
changes := bson.M{"editedDate": now}
|
||||
changed := *chapter
|
||||
changed.EditedDate = now
|
||||
|
||||
if title != nil && *title != chapter.Title {
|
||||
changes["title"] = *title
|
||||
changed.Title = *title
|
||||
}
|
||||
if source != nil && *source != chapter.Source {
|
||||
changes["source"] = *source
|
||||
changed.Source = *source
|
||||
}
|
||||
if fictionalDate != nil && *fictionalDate != chapter.FictionalDate {
|
||||
changes["fictionalDate"] = *fictionalDate
|
||||
changed.FictionalDate = *fictionalDate
|
||||
}
|
||||
|
||||
err := chapterCollection.UpdateId(chapter.ID, bson.M{"$set": changes})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*chapter = changed
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes a chapter.
|
||||
func (chapter *Chapter) Remove() error {
|
||||
return chapterCollection.RemoveId(chapter.ID)
|
||||
}
|
||||
|
||||
// makeChapterID makes a random chapter ID that's 24 characters long
|
||||
func makeChapterID() string {
|
||||
result := "SC"
|
||||
offset := 0
|
||||
data := make([]byte, 32)
|
||||
|
||||
rand.Read(data)
|
||||
for len(result) < 24 {
|
||||
result += strconv.FormatUint(binary.LittleEndian.Uint64(data[offset:]), 36)
|
||||
offset += 8
|
||||
|
||||
if offset >= 32 {
|
||||
rand.Read(data)
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
return result[:24]
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
chapterCollection = db.C("story.chapters")
|
||||
|
||||
chapterCollection.EnsureIndexKey("storyId")
|
||||
chapterCollection.EnsureIndexKey("author")
|
||||
chapterCollection.EnsureIndexKey("createdDate")
|
||||
})
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package story
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"github.com/globalsign/mgo"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
var storyCollection *mgo.Collection
|
||||
|
||||
// ErrTagAlreadyExists is an error returned by Story.AddTag
|
||||
var ErrTagAlreadyExists = errors.New("Tag already exists")
|
||||
|
||||
// ErrTagNotExists is an error returned by Story.RemoveTag
|
||||
var ErrTagNotExists = errors.New("Tag does not exist")
|
||||
|
||||
// A Story is user content that does not have a wiki-suitable format. Documents, new stories, short stories, and so on.
|
||||
// The story model is a container for multiple chapters this time, in contrast to the previous version.
|
||||
type Story struct {
|
||||
ID string `bson:"_id"`
|
||||
Author string `bson:"author"`
|
||||
Name string `bson:"name"`
|
||||
Category string `bson:"category"`
|
||||
Open bool `bson:"open"`
|
||||
Listed bool `bson:"listed"`
|
||||
Tags []Tag `bson:"tags"`
|
||||
CreatedDate time.Time `bson:"createdDate"`
|
||||
FictionalDate time.Time `bson:"fictionalDate,omitempty"`
|
||||
UpdatedDate time.Time `bson:"updatedDate"`
|
||||
}
|
||||
|
||||
// AddTag adds a tag to the story. It returns ErrTagAlreadyExists if the tag is already there
|
||||
func (story *Story) AddTag(tag Tag) error {
|
||||
for i := range story.Tags {
|
||||
if story.Tags[i].Equal(tag) {
|
||||
return ErrTagAlreadyExists
|
||||
}
|
||||
}
|
||||
|
||||
err := storyCollection.UpdateId(story.ID, bson.M{"$push": bson.M{"tags": tag}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
story.Tags = append(story.Tags, tag)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTag removes a tag to the story. It returns ErrTagNotExists if the tag does not exist.
|
||||
func (story *Story) RemoveTag(tag Tag) error {
|
||||
index := -1
|
||||
for i := range story.Tags {
|
||||
if story.Tags[i].Equal(tag) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return ErrTagNotExists
|
||||
}
|
||||
|
||||
err := storyCollection.UpdateId(story.ID, bson.M{"$pull": bson.M{"tags": tag}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
story.Tags = append(story.Tags[:index], story.Tags[index+1:]...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Edit edits the story, reflecting the new values in the story's struct values. If nothing will be
|
||||
// changed, it will silently return without a database roundtrip.
|
||||
func (story *Story) Edit(name, category *string, listed, open *bool, fictionalDate *time.Time) error {
|
||||
changes := bson.M{}
|
||||
changed := *story
|
||||
|
||||
if name != nil && *name == story.Name {
|
||||
changes["name"] = *name
|
||||
changed.Name = *name
|
||||
}
|
||||
if category != nil && *category == story.Category {
|
||||
changes["category"] = *category
|
||||
changed.Name = *category
|
||||
}
|
||||
if listed != nil && *listed == story.Listed {
|
||||
changes["listed"] = *listed
|
||||
changed.Listed = *listed
|
||||
}
|
||||
if open != nil && *open == story.Open {
|
||||
changes["open"] = *open
|
||||
changed.Open = *open
|
||||
}
|
||||
if fictionalDate != nil && *fictionalDate == story.FictionalDate {
|
||||
changes["fictionalDate"] = *fictionalDate
|
||||
changed.FictionalDate = *fictionalDate
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := storyCollection.UpdateId(story.ID, bson.M{"$set": changes})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*story = changed
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove the story from the database
|
||||
func (story *Story) Remove() error {
|
||||
return storyCollection.RemoveId(story.ID)
|
||||
}
|
||||
|
||||
// AddChapter adds a chapter to the story. This does not enforce the `Open` setting, but it will log a warning if it
|
||||
// occurs
|
||||
func (story *Story) AddChapter(title, author, source string, createdDate, finctionalDate time.Time) (Chapter, error) {
|
||||
if !story.Open && author != story.Author {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: AddChapter is breaking Open rules (story.id=%#+v, story.name=%#+v, chapter.author=%#+v, chapter.title=%#+v)", story.ID, story.Name, author, title)
|
||||
}
|
||||
|
||||
chapter := Chapter{
|
||||
ID: makeChapterID(),
|
||||
StoryID: story.ID,
|
||||
Title: title,
|
||||
Author: author,
|
||||
Source: source,
|
||||
CreatedDate: createdDate,
|
||||
FictionalDate: finctionalDate,
|
||||
EditedDate: createdDate,
|
||||
}
|
||||
|
||||
err := chapterCollection.Insert(chapter)
|
||||
if err != nil {
|
||||
return Chapter{}, err
|
||||
}
|
||||
|
||||
if createdDate.After(story.UpdatedDate) {
|
||||
if err := storyCollection.UpdateId(story.ID, bson.M{"$set": bson.M{"updatedDate": createdDate}}); err == nil {
|
||||
story.UpdatedDate = createdDate
|
||||
}
|
||||
}
|
||||
|
||||
return chapter, nil
|
||||
}
|
||||
|
||||
// New creates a new story.
|
||||
func New(name, author, category string, listed, open bool, tags []Tag, createdDate, fictionalDate time.Time) (Story, error) {
|
||||
story := Story{
|
||||
ID: makeStoryID(),
|
||||
Name: name,
|
||||
Author: author,
|
||||
Category: category,
|
||||
Listed: listed,
|
||||
Open: open,
|
||||
Tags: tags,
|
||||
CreatedDate: createdDate,
|
||||
FictionalDate: fictionalDate,
|
||||
UpdatedDate: createdDate,
|
||||
}
|
||||
|
||||
err := storyCollection.Insert(story)
|
||||
if err != nil {
|
||||
return Story{}, err
|
||||
}
|
||||
|
||||
return story, nil
|
||||
}
|
||||
|
||||
// FindID finds a story by ID
|
||||
func FindID(id string) (Story, error) {
|
||||
story := Story{}
|
||||
err := storyCollection.FindId(id).One(&story)
|
||||
|
||||
return story, err
|
||||
}
|
||||
|
||||
// List lists stories by any non-zero criteria passed with it.
|
||||
func List(author string, tags []Tag, earliest, latest time.Time, limit int) ([]Story, error) {
|
||||
query := bson.M{}
|
||||
|
||||
if author != "" {
|
||||
query["author"] = author
|
||||
}
|
||||
|
||||
if len(tags) > 0 {
|
||||
query["tags"] = bson.M{"$in": tags}
|
||||
}
|
||||
|
||||
if !earliest.IsZero() && !latest.IsZero() {
|
||||
query["fictionalDate"] = bson.M{
|
||||
"$gte": earliest,
|
||||
"$lt": latest,
|
||||
}
|
||||
} else if !latest.IsZero() {
|
||||
query["fictionalDate"] = bson.M{
|
||||
"$lt": latest,
|
||||
}
|
||||
} else if !earliest.IsZero() {
|
||||
query["fictionalDate"] = bson.M{
|
||||
"$gte": earliest,
|
||||
}
|
||||
}
|
||||
|
||||
stories := make([]Story, 0, 128)
|
||||
err := storyCollection.Find(query).Limit(limit).One(&stories)
|
||||
|
||||
return stories, err
|
||||
}
|
||||
|
||||
// makeStoryID makes a random story ID that's 16 characters long
|
||||
func makeStoryID() string {
|
||||
result := "S"
|
||||
offset := 0
|
||||
data := make([]byte, 32)
|
||||
|
||||
rand.Read(data)
|
||||
for len(result) < 16 {
|
||||
result += strconv.FormatUint(binary.LittleEndian.Uint64(data[offset:]), 36)
|
||||
offset += 8
|
||||
|
||||
if offset >= 32 {
|
||||
rand.Read(data)
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
return result[:16]
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
storyCollection = db.C("story.stories")
|
||||
|
||||
storyCollection.EnsureIndexKey("tags")
|
||||
storyCollection.EnsureIndexKey("author")
|
||||
storyCollection.EnsureIndexKey("updatedDate")
|
||||
storyCollection.EnsureIndexKey("fictionalDate")
|
||||
storyCollection.EnsureIndexKey("listed")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ChangeModel describes a model related to the change.
|
||||
type ChangeModel string
|
||||
|
||||
const (
|
||||
// ChangeModelCharacter is a value of ChangeModel
|
||||
ChangeModelCharacter ChangeModel = "Character"
|
||||
// ChangeModelChannel is a value of ChangeModel
|
||||
ChangeModelChannel ChangeModel = "Channel"
|
||||
// ChangeModelLog is a value of ChangeModel
|
||||
ChangeModelLog ChangeModel = "Log"
|
||||
// ChangeModelPost is a value of ChangeModel
|
||||
ChangeModelPost ChangeModel = "Post"
|
||||
// ChangeModelStory is a value of ChangeModel
|
||||
ChangeModelStory ChangeModel = "Story"
|
||||
// ChangeModelTag is a value of ChangeModel
|
||||
ChangeModelTag ChangeModel = "Tag"
|
||||
// ChangeModelChapter is a value of ChangeModel
|
||||
ChangeModelChapter ChangeModel = "Chapter"
|
||||
)
|
||||
|
||||
// IsValid returns true if the underlying string is one of the correct values.
|
||||
func (e ChangeModel) IsValid() bool {
|
||||
switch e {
|
||||
case ChangeModelCharacter, ChangeModelChannel, ChangeModelLog, ChangeModelPost, ChangeModelStory, ChangeModelTag, ChangeModelChapter:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e ChangeModel) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
// UnmarshalGQL unmarshals the underlying graphql value.
|
||||
func (e *ChangeModel) UnmarshalGQL(v interface{}) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = ChangeModel(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid ChangeModel", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalGQL marshals the underlying graphql value.
|
||||
func (e ChangeModel) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Change represents a change in the rpdata history through the API.
|
||||
type Change struct {
|
||||
ID string `bson:"_id"`
|
||||
Model ChangeModel `bson:"model"`
|
||||
Op string `bson:"op"`
|
||||
Author string `bson:"author"`
|
||||
Listed bool `bson:"listed"`
|
||||
Keys []ChangeKey `bson:"keys"`
|
||||
Date time.Time `bson:"date"`
|
||||
|
||||
Logs []Log `bson:"logs"`
|
||||
Characters []Character `bson:"characters"`
|
||||
Channels []Channel `bson:"channels"`
|
||||
Posts []Post `bson:"posts"`
|
||||
Stories []Story `bson:"stories"`
|
||||
Tags []Tag `bson:"tags"`
|
||||
Chapters []Chapter `bson:"chapters"`
|
||||
}
|
||||
|
||||
// ChangeKey is a key for a change that can be used when subscribing to them.
|
||||
type ChangeKey struct {
|
||||
Model ChangeModel `bson:"model"`
|
||||
ID string `bson:"id"`
|
||||
}
|
||||
|
||||
// Objects makes a combined, mixed array of all the models stored in this change.
|
||||
func (change *Change) Objects() []interface{} {
|
||||
data := make([]interface{}, 0, len(change.Logs)+len(change.Characters)+len(change.Channels)+len(change.Posts)+len(change.Stories)+len(change.Tags)+len(change.Chapters))
|
||||
|
||||
for _, log := range change.Logs {
|
||||
data = append(data, log)
|
||||
}
|
||||
for _, channel := range change.Channels {
|
||||
data = append(data, channel)
|
||||
}
|
||||
for _, character := range change.Characters {
|
||||
data = append(data, character)
|
||||
}
|
||||
for _, post := range change.Posts {
|
||||
data = append(data, post)
|
||||
}
|
||||
for _, story := range change.Stories {
|
||||
data = append(data, story)
|
||||
}
|
||||
for _, tag := range change.Tags {
|
||||
data = append(data, tag)
|
||||
}
|
||||
for _, chapter := range change.Chapters {
|
||||
data = append(data, chapter)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package changekeys
|
||||
|
||||
import "git.aiterp.net/rpdata/api/models"
|
||||
|
||||
// All makes a changelog that's (model, "*"). It's a helper to standardize it.
|
||||
func All(model string) models.ChangeKey {
|
||||
return models.ChangeKey{Model: models.ChangeModel(model), ID: "*"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package changekeys
|
||||
|
||||
import "git.aiterp.net/rpdata/api/models"
|
||||
|
||||
// Listed is a helper for cases like []models.ChangeKey{changekeys.All("Logs"), changekeys.One(log)}
|
||||
func Listed(objects ...interface{}) []models.ChangeKey {
|
||||
keys := Many(objects)
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return append(keys, All(keys[0].Model.String()))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package changekeys
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
// Many returns a key set matching the input objects, but without the asterisk key. You may pass
|
||||
// a slice as an argument, for example (log, posts) and it will be flattened
|
||||
func Many(objects ...interface{}) []models.ChangeKey {
|
||||
keys := make([]models.ChangeKey, 0, len(objects))
|
||||
for _, object := range objects {
|
||||
if v := reflect.ValueOf(object); v.Kind() == reflect.Slice {
|
||||
elems := make([]interface{}, 0, v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
elems = append(elems, v.Index(i).Interface())
|
||||
}
|
||||
|
||||
keys = append(keys, Many(elems...)...)
|
||||
} else {
|
||||
keys = append(keys, One(object))
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package changekeys_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models/changekeys"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
func TestMany(t *testing.T) {
|
||||
data := []interface{}{
|
||||
models.Log{ID: "Stuff"},
|
||||
[]interface{}{
|
||||
models.Post{ID: "P1"},
|
||||
models.Post{ID: "P2"},
|
||||
models.Post{ID: "P3"},
|
||||
[]interface{}{
|
||||
models.Post{ID: "P4"},
|
||||
models.Character{ID: "C17"},
|
||||
models.Channel{Name: "#Stuff"},
|
||||
},
|
||||
},
|
||||
models.Post{ID: "P5"},
|
||||
}
|
||||
|
||||
expectations := []models.ChangeKey{
|
||||
{Model: "Log", ID: "Stuff"},
|
||||
{Model: "Post", ID: "P1"},
|
||||
{Model: "Post", ID: "P2"},
|
||||
{Model: "Post", ID: "P3"},
|
||||
{Model: "Post", ID: "P4"},
|
||||
{Model: "Character", ID: "C17"},
|
||||
{Model: "Channel", ID: "#Stuff"},
|
||||
{Model: "Post", ID: "P5"},
|
||||
}
|
||||
|
||||
results := changekeys.Many(data...)
|
||||
|
||||
if len(results) != len(expectations) {
|
||||
t.Fatal("Incorrect result length")
|
||||
}
|
||||
|
||||
for i := range expectations {
|
||||
result := results[i]
|
||||
expectation := expectations[i]
|
||||
|
||||
if result.ID != expectation.ID || result.Model != expectation.Model {
|
||||
t.Errorf("Incorrect: actual(%#+v, %#+v) != expected(%#+v, %#+v)", result.Model, result.ID, expectation.Model, expectation.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMany(b *testing.B) {
|
||||
data := []interface{}{
|
||||
models.Log{ID: "Stuff"},
|
||||
[]interface{}{
|
||||
models.Post{ID: "P1"},
|
||||
models.Post{ID: "P2"},
|
||||
models.Post{ID: "P3"},
|
||||
models.Post{ID: "P4"},
|
||||
models.Post{ID: "P5"},
|
||||
models.Post{ID: "P6"},
|
||||
models.Post{ID: "P7"},
|
||||
models.Post{ID: "P8"},
|
||||
models.Post{ID: "P9"},
|
||||
models.Post{ID: "P10"},
|
||||
models.Post{ID: "P11"},
|
||||
},
|
||||
models.Character{ID: "C17"},
|
||||
models.Channel{Name: "#Stuff"},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for n := 0; n < b.N; n++ {
|
||||
changekeys.Many(data...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package changekeys
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
// One makes a ChangeKey for a model, or panics if it's not supported.
|
||||
func One(object interface{}) models.ChangeKey {
|
||||
switch v := object.(type) {
|
||||
case models.Post:
|
||||
return models.ChangeKey{Model: "Post", ID: v.ID}
|
||||
case models.Character:
|
||||
return models.ChangeKey{Model: "Character", ID: v.ID}
|
||||
case models.Channel:
|
||||
return models.ChangeKey{Model: "Channel", ID: v.Name}
|
||||
}
|
||||
|
||||
model := ""
|
||||
if t := reflect.TypeOf(object); t.Kind() == reflect.Ptr {
|
||||
model = t.Elem().Name()
|
||||
} else {
|
||||
model = t.Name()
|
||||
}
|
||||
|
||||
id := ""
|
||||
v := reflect.ValueOf(object)
|
||||
if f := v.FieldByName("ID"); f.Kind() == reflect.String {
|
||||
id = f.String()
|
||||
} else if f = v.FieldByName("Name"); f.Kind() == reflect.String {
|
||||
id = f.String()
|
||||
} else {
|
||||
panic("Unsupported model")
|
||||
}
|
||||
|
||||
return models.ChangeKey{Model: models.ChangeModel(model), ID: id}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package changekeys_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/models/changekeys"
|
||||
)
|
||||
|
||||
func TestOne(t *testing.T) {
|
||||
table := []struct {
|
||||
Label string
|
||||
Input interface{}
|
||||
Output models.ChangeKey
|
||||
}{
|
||||
{
|
||||
"Character_C17",
|
||||
models.Character{ID: "C17"},
|
||||
models.ChangeKey{Model: "Character", ID: "C17"},
|
||||
},
|
||||
{
|
||||
"Channel_#Miner'sRespite",
|
||||
models.Channel{Name: "#Miner'sRespite"},
|
||||
models.ChangeKey{Model: "Channel", ID: "#Miner'sRespite"},
|
||||
},
|
||||
{
|
||||
"Log_2018-10-23_210303325_RedrockAgency",
|
||||
models.Log{ID: "2018-10-23_210303325_RedrockAgency", ShortID: "L807"},
|
||||
models.ChangeKey{Model: "Log", ID: "2018-10-23_210303325_RedrockAgency"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, row := range table {
|
||||
t.Run(row.Label, func(t *testing.T) {
|
||||
key := changekeys.One(row.Input)
|
||||
|
||||
if key.ID != row.Output.ID || key.Model != row.Output.Model {
|
||||
t.Errorf("Incorrect: actual(%#+v, %#+v) != expected(%#+v, %#+v)", key.Model, key.ID, row.Output.Model, row.Output.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOneHit(b *testing.B) {
|
||||
var post = models.Post{ID: "P123412341234123"}
|
||||
|
||||
for n := 0; n < b.N; n++ {
|
||||
changekeys.One(post)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOneMiss(b *testing.B) {
|
||||
var story = models.Story{ID: "P123412341234123"}
|
||||
|
||||
for n := 0; n < b.N; n++ {
|
||||
changekeys.One(story)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
var collection *mgo.Collection
|
||||
var submitMutex sync.Mutex
|
||||
|
||||
func list(query bson.M, limit int) ([]models.Change, error) {
|
||||
changes := make([]models.Change, 0, 64)
|
||||
err := collection.Find(query).Limit(limit).Sort("date").All(&changes)
|
||||
|
||||
return changes, err
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
collection = db.C("common.changes")
|
||||
|
||||
collection.EnsureIndexKey("date")
|
||||
collection.EnsureIndexKey("author")
|
||||
collection.EnsureIndexKey("keys")
|
||||
|
||||
err := collection.EnsureIndex(mgo.Index{
|
||||
Name: "expiry",
|
||||
Key: []string{"date"},
|
||||
ExpireAfter: time.Hour * 2400, // 100 days
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// Filter is a filter for changes.List.
|
||||
type Filter struct {
|
||||
Keys []models.ChangeKey
|
||||
EarliestDate *time.Time
|
||||
Limit *int
|
||||
}
|
||||
|
||||
// List lists changes.
|
||||
func List(filter *Filter) ([]models.Change, error) {
|
||||
query := bson.M{}
|
||||
limit := 0
|
||||
|
||||
if filter != nil {
|
||||
if filter.Limit != nil {
|
||||
limit = *filter.Limit
|
||||
}
|
||||
|
||||
if filter.Keys != nil {
|
||||
query["keys"] = bson.M{"$in": filter.Keys}
|
||||
} else {
|
||||
query["listed"] = true
|
||||
}
|
||||
|
||||
if filter.EarliestDate != nil {
|
||||
query["date"] = bson.M{"$gte": *filter.EarliestDate}
|
||||
}
|
||||
} else {
|
||||
query["listed"] = true
|
||||
limit = 256
|
||||
}
|
||||
|
||||
return list(query, limit)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/counter"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
// Submit a change to the database. The objects may be any supported model, or arrays.
|
||||
func Submit(model, op, author string, listed bool, keys []models.ChangeKey, objects ...interface{}) (models.Change, error) {
|
||||
submitMutex.Lock()
|
||||
defer submitMutex.Unlock()
|
||||
|
||||
id, err := counter.Next("auto_increment", "Change")
|
||||
if err != nil {
|
||||
return models.Change{}, err
|
||||
}
|
||||
|
||||
if !models.ChangeModel(model).IsValid() {
|
||||
panic("Invalid model")
|
||||
}
|
||||
|
||||
// Silently discard * keys on unlisted changes.
|
||||
if !listed {
|
||||
keysCopy := make([]models.ChangeKey, len(keys))
|
||||
copy(keysCopy, keys)
|
||||
keys = keysCopy
|
||||
|
||||
deletes := make([]int, 0, 1)
|
||||
for i, key := range keys {
|
||||
if key.ID == "*" {
|
||||
deletes = append(deletes, i-len(deletes))
|
||||
}
|
||||
}
|
||||
|
||||
for _, index := range deletes {
|
||||
keys = append(keys[:index], keys[index+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
change := models.Change{
|
||||
ID: "Change_" + strconv.Itoa(id),
|
||||
Model: models.ChangeModel(model),
|
||||
Date: time.Now(),
|
||||
Op: op,
|
||||
Author: author,
|
||||
Keys: keys,
|
||||
Listed: listed,
|
||||
}
|
||||
|
||||
for _, object := range objects {
|
||||
switch object := object.(type) {
|
||||
case models.Log:
|
||||
change.Logs = append(change.Logs, object)
|
||||
case []models.Log:
|
||||
change.Logs = append(change.Logs, object...)
|
||||
case models.Character:
|
||||
change.Characters = append(change.Characters, object)
|
||||
case []models.Character:
|
||||
change.Characters = append(change.Characters, object...)
|
||||
case models.Channel:
|
||||
change.Channels = append(change.Channels, object)
|
||||
case []models.Channel:
|
||||
change.Channels = append(change.Channels, object...)
|
||||
case models.Post:
|
||||
change.Posts = append(change.Posts, object)
|
||||
case []models.Post:
|
||||
change.Posts = append(change.Posts, object...)
|
||||
case models.Story:
|
||||
change.Stories = append(change.Stories, object)
|
||||
case []models.Story:
|
||||
change.Stories = append(change.Stories, object...)
|
||||
case models.Tag:
|
||||
change.Tags = append(change.Tags, object)
|
||||
case []models.Tag:
|
||||
change.Tags = append(change.Tags, object...)
|
||||
case models.Chapter:
|
||||
change.Chapters = append(change.Chapters, object)
|
||||
case []models.Chapter:
|
||||
change.Chapters = append(change.Chapters, object...)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Push to subscribers
|
||||
|
||||
err = collection.Insert(&change)
|
||||
if err != nil {
|
||||
return models.Change{}, err
|
||||
}
|
||||
|
||||
return change, nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
// A Channel represents information abount an IRC RP channel, and whether it should be logged
|
||||
type Channel struct {
|
||||
Name string `bson:"_id"`
|
||||
Logged bool `bson:"logged"`
|
||||
Hub bool `bson:"hub"`
|
||||
EventName string `bson:"eventName,omitempty"`
|
||||
LocationName string `bson:"locationName,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
// ErrInvalidName is an error for an invalid channel name.
|
||||
var ErrInvalidName = errors.New("Invalid channel name")
|
||||
|
||||
// Add creates a new channel.
|
||||
func Add(name string, logged, hub bool, event, location string) (models.Channel, error) {
|
||||
if len(name) < 3 && !strings.HasPrefix(name, "#") {
|
||||
return models.Channel{}, ErrInvalidName
|
||||
}
|
||||
|
||||
channel := models.Channel{
|
||||
Name: name,
|
||||
Logged: logged,
|
||||
Hub: hub,
|
||||
EventName: event,
|
||||
LocationName: location,
|
||||
}
|
||||
|
||||
err := collection.Insert(channel)
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"github.com/globalsign/mgo"
|
||||
)
|
||||
|
||||
var collection *mgo.Collection
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
collection = db.C("common.channels")
|
||||
|
||||
collection.EnsureIndexKey("logged")
|
||||
collection.EnsureIndexKey("hub")
|
||||
collection.EnsureIndexKey("event")
|
||||
collection.EnsureIndexKey("location")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// Edit edits a channels
|
||||
func Edit(channel models.Channel, logged, hub *bool, eventName, locationName *string) (models.Channel, error) {
|
||||
mutation := bson.M{}
|
||||
|
||||
if logged != nil && *logged != channel.Logged {
|
||||
mutation["logged"] = *logged
|
||||
channel.Logged = *logged
|
||||
}
|
||||
if hub != nil && *hub != channel.Hub {
|
||||
mutation["hub"] = *hub
|
||||
channel.Hub = *hub
|
||||
}
|
||||
if eventName != nil && *eventName != channel.EventName {
|
||||
mutation["eventName"] = *eventName
|
||||
channel.EventName = *eventName
|
||||
}
|
||||
if locationName != nil && *locationName != channel.LocationName {
|
||||
mutation["locationName"] = *locationName
|
||||
channel.LocationName = *locationName
|
||||
}
|
||||
|
||||
if len(mutation) == 0 {
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
err := collection.UpdateId(channel.Name, bson.M{"$set": mutation})
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/globalsign/mgo"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
// Ensure adds a channel if it doesn't exist. If logged is set and the found channel isn't logged,
|
||||
// that is changed.
|
||||
func Ensure(name string, logged bool) (models.Channel, error) {
|
||||
if len(name) < 3 && !strings.HasPrefix(name, "#") {
|
||||
return models.Channel{}, ErrInvalidName
|
||||
}
|
||||
|
||||
channel, err := FindName(name)
|
||||
if err == mgo.ErrNotFound {
|
||||
return Add(name, logged, false, "", "")
|
||||
} else if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
|
||||
if logged && !channel.Logged {
|
||||
channel, err = Edit(channel, &logged, nil, nil, nil)
|
||||
if err != nil {
|
||||
return models.Channel{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package channels
|
||||
|
||||
import "git.aiterp.net/rpdata/api/models"
|
||||
|
||||
// FindName finds a channel by its id (its name).
|
||||
func FindName(name string) (models.Channel, error) {
|
||||
channel := models.Channel{}
|
||||
err := collection.FindId(name).One(&channel)
|
||||
|
||||
return channel, err
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// Filter for searching
|
||||
type Filter struct {
|
||||
Logged *bool `json:"logged"`
|
||||
EventName string `json:"eventName"`
|
||||
LocationName string `json:"locationName"`
|
||||
}
|
||||
|
||||
// List finds channels, if logged is true it will be limited to logged
|
||||
// channels
|
||||
func List(filter *Filter) ([]models.Channel, error) {
|
||||
query := bson.M{}
|
||||
|
||||
if filter != nil {
|
||||
if filter.Logged != nil {
|
||||
query["logged"] = *filter.Logged
|
||||
}
|
||||
if filter.EventName != "" {
|
||||
query["eventName"] = filter.EventName
|
||||
}
|
||||
if filter.LocationName != "" {
|
||||
query["locationName"] = filter.LocationName
|
||||
}
|
||||
}
|
||||
|
||||
channels := make([]models.Channel, 0, 128)
|
||||
err := collection.Find(query).Sort("_id").All(&channels)
|
||||
|
||||
return channels, err
|
||||
}
|
||||
|
||||
// ListNames finds channels by the names provided
|
||||
func ListNames(names ...string) ([]models.Channel, error) {
|
||||
query := bson.M{"_id": bson.M{"$in": names}}
|
||||
|
||||
channels := make([]models.Channel, 0, 32)
|
||||
err := collection.Find(query).All(&channels)
|
||||
|
||||
return channels, err
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// A Chapter is a part of a story.
|
||||
type Chapter struct {
|
||||
ID string `bson:"_id"`
|
||||
StoryID string `bson:"storyId"`
|
||||
Title string `bson:"title"`
|
||||
Author string `bson:"author"`
|
||||
Source string `bson:"source"`
|
||||
CreatedDate time.Time `bson:"createdDate"`
|
||||
FictionalDate time.Time `bson:"fictionalDate,omitempty"`
|
||||
EditedDate time.Time `bson:"editedDate"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// Add adds a new chapter.
|
||||
func Add(story models.Story, title, author, source string, createdDate time.Time, finctionalDate *time.Time) (models.Chapter, error) {
|
||||
chapter := models.Chapter{
|
||||
ID: makeChapterID(),
|
||||
StoryID: story.ID,
|
||||
Title: title,
|
||||
Author: author,
|
||||
Source: source,
|
||||
CreatedDate: createdDate,
|
||||
EditedDate: createdDate,
|
||||
}
|
||||
|
||||
if finctionalDate != nil {
|
||||
chapter.FictionalDate = *finctionalDate
|
||||
}
|
||||
|
||||
err := collection.Insert(chapter)
|
||||
if err != nil {
|
||||
return models.Chapter{}, err
|
||||
}
|
||||
|
||||
if createdDate.After(story.UpdatedDate) {
|
||||
if err := storyCollection.UpdateId(story.ID, bson.M{"$set": bson.M{"updatedDate": createdDate}}); err == nil {
|
||||
story.UpdatedDate = createdDate
|
||||
}
|
||||
}
|
||||
|
||||
return chapter, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"strconv"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo"
|
||||
)
|
||||
|
||||
var collection *mgo.Collection
|
||||
var storyCollection *mgo.Collection
|
||||
|
||||
func find(query interface{}) (models.Chapter, error) {
|
||||
chapter := models.Chapter{}
|
||||
err := collection.Find(query).One(&chapter)
|
||||
|
||||
return chapter, err
|
||||
}
|
||||
|
||||
func list(query interface{}) ([]models.Chapter, error) {
|
||||
chapters := make([]models.Chapter, 0, 8)
|
||||
err := collection.Find(query).Sort("createdDate").All(&chapters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
func makeChapterID() string {
|
||||
result := "SC"
|
||||
offset := 0
|
||||
data := make([]byte, 32)
|
||||
|
||||
rand.Read(data)
|
||||
for len(result) < 24 {
|
||||
result += strconv.FormatUint(binary.LittleEndian.Uint64(data[offset:]), 36)
|
||||
offset += 8
|
||||
|
||||
if offset >= 32 {
|
||||
rand.Read(data)
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
return result[:24]
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
collection = db.C("story.chapters")
|
||||
storyCollection = db.C("story.stories")
|
||||
|
||||
collection.EnsureIndexKey("storyId")
|
||||
collection.EnsureIndexKey("author")
|
||||
collection.EnsureIndexKey("createdDate")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// Edit edits a chapter, and updates EditedDate. While many Edit functions cheat if there's nothing to
|
||||
// change, this functill will due to EditedDate.
|
||||
func Edit(chapter models.Chapter, title, source *string, fictionalDate *time.Time) (models.Chapter, error) {
|
||||
now := time.Now()
|
||||
changes := bson.M{"editedDate": now}
|
||||
|
||||
edited := chapter
|
||||
edited.EditedDate = now
|
||||
|
||||
if title != nil && *title != chapter.Title {
|
||||
changes["title"] = *title
|
||||
edited.Title = *title
|
||||
}
|
||||
if source != nil && *source != chapter.Source {
|
||||
changes["source"] = *source
|
||||
edited.Source = *source
|
||||
}
|
||||
if fictionalDate != nil && !fictionalDate.Equal(chapter.FictionalDate) {
|
||||
changes["fictionalDate"] = *fictionalDate
|
||||
edited.FictionalDate = *fictionalDate
|
||||
}
|
||||
|
||||
err := collection.UpdateId(chapter.ID, bson.M{"$set": changes})
|
||||
if err != nil {
|
||||
return chapter, err
|
||||
}
|
||||
|
||||
return edited, nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// FindID finds a chapter by ID
|
||||
func FindID(id string) (models.Chapter, error) {
|
||||
return find(bson.M{"_id": id})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// ListStoryID lists all chapters for the story ID
|
||||
func ListStoryID(storyID string) ([]models.Chapter, error) {
|
||||
return list(bson.M{"storyId": storyID})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// Move updates the chapter, moving it to the given story.
|
||||
func Move(chapter models.Chapter, story models.Story) (models.Chapter, error) {
|
||||
now := time.Now()
|
||||
|
||||
err := collection.UpdateId(chapter.ID, bson.M{"$set": bson.M{"editedDate": now, "storyId": story.ID}})
|
||||
if err != nil {
|
||||
return models.Chapter{}, err
|
||||
}
|
||||
|
||||
chapter.EditedDate = now
|
||||
|
||||
if chapter.CreatedDate.After(story.UpdatedDate) {
|
||||
if err := storyCollection.UpdateId(story.ID, bson.M{"$set": bson.M{"updatedDate": chapter.CreatedDate}}); err == nil {
|
||||
story.UpdatedDate = chapter.CreatedDate
|
||||
}
|
||||
}
|
||||
|
||||
return chapter, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// Remove removes a chapter.
|
||||
func Remove(chapter models.Chapter) (models.Chapter, error) {
|
||||
if err := collection.RemoveId(chapter.ID); err != nil {
|
||||
return models.Chapter{}, err
|
||||
}
|
||||
|
||||
return chapter, nil
|
||||
}
|
||||
|
||||
// RemoveStory removes all chapters belonging to a story
|
||||
func RemoveStory(story models.Story) error {
|
||||
if _, err := collection.RemoveAll(bson.M{"storyId": story.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Nick gets the character's nick.
|
||||
func (character *Character) Nick() *string {
|
||||
if len(character.Nicks[0]) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &character.Nicks[0]
|
||||
}
|
||||
|
||||
// HasNick gets whether the character has the nick.
|
||||
func (character *Character) HasNick(nick string) bool {
|
||||
for i := range character.Nicks {
|
||||
if nick == character.Nicks[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package characters
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// AddNick adds a nick to a characters
|
||||
func AddNick(character models.Character, nick string) (models.Character, error) {
|
||||
for i := range character.Nicks {
|
||||
if strings.EqualFold(character.Nicks[i], nick) {
|
||||
return models.Character{}, errors.New("Nick already exists")
|
||||
}
|
||||
}
|
||||
|
||||
err := collection.UpdateId(character.ID, bson.M{"$push": bson.M{"nicks": nick}})
|
||||
if err != nil {
|
||||
return models.Character{}, err
|
||||
}
|
||||
|
||||
character.Nicks = append(character.Nicks, nick)
|
||||
|
||||
return character, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package characters
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/counter"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
)
|
||||
|
||||
// Add creates a Character and pushes it to the database. It does some validation
|
||||
// on nick, name, shortName and author. It will generate a shortname from the first
|
||||
// name if a blank one is provided.
|
||||
func Add(nick, name, shortName, author, description string) (models.Character, error) {
|
||||
if len(nick) < 1 || len(name) < 1 || len(author) < 1 {
|
||||
return models.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 models.Character{}, errors.New("Nick is occupied")
|
||||
}
|
||||
|
||||
nextID, err := counter.Next("auto_increment", "Character")
|
||||
if err != nil {
|
||||
return models.Character{}, err
|
||||
}
|
||||
|
||||
character := models.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 models.Character{}, err
|
||||
}
|
||||
|
||||
return character, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package characters
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.aiterp.net/rpdata/api/internal/store"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo"
|
||||
)
|
||||
|
||||
var collection *mgo.Collection
|
||||
|
||||
func find(query interface{}) (models.Character, error) {
|
||||
character := models.Character{}
|
||||
err := collection.Find(query).One(&character)
|
||||
if err != nil {
|
||||
return models.Character{}, err
|
||||
}
|
||||
|
||||
return character, nil
|
||||
}
|
||||
|
||||
func list(query interface{}) ([]models.Character, error) {
|
||||
characters := make([]models.Character, 0, 64)
|
||||
err := collection.Find(query).All(&characters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return characters, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.HandleInit(func(db *mgo.Database) {
|
||||
collection = db.C("common.characters")
|
||||
|
||||
collection.EnsureIndexKey("name")
|
||||
collection.EnsureIndexKey("shortName")
|
||||
collection.EnsureIndexKey("author")
|
||||
err := collection.EnsureIndex(mgo.Index{
|
||||
Key: []string{"nicks"},
|
||||
Unique: true,
|
||||
DropDups: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln("init common.characters:", err)
|
||||
}
|
||||
err = collection.EnsureIndex(mgo.Index{
|
||||
Key: []string{"$text:description"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln("init common.characters:", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package characters
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// 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 Edit(character models.Character, name, shortName, description *string) (models.Character, error) {
|
||||
changes := bson.M{}
|
||||
|
||||
if name != nil && *name != character.Name {
|
||||
character.Name = *name
|
||||
changes["name"] = *name
|
||||
}
|
||||
if shortName != nil && *shortName != character.ShortName {
|
||||
character.ShortName = *shortName
|
||||
changes["shortName"] = *shortName
|
||||
}
|
||||
if description != nil && *description != character.Description {
|
||||
character.Description = *description
|
||||
changes["description"] = *description
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
return character, nil
|
||||
}
|
||||
|
||||
err := collection.UpdateId(character.ID, bson.M{"$set": changes})
|
||||
if err != nil {
|
||||
return models.Character{}, err
|
||||
}
|
||||
|
||||
return character, nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package characters
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// FindID finds a character by id.
|
||||
func FindID(id string) (models.Character, error) {
|
||||
return find(bson.M{"_id": id})
|
||||
}
|
||||
|
||||
// FindNick finds a character by nick
|
||||
func FindNick(nick string) (models.Character, error) {
|
||||
return find(bson.M{"nicks": nick})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package characters
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// Filter is used to filter the list of characters
|
||||
type Filter struct {
|
||||
IDs []string `json:"ids"`
|
||||
Nicks []string `json:"nicks"`
|
||||
Names []string `json:"names"`
|
||||
Author *string `json:"author"`
|
||||
Search *string `json:"search"`
|
||||
}
|
||||
|
||||
// List lists all characters
|
||||
func List(filter *Filter) ([]models.Character, error) {
|
||||
query := bson.M{}
|
||||
|
||||
if filter != nil {
|
||||
if len(filter.IDs) > 1 {
|
||||
query["_id"] = bson.M{"$in": filter.IDs}
|
||||
} else if len(filter.IDs) == 1 {
|
||||
query["_id"] = filter.IDs[0]
|
||||
}
|
||||
|
||||
if len(filter.Nicks) == 1 {
|
||||
query["nicks"] = filter.Nicks[0]
|
||||
} else if filter.Nicks != nil {
|
||||
query["nicks"] = bson.M{"$in": filter.Nicks}
|
||||
}
|
||||
|
||||
if len(filter.Names) > 1 {
|
||||
query["$or"] = bson.M{
|
||||
"name": bson.M{"$in": filter.Names},
|
||||
"shortName": bson.M{"$in": filter.Names},
|
||||
}
|
||||
} else if len(filter.Names) == 1 {
|
||||
query["$or"] = bson.M{
|
||||
"name": filter.Names[0],
|
||||
"shortName": filter.Names[0],
|
||||
}
|
||||
}
|
||||
|
||||
if filter.Author != nil {
|
||||
query["author"] = *filter.Author
|
||||
}
|
||||
|
||||
if filter.Search != nil {
|
||||
query["$text"] = bson.M{"$search": *filter.Search}
|
||||
}
|
||||
}
|
||||
|
||||
return list(query)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package characters
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"github.com/globalsign/mgo/bson"
|
||||
)
|
||||
|
||||
// RemoveNick removes a nick to a characters
|
||||
func RemoveNick(character models.Character, nick string) (models.Character, error) {
|
||||
index := -1
|
||||
for i := range character.Nicks {
|
||||
if strings.EqualFold(character.Nicks[i], nick) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return models.Character{}, errors.New("Nick does not exist")
|
||||
}
|
||||
|
||||
err := collection.UpdateId(character.ID, bson.M{"$pull": bson.M{"nicks": nick}})
|
||||
if err != nil {
|
||||
return models.Character{}, err
|
||||
}
|
||||
|
||||
character.Nicks = append(character.Nicks[:index], character.Nicks[index+1:]...)
|
||||
|
||||
return character, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user