Compare commits

...
130 Commits
Author SHA1 Message Date
gisle 947765884e models: Fixed earliestDate being exclusive of the earliest date in chnages.List.
the build was successful
2018-11-01 19:38:01 +01:00
gisle ffec008820 graph2: Added change type and query.
the build was successful
2018-10-31 21:38:45 +01:00
gisle 138b3e3eab graph2: Fixed post change on move not including all posts as objects. 2018-10-31 21:38:32 +01:00
gisle 929abfdbe4 package: Removed rpdata-ui. 2018-10-31 20:28:09 +01:00
gisle a8cd595876 graph2: Added change logging for story and chapter, fixed channel ops not being logged.
the build was successful
2018-10-29 19:12:23 +01:00
gisle 9ed3b32b42 models: Added support for multiple on changekeys.All, though only first is used for * key. 2018-10-29 17:51:39 +01:00
gisle 6bae52d652 models: Added benchmarks to changekeys, added shortcuts for common types there could be many of. 2018-10-29 17:44:48 +01:00
gisle cee370c2ac models: Changed change logging system to be allow for better querying in the future.
the build was successful
2018-10-28 12:18:17 +01:00
gisle 9affdbd20e graph2: Added move mutation to chapter.
the build was successful
2018-10-27 14:59:47 +02:00
gisle cf545cde3b graphql: Removed old graphql setup.
the build was successful
2018-10-27 10:16:25 +02:00
gisle b414cf118f rpdata-wikifileimport: Fixed it to use new models module.
the build was successful
2018-10-27 10:15:23 +02:00
gisle 4979b02104 models: Migrated last from model 2018-10-27 10:14:58 +02:00
gisle b7d5059d60 graph2: Fixed chapter using story permissions.
the build was successful
2018-10-16 19:01:11 +02:00
gisle 9fbdc767ad models2: Added 'post.remove' to default user permissionss.
the build was successful
2018-10-16 18:49:14 +02:00
gisle daeaaf5fa9 rpdata-as2storyimport: Fixed category vailidty check failing when it is valid, not when it isn't.
the build was successful
2018-10-16 18:30:24 +02:00
gisle d3277c7476 models2: Fixed text search not working.
the build was successful
2018-10-13 14:04:02 +02:00
gisle 90681c67b0 models2: Added post removal on log removal. 2018-10-13 11:43:53 +02:00
gisle bebd99833b models2: Fixed incorrect db mappings, fixed Edit not returning mutated model. 2018-10-11 22:26:08 +02:00
gisle 34f86d5037 graph2: Added logging changes for Channel, Log, Post and Character.
the build failed
2018-10-09 20:25:55 +02:00
gisle 8a85313762 docker: Updated DockerFile.
the build failed
2018-10-09 19:33:51 +02:00
gisle 017e68eae7 rpdata-ensurechannels: Ported to use new models package.
the build failed
2018-10-07 14:48:23 +02:00
gisle 1955ad0517 rpdata-lb2logimport: Changed script to use new models package.
the build failed
2018-10-07 14:45:44 +02:00
gisle 34ba5d5d3a models2: Fixed issue caused by last commit removing packages that were still barely in use. 2018-10-07 13:41:39 +02:00
gisle 8c8a251080 package: Removed old models packages that are ported over to the new format.
the build failed
2018-10-07 12:18:55 +02:00
gisle 73fe125870 models2: Added log character updates.
the build failed
2018-10-07 12:16:53 +02:00
gisle 04ec85aadf graph2: Removed limit on posts returned for Log.posts field. 2018-10-07 12:16:12 +02:00
gisle 59233ee549 models2: Fixed characters using wrong auto increment
the build failed
2018-10-07 10:56:29 +02:00
gisle 7aa0de9135 models2: Fixed error caused by empty change set in edit
the build failed
2018-10-06 14:02:39 +02:00
gisle 6dfbee7ff2 models2: Fixed editing characters erasing all unedited data.
the build failed
2018-10-06 13:55:16 +02:00
gisle 8a07c2ffdb graph2: Remove CharactersFilter logged property.
the build failed
2018-10-06 10:53:15 +02:00
gisle 0e9536b0a8 models2: Fixed StoryCategory UnmarshalGQL validating before writing the value.
the build failed
2018-10-03 20:56:18 +02:00
gisle b8e5ed5813 graph2: Added channel mutations.
the build failed
2018-10-01 22:20:52 +02:00
gisle 2317f66920 graph2: Added character mutations
the build failed
2018-10-01 22:05:12 +02:00
gisle fc488b938d graph2: Fixed last change closing all logs upon opening one, even the one just opened.
the build failed
2018-10-01 21:01:43 +02:00
gisle ceb2617571 graph2: Added closing of all other logs in the same channel if a log is opened.
the build failed
2018-10-01 20:22:37 +02:00
gisle aeb5b26627 graph2: Added Log mutations
the build failed
2018-09-30 17:05:26 +02:00
gisle 6aafc67790 rpdata-server: >_>
the build failed
2018-09-30 11:28:25 +02:00
gisle c6f8fd9faa git: readme
the build failed
2018-09-30 11:14:01 +02:00
gisle efa98b80cc rpdata-as2storyimport: Changed to use new models.
the build failed
2018-09-29 16:38:36 +02:00
gisle 707dcb43eb models2: Added IsValid() method to StoryCategory 2018-09-29 16:38:21 +02:00
gisle 9bcc28a0ea rpdata-graphiql: removed command
the build failed
2018-09-29 16:21:36 +02:00
gisle 2fceaa62ed rpdata-lb2charimport: Ported to using new models package
the build failed
2018-09-29 16:20:36 +02:00
gisle 86125c7ce9 graph2: Added add/remove nick mutations to character. 2018-09-29 16:20:08 +02:00
gisle bb28795cd5 graph2: Added post mutations. general: Did some cleaning.
the build failed
2018-09-29 15:54:22 +02:00
gisle 815f30597e graph2: Added removal of chapters upon story being deleted.
the build failed
2018-09-23 13:53:13 +02:00
gisle cd1f122502 graph2: Fixed inverse permission checks in story mutations.
the build failed
2018-09-22 15:31:53 +02:00
gisle 0e0d802a32 graph2: Added Story mutations
the build failed
2018-09-20 20:50:22 +02:00
gisle 3c42043ab6 models: Added Equals method to tag model. 2018-09-20 20:49:57 +02:00
gisle 8388ee2446 graph2: Added clearFictionalDate option to editChapter mutation input
the build failed
2018-09-19 22:19:39 +02:00
gisle 260c2e7f76 auth: Changed token checking to ignore disallowed permissions instead of invalidate the key
the build failed
2018-09-18 20:24:04 +02:00
gisle a4034b0faa graph2: Added Chapter mutations
the build failed
2018-09-17 20:11:57 +02:00
gisle c3a6798050 rpdata-server: Changed endpoint from /query to /graphql 2018-09-16 23:52:45 +02:00
gisle 2ab933149e auth: Moved token and user to models package, added graphql endpoint to check token
the build failed
2018-09-16 16:57:40 +02:00
gisle 07b94b3cab auth: Added list of permissions
the build failed
2018-09-16 12:55:28 +02:00
gisle 79171bf9a2 graph2: Added Story type and queries. 2018-09-16 12:55:14 +02:00
gisle bac57bc378 graph2: Added File type and queries.
the build failed
2018-09-16 11:30:18 +02:00
gisle 875dac9ecb graph2: Added Chapter type and queries, moved loader out of old graphql pkg.
the build failed
2018-09-16 10:33:55 +02:00
gisle 0768cfd0bd models: Painstakingly converted half the model package to a models & repostiry stucture. All binaries are probably dead at this point.
the build failed
2018-09-16 00:13:12 +02:00
gisle 2b05d90f4a graph2: Added Log type and queries, moved loader out of old graphql pkg.
the build failed
2018-09-15 13:14:05 +02:00
gisle 0444911e73 graph2: Added Post type and queries 2018-09-15 09:29:04 +02:00
gisle 1be4088a8f graph2: Added Channel type and queries
the build failed
2018-09-13 22:49:36 +02:00
gisle 9978d7380b graph, rpdata-server: Started on new GraphQL implementation.
the build failed
2018-09-13 21:13:05 +02:00
gisle 2e5162a29b auth: Fixed incorrect error reporting on permissions mismatching, added ensuring of user in auth.FindUser.
the build failed
2018-09-13 20:08:37 +02:00
gisle dac0060155 auth: Replaced old system with api key-based system.
the build failed
2018-09-12 21:13:14 +02:00
gisle 9d7fd498a9 Auth: Removed session/auth system from GraphQL API 2018-09-07 17:59:57 +02:00
gisle e0af2685ee Logs: Moved filtering logic out of GraphQL query resolver to the Log model.
the build failed
2018-09-02 13:46:12 +02:00
gisle 3184b60310 GraphQL: Optimized dataloaders by adding a 'priming' mechanic to make fewer trips to the database.
the build failed
2018-09-02 12:38:17 +02:00
gisle 675c09ef04 git: removed binary file from project root
the build failed
2018-08-30 20:32:05 +02:00
gisle 9707320960 git: updated gitignore
the build failed
2018-08-30 20:29:50 +02:00
gisle 20e53b4f5e GraphQL: Fixed stories resolver crashing if filter is nil.
the build failed
2018-08-30 20:21:00 +02:00
gisle 8bb4dea358 UI: Started on story content 2018-08-25 10:35:29 +02:00
gisle 75b2382f09 Changed colors to more closely match aitestory
the build was successful
2018-08-01 22:26:18 +02:00
gisle 1ca25eff8e Fixed warnings
the build was successful
2018-08-01 21:49:58 +02:00
gisle bf10ed1fe3 Added ListItem and changed StoryList ot use it, reorganized StoryMenu, added scrollbar styling, added moment dependency
the build was successful
2018-08-01 18:02:01 +02:00
gisle 54136be912 Removed leftover console.log()s 2018-08-01 15:51:38 +02:00
gisle 228c2bfeb4 Removed docker build on non-tag push from CI
the build was successful
2018-08-01 15:47:27 +02:00
gisle 2f98be5747 Removed default limit for stories query
the build was successful
2018-08-01 15:19:08 +02:00
gisle 2a40f97282 Added order to stories query response
the build was successful
2018-08-01 15:04:16 +02:00
gisle e5b4184106 Fixed query for category lists not getting parameters 2018-08-01 14:57:49 +02:00
gisle c348a90b70 Fixed nil deref error on no filter, fixed no results
the build was successful
2018-08-01 14:51:45 +02:00
gisle 132cf28f0f Added category to StoriesFilter
the build was successful
2018-08-01 14:38:09 +02:00
gisle d5df624188 Added open and unlsited to StoriesFilter
the build was successful
2018-08-01 14:27:28 +02:00
gisle ca98648274 Added router for story menu and queries for the lists 2018-08-01 14:07:54 +02:00
gisle 4642a8abec Added missing dpeencency (apollo-cache-inmemory), changed proxy to work within the local network.
the build was successful
2018-08-01 11:23:15 +02:00
gisle 0ec923d686 Added logged filter to characters query: character appears in logs.
the build was successful
2018-07-18 00:13:19 +02:00
gisle 388d5378e7 Cleaned up the ui code, addressed warnings
the build was successful
2018-07-16 20:24:01 +02:00
gisle 67e9e8f1e6 Renamed LogsIndex -> LogsRoot, removed hardcoded bg image from LogsRoot
the build was successful
2018-07-15 20:08:32 +02:00
gisle 06bc7e5a1b Updated StoryRoot graphql query according to api changes 2018-07-15 19:59:23 +02:00
gisle 237190e498 Added LoadingScreen for full-size loading placeholder 2018-07-15 19:58:12 +02:00
gisle b9f0fef966 Fixed Background landscape detection always returning the intiial result, reacitified the Background class 2018-07-15 19:57:28 +02:00
gisle ba1c1e0a4b Changed characters graphql query to use the filter format, changed description for files query to match the others'
the build was successful
2018-07-15 17:55:21 +02:00
gisle 18e77b8205 Changed .gitignore to include new bindata.go location 2018-07-15 17:14:35 +02:00
gisle d8871259a1 Changed channels graphql query to use filter, added eventName and locationName filters 2018-07-15 16:58:17 +02:00
gisle 26d7e36631 Changed files graphql query to use a filter argument to look more like other queries. 2018-07-15 15:34:58 +02:00
gisle 51489ed901 Changed log query's ID parameter to be compulsory 2018-07-15 15:16:47 +02:00
gisle ed3e384d55 Moved query and mutation resolvers into individual files
the build was successful
2018-07-15 15:10:35 +02:00
gisle 9455fd960f Moved packages loader, resolver and schema under graphql
the build was successful
2018-07-15 14:10:00 +02:00
gisle 36939ffc59 Moved SessionResolver and UserResolver into resolvers/types package, updated references to it.
the build was successful
2018-07-14 19:14:56 +02:00
gisle 5fedce5899 Moved StoryResolver and TagResolver into resolvers/types package, updated references to it. 2018-07-14 19:13:43 +02:00
gisle dced5282b1 Moved LogResolver and PostResolver into resolvers/types package, updated references to it. 2018-07-14 19:11:04 +02:00
gisle d05f3a8ca8 Moved FileResolver into resolvers/types package, updated references to it.
the build was successful
2018-07-14 18:39:34 +02:00
gisle 53e2dfc6b6 Moved CharacterResolver into resolvers/types package, updated references to it. 2018-07-14 18:37:47 +02:00
gisle fba499024b Moved ChapterResolver into resolvers/types package, updated references to it. 2018-07-14 18:36:08 +02:00
gisle cc765e220a Moved ChannelResolver into resolvers/types package, updated references to it. 2018-07-14 18:33:37 +02:00
gisle ba49b1fd30 Renamed input parameter in stories and logs to filter, made filter optional for stories query 2018-07-14 18:29:00 +02:00
gisle 8c290c8dcd Removed hard-coded sample tags 2018-07-12 22:15:12 +02:00
gisle 576e1df001 UI: Menu and routing works with style reminiscent of aitestory
the build was successful
2018-07-12 22:12:01 +02:00
gisle 7adcdfe1e0 Added change.Timeline, added change submissions to channel mutations, made all change submissions spin off a goroutine
the build was successful
2018-07-08 13:25:18 +02:00
gisle 6f04da49b8 Fixed inconsistencies in code and documentation under resolvers, changed log.Remove to be a method instead of a function as well as take the posts with it.
the build was successful
2018-07-08 12:10:06 +02:00
gisle 9b705e6514 Added change.Submit to addStory, editStory and removeStory
the build was successful
2018-07-08 00:35:15 +02:00
gisle 155309693a Changed removeFile mutation's type to File!
the build was successful
2018-07-08 00:30:07 +02:00
gisle 5895e09f37 Added addStory, addStoryTag, removeStoryTag, editStory and removeStory mutations 2018-07-08 00:28:52 +02:00
gisle 223d9922e9 Changed story.ListTags to ignore tags for unlisted stories 2018-07-07 23:14:17 +02:00
gisle 429387c9ac Added @playlyfe/gql package to UI, added .vscode to gitignore
the build failed
2018-07-07 22:46:54 +02:00
gisle a7113d5efe Added add/edit/remove mutations for Chapter
the build was successful
2018-07-07 17:45:24 +02:00
gisle a0cb8d7fa8 Added tags query 2018-07-07 15:48:14 +02:00
gisle 5189d486a5 Added stories query and resolvers
the build was successful
2018-07-07 15:16:10 +02:00
gisle 011cb00001 Fixed incorrect unmarshalling of results from story.List 2018-07-07 15:15:40 +02:00
gisle 7a52504381 Removed wiki: prefix on authors for imported stories from rpdata-as2storyimport 2018-07-07 15:15:02 +02:00
gisle 866a5056be Added Story.chapters to schema, added Story and Tag resolver
the build was successful
2018-07-07 14:48:15 +02:00
gisle e0409a6635 Fixed all imported stories with rpdata-as2storyimport being unlisted 2018-07-07 13:57:31 +02:00
gisle bae15de3e8 Fixed edit functions not detecting changes correctly 2018-07-07 13:53:11 +02:00
gisle 3f5af44dd6 Fixed error 'Unsupported document type for unmarshalling: []story.Chapter' in story.ListChapterStoryID 2018-07-07 13:48:11 +02:00
gisle 4bbb8bb9ae Fixed typo in commenst for fictionalDate and editedDate resolvers 2018-07-07 13:10:54 +02:00
gisle 87cd5e8e3a Added chapter query and Chapter type resolvers, added functions to get chapters 2018-07-07 12:55:41 +02:00
gisle 136302084d Fixed newline shenanigans in rpdata-as2storyimport
the build was successful
2018-07-07 12:53:52 +02:00
gisle 0122f64fbb Fixed Tags->Tag typo in Story schema
the build was successful
2018-07-07 10:21:10 +02:00
gisle 6bfb9cbe20 Updated packages
the build failed
2018-07-01 21:16:13 +02:00
gisle fca5176579 Changed go base image back to non-alpine version because it lacks git
the build was successful
2018-06-30 20:40:37 +02:00
gisle c4f7846269 Created react app, changed Dockerfile to include UI files
the build failed
2018-06-30 20:38:58 +02:00
161 changed files with 5271 additions and 3784 deletions
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+2 -15
View File
@@ -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`
+20 -6
View File
@@ -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)
}
+7 -8
View File
@@ -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")
}
}
-2
View File
@@ -1,2 +0,0 @@
config.json
debug
-130
View File
@@ -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>
`)
+4 -4
View File
@@ -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 -6
View File
@@ -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)
}
+68
View File
@@ -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)
}
}
+3 -3
View File
@@ -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
+4
View File
@@ -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
+67
View File
@@ -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
+51
View File
@@ -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
}
+14
View File
@@ -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)
}
+79
View File
@@ -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
}
+163
View File
@@ -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
}
+174
View File
@@ -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
}
+34
View File
@@ -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
+139
View File
@@ -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
}
+162
View File
@@ -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
}
+10
View File
@@ -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
+169
View File
@@ -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
}
+12
View File
@@ -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()
}
+18
View File
@@ -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
}
+143
View File
@@ -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
+81
View File
@@ -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!
+77
View File
@@ -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
}
+160
View File
@@ -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
}
}
+8
View File
@@ -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!]!
}
+8
View File
@@ -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!]!
}
+24
View File
@@ -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
+21
View File
@@ -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
+16
View File
@@ -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
+41
View File
@@ -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
+27
View File
@@ -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
+17
View File
@@ -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
+109
View File
@@ -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",
+154
View File
@@ -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})
}
}
+34 -3
View File
@@ -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()
}
-20
View File
@@ -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)
}
-182
View File
@@ -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)
}
})
}
+84
View File
@@ -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()
}
-75
View File
@@ -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)
}
})
}
-143
View File
@@ -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")
})
}
-231
View File
@@ -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)
}
})
}
-252
View File
@@ -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,
}
-380
View File
@@ -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)
}
})
}
-198
View File
@@ -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)
}
})
}
-42
View File
@@ -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")
})
}
-84
View File
@@ -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
}
-92
View File
@@ -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")
})
}
-253
View File
@@ -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")
})
}
+59
View File
@@ -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()))
}
+57
View File
@@ -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
}
+8
View File
@@ -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: "*"}
}
+13
View File
@@ -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()))
}
+27
View File
@@ -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
}
+79
View File
@@ -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...)
}
}
+38
View File
@@ -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}
}
+58
View File
@@ -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)
}
}
+41
View File
@@ -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)
}
})
}
+42
View File
@@ -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)
}
+94
View File
@@ -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
}
+10
View File
@@ -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"`
}
+33
View File
@@ -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
}
+19
View File
@@ -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")
})
}
+39
View File
@@ -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
}
+33
View File
@@ -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
}
+11
View File
@@ -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
}
+46
View File
@@ -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
}
+15
View File
@@ -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"`
}
+38
View File
@@ -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
}
+61
View File
@@ -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")
})
}
+38
View File
@@ -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
}
+11
View File
@@ -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})
}
+11
View File
@@ -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})
}
+28
View File
@@ -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
}
+24
View File
@@ -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
}
+31
View File
@@ -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
}
+27
View File
@@ -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
}
+48
View File
@@ -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
}
+55
View File
@@ -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)
}
})
}
+36
View File
@@ -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
}
+16
View File
@@ -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})
}
+56
View File
@@ -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)
}
+32
View File
@@ -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