Compare commits

...
148 Commits
Author SHA1 Message Date
gisle 162ccf6bfb The great module madness continus, now with pointers.
the build was successful
2019-05-17 11:51:23 +02:00
gisle 7e3dc49635 Basic stuff, doesn't compile 2019-05-16 15:57:08 +02:00
gisle aff0d99842 graph2: Added exception for open logs query in complexity.
the build was successful
2019-04-27 11:37:01 +02:00
gisle 00150118d2 graph2: Added complexity to prevent queries that can take down the server.
the build was successful
2019-04-25 20:47:20 +02:00
gisle 332a1eb32b Fixed rpdata-as2storyimport not compiling. 2019-03-17 21:32:21 +01:00
gisle db4d46c368 Added story to comment changes.
the build was successful
2019-03-17 20:44:30 +01:00
gisle a8c8610ca2 models: Fixed wrong database mapping for comment source.
the build was successful
2019-01-17 21:19:13 +01:00
gisle d09c17d30d graph2: Changed addComment error message on can-comment check to be consistent with others.
the build was successful
2019-01-16 22:07:00 +01:00
gisle e3e3ea07db graph2: Changed Chapter to not return any comments if mode is disabled, even if there are comments.
the build was successful
2019-01-16 22:05:40 +01:00
gisle 22592439da graph2, models: Added removeComment mutation.
the build was successful
2019-01-16 22:03:28 +01:00
gisle dfa9fb4025 graph2: Changed Comment to return null instead of a zero-date if it's cleared.
the build was successful
2019-01-16 21:50:53 +01:00
gisle 0e2a68a2d4 graph2, models: Added editComment mutation. 2019-01-16 21:45:00 +01:00
gisle 1eefa65527 models: Fixed wrong database mapping for comment id. 2019-01-16 21:41:36 +01:00
gisle f9b6af58c9 graph2: Changed Comment character resolver to use loader.
the build was successful
2019-01-15 21:42:44 +01:00
gisle 56ecf48b57 graph2, models: Added addComment mutation.
the build was successful
2019-01-15 21:34:57 +01:00
gisle 06fbfc90c5 graph2, model: Added Comment type to Change. 2019-01-14 19:58:05 +01:00
gisle 39a4fef506 models, graph2: Added comment removal on chapter removal.
the build was successful
2019-01-14 18:01:43 +01:00
gisle f9fe0bb5f8 models, graph2: Added comment options to chapter mutations.
the build was successful
2019-01-14 17:55:38 +01:00
gisle d22ab387dd models, graph2: Added Comment type and queries, and hooked it up to chapter.
the build was successful
2019-01-14 17:03:16 +01:00
gisle 53a86c0b9c store, config: Added option to disable space.
the build was successful
2019-01-12 12:13:24 +01:00
gisle e0b93c6b4b graph2: Changed story tagging permissions to allow anyone to tag open stories.
the build was successful
2019-01-12 11:38:19 +01:00
gisle 1ea04f1577 importers: Fixed panic in mirclike.ParsePost if the post contains no text.
the build was successful
2019-01-05 10:33:50 +01:00
gisle 17fd3c60f8 rpdata-server: Fixed unhelpful error message being returned on panic.
the build was successful
2019-01-05 10:09:31 +01:00
gisle c5b62a42db graph2: Fixed data race in change logging for importLog.
the build was successful
2018-12-11 20:05:03 +01:00
gisle fa0926c311 rpdata-lb2logimport: Updated it to fix build errors related to change in logs.UpdateChracters.
the build was successful
2018-12-08 16:08:28 +01:00
gisle d21efc1c7f rpdata-logpaste: Removed the command since it's replaced by GraphQL API endpoint. 2018-12-08 16:07:44 +01:00
gisle 852030b141 docker: Trimmed down DockerFile. 2018-12-08 16:07:11 +01:00
gisle f7f0b76379 changes: Reversed ordering in result.
the build was successful
2018-12-05 21:00:52 +01:00
gisle 2711654816 posts: Fixed AddMany not returning the IDs in the result.
the build was successful
2018-12-05 20:58:37 +01:00
gisle b60897a597 unknownnicks, logs: Changed unknownnicks counter to use posts instead of logs as a metric to make characters stick out more.
the build was successful
2018-12-04 20:49:31 +01:00
gisle 80e6c048c9 graph2: Added unknownNick query
the build was successful
2018-12-04 20:33:21 +01:00
gisle 6310cbbc1e unknownnicks: Added unknown nicks model and hooked it up. 2018-12-04 20:17:48 +01:00
gisle 89a1c2366c changes: Fixed crash on disconnect when there are more than one client connected. 2018-12-03 20:11:15 +01:00
gisle ac2b79e88a posts, counter: Fixed post positions issues when a post is deleted.
the build was successful
2018-12-02 21:27:50 +01:00
gisle 31239330df graph2, changes, rpdata-server: Added changes subscription. 2018-12-02 21:17:49 +01:00
gisle a58535fcb9 graph2: Added importLog query and associated models and repository functions.
the build was successful
2018-12-01 16:52:06 +01:00
gisle 2a91b6f9bc logs: Added forumlog parsers.
the build was successful
2018-12-01 12:48:05 +01:00
gisle caa7727c02 logs: Added mirclike parsers.
the build was successful
2018-11-28 19:21:53 +01:00
gisle 047c9784ae rpdata-logpaste: Fixed characters not being updated.
the build was successful
2018-11-07 19:56:56 +01:00
gisle d038c1cfea Dockerfile: Added tzdata package to final container.
the build was successful
2018-11-07 19:48:03 +01:00
gisle 7e812bd615 rpdata-logpaste: Added new commandline tool.
the build was successful
2018-11-07 19:31:51 +01:00
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
210 changed files with 7466 additions and 19372 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/*
+8 -2
View File
@@ -1,7 +1,13 @@
vendor
schema/bindata.go
graphql/schema/bindata.go
config.json
api
debug
build
.vscode
rpdata-graphiql
rpdata-*
!rpdata-*/
.vscode
generated.gql
generated.go
+16 -34
View File
@@ -1,50 +1,32 @@
## 1. Server
# Use Go 1.10
FROM golang:1.10 AS build-server
## 1. Build Server
# Use Go 1.12
FROM golang:1.12 AS build-server
# Load repository into docker
WORKDIR /go/src/git.aiterp.net/rpdata/api/
WORKDIR /project/
COPY . .
# Load build tools and dependencies
RUN go get -u github.com/golang/dep/cmd/dep
RUN go get -u github.com/jteeuwen/go-bindata/...
RUN dep ensure
# Install build tools and dependencies
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-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
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
RUN go build -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-server ./cmd/rpdata-server
RUN go build -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-lb2charimport ./cmd/rpdata-lb2charimport
RUN go build -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-lb2logimport ./cmd/rpdata-lb2logimport
# TODO: RUN go build -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-wikifileimport ./cmd/rpdata-wikifileimport
RUN go build -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-ensurechannels ./cmd/rpdata-ensurechannels
RUN go build -installsuffix cgo -ldflags="-s -w" -o /binaries/rpdata-as2storyimport ./cmd/rpdata-as2storyimport
## 2. React UI
# Use Node.JS 18
FROM node:10.5-alpine AS build-id
# The content is already there
WORKDIR /rpdata-ui
COPY ./rpdata-ui /rpdata-ui
# Prepare dependencies
RUN npm install
# Run build
RUN npm run build
## 3. Regroup
## 2. Distribute
# Use alpine linux
FROM alpine:3.7
# Install certificates to enable HTTPS GET requests
RUN apk add --no-cache ca-certificates
# Install certificates to enable HTTPS GET requests, and tzdata for log imports.
RUN apk add --no-cache ca-certificates tzdata
# Copy build files into final container
COPY --from=build-server /binaries/* /usr/local/bin/
COPY --from=build-id /rpdata-ui/build /var/www/
# TODO: Use proper server instead of grapihql server
CMD ["/usr/local/bin/rpdata-graphiql"]
# The server is the main entry point
CMD ["/usr/local/bin/rpdata-server"]
Generated
-178
View File
@@ -1,178 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
branch = "master"
name = "git.aiterp.net/aiterp/wikiauth"
packages = ["."]
revision = "33860de804ddcec4fc17998745233b4ec477e7c2"
[[projects]]
branch = "master"
name = "github.com/dustin/go-humanize"
packages = ["."]
revision = "bb3d318650d48840a39aa21a027c6630e198e626"
[[projects]]
branch = "master"
name = "github.com/globalsign/mgo"
packages = [
".",
"bson",
"internal/json",
"internal/sasl",
"internal/scram"
]
revision = "f76e4f9da92ecd56e3be26f5ba92580af1ef97b4"
[[projects]]
name = "github.com/go-ini/ini"
packages = ["."]
revision = "ace140f73450505f33e8b8418216792275ae82a7"
version = "v1.35.0"
[[projects]]
name = "github.com/go-sql-driver/mysql"
packages = ["."]
revision = "d523deb1b23d913de5bdada721a6071e71283618"
version = "v1.4.0"
[[projects]]
name = "github.com/graph-gophers/dataloader"
packages = ["."]
revision = "78139374585c29dcb97b8f33089ed11959e4be59"
version = "v5"
[[projects]]
branch = "master"
name = "github.com/graph-gophers/graphql-go"
packages = [
".",
"errors",
"internal/common",
"internal/exec",
"internal/exec/packer",
"internal/exec/resolvable",
"internal/exec/selected",
"internal/query",
"internal/schema",
"internal/validation",
"introspection",
"log",
"relay",
"trace"
]
revision = "9ebf33af539ab8cb832c7107bc0a978ca8dbc0de"
[[projects]]
branch = "master"
name = "github.com/jmoiron/sqlx"
packages = [
".",
"reflectx"
]
revision = "0dae4fefe7c0e190f7b5a78dac28a1c82cc8d849"
[[projects]]
name = "github.com/minio/minio-go"
packages = [
".",
"pkg/credentials",
"pkg/encrypt",
"pkg/s3signer",
"pkg/s3utils",
"pkg/set"
]
revision = "3d2d02921f0510e9d1f66ef77a265b8dddd36992"
version = "6.0.0"
[[projects]]
branch = "master"
name = "github.com/mitchellh/go-homedir"
packages = ["."]
revision = "b8bc1bf767474819792c23f32d8286a45736f1c6"
[[projects]]
name = "github.com/opentracing/opentracing-go"
packages = [
".",
"ext",
"log"
]
revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38"
version = "v1.0.2"
[[projects]]
name = "github.com/sadbox/mediawiki"
packages = ["."]
revision = "39fea8a1336076a961a300d1d95765dcd17e8a3c"
version = "v0.1"
[[projects]]
name = "github.com/sirupsen/logrus"
packages = ["."]
revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc"
version = "v1.0.5"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = [
"argon2",
"blake2b",
"ssh/terminal"
]
revision = "d6449816ce06963d9d136eee5a56fca5b0616e7e"
[[projects]]
branch = "master"
name = "golang.org/x/net"
packages = [
"context",
"idna",
"lex/httplex"
]
revision = "a35a21de978d84ffc92f010a153705b170b2f9d1"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = [
"unix",
"windows"
]
revision = "2f57af4873d00d535c5c9028850aa2152e6a5566"
[[projects]]
name = "golang.org/x/text"
packages = [
"collate",
"collate/build",
"internal/colltab",
"internal/gen",
"internal/tag",
"internal/triegen",
"internal/ucd",
"language",
"secure/bidirule",
"transform",
"unicode/bidi",
"unicode/cldr",
"unicode/norm",
"unicode/rangetable"
]
revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0"
version = "v0.3.0"
[[projects]]
name = "google.golang.org/appengine"
packages = ["cloudsql"]
revision = "b1f26356af11148e710935ed1ac8a7f5702c7612"
version = "v1.1.0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "16fd84401bc5796ffeaa895f31d099e1a23b363b15b87386877de0829ac9575b"
solver-name = "gps-cdcl"
solver-version = 1
-41
View File
@@ -1,41 +0,0 @@
# Gopkg.toml example
#
# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
#
# [prune]
# non-go = false
# go-tests = true
# unused-packages = true
[[constraint]]
branch = "master"
name = "github.com/globalsign/mgo"
[[constraint]]
name = "github.com/minio/minio-go"
version = "6.0.0"
[prune]
go-tests = true
unused-packages = true
[[constraint]]
branch = "master"
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`
+13 -13
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,14 +73,20 @@ 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[5:], 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)
}
@@ -96,19 +104,11 @@ func main() {
}
}
chapter, err := story.AddChapter(title, result.Author[5:], result.Source, publishDate, fictionalDate)
chapter, err := chapters.Add(story, title, result.Author[5:], result.Source, publishDate, &fictionalDate, models.ChapterCommentModeDisabled)
if err != nil {
log.Fatalln(err)
}
if !result.Unlisted {
listed := true
err := story.Edit(nil, nil, &listed, nil, nil)
if err != nil {
log.Fatalln(err)
}
}
fmt.Println(result.ID, "->", story.ID, chapter.ID)
}
}
+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
-150
View File
@@ -1,150 +0,0 @@
package main
import (
"context"
"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/change"
"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)
})
change.InitializeTimeline()
go func() {
index := int64(0)
for {
index++
ch, err := change.Timeline.Get(context.Background(), index)
if err == change.ErrExpired {
continue
} else if err != nil {
log.Println("Timeline:", err)
break
}
log.Printf("Change: time=%s model=%s op=%s id=%s author=%s\n", ch.Time.Local().Format("[Mon Jan 02 15:04 MST]"), ch.Model, ch.Op, ch.ObjectID, ch.Author)
}
}()
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, nil)
if err != nil {
log.Println(err)
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"runtime/debug"
"strings"
"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"
"git.aiterp.net/rpdata/api/models"
"git.aiterp.net/rpdata/api/models/changes"
"git.aiterp.net/rpdata/api/models/logs"
"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")
}()
go logListedChanges()
log.Fatal(http.ListenAndServe(":8081", nil))
}
func logListedChanges() {
sub := changes.Subscribe(context.Background(), nil, true)
for change := range sub {
log.Printf("Change: Author=%#+v Model=%#+v Op=%#+v", change.Author, change.Model, change.Op)
for _, object := range change.Objects() {
switch object := object.(type) {
case models.Log:
log.Printf("\tLog: %s (%s)", object.ID, object.ShortID)
case models.Character:
log.Printf("\tCharacter: %s", object.ID)
case models.Channel:
log.Printf("\tChannel: %s", object.Name)
case models.Post:
log.Printf("\tPost: %s", object.ID)
case models.Story:
log.Printf("\tStory: %s", object.ID)
case models.Tag:
log.Printf("\tTag: (%s,%s)", object.Kind, object.Name)
case models.Chapter:
log.Printf("\tChapter: %s", object.ID)
case models.Comment:
log.Printf("\tComment: %s", object.ID)
}
}
}
log.Println("Change subscription closed.")
}
func queryHandler() http.HandlerFunc {
handler := handler.GraphQL(
graph2.New(),
handler.RecoverFunc(func(ctx context.Context, err interface{}) error {
log.Println(err)
log.Println(string(debug.Stack()))
return fmt.Errorf("The server failed to serve your request due to an internal error, it has been logged")
}),
handler.ComplexityLimit(300),
)
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
+34
View File
@@ -0,0 +1,34 @@
module git.aiterp.net/rpdata/api
go 1.12
require (
github.com/99designs/gqlgen v0.8.0
github.com/agnivade/levenshtein v1.0.1
github.com/davecgh/go-spew v1.1.1
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4
github.com/globalsign/mgo v0.0.0-20180403085842-f76e4f9da92e
github.com/go-ini/ini v1.35.0
github.com/go-sql-driver/mysql v1.4.0
github.com/gorilla/websocket v1.4.0
github.com/graph-gophers/dataloader v0.0.0-20180104184831-78139374585c
github.com/hashicorp/golang-lru v0.5.0
github.com/jmoiron/sqlx v0.0.0-20180614180643-0dae4fefe7c0
github.com/minio/minio-go v0.0.0-20180409193742-3d2d02921f05
github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747
github.com/opentracing/opentracing-go v1.0.2
github.com/pkg/errors v0.8.1
github.com/pmezard/go-difflib v1.0.0
github.com/sirupsen/logrus v1.0.5
github.com/stretchr/testify v1.3.0
github.com/urfave/cli v1.20.0
github.com/vektah/gqlparser v1.1.0
golang.org/x/crypto v0.0.0-20180411161317-d6449816ce06
golang.org/x/net v0.0.0-20180416171110-a35a21de978d
golang.org/x/sys v0.0.0-20180416112224-2f57af4873d0
golang.org/x/text v0.3.0
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6
google.golang.org/appengine v1.1.0
gopkg.in/yaml.v2 v2.2.2
)
+90
View File
@@ -0,0 +1,90 @@
github.com/99designs/gqlgen v0.5.1 h1:cRsbpZgX83PrXb0/hj5EkNdmaVN4l/Eii81Z8LCexgY=
github.com/99designs/gqlgen v0.5.1/go.mod h1:KSQDfLlTTGmzlRgLGm6HeKKKo598l5E2svEM6Nz2Jnw=
github.com/99designs/gqlgen v0.8.0 h1:ZBteuSgeeFwn+mztjDqQZQdVa9pqslmPnDeCovcnc0Y=
github.com/99designs/gqlgen v0.8.0/go.mod h1:st7qHA6ssU3uRZkmv+wzrzgX4srvIqEIdE5iuRW8GhE=
github.com/99designs/gqlgen v0.8.3 h1:I6bMglXNKkn4KlvkSMzqZw53e1N2FF9Gud4NmsOxqiA=
github.com/99designs/gqlgen v0.8.3/go.mod h1:aLyJw9xUgdJxZ8EqNQxo2pGFhXXJ/hq8t7J4yn8TgI4=
github.com/agnivade/levenshtein v1.0.0 h1:q+77q31bLT5jhN3BHKA1276nUEdbz7XjDa0o5dRKcZ0=
github.com/agnivade/levenshtein v1.0.0/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.0.1 h1:3oJU7J3FGFmyhn8KHjmVaZCN5hxTr7GxgRue+sxIXdQ=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/globalsign/mgo v0.0.0-20180403085842-f76e4f9da92e h1:hmrbHva/wKD6X4M7pgHVbg/KfVV1wwgr1NEkQaVD3rU=
github.com/globalsign/mgo v0.0.0-20180403085842-f76e4f9da92e/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-ini/ini v1.35.0 h1:D/my3+xOfqZMkJpciRcyqU7XMBUgiZa9qXjZIa8uv2k=
github.com/go-ini/ini v1.35.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/graph-gophers/dataloader v0.0.0-20180104184831-78139374585c h1:94S+uoVVMpQAEOrqGjCDyUdML4dJDkh6aC4MYmXECg4=
github.com/graph-gophers/dataloader v0.0.0-20180104184831-78139374585c/go.mod h1:jk4jk0c5ZISbKaMe8WsVopGB5/15GvGHMdMdPtwlRp4=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/jmoiron/sqlx v0.0.0-20180614180643-0dae4fefe7c0 h1:5B0uxl2lzNRVkJVg+uGHxWtRt4C0Wjc6kJKo5XYx8xE=
github.com/jmoiron/sqlx v0.0.0-20180614180643-0dae4fefe7c0/go.mod h1:IiEW3SEiiErVyFdH8NTuWjSifiEQKUoyK3LNqr2kCHU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/minio/minio-go v0.0.0-20180409193742-3d2d02921f05 h1:YEyFCqmHcmbcTLqZ7yEAg7VIUat6PuDzGbIctZy888k=
github.com/minio/minio-go v0.0.0-20180409193742-3d2d02921f05/go.mod h1:7guKYtitv8dktvNUGrhzmNlA5wrAABTQXCoesZdFQO8=
github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747 h1:eQox4Rh4ewJF+mqYPxCkmBAirRnPaHEB26UkNuPyjlk=
github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/vektah/dataloaden v0.2.0/go.mod h1:vxM6NuRlgiR0M6wbVTJeKp9vQIs81ZMfCYO+4yq/jbE=
github.com/vektah/gqlparser v0.0.0-20180831041411-14e83ae06ec1 h1:FYOXUtr3sYR9shto7Q/aQ1B0Onyk77aws9wGOORiz+I=
github.com/vektah/gqlparser v0.0.0-20180831041411-14e83ae06ec1/go.mod h1:K4QdSSpS2XiHHwzb18kWh3iBljB8rLC8okGXsnQy3Nc=
github.com/vektah/gqlparser v1.1.0 h1:3668p2gUlO+PiS81x957Rpr3/FPRWG6cxgCXAvTS1hw=
github.com/vektah/gqlparser v1.1.0/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
github.com/vektah/gqlparser v1.1.2 h1:ZsyLGn7/7jDNI+y4SEhI4yAxRChlv15pUHMjijT+e68=
github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
golang.org/x/crypto v0.0.0-20180411161317-d6449816ce06 h1:EOqG0JqGlLr+punVB69jvWCv/ErZKGlC7PMdyHfv+Bc=
golang.org/x/crypto v0.0.0-20180411161317-d6449816ce06/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/net v0.0.0-20180404174746-b3c676e531a6/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180416171110-a35a21de978d h1:O2P57H5Cc+d+DJos+iweraI9rmzMYwV+45vkZdDY0Oo=
golang.org/x/net v0.0.0-20180416171110-a35a21de978d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sys v0.0.0-20180416112224-2f57af4873d0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20180911133044-677d2ff680c1 h1:dzEuQYa6+a3gROnSlgly5ERUm4SZKJt+dh+4iSbO+bI=
golang.org/x/tools v0.0.0-20180911133044-677d2ff680c1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6 h1:iZgcI2DDp6zW5v9Z/5+f0NuqoxNdmzg4hivjk2WLXpY=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=
+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
+182
View File
@@ -0,0 +1,182 @@
package graph2
import (
input "git.aiterp.net/rpdata/api/graph2/input"
changes "git.aiterp.net/rpdata/api/models/changes"
channels "git.aiterp.net/rpdata/api/models/channels"
characters "git.aiterp.net/rpdata/api/models/characters"
files "git.aiterp.net/rpdata/api/models/files"
logs "git.aiterp.net/rpdata/api/models/logs"
posts "git.aiterp.net/rpdata/api/models/posts"
stories "git.aiterp.net/rpdata/api/models/stories"
)
func complexity() (cr ComplexityRoot) {
subListComplexity := 25
bigSublistComplexity := 75
hugeSublistComplexity := 100
findComplexity := 100
listComplexity := 200
mutationComplexity := 100
cr.Query.Character = func(childComplexity int, id *string, nick *string) int {
return childComplexity + findComplexity
}
cr.Query.Characters = func(childComplexity int, filter *characters.Filter) int {
return childComplexity + listComplexity
}
cr.Query.Channel = func(childComplexity int, name string) int {
return childComplexity + findComplexity
}
cr.Query.Channels = func(childComplexity int, filter *channels.Filter) int {
return childComplexity + listComplexity
}
cr.Query.Post = func(childComplexity int, id string) int {
return childComplexity + findComplexity
}
cr.Query.Posts = func(childComplexity int, filter *posts.Filter) int {
return childComplexity + listComplexity
}
cr.Query.UnknownNicks = func(childComplexity int, filter *input.UnknownNicksFilter) int {
return childComplexity + listComplexity
}
cr.Query.Log = func(childComplexity int, id string) int {
return childComplexity + findComplexity
}
cr.Query.Logs = func(childComplexity int, filter *logs.Filter) int {
if filter != nil && filter.Open != nil && *filter.Open == true {
return childComplexity + findComplexity
}
return childComplexity + listComplexity
}
cr.Query.Chapter = func(childComplexity int, id string) int {
return childComplexity + findComplexity
}
cr.Query.Comment = func(childComplexity int, id string) int {
return childComplexity + findComplexity
}
cr.Query.Tags = func(childComplexity int) int {
return childComplexity + listComplexity
}
cr.Query.Story = func(childComplexity int, id string) int {
return childComplexity + findComplexity
}
cr.Query.Stories = func(childComplexity int, filter *stories.Filter) int {
return childComplexity + listComplexity
}
cr.Query.File = func(childComplexity int, id string) int {
return childComplexity + findComplexity
}
cr.Query.Files = func(childComplexity int, filter *files.Filter) int {
return childComplexity + listComplexity
}
cr.Query.Changes = func(childComplexity int, filter *changes.Filter) int {
return childComplexity + listComplexity
}
cr.Query.Token = func(childComplexity int) int {
return childComplexity + findComplexity
}
cr.Mutation.AddStory = func(childComplexity int, input input.StoryAddInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.AddStoryTag = func(childComplexity int, input input.StoryTagAddInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.RemoveStoryTag = func(childComplexity int, input input.StoryTagRemoveInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.EditStory = func(childComplexity int, input input.StoryEditInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.RemoveStory = func(childComplexity int, input input.StoryRemoveInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.AddChapter = func(childComplexity int, input input.ChapterAddInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.EditChapter = func(childComplexity int, input input.ChapterEditInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.MoveChapter = func(childComplexity int, input input.ChapterMoveInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.RemoveChapter = func(childComplexity int, input input.ChapterRemoveInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.AddComment = func(childComplexity int, input input.CommentAddInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.EditComment = func(childComplexity int, input input.CommentEditInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.RemoveComment = func(childComplexity int, input input.CommentRemoveInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.AddLog = func(childComplexity int, input input.LogAddInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.ImportLog = func(childComplexity int, input input.LogImportInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.EditLog = func(childComplexity int, input input.LogEditInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.RemoveLog = func(childComplexity int, input input.LogRemoveInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.AddPost = func(childComplexity int, input input.PostAddInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.EditPost = func(childComplexity int, input input.PostEditInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.MovePost = func(childComplexity int, input input.PostMoveInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.RemovePost = func(childComplexity int, input input.PostRemoveInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.AddCharacter = func(childComplexity int, input input.CharacterAddInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.AddCharacterNick = func(childComplexity int, input input.CharacterNickInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.RemoveCharacterNick = func(childComplexity int, input input.CharacterNickInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.EditCharacter = func(childComplexity int, input input.CharacterEditInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.RemoveCharacter = func(childComplexity int, input input.CharacterRemoveInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.AddChannel = func(childComplexity int, input input.ChannelAddInput) int {
return childComplexity + mutationComplexity
}
cr.Mutation.EditChannel = func(childComplexity int, input input.ChannelEditInput) int {
return childComplexity + mutationComplexity
}
cr.Log.Characters = func(childComplexity int) int {
return childComplexity + subListComplexity
}
cr.Log.Posts = func(childComplexity int, kinds []string) int {
return childComplexity + hugeSublistComplexity
}
cr.Story.Chapters = func(childComplexity int) int {
return childComplexity + bigSublistComplexity
}
cr.Story.Tags = func(childComplexity int) int {
return childComplexity + subListComplexity
}
cr.Chapter.Comments = func(childComplexity int, limit *int) int {
return childComplexity + subListComplexity
}
return
}
+78
View File
@@ -0,0 +1,78 @@
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
UnknownNick:
model: git.aiterp.net/rpdata/api/models.UnknownNick
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
LogImporter:
model: git.aiterp.net/rpdata/api/models.LogImporter
Comment:
model: git.aiterp.net/rpdata/api/models.Comment
fields:
fictionalDate:
resolver: true
ChapterCommentMode:
model: git.aiterp.net/rpdata/api/models.ChapterCommentMode
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
+56
View File
@@ -0,0 +1,56 @@
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 go run github.com/99designs/gqlgen -v
// New creates a new GraphQL schema.
func New() graphql.ExecutableSchema {
return NewExecutableSchema(Config{
Resolvers: &rootResolver{},
Complexity: complexity(),
})
}
type rootResolver struct{}
func (r *rootResolver) Query() QueryResolver {
return &queries.Resolver
}
func (r *rootResolver) Mutation() MutationResolver {
return queries.MutationResolver
}
func (r *rootResolver) Subscription() SubscriptionResolver {
return queries.SubscriptionResolver
}
func (r *rootResolver) Log() LogResolver {
return &types.LogResolver
}
func (r *rootResolver) Comment() CommentResolver {
return &types.CommentResolver
}
func (r *rootResolver) Chapter() ChapterResolver {
return &types.ChapterResolver
}
func (r *rootResolver) Story() StoryResolver {
return &types.StoryResolver
}
func (r *rootResolver) Change() ChangeResolver {
return &types.ChangeResolver
}
func (r *rootResolver) Token() TokenResolver {
return &types.TokenResolver
}
+25
View File
@@ -0,0 +1,25 @@
package queries
import (
"context"
"errors"
"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)
}
/// Subscriptions
func (r *subscriptionResolver) Changes(ctx context.Context, keys []models.ChangeKey) (<-chan *models.Change, error) {
if len(keys) == 0 {
return nil, errors.New("At least one key is required for a subscription")
}
return changes.Subscribe(ctx, keys, false), nil
}
+84
View File
@@ -0,0 +1,84 @@
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) {
channel, err := channels.FindName(name)
if err != nil {
return nil, err
}
return &channel, nil
}
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 nil, 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 nil, 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 nil, errors.New("You are not permitted to edit channels")
}
channel, err := channels.FindName(input.Name)
if err != nil {
return nil, errors.New("Channel not found")
}
channel, err = channels.Edit(channel, input.Logged, input.Hub, input.EventName, input.LocationName)
if err != nil {
return nil, errors.New("Failed to edit channel: " + err.Error())
}
go changes.Submit("Channel", "edit", token.UserID, true, changekeys.Listed(channel), channel)
return &channel, nil
}
+177
View File
@@ -0,0 +1,177 @@
package queries
import (
"context"
"errors"
"time"
"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/chapters"
"git.aiterp.net/rpdata/api/models/comments"
"git.aiterp.net/rpdata/api/models/stories"
)
// Queries
func (r *resolver) Chapter(ctx context.Context, id string) (*models.Chapter, error) {
chapter, err := chapters.FindID(id)
if err != nil {
return nil, err
}
return &chapter, nil
}
// Mutations
func (r *mutationResolver) AddChapter(ctx context.Context, input input.ChapterAddInput) (*models.Chapter, error) {
story, err := stories.FindID(input.StoryID)
if err != nil {
return nil, errors.New("Story not found")
}
token := auth.TokenFromContext(ctx)
if !token.Permitted("member", "story.add") {
return nil, errors.New("Unauthorized")
}
author := token.UserID
if input.Author != nil && *input.Author != author {
if !token.Permitted("story.add") {
return nil, errors.New("False pretender")
}
author = *input.Author
}
if !story.Open && story.Author != author {
return nil, errors.New("Story is not open")
}
commentMode := models.ChapterCommentModeDisabled
if input.CommentMode != nil {
commentMode = *input.CommentMode
}
chapter, err := chapters.Add(story, input.Title, author, input.Source, time.Now(), input.FictionalDate, commentMode)
if err != nil {
return nil, 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 nil, errors.New("Chapter not found")
}
token := auth.TokenFromContext(ctx)
if !token.Authenticated() || !token.PermittedUser(chapter.Author, "member", "chapter.move") {
return nil, errors.New("You are not allowed to move this chapter")
}
target, err := stories.FindID(input.StoryID)
if err != nil {
return nil, errors.New("Target story not found")
}
if !target.Open && !token.PermittedUser(target.Author, "member", "chapter.move") {
return nil, 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 nil, 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 nil, errors.New("Chapter not found")
}
token := auth.TokenFromContext(ctx)
if !token.Authenticated() || !token.PermittedUser(chapter.Author, "member", "chapter.edit") {
return nil, 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, input.CommentMode, input.CommentsLocked)
if err != nil {
return nil, 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 nil, errors.New("Chapter not found")
}
token := auth.TokenFromContext(ctx)
if !token.Authenticated() || !token.PermittedUser(chapter.Author, "member", "chapter.remove") {
return nil, errors.New("Unauthorized")
}
chapter, err = chapters.Remove(chapter)
if err != nil {
return nil, errors.New("Failed to remove chapter: " + err.Error())
}
err = comments.RemoveChapter(chapter)
if err != nil {
return nil, errors.New("Chapter was removed, but comment removal failed: " + 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
}
+184
View File
@@ -0,0 +1,184 @@
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 {
character, err := characters.FindID(*id)
if err != nil {
return nil, err
}
return &character, nil
} else if nick != nil {
character, err := characters.FindNick(*nick)
if err != nil {
return nil, err
}
return &character, nil
} else {
return nil, 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 nil, errors.New("You are not permitted to add characters")
}
if len(input.Name) < 2 || len(input.Nick) < 2 {
return nil, 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 nil, 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 nil, 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 nil, errors.New("Character not found")
}
if len(input.Nick) < 2 {
return nil, 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 nil, errors.New("You are not permitted to edit this character")
}
logs.ScheduleFullUpdate()
character, err = characters.AddNick(character, input.Nick)
if err != nil {
return nil, 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 nil, errors.New("Character not found")
}
token := auth.TokenFromContext(ctx)
if !token.PermittedUser(character.Author, "member", "character.edit") {
return nil, errors.New("You are not permitted to edit this character")
}
character, err = characters.RemoveNick(character, input.Nick)
if err != nil {
return nil, 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 nil, errors.New("Character not found")
}
if input.Name != nil && len(*input.Name) < 2 {
return nil, errors.New("You need to provide a valid name (min length: 2)")
}
if input.ShortName != nil && len(*input.ShortName) < 2 {
return nil, 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 nil, 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 nil, 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 nil, errors.New("Character not found")
}
token := auth.TokenFromContext(ctx)
if !token.PermittedUser(character.Author, "member", "character.remove") {
return nil, errors.New("You are not permitted to remove this character")
}
character, err = characters.Remove(character)
if err != nil {
return nil, errors.New("Failed to remove character: " + err.Error())
}
go changes.Submit("Character", "remove", token.UserID, true, changekeys.Listed(character), character)
return &character, nil
}
+180
View File
@@ -0,0 +1,180 @@
package queries
import (
"context"
"errors"
"log"
"time"
"git.aiterp.net/rpdata/api/models/stories"
"git.aiterp.net/rpdata/api/models/characters"
"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/chapters"
"git.aiterp.net/rpdata/api/models/comments"
"git.aiterp.net/rpdata/api/models"
)
// Queries
func (r *resolver) Comment(ctx context.Context, id string) (*models.Comment, error) {
comment, err := comments.Find(id)
if err != nil {
return nil, err
}
return &comment, nil
}
// Mutations
func (r *mutationResolver) AddComment(ctx context.Context, input input.CommentAddInput) (*models.Comment, error) {
chapter, err := chapters.FindID(input.ChapterID)
if err != nil {
return nil, errors.New("Chapter not found")
}
token := auth.TokenFromContext(ctx)
if !token.Permitted("member", "story.edit") {
return nil, errors.New("Unauthorized")
}
if !chapter.CanComment() {
return nil, errors.New("Comments are disabled or locked")
}
var characterPtr *models.Character
if input.CharacterID != nil {
character, err := characters.FindID(*input.CharacterID)
if err != nil {
return nil, errors.New("Character not found")
} else if character.Author != token.UserID {
return nil, errors.New("That is not your character")
}
characterPtr = &character
}
fictionalDate := time.Time{}
if input.FictionalDate != nil {
fictionalDate = *input.FictionalDate
}
subject := ""
if input.Subject != nil {
subject = *input.Subject
}
comment, err := comments.Add(chapter, subject, token.UserID, input.Source, input.CharacterName, characterPtr, time.Now(), fictionalDate)
if err != nil {
return nil, errors.New("Failed to add comment: " + err.Error())
}
go func() {
story, err := stories.FindID(chapter.StoryID)
if err != nil {
log.Println("WARNING: Couldn't log comment change:", err)
return
}
changes.Submit("Comment", "add", token.UserID, true, changekeys.Many(comment, chapter, models.Story{ID: chapter.StoryID}), comment, chapter, story)
}()
return &comment, nil
}
func (r *mutationResolver) EditComment(ctx context.Context, input input.CommentEditInput) (*models.Comment, error) {
comment, err := comments.Find(input.CommentID)
if err != nil {
return nil, errors.New("Comment not found")
}
token := auth.TokenFromContext(ctx)
if !token.PermittedUser(comment.Author, "member", "story.edit") {
return nil, errors.New("You cannot edit this comment")
}
chapter, err := chapters.FindID(comment.ChapterID)
if err != nil {
return nil, errors.New("Comment's chapter not found")
}
if !chapter.CanComment() {
return nil, errors.New("Comments are disabled or locked")
}
if input.ClearFictionalDate != nil && *input.ClearFictionalDate == true {
input.FictionalDate = &time.Time{}
}
if input.CharacterID != nil && *input.CharacterID != "" {
character, err := characters.FindID(*input.CharacterID)
if err != nil {
return nil, errors.New("Character not found")
} else if character.Author != token.UserID {
return nil, errors.New("That is not your character")
}
}
comment, err = comments.Edit(comment, input.Source, input.CharacterName, input.CharacterID, input.Subject, input.FictionalDate)
if err != nil {
return nil, errors.New("Could not post comment: " + err.Error())
}
go func() {
story, err := stories.FindID(chapter.StoryID)
if err != nil {
log.Println("WARNING: Couldn't log comment change:", err)
return
}
changes.Submit("Comment", "edit", token.UserID, true, changekeys.Many(comment, chapter, models.Story{ID: chapter.StoryID}), comment, chapter, story)
}()
return &comment, nil
}
func (r *mutationResolver) RemoveComment(ctx context.Context, input input.CommentRemoveInput) (*models.Comment, error) {
comment, err := comments.Find(input.CommentID)
if err != nil {
return nil, errors.New("Comment not found")
}
token := auth.TokenFromContext(ctx)
if !token.PermittedUser(comment.Author, "member", "story.edit") {
return nil, errors.New("You cannot remove this comment")
}
chapter, err := chapters.FindID(comment.ChapterID)
if err != nil {
return nil, errors.New("Comment's chapter not found")
}
if !chapter.CanComment() {
return nil, errors.New("Comments are disabled or locked")
}
err = comments.Remove(comment)
if err != nil {
return nil, errors.New("Failed to remove comment: " + err.Error())
}
go func() {
story, err := stories.FindID(chapter.StoryID)
if err != nil {
log.Println("WARNING: Couldn't log comment change:", err)
return
}
changes.Submit("Comment", "remove", token.UserID, true, changekeys.Many(comment, chapter, models.Story{ID: chapter.StoryID}), comment, chapter, story)
}()
return &comment, nil
}
+39
View File
@@ -0,0 +1,39 @@
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) {
file, err := files.FindID(id)
if err != nil {
return nil, err
}
return &file, nil
}
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
+189
View File
@@ -0,0 +1,189 @@
package queries
import (
"context"
"errors"
"strings"
"time"
"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) {
log, err := logs.FindID(id)
if err != nil {
return nil, err
}
return &log, nil
}
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 nil, 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 nil, 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) ImportLog(ctx context.Context, input input.LogImportInput) ([]models.Log, error) {
token := auth.TokenFromContext(ctx)
if !token.Authenticated() || !token.Permitted("log.add") {
return nil, errors.New("You are not permitted to add logs")
}
date := time.Time{}
if input.Date != nil {
date = *input.Date
}
tz := time.UTC
if input.Timezone != nil {
parsedTZ, err := time.LoadLocation(*input.Timezone)
if err != nil {
return nil, errors.New("Unknown timezone: " + *input.Timezone)
}
tz = parsedTZ
}
results, err := logs.Import(input.Importer, date, tz, input.ChannelName, input.Data)
if err != nil {
return nil, err
}
newLogs := make([]models.Log, 0, len(results))
for _, result := range results {
go func(result logs.ImportedLog) {
changes.Submit("Log", "add", token.UserID, true, changekeys.Many(result.Log), result.Log)
changes.Submit("Post", "add", token.UserID, true, changekeys.Many(result.Log, result.Posts), result.Posts)
}(result)
log, err := logs.UpdateCharacters(result.Log, nil)
if err != nil {
log = result.Log
}
newLogs = append(newLogs, log)
}
return newLogs, 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 nil, errors.New("You are not permitted to edit logs")
}
log, err := logs.FindID(input.ID)
if err != nil {
return nil, errors.New("Log not found")
}
log, err = logs.Edit(log, input.Title, input.Event, input.Description, input.Open)
if err != nil {
return nil, 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 nil, errors.New("You are not permitted to remove logs")
}
log, err := logs.FindID(input.ID)
if err != nil {
return nil, errors.New("Log not found")
}
log, err = logs.Remove(log)
if err != nil {
return nil, errors.New("Failed to remove log: " + err.Error())
}
go changes.Submit("Log", "remove", token.UserID, true, changekeys.Listed(log), log)
return &log, nil
}
+167
View File
@@ -0,0 +1,167 @@
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) {
post, err := posts.FindID(id)
if err != nil {
return nil, err
}
return &post, nil
}
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 nil, errors.New("You are not permitted to edit logs")
}
log, err := logs.FindID(input.LogID)
if err != nil {
return nil, err
}
post, err := posts.Add(log, input.Time, input.Kind, input.Nick, input.Text)
if err != nil {
return nil, err
}
go logs.UpdateCharacters(log, nil)
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 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")
}
if input.Nick != nil {
go func() {
log, err := logs.FindID(post.LogID)
if err != nil {
return
}
logs.UpdateCharacters(log, nil)
}()
}
post, err = posts.Edit(post, input.Time, input.Kind, input.Nick, input.Text)
if err != nil {
return nil, 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 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 (before removing, of course)")
}
post, err = posts.Remove(post)
if err != nil {
return nil, errors.New("Could not remove post: " + err.Error())
}
go func() {
log, err := logs.FindID(post.LogID)
if err != nil {
return
}
logs.UpdateCharacters(log, nil)
changes.Submit("Post", "remove", token.UserID, true, changekeys.Many(log, post), post)
}()
return &post, nil
}
+14
View File
@@ -0,0 +1,14 @@
package queries
type resolver struct{}
type mutationResolver struct{}
type subscriptionResolver struct{}
// Resolver has all the queries
var Resolver resolver
// MutationResolver brings the mutagens.
var MutationResolver *mutationResolver
// SubscriptionResolver has the real-time magic.
var SubscriptionResolver *subscriptionResolver
+186
View File
@@ -0,0 +1,186 @@
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) {
story, err := stories.FindID(id)
if err != nil {
return nil, err
}
return &story, nil
}
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 nil, errors.New("Permission denied")
}
author := token.UserID
if input.Author != nil && *input.Author != author {
if !token.Permitted("story.add") {
return nil, 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 nil, 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 nil, errors.New("Story not found")
}
if story.Open {
if !token.Permitted("member") {
return nil, errors.New("You are not permitted to edit this story")
}
} else {
if !token.PermittedUser(story.Author, "member", "story.edit") {
return nil, errors.New("You are not permitted to edit this story")
}
}
story, err = stories.AddTag(story, input.Tag)
if err != nil {
return nil, 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 nil, errors.New("Story not found")
}
if story.Open {
if !token.Permitted("member") {
return nil, errors.New("You are not permitted to edit this story")
}
} else {
if !token.PermittedUser(story.Author, "member", "story.edit") {
return nil, errors.New("You are not permitted to edit this story")
}
}
story, err = stories.RemoveTag(story, input.Tag)
if err != nil {
return nil, 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 nil, errors.New("Story not found")
}
if !token.PermittedUser(story.Author, "member", "story.edit") {
return nil, 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 nil, 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 nil, errors.New("Story not found")
}
if !token.PermittedUser(story.Author, "member", "story.remove") {
return nil, errors.New("You are not permitted to remove this story")
}
story, err = stories.Remove(story)
if err != nil {
return nil, err
}
err = chapters.RemoveStory(story)
if err != nil {
return nil, 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 nil, errors.New("No (valid) token")
}
return token, nil
}
+23
View File
@@ -0,0 +1,23 @@
package queries
import (
"context"
"git.aiterp.net/rpdata/api/models"
"git.aiterp.net/rpdata/api/models/unknownnicks"
"git.aiterp.net/rpdata/api/graph2/input"
)
/// Queries
func (r *resolver) UnknownNicks(ctx context.Context, filter *input.UnknownNicksFilter) ([]models.UnknownNick, error) {
limit := 100
if filter != nil {
if filter.Limit > 0 {
limit = filter.Limit
}
}
return unknownnicks.List(limit)
}
+172
View File
@@ -0,0 +1,172 @@
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
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!]!
"List unknown nicks"
unknownNicks(filter: UnknownNicksFilter): [UnknownNick!]!
# Find log by ID
log(id: String!): Log!
# Find logs
logs(filter: LogsFilter): [Log!]!
# Find story chapter by ID
chapter(id: String!): Chapter!
# Find comment by ID
comment(id: String!): Comment!
# 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 comment to a chapter."
addComment(input: CommentAddInput!): Comment!
"Edit a comment in a chapter."
editComment(input: CommentEditInput!): Comment!
"Remove a comemnt in a chapter."
removeComment(input: CommentRemoveInput!): Comment!
# Add a new log
addLog(input: LogAddInput!): Log!
# Import a log
importLog(input: LogImportInput!): [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!
}
type Subscription {
"""
Changes subscribes to the changes matching the following keys.
"""
changes(keys: [ChangeKeyInput!]!): Change!
}
# 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 | Comment
"""
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!
+115
View File
@@ -0,0 +1,115 @@
# 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!
"The comment mode."
commentMode: ChapterCommentMode!
"Whether the author has locket comments."
commentsLocked: Boolean!
"A shorthand for checking whether a logged-in user can comment on this chapter."
canComment: Boolean!
"Get all chapter comments."
comments(limit: Int): [Comment!]!
}
"""
The possibles modes of comments for a chapter.
"""
enum ChapterCommentMode {
"Comments are disabled and hidden."
Disabled
"Comments should be shown as typical news article comments."
Article
"Comments should be shown in a compact form suitable for many small messages."
Chat
"Comments are going to be shown as omni-tool messages."
Message
}
# 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
"The comment mode for the chapter."
commentMode: ChapterCommentMode
}
# 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
"Set the comment mode for the chapter"
commentMode: ChapterCommentMode
"Set whether the chapter comments are locked"
commentsLocked: 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!
}
+91
View File
@@ -0,0 +1,91 @@
"""
A Comment represents a comment to a story chapter.
"""
type Comment {
"A unique ID of the change."
id: String!
"subject"
subject: String!
"The chapter ID the comment belongs to."
chapterId: String!
"The comment's author."
author: String!
"The displayed name of the character. This may be the same as character.name, but it does not need to be."
characterName: String!
"The character associated with the comment."
character: Character
"The fictional (IC) date of the comment."
fictionalDate: Date
"The date of creation."
createdDate: Date!
"The date of the last edit."
editedDate: Date!
"The markdown source of the comment."
source: String!
}
"""
Input for the addComment mutation
"""
input CommentAddInput {
"What chapter to comment on."
chapterId: String!
"The markdown source of the comment."
source: String!
"The name of the character associated with this comment."
characterName: String!
"Optioanlly, a character ID."
characterId: String
"Optional in-universe date of the comment."
fictionalDate: Date
"Optional subject to add to the comment, only shown in some modes."
subject: String
}
"""
Input for the editComment mutation
"""
input CommentEditInput {
"What comment to edit."
commentId: String!
"Change the markdown source."
source: String
"Change the name of the character associated with this comment."
characterName: String
"Change the character assocaited with this comment."
characterId: String
"Change the optional in-universe date of the comment."
fictionalDate: Date
"Set to clear the fictional date."
clearFictionalDate: Boolean
"Change the optional subject for the comment, only shown in some modes."
subject: String
}
"""
Input for the removeComment mutation
"""
input CommentRemoveInput {
"What comment to edit."
commentId: 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,40 @@ 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.
"""
Input for importLog mutation.
"""
input LogImportInput {
"The channel of the log, alwyas required."
channelName: String!
# Information about the channel this log is in.
channel: Channel!
# The session's title.
title: String!
"Which importer to use."
importer: LogImporter!
# 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 timezone of the import, all log and post dates will be treated as it. It will be UTC if none is specified."
timezone: String
# The description of a session, which is empty if unset.
description: String!
"The date of the log, if not provided in the log body."
date: Date
# Whether the log session is open.
open: Boolean!
# The characters involved in the log file.
characters: [Character!]!
"The log body itself."
data: String!
}
enum LogImporter {
"""
mIRC-like: This importer parses logs that copied from mIRC posts without spaces.
"""
MircLike
"""
Forum log: This importer parses the format on the forum. The displayed log, not the post source.
"""
ForumLog
}
@@ -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
}
@@ -25,28 +25,38 @@ type Story {
# The chapters of this story
chapters: [Chapter!]!
# The date the story was created (RFC3339 format).
createdDate: String!
# The date the story was created.
createdDate: Date!
# The date the story is set in (RFC3339 format).
fictionalDate: String!
# The date the story is set in.
fictionalDate: Date
# The date of the last major update to the story (RFC3339 format). This being bumped means a new chapter has been added.
updatedDate: String!
# The date of the last major update to the story. This being bumped means a new chapter has been added.
updatedDate: Date!
}
input StoriesQueryInput {
# 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: String
earliestFictionalDate: Date
# The latest fictionalDate
latestFictionalDate: String
latestFictionalDate: Date
# The max amount of stories to get (default: 30)
limit: Int
@@ -74,8 +84,8 @@ input StoryAddInput {
# Set which tags the story should have.
tags: [TagInput!]
# Set the fictional date of the story (RFC3339 format).
fictionalDate: String
# Set the fictional date of the story.
fictionalDate: Date
}
# Input for the editStory mutation
@@ -100,8 +110,11 @@ input StoryEditInput {
# Change whether to show this story in the list and search results
listed: Boolean
# Set the fictional date of the story (RFC3339 format).
fictionalDate: String
# Set the fictional date of the story.
fictionalDate: Date
# Clear the fictional date of the story.
clearFictionalDate: Boolean
}
# Input for the addStoryTag mutation
@@ -122,20 +135,26 @@ input StoryTagRemoveInput {
tag: TagInput!
}
# Input for the removeStory mutation
input StoryRemoveInput {
# What story to remove.
id: String!
}
# Possible values for Story.category
enum StoryCategory {
# Description and content of a document or item
Document
# 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
# General information
Info
# A short story
Story
}
@@ -33,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!]!
}
+20
View File
@@ -0,0 +1,20 @@
"""
An unknown nick is a nick found by the logbot that's not associated with
any characters. Many are going to be false positives, but most should be
useful in pointing out which characters are missing.
"""
type UnknownNick {
"The IRC nick that's unknown."
nick: String!
"The amount of logs that contains the unknown nick."
score: Int!
}
"""
Input for the unknownNicks query.
"""
input UnknownNicksFilter {
"The highest amount of unknown nicks to return."
limit: Int!
}
+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!]!
}
+26
View File
@@ -0,0 +1,26 @@
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, 0, len(objects))
for _, object := range objects {
if changeObject, ok := object.(input.ChangeObject); ok {
results = append(results, changeObject)
}
}
return results, nil
}
// ChangeResolver is a resolver
var ChangeResolver changeResolver
+40
View File
@@ -0,0 +1,40 @@
package types
import (
"context"
"errors"
"time"
"git.aiterp.net/rpdata/api/models"
"git.aiterp.net/rpdata/api/models/comments"
)
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
}
func (r *chapterResolver) Comments(ctx context.Context, chapter *models.Chapter, limit *int) ([]models.Comment, error) {
limitValue := 0
if limit != nil {
if *limit < 0 {
return nil, errors.New("Limit cannot be negative")
}
limitValue = *limit
}
if !chapter.CommentMode.IsEnabled() {
return nil, nil
}
return comments.ListChapterID(chapter.ID, limitValue)
}
// ChapterResolver is a resolver
var ChapterResolver chapterResolver
+41
View File
@@ -0,0 +1,41 @@
package types
import (
"context"
"errors"
"time"
"git.aiterp.net/rpdata/api/internal/loader"
"git.aiterp.net/rpdata/api/models"
)
type commentResolver struct{}
func (r *commentResolver) Character(ctx context.Context, obj *models.Comment) (*models.Character, error) {
if obj.CharacterID == "" {
return nil, nil
}
loader := loader.FromContext(ctx)
if loader == nil {
return nil, errors.New("no loader")
}
character, err := loader.Character("id", obj.CharacterID)
if err != nil {
return nil, err
}
return &character, nil
}
func (r *commentResolver) FictionalDate(ctx context.Context, obj *models.Comment) (*time.Time, error) {
if obj.FictionalDate.IsZero() {
return nil, nil
}
return &obj.FictionalDate, nil
}
// CommentResolver is a resolver
var CommentResolver commentResolver
+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 nil, 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
+22
View File
@@ -0,0 +1,22 @@
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) {
user, err := users.Find(token.UserID)
if err != nil {
return nil, err
}
return &user, nil
}
// 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
}
+1
View File
@@ -14,6 +14,7 @@ var global *Config
// Config is configuration
type Config struct {
Space struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
@@ -30,6 +30,24 @@ func Next(category, name string) (int, error) {
return doc.Value, nil
}
// NextMany gets the next value of a counter, or an error if it hasn't, and increments by a specified value.
// Any value `returned` to `returned+(increment-1)` should then be safe to use.
func NextMany(category, name string, increment int) (int, error) {
id := category + "." + name
doc := counter{}
_, err := collection.Find(bson.M{"_id": id}).Apply(mgo.Change{
Update: bson.M{"$inc": bson.M{"value": increment}},
Upsert: true,
ReturnNew: true,
}, &doc)
if err != nil {
return -1, err
}
return doc.Value, nil
}
func init() {
store.HandleInit(func(db *mgo.Database) {
collection = db.C("core.counters")
+101
View File
@@ -0,0 +1,101 @@
package forumlog
import (
"bufio"
"fmt"
"strings"
"time"
"git.aiterp.net/rpdata/api/internal/importers/mirclike"
"git.aiterp.net/rpdata/api/models"
)
// A ParsedLog contains the parsed log header and its posts.
type ParsedLog struct {
Log models.Log
Posts []models.Post
}
// ParseLogs parses the logs from the data.
func ParseLogs(data string, tz *time.Location) ([]ParsedLog, error) {
metadata := ParseMetadata(data)
results := make([]ParsedLog, 0, len(metadata["Date"]))
scanner := bufio.NewScanner(strings.NewReader(data))
for i, dateStr := range metadata["Date"] {
// Parse date
date, err := time.ParseInLocation("January 2, 2006", dateStr, tz)
if err != nil {
return nil, fmt.Errorf("Failed to parse date #%d: %#+v is not the in the correct format of \"January 2, 2006\"", i+1, dateStr)
}
// Parse posts
posts := make([]models.Post, 0, 128)
parsing := false
prev := ""
prevPost := models.Post{}
for scanner.Scan() {
line := strings.Trim(scanner.Text(), "\r\t  ")
// Skip lines with links to other logs using the --> and <-- notation.
if strings.HasPrefix(line, "-") || strings.HasPrefix(line, "<") {
prev = line
continue
}
// If parsing and reaching a double empty-line, the session is done.
if parsing && len(line) < 2 && len(prev) < 2 {
break
}
// Skip empty lines, but record them as thep revious for the above check.
if len(line) < 2 {
prev = line
continue
}
// If not parsing, skip until the first mirclike post.
if !parsing {
if strings.HasPrefix(line, "[") {
parsing = true
} else {
prev = line
continue
}
}
// Parse the post.
post, err := mirclike.ParsePost(line, date, prevPost)
if err != nil {
summary := ""
for _, ru := range line {
summary += string(ru)
if len(summary) > 60 {
summary += "..."
break
}
}
return nil, fmt.Errorf("Failed to parse post: %s", summary)
}
posts = append(posts, post)
prevPost = post
prev = line
}
// No posts means there's a problem.
if len(posts) == 0 {
return nil, fmt.Errorf("Session %d (%s) has no posts (too many dates?)", i+1, dateStr)
}
// Add it.
results = append(results, ParsedLog{
Log: models.Log{Date: posts[0].Time},
Posts: posts,
})
}
return results, nil
}
+151
View File
@@ -0,0 +1,151 @@
package forumlog_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"git.aiterp.net/rpdata/api/internal/importers/forumlog"
"git.aiterp.net/rpdata/api/models"
)
func TestParseLogs(t *testing.T) {
results, err := forumlog.ParseLogs(testLog, time.UTC)
if err != nil {
t.Fatalf("Parse: %s", err)
}
assert.Equal(t, 2, len(results), "Amount of results.")
assert.Equal(t, testLogPosts[0], results[0].Posts, "First log's posts.")
assert.Equal(t, testLogPosts[1], results[1].Posts, "Second log's posts.")
}
func TestParseLogsErrors(t *testing.T) {
_, err1 := forumlog.ParseLogs(brokenLogNoPosts, time.UTC)
_, err2 := forumlog.ParseLogs(brokenLogBrokenPost, time.UTC)
_, err3 := forumlog.ParseLogs(brokenLogBrokenDate, time.UTC)
t.Log("Should be about no posts:", err1)
t.Log("Should be about a broken post:", err2)
t.Log("Should be about a broken date:", err3)
assert.NotEqual(t, err1, nil, "Should be no posts")
assert.NotEqual(t, err2, nil, "Should be about a bad post")
assert.NotEqual(t, err3, nil, "Should be about a broken date")
}
var testLog = `
Date:
July 25, 2013
July 26, 2013
GM:
Tyranniac
Length:
92 posts
Characters:
Fera'Sel nar Veltar - Baphomet
Renala T’Iavay - Gisle
Steven Briggs - Dante
NPCs:
Receptionist
Relevant RPs:
<-- Hub - Miner's Respite (July 16)
--> Event - Hinpinn's Salvage Mission (August 5)
[21:28] * @Tyranniac | The Hnipinn Minerals local administrative center is a rather small building located next to the refinery. The area is mostly asphalt and industrial surroundings. The gate in the fence surrounding the refinery is nearby, with a transport truck just being admitted by the guard - rather heavily armed for corporate security. Despite not being that late, it's rather dark due to the rain-bearing clouds that have just started emptying their content. The windows of the office glow with invitingly warm light. A receptionist can be seen working through the glass door.
[21:46] * Steve_Briggs approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.
[21:46] * Steve_Briggs approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.
--> Stuff and things
<-- Stuff and things
[21:46] * Steve_Briggs approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.
`
var testLogPosts = [][]models.Post{
{
{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: parseDate(nil, "2013-07-25 21:28:00"),
Kind: "action",
Nick: "Tyranniac",
Text: "| The Hnipinn Minerals local administrative center is a rather small building located next to the refinery. The area is mostly asphalt and industrial surroundings. The gate in the fence surrounding the refinery is nearby, with a transport truck just being admitted by the guard - rather heavily armed for corporate security. Despite not being that late, it's rather dark due to the rain-bearing clouds that have just started emptying their content. The windows of the office glow with invitingly warm light. A receptionist can be seen working through the glass door.",
Position: 1,
},
{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: parseDate(nil, "2013-07-25 21:46:00"),
Kind: "action",
Nick: "Steve_Briggs",
Text: `approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.`,
Position: 2,
},
},
{
{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: parseDate(nil, "2013-07-26 21:46:00"),
Kind: "action",
Nick: "Steve_Briggs",
Text: `approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.`,
Position: 1,
},
{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: parseDate(nil, "2013-07-26 21:46:00"),
Kind: "action",
Nick: "Steve_Briggs",
Text: `approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.`,
Position: 2,
},
},
}
var brokenLogNoPosts = `
Date:
September 27, 2014
`
var brokenLogBrokenPost = `
Date:
September 10, 2038
[12:38] * Some_Character is lasered by a reaper, and turns to ash before even knowing what happened.
[12:4 * Some_Other_Character also meets the same fate, because the Reapers are no joke.
`
var brokenLogBrokenDate = `
Date:
10 September, 2038
[12:34] * =Scene= Stuff happens.
`
func parseDate(t *testing.T, date string) time.Time {
result, err := time.Parse("2006-01-02 15:04:05", date)
if err != nil {
if t != nil {
t.Fatal("Could not parse date", date, err)
} else {
panic("Could not parse date: " + err.Error())
}
}
return result
}
+35
View File
@@ -0,0 +1,35 @@
package forumlog
import (
"bufio"
"strings"
)
// ParseMetadata parses metadata, discards the broken parts, and returns the
// parsed data as a map (`m`) and the position of the first IRC post (`n`)
func ParseMetadata(data string) map[string][]string {
result := make(map[string][]string)
key := ""
scanner := bufio.NewScanner(strings.NewReader(data))
for scanner.Scan() {
line := strings.Trim(scanner.Text(), "\r\t  ")
if strings.HasPrefix(line, "[") {
break
}
if len(line) < 1 {
key = ""
continue
}
if key == "" {
split := strings.Split(line, ":")
key = split[0]
} else {
result[key] = append(result[key], line)
}
}
return result
}
@@ -0,0 +1,81 @@
package forumlog_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"git.aiterp.net/rpdata/api/internal/importers/forumlog"
)
func TestParseMetadata(t *testing.T) {
data := forumlog.ParseMetadata(testData1)
assert.Equal(t, data["Date"], []string{
"August 11, 2014",
"August 12, 2014",
"August 13, 2014",
})
assert.Equal(t, data["Location"], []string{
"Aroste, an island off the Derraian coastline.",
})
assert.Equal(t, data["GM"], []string{
"Gisle",
})
assert.Equal(t, data["Length"], []string{
"847 posts",
})
assert.Equal(t, len(data["Stuff"]), 0)
for key := range data {
if strings.HasPrefix(key, "[") {
t.Error("It should have stopped at the IRC post, yet this key exists:", key)
}
}
}
var testData1 = `
Date: August 2185
August 11, 2014
August 12, 2014
August 13, 2014
Location:
Aroste, an island off the Derraian coastline.
GM:
Gisle
Length:
847 posts
Characters:
Calyx Vadris - Osiris
Damien Monroe - Bowe
Jason Wolfe - Dante
Renala T'Iavay - Gisle
Victoria Steels - MCB280
NPCs:
Recurring:
Halisi - Tyranniac
Jattic - Dante
Jelvan Darennon - Gisle
Leah - Dante
Marissa T'Evin - Gisle
Philip Lacour - Tyranniac
One-off:
Reidas Falten (Turian - Guard meeting them on their trip to the building)
Sarian T'Dera (Asari - Asari opening fire on Calyx, Leah and Renala)
Mariam Adams (Human - The receptionist.)
Prerix Falten (Turian2 - Victoria's chair)
Jonathan Lyng (OtherHuman - The one opening fire on Leah entering the garage)
Alerena Teris (Teris - Asari met in the maintenance corridor)
<-- Redorck Agency - August 11, 2014
[13:19] * Leah looks over at Damien. "Haven't seen you around the agency." she says curiously, "New hire?"
`
+48
View File
@@ -0,0 +1,48 @@
package mirclike
import (
"errors"
"strings"
"time"
"git.aiterp.net/rpdata/api/models"
)
// ErrEmptyLog is returned by ParseLog if there are no (valid) posts in the log.
var ErrEmptyLog = errors.New("No valid posts found in log")
// ParseLog parses the log and returns the things that can be gleamed from them.
func ParseLog(data string, date time.Time, strict bool) (models.Log, []models.Post, error) {
lines := strings.Split(data, "\n")
posts := make([]models.Post, 0, len(lines))
prev := models.Post{}
for _, line := range lines {
line = strings.Trim(line, "\r\t  ")
if len(line) < 1 {
continue
}
post, err := ParsePost(line, date, prev)
if err != nil {
if strict {
return models.Log{}, nil, err
}
continue
}
posts = append(posts, post)
prev = post
}
if len(posts) == 0 {
return models.Log{}, nil, ErrEmptyLog
}
log := models.Log{
Date: posts[0].Time,
}
return log, posts, nil
}
+69
View File
@@ -0,0 +1,69 @@
package mirclike_test
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"git.aiterp.net/rpdata/api/internal/importers/mirclike"
"git.aiterp.net/rpdata/api/models"
)
var testLog = strings.Join([]string{
"[11:21] * Va`ynna_Atana returns to the apartment at the end of another long day. She hangs up her jacket and a green scarf next to the door before going straight to the bathroom; she leaves the door open, though. She walked home with Uvena this time, but the two parted ways downstairs.",
"",
"[11:21] <=Scene=> The weather outside is not pleasant in the slightest. The storm is still going on, visibility is reduced and the worst gusts of wind can be felt inside the apartment as a faint shudder. ",
"",
"[11:",
" [11:27] <Test> Stuff and things.",
}, "\r\n")
var testLogPosts = []models.Post{
{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: parseDate(nil, "2018-05-11 11:21:00"),
Kind: "action",
Nick: "Va`ynna_Atana",
Position: 1,
Text: "returns to the apartment at the end of another long day. She hangs up her jacket and a green scarf next to the door before going straight to the bathroom; she leaves the door open, though. She walked home with Uvena this time, but the two parted ways downstairs.",
},
{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: parseDate(nil, "2018-05-11 11:21:00"),
Kind: "scene",
Nick: "=Scene=",
Position: 2,
Text: "The weather outside is not pleasant in the slightest. The storm is still going on, visibility is reduced and the worst gusts of wind can be felt inside the apartment as a faint shudder.",
},
{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: parseDate(nil, "2018-05-11 11:27:00"),
Kind: "text",
Nick: "Test",
Position: 3,
Text: "Stuff and things.",
},
}
func TestParseLog(t *testing.T) {
log, posts, err := mirclike.ParseLog(testLog, parseDate(t, "2018-05-11 00:00:00"), false)
if err != nil {
t.Fatal("ParseLog", err)
}
assert.Equal(t, testLogPosts, posts)
assert.Equal(t, posts[0].Time, log.Date, "Log's date should be the first post's.")
}
func TestParseLogErrors(t *testing.T) {
_, _, err1 := mirclike.ParseLog("\n\n\n\n\n \n\t\r\n", time.Time{}, false)
_, _, err2 := mirclike.ParseLog("\n\n\n\n\n[14:57]* Stuff \n\t\r\n", time.Time{}, true)
assert.Equal(t, mirclike.ErrEmptyLog, err1)
assert.Equal(t, mirclike.ErrNotPost, err2)
}
+98
View File
@@ -0,0 +1,98 @@
package mirclike
import (
"errors"
"strconv"
"strings"
"time"
"git.aiterp.net/rpdata/api/models"
)
// ErrNotPost is returned by parsePost if the line is empty or not a post.
var ErrNotPost = errors.New("not a post")
// ParsePost parses a post from a mirc-like line. If the previous post is included (it can be empty), it will be used
// to determine whether midnight has passed.
func ParsePost(line string, date time.Time, prev models.Post) (models.Post, error) {
// Do basic validation
line = strings.Trim(line, "  \t\n\r")
if len(line) == 0 || !strings.HasPrefix(line, "[") {
return models.Post{}, ErrNotPost
}
// Parse timestamp
tsEndIndex := strings.IndexByte(line, ']')
if tsEndIndex == -1 || len(line) < tsEndIndex+5 {
return models.Post{}, ErrNotPost
}
tsStr := line[1:tsEndIndex]
tsSplit := strings.Split(tsStr, ":")
tsUnits := make([]int, len(tsSplit))
if len(tsSplit) < 2 {
return models.Post{}, ErrNotPost
}
for i := range tsSplit {
n, err := strconv.Atoi(tsSplit[i])
if err != nil {
return models.Post{}, ErrNotPost
}
tsUnits[i] = n
}
if len(tsUnits) == 2 {
tsUnits = append(tsUnits, 0)
}
// Determine timestamp from parsed data and previous post.
ts := time.Date(date.Year(), date.Month(), date.Day(), tsUnits[0], tsUnits[1], tsUnits[2], 0, date.Location())
if !prev.Time.IsZero() && prev.Time.Sub(ts) > 30*time.Minute {
ts = time.Date(prev.Time.Year(), prev.Time.Month(), prev.Time.Day()+1, tsUnits[0], tsUnits[1], tsUnits[2], 0, date.Location())
}
if line[tsEndIndex+2] == '*' {
split := strings.SplitN(line[tsEndIndex+4:], " ", 2)
if len(split) == 1 {
return models.Post{}, ErrNotPost
}
post := models.Post{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: ts,
Kind: "action",
Nick: strings.TrimLeft(split[0], "+@!~"),
Text: split[1],
Position: prev.Position + 1,
}
if post.Nick[0] == '=' {
post.Kind = "scene"
}
return post, nil
} else if line[tsEndIndex+2] == '<' {
split := strings.SplitN(line[tsEndIndex+2:], " ", 2)
if len(split) == 1 {
return models.Post{}, ErrNotPost
}
post := models.Post{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: ts,
Kind: "text",
Nick: strings.TrimLeft(split[0][1:len(split[0])-1], "+@!~"),
Text: split[1],
Position: prev.Position + 1,
}
if post.Nick[0] == '=' {
post.Kind = "scene"
}
return post, nil
} else {
return models.Post{}, ErrNotPost
}
}
+130
View File
@@ -0,0 +1,130 @@
package mirclike_test
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"git.aiterp.net/rpdata/api/internal/importers/mirclike"
"git.aiterp.net/rpdata/api/models"
)
func TestParsePost(t *testing.T) {
table := []struct {
Input string
TS string
Kind string
Nick string
Text string
}{
{
"[12:34] * Stuff does things.",
"12:34:00", "action", "Stuff", "does things.",
},
{
"[12:34] <Stuff> Things said.",
"12:34:00", "text", "Stuff", "Things said.",
},
{
"[13:36:59] <Stuff> Things said.",
"13:36:59", "text", "Stuff", "Things said.",
},
{
"[23:59] <=Scene=> Scenery and such.",
"23:59:00", "scene", "=Scene=", "Scenery and such.",
},
{
"[01:10:11] * =Scene= Scenery and such from the forum or mIRC using my old script.",
"01:10:11", "scene", "=Scene=", "Scenery and such from the forum or mIRC using my old script.",
},
}
for i, row := range table {
t.Run(fmt.Sprintf("Row_%d", i), func(t *testing.T) {
post, err := mirclike.ParsePost(row.Input, time.Now(), models.Post{})
if err != nil {
t.Fatal("Could not parse post:", err)
}
assert.Equal(t, row.TS, post.Time.Format("15:04:05"), "Timestamps should match.")
assert.Equal(t, row.Kind, post.Kind, "Kinds should match.")
assert.Equal(t, row.Nick, post.Nick, "Kinds should match.")
assert.Equal(t, row.Text, post.Text, "Kinds should match.")
})
}
}
func TestParsePostErrors(t *testing.T) {
table := []struct {
Input string
Err error
}{
{"[12:34] <Stuff> Things said.", nil},
{"[12:34] >Stuff> Things said.", mirclike.ErrNotPost},
{"12:34] <Stuff> Things said.", mirclike.ErrNotPost},
{"* Stuff Things said.", mirclike.ErrNotPost},
{"", mirclike.ErrNotPost},
{"[12:34 <Stuff> Things said.", mirclike.ErrNotPost},
{"[TE:XT] <Stuff> Things said.", mirclike.ErrNotPost},
{"[10] <Stuff> Things said.", mirclike.ErrNotPost},
{"[12:34:56:789] <Stuff> Things said.", nil},
{"[12:34:56.789] <Stuff> Things said.", mirclike.ErrNotPost},
{"[12:34] <Stuff>", mirclike.ErrNotPost},
{"[12:34] * Stuff", mirclike.ErrNotPost},
{"[12:34] =Scene=", mirclike.ErrNotPost},
{"[12:34] <=Scene=>", mirclike.ErrNotPost},
}
for i, row := range table {
t.Run(fmt.Sprintf("Row_%d", i), func(t *testing.T) {
_, err := mirclike.ParsePost(row.Input, time.Now(), models.Post{})
assert.Equal(t, row.Err, err, "Error should match")
})
}
}
func TestParseNextDay(t *testing.T) {
table := []struct {
Prev time.Time
TS string
Time time.Time
}{
{Prev: parseDate(t, "2019-01-12 12:00:00"), TS: "12:01", Time: parseDate(t, "2019-01-12 12:01:00")},
{Prev: parseDate(t, "2019-01-12 12:00:00"), TS: "11:53:13", Time: parseDate(t, "2019-01-12 11:53:13")},
{Prev: parseDate(t, "2019-04-08 23:51:59"), TS: "00:09", Time: parseDate(t, "2019-04-09 00:09:00")},
{Prev: parseDate(t, "2019-01-12 12:00:00"), TS: "11:29:59", Time: parseDate(t, "2019-01-13 11:29:59")},
}
for i, row := range table {
t.Run(fmt.Sprintf("Row_%d", i), func(t *testing.T) {
input := fmt.Sprintf("[%s] * Stuff does things.", row.TS)
post, err := mirclike.ParsePost(input, row.Prev, models.Post{Time: row.Prev})
if err != nil {
t.Fatal("Could not parse post:", err)
}
assert.Equal(t, row.Time, post.Time)
})
}
}
func parseDate(t *testing.T, date string) time.Time {
result, err := time.Parse("2006-01-02 15:04:05", date)
if err != nil {
if t != nil {
t.Fatal("Could not parse date", date, err)
} else {
panic("Could not parse date: " + err.Error())
}
}
return result
}
func formatDate(date time.Time) string {
return date.UTC().Format("2006-01-02 15:04:05")
}
@@ -5,42 +5,54 @@ 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 nil, 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 nil, err
}
channel, ok := res.(channel.Channel)
channel, ok := res.(models.Channel)
if !ok {
return channel, errors.New("incorrect type")
return nil, errors.New("incorrect type")
}
return channel, nil
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)
}
})
}
+5 -3
View File
@@ -26,9 +26,11 @@ func Init() error {
}
sconf := conf.Space
err = ConnectSpace(sconf.Host, sconf.AccessKey, sconf.SecretKey, sconf.Bucket, sconf.MaxSize, sconf.Root)
if err != nil {
return err
if sconf.Enabled {
err = ConnectSpace(sconf.Host, sconf.AccessKey, sconf.SecretKey, sconf.Bucket, sconf.MaxSize, sconf.Root)
if err != nil {
return err
}
}
hasInitialized = true
+12
View File
@@ -43,6 +43,10 @@ func ConnectSpace(host, accessKey, secretKey, bucket string, maxSize int64, root
// UploadFile uploads the file to the space. This does not do any checks on it, so the endpoints should
// ensure that's all okay.
func UploadFile(ctx context.Context, folder string, name string, mimeType string, reader io.Reader, size int64) (string, error) {
if spaceClient == nil {
return "", errors.New("This functionality is not enabled")
}
path := folder + "/" + name
if size > spaceMaxSize {
@@ -69,6 +73,10 @@ func UploadFile(ctx context.Context, folder string, name string, mimeType string
// RemoveFile removes a file from the space
func RemoveFile(folder string, name string) error {
if spaceClient == nil {
return errors.New("This functionality is not enabled")
}
path := folder + "/" + name
return spaceClient.RemoveObject(spaceBucket, spaceRoot+"/"+path)
@@ -76,6 +84,10 @@ func RemoveFile(folder string, name string) error {
// DownloadFile opens a file for download, using the same path format as the UploadFile function. Remember to Close it!
func DownloadFile(ctx context.Context, path string) (io.ReadCloser, error) {
if spaceClient == nil {
return nil, errors.New("This functionality is not enabled")
}
return spaceClient.GetObjectWithContext(ctx, spaceBucket, spaceRoot+"/"+path, minio.GetObjectOptions{})
}
+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()
}
-105
View File
@@ -1,105 +0,0 @@
package change
import (
"log"
"sync"
"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",
}
var submitMutex sync.Mutex
// Submit submits a change to the history.
func Submit(model, op, author, objectID string, data interface{}) (Change, error) {
submitMutex.Lock()
defer submitMutex.Unlock()
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,
}
Timeline.push(change)
err = collection.Insert(&change)
if err != nil {
return Change{}, err
}
return change, err
}
// FindID gets a change by ID
func FindID(id int) (Change, error) {
change := Change{}
err := collection.FindId(id).One(&change)
if err != nil {
return Change{}, err
}
return change, nil
}
// List gets all changes in ascending order
func List() ([]Change, error) {
changes := make([]Change, 0, 64)
err := collection.Find(nil).Sort("_id").All(&changes)
if err != nil {
return nil, err
}
return changes, nil
}
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)
}
})
}
-118
View File
@@ -1,118 +0,0 @@
package change
import (
"context"
"errors"
"sync"
"github.com/globalsign/mgo"
)
// ErrInvalidIndex is returned when a timeline subscriber tries to get a value
// with a negative id
var ErrInvalidIndex = errors.New("change.timeline: Invalid ID")
// ErrWaitRequired is returned when a timeline subscriber requests an entry that
// has not yet been pushed
var ErrWaitRequired = errors.New("change.timeline: Waiting required")
// ErrExpired is returned when a timeline subscriber requests a change that happened
// too long ago for it to be in the database.
var ErrExpired = errors.New("change.timeline: Change has expired")
type timeline struct {
mutex sync.Mutex
firstIndex int64
currentID int64
cache []Change
notifyCh chan struct{}
}
func (timeline *timeline) Get(ctx context.Context, id int64) (Change, error) {
change, err := timeline.get(id, false)
if err == ErrWaitRequired {
if err := timeline.wait(ctx); err != nil {
return Change{}, err
}
return timeline.get(id, true)
} else if err == mgo.ErrNotFound {
return Change{}, ErrExpired
}
return change, nil
}
func (timeline *timeline) push(change Change) {
timeline.mutex.Lock()
timeline.cache = append(timeline.cache, change)
if len(timeline.cache) > 128 {
copy(timeline.cache, timeline.cache[32:])
timeline.cache = timeline.cache[:len(timeline.cache)-32]
}
// Only update the currentID if this is more recent than the existing ones
if int64(change.ID) > timeline.currentID {
timeline.currentID = int64(change.ID)
}
if timeline.notifyCh != nil {
close(timeline.notifyCh)
timeline.notifyCh = nil
}
timeline.mutex.Unlock()
}
func (timeline *timeline) get(id int64, hasWaited bool) (Change, error) {
if id < 0 {
return Change{}, ErrInvalidIndex
}
timeline.mutex.Lock()
if !hasWaited && id > timeline.currentID {
timeline.mutex.Unlock()
return Change{}, ErrWaitRequired
}
for _, change := range timeline.cache {
if change.ID == int(id) {
timeline.mutex.Unlock()
return change, nil
}
}
timeline.mutex.Unlock()
return FindID(int(id))
}
func (timeline *timeline) wait(ctx context.Context) error {
timeline.mutex.Lock()
if timeline.notifyCh == nil {
timeline.notifyCh = make(chan struct{})
}
ch := timeline.notifyCh
timeline.mutex.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
return nil
}
}
// Timeline has operations for subscribing to changes in real time
var Timeline = &timeline{}
// InitializeTimeline fills in the timeline with existing entries
func InitializeTimeline() {
changes, err := List()
if err != nil {
return
}
for _, change := range changes {
Timeline.push(change)
}
}
-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,
}
-388
View File
@@ -1,388 +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)
}
// 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
}
// Remove removes the log and all associated posts from the database
func (log *Log) Remove() error {
err := logsCollection.Remove(bson.M{"_id": log.ID})
if err != nil {
return err
}
_, err = postCollection.RemoveAll(bson.M{"$or": []bson.M{
bson.M{"logId": log.ID},
bson.M{"logId": log.ShortID},
}})
if err != nil {
return err
}
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
}
-111
View File
@@ -1,111 +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.Equal(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)
}
// FindChapterID finds a chapter by its own ID
func FindChapterID(id string) (Chapter, error) {
chapter := Chapter{}
err := chapterCollection.FindId(id).One(&chapter)
return chapter, err
}
// ListChapterStoryID lists all chapters for the story ID
func ListChapterStoryID(storyID string) ([]Chapter, error) {
chapters := make([]Chapter, 0, 8)
err := chapterCollection.Find(bson.M{"storyId": storyID}).Sort("createdDate").All(&chapters)
if err != nil {
return nil, err
}
return chapters, nil
}
// 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")
})
}
-258
View File
@@ -1,258 +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.Equal(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)
}
// Chapters calls ListChapterStoryID with the story's ID:
func (story *Story) Chapters() ([]Chapter, error) {
return ListChapterStoryID(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).All(&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")
})
}
-36
View File
@@ -1,36 +0,0 @@
package story
import (
"sort"
"strings"
"github.com/globalsign/mgo/bson"
)
// A Tag associates a story with other content, like other stories, logs and more.
type Tag struct {
Kind string `bson:"kind"`
Name string `bson:"name"`
}
// Equal returns true if the tags match one another.
func (tag *Tag) Equal(other Tag) bool {
return tag.Kind == other.Kind && tag.Name == other.Name
}
// ListTags lists all tags
func ListTags() ([]Tag, error) {
tags := make([]Tag, 0, 64)
err := storyCollection.Find(bson.M{"listed": true, "tags": bson.M{"$ne": nil}}).Distinct("tags", &tags)
sort.Slice(tags, func(i, j int) bool {
kindCmp := strings.Compare(tags[i].Kind, tags[j].Kind)
if kindCmp != 0 {
return kindCmp < 0
}
return strings.Compare(tags[i].Name, tags[j].Name) < 0
})
return tags, err
}
+61
View File
@@ -0,0 +1,61 @@
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"
// ChangeModelComment is a value of ChangeModel
ChangeModelComment ChangeModel = "Comment"
)
// 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, ChangeModelComment:
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()))
}
+61
View File
@@ -0,0 +1,61 @@
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"`
Comments []Comment `bson:"comments"`
}
// 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)+len(change.Comments))
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)
}
for _, comment := range change.Comments {
data = append(data, comment)
}
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
}
+81
View File
@@ -0,0 +1,81 @@
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"},
models.Comment{ID: "SCC1234"},
}
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"},
{Model: "Comment", ID: "SCC1234"},
}
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}
}
+63
View File
@@ -0,0 +1,63 @@
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"},
},
{
"Comment_SCC1234",
models.Comment{ID: "SCC1234"},
models.ChangeKey{Model: "Comment", ID: "SCC1234"},
},
}
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)
}
+101
View File
@@ -0,0 +1,101 @@
package changes
import (
"log"
"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...)
case models.Comment:
change.Comments = append(change.Comments, object)
case []models.Comment:
change.Comments = append(change.Comments, object...)
default:
log.Printf("Warning: unrecognized object in change: %#+v", object)
}
}
pushToSubscribers(change)
err = collection.Insert(&change)
if err != nil {
return models.Change{}, err
}
return change, nil
}
+90
View File
@@ -0,0 +1,90 @@
package changes
import (
"context"
"sync"
"git.aiterp.net/rpdata/api/models"
)
var subMutex sync.Mutex
var subList []*subscription
type subscription struct {
Keys map[string]bool
Channel chan<- *models.Change
WildCard bool
}
// Subscribe subscribes to all changes.
func Subscribe(ctx context.Context, keys []models.ChangeKey, wildcard bool) <-chan *models.Change {
channel := make(chan *models.Change, 64)
sub := &subscription{
Keys: make(map[string]bool, len(keys)),
Channel: channel,
WildCard: wildcard,
}
for _, key := range keys {
sub.Keys[mapKey(key)] = true
}
subMutex.Lock()
subList = append(subList, sub)
subMutex.Unlock()
go func() {
<-ctx.Done()
subMutex.Lock()
for i := range subList {
if subList[i] == sub {
subList = append(subList[:i], subList[i+1:]...)
break
}
}
subMutex.Unlock()
}()
return channel
}
func pushToSubscribers(change models.Change) {
keys := make([]string, len(change.Keys))
for i := range change.Keys {
keys[i] = mapKey(change.Keys[i])
}
subMutex.Lock()
SubLoop:
for _, sub := range subList {
changeCopy := change
if sub.WildCard && change.Listed {
select {
case sub.Channel <- &changeCopy:
default:
}
} else {
for _, key := range keys {
if sub.Keys[key] {
select {
case sub.Channel <- &changeCopy:
default:
}
continue SubLoop
}
}
}
}
subMutex.Unlock()
}
func mapKey(ck models.ChangeKey) string {
return ck.Model.String() + "." + ck.ID
}
func init() {
subList = make([]*subscription, 0, 16)
}
+16
View File
@@ -0,0 +1,16 @@
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"`
}
// IsChangeObject is an interface implementation to identify it as a valid
// ChangeObject in GQL.
func (*Channel) IsChangeObject() {
panic("this method is a dummy, and so is its caller")
}
+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
}

Some files were not shown because too many files have changed in this diff Show More