Gisle Aune
5 years ago
41 changed files with 617 additions and 1240 deletions
-
9cmd/rpdata-server/main.go
-
2database/database.go
-
24database/mongodb/db.go
-
69database/mongodb/keys.go
-
41database/mongodb/users.go
-
3graph2/complexity.go
-
2graph2/gqlgen.yml
-
2graph2/graph.go
-
39graph2/resolvers/file.go
-
5graph2/resolvers/token.go
-
2graph2/schema/types/File.gql
-
17graph2/types/token.go
-
109internal/auth/key.go
-
33internal/auth/permissions.go
-
60internal/auth/permitted.go
-
167internal/auth/token.go
-
55internal/counter/counter.go
-
4internal/generate/id.go
-
78internal/loader/channel.go
-
146internal/loader/character.go
-
80internal/loader/loader.go
-
73internal/store/db.go
-
39internal/store/init.go
-
97internal/store/space.go
-
33models/channels/add.go
-
19models/channels/db.go
-
39models/channels/edit.go
-
33models/channels/ensure.go
-
11models/channels/find.go
-
46models/channels/list.go
-
22models/key.go
-
13repositories/key.go
-
11repositories/user.go
-
331services/auth.go
-
11services/changes.go
-
8services/channels.go
-
16services/characters.go
-
10services/files.go
-
27services/logs.go
-
15services/services.go
-
50services/stories.go
@ -0,0 +1,69 @@ |
|||||
|
package mongodb |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/rpdata/api/internal/generate" |
||||
|
"git.aiterp.net/rpdata/api/models" |
||||
|
"git.aiterp.net/rpdata/api/repositories" |
||||
|
"github.com/globalsign/mgo" |
||||
|
"github.com/globalsign/mgo/bson" |
||||
|
) |
||||
|
|
||||
|
type keyRepository struct { |
||||
|
keys *mgo.Collection |
||||
|
} |
||||
|
|
||||
|
func newKeyRepository(db *mgo.Database) (repositories.KeyRepository, error) { |
||||
|
collection := db.C("auth.keys") |
||||
|
|
||||
|
err := collection.EnsureIndexKey("user") |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &keyRepository{keys: collection}, nil |
||||
|
} |
||||
|
|
||||
|
func (r *keyRepository) Find(ctx context.Context, id string) (*models.Key, error) { |
||||
|
key := models.Key{} |
||||
|
err := r.keys.FindId(id).One(&key) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &key, err |
||||
|
} |
||||
|
|
||||
|
func (r *keyRepository) List(ctx context.Context, filter models.KeyFilter) ([]*models.Key, error) { |
||||
|
query := bson.M{} |
||||
|
if filter.UserID != nil { |
||||
|
query["user"] = *filter.UserID |
||||
|
} |
||||
|
|
||||
|
keys := make([]*models.Key, 0, 4) |
||||
|
err := r.keys.Find(query).All(&keys) |
||||
|
if err != nil { |
||||
|
if err == mgo.ErrNotFound { |
||||
|
return nil, nil |
||||
|
} |
||||
|
|
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return keys, nil |
||||
|
} |
||||
|
|
||||
|
func (r *keyRepository) Insert(ctx context.Context, key models.Key) (*models.Key, error) { |
||||
|
key.ID = generate.KeyID() |
||||
|
|
||||
|
err := r.keys.Insert(&key) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &key, nil |
||||
|
} |
||||
|
|
||||
|
func (r *keyRepository) Delete(ctx context.Context, key models.Key) error { |
||||
|
return r.keys.RemoveId(key.ID) |
||||
|
} |
@ -0,0 +1,41 @@ |
|||||
|
package mongodb |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/rpdata/api/models" |
||||
|
"git.aiterp.net/rpdata/api/repositories" |
||||
|
"github.com/globalsign/mgo" |
||||
|
) |
||||
|
|
||||
|
type userRepository struct { |
||||
|
users *mgo.Collection |
||||
|
} |
||||
|
|
||||
|
func newUserRepository(db *mgo.Database) (repositories.UserRepository, error) { |
||||
|
collection := db.C("core.users") |
||||
|
|
||||
|
return &userRepository{users: collection}, nil |
||||
|
} |
||||
|
|
||||
|
func (r *userRepository) Find(ctx context.Context, id string) (*models.User, error) { |
||||
|
user := new(models.User) |
||||
|
err := r.users.FindId(id).One(user) |
||||
|
if err != nil { |
||||
|
if err == mgo.ErrNotFound { |
||||
|
return nil, repositories.ErrNotFound |
||||
|
} |
||||
|
|
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return user, nil |
||||
|
} |
||||
|
|
||||
|
func (r *userRepository) Insert(ctx context.Context, user models.User) (*models.User, error) { |
||||
|
err := r.users.Insert(user) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &user, nil |
||||
|
} |
@ -1,109 +0,0 @@ |
|||||
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,33 +0,0 @@ |
|||||
package auth |
|
||||
|
|
||||
// AllPermissions gets all permissions and their purpose
|
|
||||
func AllPermissions() map[string]string { |
|
||||
return map[string]string{ |
|
||||
"member": "Can add/edit/remove own content", |
|
||||
"user.edit": "Can edit non-owned users", |
|
||||
"character.add": "Can add non-owned characters", |
|
||||
"character.edit": "Can edit non-owned characters", |
|
||||
"character.remove": "Can remove non-owned characters", |
|
||||
"channel.add": "Can add channels", |
|
||||
"channel.edit": "Can edit channels", |
|
||||
"channel.remove": "Can remove channels", |
|
||||
"comment.edit": "Can edit non-owned comments", |
|
||||
"comment.remove": "Can remove non-owned comments", |
|
||||
"log.add": "Can add logs", |
|
||||
"log.edit": "Can edit logs", |
|
||||
"log.remove": "Can remove logs", |
|
||||
"post.add": "Can add posts", |
|
||||
"post.edit": "Can edit posts", |
|
||||
"post.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.list": "Can list non-owned files", |
|
||||
"file.view": "Can view non-owned files", |
|
||||
"file.edit": "Can edit non-owned files", |
|
||||
"file.remove": "Can remove non-owned files", |
|
||||
} |
|
||||
} |
|
@ -1,60 +0,0 @@ |
|||||
package auth |
|
||||
|
|
||||
import ( |
|
||||
"context" |
|
||||
"log" |
|
||||
"reflect" |
|
||||
|
|
||||
"git.aiterp.net/rpdata/api/models" |
|
||||
) |
|
||||
|
|
||||
// CheckPermission does some magic.
|
|
||||
func CheckPermission(ctx context.Context, op string, obj interface{}) error { |
|
||||
token := TokenFromContext(ctx) |
|
||||
if token == nil { |
|
||||
return ErrUnauthenticated |
|
||||
} |
|
||||
|
|
||||
if v := reflect.ValueOf(obj); v.Kind() == reflect.Struct { |
|
||||
ptr := reflect.PtrTo(v.Type()) |
|
||||
ptrValue := reflect.New(ptr.Elem()) |
|
||||
ptrValue.Elem().Set(v) |
|
||||
|
|
||||
obj = ptrValue.Interface() |
|
||||
} |
|
||||
|
|
||||
var authorized = false |
|
||||
|
|
||||
switch v := obj.(type) { |
|
||||
case *models.Channel: |
|
||||
authorized = token.Permitted("channel." + op) |
|
||||
case *models.Character: |
|
||||
authorized = token.PermittedUser(v.Author, "member", "character."+op) |
|
||||
case *models.Chapter: |
|
||||
authorized = token.PermittedUser(v.Author, "member", "chapter."+op) |
|
||||
case *models.Comment: |
|
||||
if op == "add" && v.Author != token.UserID { |
|
||||
return ErrInvalidOperation |
|
||||
} |
|
||||
|
|
||||
authorized = token.PermittedUser(v.Author, "member", "comment."+op) |
|
||||
case *models.File: |
|
||||
authorized = token.PermittedUser(v.Author, "member", "file."+op) |
|
||||
case *models.Log: |
|
||||
authorized = token.Permitted("log." + op) |
|
||||
case *models.Post: |
|
||||
authorized = token.Permitted("post." + op) |
|
||||
case *models.Story: |
|
||||
authorized = token.PermittedUser(v.Author, "member", "story."+op) |
|
||||
case *models.User: |
|
||||
authorized = token.Permitted("user." + op) |
|
||||
default: |
|
||||
log.Panicf("Invalid model %T: %#+v", v, v) |
|
||||
} |
|
||||
|
|
||||
if !authorized { |
|
||||
return ErrUnauthorized |
|
||||
} |
|
||||
|
|
||||
return nil |
|
||||
} |
|
@ -1,167 +0,0 @@ |
|||||
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") |
|
||||
|
|
||||
// ErrUnauthenticated is returned when the user is not authenticated
|
|
||||
var ErrUnauthenticated = errors.New("You are not authenticated") |
|
||||
|
|
||||
// ErrUnauthorized is returned when the user doesn't have access to this resource
|
|
||||
var ErrUnauthorized = errors.New("You are not authorized to perform this action") |
|
||||
|
|
||||
// ErrInvalidOperation is returned for operations that should never be allowed
|
|
||||
var ErrInvalidOperation = errors.New("No permission exists for this operation") |
|
||||
|
|
||||
// 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, len(user.Permissions)) |
|
||||
if len(permissions) > 0 { |
|
||||
for _, permission := range permissions { |
|
||||
found := false |
|
||||
|
|
||||
for _, userPermission := range user.Permissions { |
|
||||
if permission == userPermission { |
|
||||
found = true |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if found { |
|
||||
acceptedPermissions = append(acceptedPermissions, permission) |
|
||||
} |
|
||||
} |
|
||||
} else { |
|
||||
acceptedPermissions = append(acceptedPermissions, user.Permissions...) |
|
||||
} |
|
||||
|
|
||||
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,55 +0,0 @@ |
|||||
package counter |
|
||||
|
|
||||
import ( |
|
||||
"git.aiterp.net/rpdata/api/internal/store" |
|
||||
"github.com/globalsign/mgo" |
|
||||
"github.com/globalsign/mgo/bson" |
|
||||
) |
|
||||
|
|
||||
var collection *mgo.Collection |
|
||||
|
|
||||
type counter struct { |
|
||||
ID string `bson:"_id"` |
|
||||
Value int `bson:"value"` |
|
||||
} |
|
||||
|
|
||||
// Next gets the next value of a counter, or an error if it hasn't.
|
|
||||
func Next(category, name string) (int, error) { |
|
||||
id := category + "." + name |
|
||||
doc := counter{} |
|
||||
|
|
||||
_, err := collection.Find(bson.M{"_id": id}).Apply(mgo.Change{ |
|
||||
Update: bson.M{"$inc": bson.M{"value": 1}}, |
|
||||
Upsert: true, |
|
||||
ReturnNew: true, |
|
||||
}, &doc) |
|
||||
if err != nil { |
|
||||
return -1, err |
|
||||
} |
|
||||
|
|
||||
return doc.Value, nil |
|
||||
} |
|
||||
|
|
||||
// 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") |
|
||||
}) |
|
||||
} |
|
@ -1,78 +0,0 @@ |
|||||
package loader |
|
||||
|
|
||||
import ( |
|
||||
"context" |
|
||||
"errors" |
|
||||
"strings" |
|
||||
|
|
||||
"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) (*models.Channel, error) { |
|
||||
if !strings.HasPrefix(key, "Channel.") { |
|
||||
key = "Channel." + key |
|
||||
} |
|
||||
|
|
||||
if loader.loaders[key] == nil { |
|
||||
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 nil, err |
|
||||
} |
|
||||
|
|
||||
channel, ok := res.(models.Channel) |
|
||||
if !ok { |
|
||||
return nil, errors.New("incorrect type") |
|
||||
} |
|
||||
|
|
||||
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 := channels.ListNames(names...) |
|
||||
if err != nil { |
|
||||
for range names { |
|
||||
results = append(results, &dataloader.Result{Data: models.Channel{}, Error: err}) |
|
||||
} |
|
||||
|
|
||||
return results |
|
||||
} |
|
||||
|
|
||||
for _, name := range names { |
|
||||
found := false |
|
||||
for i := range channels { |
|
||||
if channels[i].Name == name { |
|
||||
results = append(results, &dataloader.Result{Data: channels[i]}) |
|
||||
|
|
||||
found = true |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if !found { |
|
||||
results = append(results, &dataloader.Result{Data: models.Channel{}, Error: err}) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return results |
|
||||
} |
|
@ -1,146 +0,0 @@ |
|||||
package loader |
|
||||
|
|
||||
import ( |
|
||||
"context" |
|
||||
"errors" |
|
||||
"strings" |
|
||||
|
|
||||
"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) (models.Character, error) { |
|
||||
if !strings.HasPrefix(key, "Character.") { |
|
||||
key = "Character." + key |
|
||||
} |
|
||||
|
|
||||
loader.loadPrimed(key) |
|
||||
|
|
||||
if loader.loaders[key] == nil { |
|
||||
return models.Character{}, errors.New("unsupported key") |
|
||||
} |
|
||||
|
|
||||
thunk := loader.loaders[key].Load(loader.ctx, dataloader.StringKey(value)) |
|
||||
res, err := thunk() |
|
||||
if err != nil { |
|
||||
return models.Character{}, err |
|
||||
} |
|
||||
|
|
||||
char, ok := res.(models.Character) |
|
||||
if !ok { |
|
||||
return models.Character{}, errors.New("incorrect type") |
|
||||
} |
|
||||
|
|
||||
return char, nil |
|
||||
} |
|
||||
|
|
||||
// Characters gets characters by key
|
|
||||
func (loader *Loader) Characters(key string, values ...string) ([]models.Character, error) { |
|
||||
if !strings.HasPrefix(key, "Character.") { |
|
||||
key = "Character." + key |
|
||||
} |
|
||||
|
|
||||
if loader.loaders[key] == nil { |
|
||||
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 { |
|
||||
if err != nil && err != ErrNotFound { |
|
||||
return nil, err |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
chars := make([]models.Character, len(res)) |
|
||||
|
|
||||
for i := range res { |
|
||||
char, ok := res[i].(models.Character) |
|
||||
if !ok { |
|
||||
return nil, errors.New("incorrect type") |
|
||||
} |
|
||||
|
|
||||
chars[i] = 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 := characters.List(&characters.Filter{IDs: ids}) |
|
||||
if err != nil { |
|
||||
for range ids { |
|
||||
results = append(results, &dataloader.Result{Error: err}) |
|
||||
} |
|
||||
|
|
||||
return results |
|
||||
} |
|
||||
|
|
||||
for _, id := range ids { |
|
||||
found := false |
|
||||
|
|
||||
for _, character := range characters { |
|
||||
if character.ID == id { |
|
||||
results = append(results, &dataloader.Result{Data: character}) |
|
||||
found = true |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if !found { |
|
||||
results = append(results, &dataloader.Result{Data: models.Character{}, Error: ErrNotFound}) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return results |
|
||||
} |
|
||||
|
|
||||
func characterNickBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { |
|
||||
var results []*dataloader.Result |
|
||||
nicks := keys.Keys() |
|
||||
|
|
||||
characters, err := characters.List(&characters.Filter{Nicks: nicks}) |
|
||||
if err != nil { |
|
||||
for range nicks { |
|
||||
results = append(results, &dataloader.Result{Error: err}) |
|
||||
} |
|
||||
|
|
||||
return results |
|
||||
} |
|
||||
|
|
||||
for _, nick := range nicks { |
|
||||
found := false |
|
||||
for i := range characters { |
|
||||
if characters[i].HasNick(nick) { |
|
||||
results = append(results, &dataloader.Result{Data: characters[i]}) |
|
||||
|
|
||||
found = true |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if !found { |
|
||||
results = append(results, &dataloader.Result{Data: models.Character{}, Error: err}) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return results |
|
||||
} |
|
@ -1,80 +0,0 @@ |
|||||
package loader |
|
||||
|
|
||||
import ( |
|
||||
"context" |
|
||||
"errors" |
|
||||
"sync" |
|
||||
"time" |
|
||||
|
|
||||
"github.com/graph-gophers/dataloader" |
|
||||
) |
|
||||
|
|
||||
var contextKey struct{} |
|
||||
|
|
||||
// ErrNotFound is returned in batches when one or more things weren't found. Usually harmless.
|
|
||||
var ErrNotFound = errors.New("not found") |
|
||||
|
|
||||
// A Loader is a collection of data loaders and functions to act on them. It's supposed to be
|
|
||||
// request-scoped, and will thus keep things cached indefinitely.
|
|
||||
type Loader struct { |
|
||||
mutex sync.Mutex |
|
||||
ctx context.Context |
|
||||
loaders map[string]*dataloader.Loader |
|
||||
|
|
||||
primedKeys map[string]map[string]bool |
|
||||
} |
|
||||
|
|
||||
// New initializes the loader.
|
|
||||
func New() *Loader { |
|
||||
return &Loader{ |
|
||||
ctx: context.Background(), |
|
||||
loaders: map[string]*dataloader.Loader{ |
|
||||
"Character.id": dataloader.NewBatchedLoader(characterIDBatch, dataloader.WithWait(time.Millisecond*2)), |
|
||||
"Character.nick": dataloader.NewBatchedLoader(characterNickBatch, dataloader.WithWait(time.Millisecond*2)), |
|
||||
"Channel.name": dataloader.NewBatchedLoader(channelNameBatch, dataloader.WithWait(time.Millisecond*2)), |
|
||||
}, |
|
||||
primedKeys: make(map[string]map[string]bool), |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// FromContext gets the Loader from context.
|
|
||||
func FromContext(ctx context.Context) *Loader { |
|
||||
value := ctx.Value(&contextKey) |
|
||||
if value == nil { |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
return value.(*Loader) |
|
||||
} |
|
||||
|
|
||||
// ToContext gets a context with the loader as a value
|
|
||||
func (loader *Loader) ToContext(ctx context.Context) context.Context { |
|
||||
loader.ctx = ctx |
|
||||
return context.WithValue(ctx, &contextKey, loader) |
|
||||
} |
|
||||
|
|
||||
func (loader *Loader) prime(key string, values []string) { |
|
||||
loader.mutex.Lock() |
|
||||
if loader.primedKeys[key] == nil { |
|
||||
loader.primedKeys[key] = make(map[string]bool) |
|
||||
} |
|
||||
|
|
||||
for _, value := range values { |
|
||||
loader.primedKeys[key][value] = true |
|
||||
} |
|
||||
loader.mutex.Unlock() |
|
||||
} |
|
||||
|
|
||||
func (loader *Loader) loadPrimed(key string) { |
|
||||
loader.mutex.Lock() |
|
||||
if len(loader.primedKeys[key]) > 0 { |
|
||||
primedKeys := make([]string, 0, len(loader.primedKeys[key])) |
|
||||
for key := range loader.primedKeys[key] { |
|
||||
primedKeys = append(primedKeys, key) |
|
||||
} |
|
||||
|
|
||||
loader.loaders[key].LoadMany(loader.ctx, dataloader.NewKeysFromStrings(primedKeys)) |
|
||||
loader.primedKeys[key] = nil |
|
||||
} |
|
||||
loader.mutex.Unlock() |
|
||||
} |
|
@ -1,73 +0,0 @@ |
|||||
package store |
|
||||
|
|
||||
import ( |
|
||||
"fmt" |
|
||||
"time" |
|
||||
|
|
||||
"github.com/globalsign/mgo" |
|
||||
) |
|
||||
|
|
||||
var db *mgo.Database |
|
||||
var dbInits []func(db *mgo.Database) |
|
||||
|
|
||||
// ConnectDB connects to a mongodb database.
|
|
||||
func ConnectDB(host string, port int, database, username, password, mechanism string) error { |
|
||||
session, err := mgo.DialWithInfo(&mgo.DialInfo{ |
|
||||
Addrs: []string{fmt.Sprintf("%s:%d", host, port)}, |
|
||||
Timeout: 30 * time.Second, |
|
||||
Database: database, |
|
||||
Username: username, |
|
||||
Password: password, |
|
||||
Mechanism: mechanism, |
|
||||
Source: database, |
|
||||
}) |
|
||||
if err != nil { |
|
||||
return err |
|
||||
} |
|
||||
|
|
||||
db = session.DB(database) |
|
||||
|
|
||||
return setupDB() |
|
||||
} |
|
||||
|
|
||||
// HandleInit handles the initialization of the database
|
|
||||
func HandleInit(function func(db *mgo.Database)) { |
|
||||
dbInits = append(dbInits, function) |
|
||||
} |
|
||||
|
|
||||
func setupDB() error { |
|
||||
db.C("common.characters").EnsureIndexKey("name") |
|
||||
db.C("common.characters").EnsureIndexKey("shortName") |
|
||||
db.C("common.characters").EnsureIndexKey("author") |
|
||||
err := db.C("common.characters").EnsureIndex(mgo.Index{ |
|
||||
Key: []string{"nicks"}, |
|
||||
Unique: true, |
|
||||
DropDups: true, |
|
||||
}) |
|
||||
if err != nil { |
|
||||
return err |
|
||||
} |
|
||||
|
|
||||
db.C("logbot3.logs").EnsureIndexKey("date") |
|
||||
db.C("logbot3.logs").EnsureIndexKey("channel") |
|
||||
db.C("logbot3.logs").EnsureIndexKey("channel", "open") |
|
||||
db.C("logbot3.logs").EnsureIndexKey("open") |
|
||||
db.C("logbot3.logs").EnsureIndexKey("oldId") |
|
||||
db.C("logbot3.logs").EnsureIndexKey("characterIds") |
|
||||
db.C("logbot3.logs").EnsureIndexKey("event") |
|
||||
db.C("logbot3.logs").EnsureIndexKey("$text:channel", "$text:title", "$text:event", "$text:description", "$text:posts.nick", "$text:posts.text") |
|
||||
|
|
||||
err = db.C("server.changes").EnsureIndex(mgo.Index{ |
|
||||
Key: []string{"date"}, |
|
||||
ExpireAfter: time.Hour * (24 * 14), |
|
||||
}) |
|
||||
if err != nil { |
|
||||
return err |
|
||||
} |
|
||||
|
|
||||
for _, dbInit := range dbInits { |
|
||||
dbInit(db) |
|
||||
} |
|
||||
|
|
||||
return nil |
|
||||
} |
|
@ -1,39 +0,0 @@ |
|||||
package store |
|
||||
|
|
||||
import ( |
|
||||
"sync" |
|
||||
|
|
||||
"git.aiterp.net/rpdata/api/internal/config" |
|
||||
) |
|
||||
|
|
||||
var initMuted sync.Mutex |
|
||||
var hasInitialized bool |
|
||||
|
|
||||
// Init initalizes the store
|
|
||||
func Init() error { |
|
||||
initMuted.Lock() |
|
||||
defer initMuted.Unlock() |
|
||||
if hasInitialized { |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
conf := config.Global() |
|
||||
|
|
||||
dbconf := conf.Database |
|
||||
err := ConnectDB(dbconf.Host, dbconf.Port, dbconf.Db, dbconf.Username, dbconf.Password, dbconf.Mechanism) |
|
||||
if err != nil { |
|
||||
return err |
|
||||
} |
|
||||
|
|
||||
sconf := conf.Space |
|
||||
if sconf.Enabled { |
|
||||
err = ConnectSpace(sconf.Host, sconf.AccessKey, sconf.SecretKey, sconf.Bucket, sconf.MaxSize, sconf.Root) |
|
||||
if err != nil { |
|
||||
return err |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
hasInitialized = true |
|
||||
|
|
||||
return nil |
|
||||
} |
|
@ -1,97 +0,0 @@ |
|||||
package store |
|
||||
|
|
||||
import ( |
|
||||
"context" |
|
||||
"errors" |
|
||||
"fmt" |
|
||||
"io" |
|
||||
|
|
||||
minio "github.com/minio/minio-go" |
|
||||
) |
|
||||
|
|
||||
var spaceBucket string |
|
||||
var spaceURLRoot string |
|
||||
var spaceRoot string |
|
||||
var spaceClient *minio.Client |
|
||||
var spaceMaxSize int64 |
|
||||
|
|
||||
// ConnectSpace connects to a S3 space.
|
|
||||
func ConnectSpace(host, accessKey, secretKey, bucket string, maxSize int64, rootDirectory string) error { |
|
||||
client, err := minio.New(host, accessKey, secretKey, true) |
|
||||
if err != nil { |
|
||||
return err |
|
||||
} |
|
||||
|
|
||||
exists, err := client.BucketExists(bucket) |
|
||||
if err != nil { |
|
||||
return err |
|
||||
} |
|
||||
|
|
||||
if !exists { |
|
||||
return errors.New("Bucket not found") |
|
||||
} |
|
||||
|
|
||||
spaceClient = client |
|
||||
spaceBucket = bucket |
|
||||
spaceURLRoot = fmt.Sprintf("https://%s.%s/%s/", bucket, host, rootDirectory) |
|
||||
spaceMaxSize = maxSize |
|
||||
spaceRoot = rootDirectory |
|
||||
|
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// UploadFile uploads the file to the space. This does not do any checks on it, so the endpoints should
|
|
||||
// ensure that's all okay.
|
|
||||
func UploadFile(ctx context.Context, folder string, name string, mimeType string, reader io.Reader, size int64) (string, error) { |
|
||||
if spaceClient == nil { |
|
||||
return "", errors.New("This functionality is not enabled") |
|
||||
} |
|
||||
|
|
||||
path := folder + "/" + name |
|
||||
|
|
||||
if size > spaceMaxSize { |
|
||||
return "", errors.New("File is too big") |
|
||||
} |
|
||||
|
|
||||
_, err := spaceClient.PutObjectWithContext(ctx, spaceBucket, spaceRoot+"/"+path, reader, size, minio.PutObjectOptions{ |
|
||||
ContentType: mimeType, |
|
||||
UserMetadata: map[string]string{ |
|
||||
"x-amz-acl": "public-read", |
|
||||
}, |
|
||||
}) |
|
||||
if err != nil { |
|
||||
return "", err |
|
||||
} |
|
||||
|
|
||||
_, err = spaceClient.StatObject(spaceBucket, spaceRoot+"/"+path, minio.StatObjectOptions{}) |
|
||||
if err != nil { |
|
||||
return "", err |
|
||||
} |
|
||||
|
|
||||
return path, nil |
|
||||
} |
|
||||
|
|
||||
// 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) |
|
||||
} |
|
||||
|
|
||||
// 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{}) |
|
||||
} |
|
||||
|
|
||||
// URLFromPath gets the URL from the path returned by UploadFile
|
|
||||
func URLFromPath(path string) string { |
|
||||
return spaceURLRoot + path |
|
||||
} |
|
@ -1,33 +0,0 @@ |
|||||
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 |
|
||||
} |
|
@ -1,19 +0,0 @@ |
|||||
package channels |
|
||||
|
|
||||
import ( |
|
||||
"git.aiterp.net/rpdata/api/internal/store" |
|
||||
"github.com/globalsign/mgo" |
|
||||
) |
|
||||
|
|
||||
var collection *mgo.Collection |
|
||||
|
|
||||
func init() { |
|
||||
store.HandleInit(func(db *mgo.Database) { |
|
||||
collection = db.C("common.channels") |
|
||||
|
|
||||
collection.EnsureIndexKey("logged") |
|
||||
collection.EnsureIndexKey("hub") |
|
||||
collection.EnsureIndexKey("event") |
|
||||
collection.EnsureIndexKey("location") |
|
||||
}) |
|
||||
} |
|
@ -1,39 +0,0 @@ |
|||||
package channels |
|
||||
|
|
||||
import ( |
|
||||
"git.aiterp.net/rpdata/api/models" |
|
||||
"github.com/globalsign/mgo/bson" |
|
||||
) |
|
||||
|
|
||||
// Edit edits a channels
|
|
||||
func Edit(channel models.Channel, logged, hub *bool, eventName, locationName *string) (models.Channel, error) { |
|
||||
mutation := bson.M{} |
|
||||
|
|
||||
if logged != nil && *logged != channel.Logged { |
|
||||
mutation["logged"] = *logged |
|
||||
channel.Logged = *logged |
|
||||
} |
|
||||
if hub != nil && *hub != channel.Hub { |
|
||||
mutation["hub"] = *hub |
|
||||
channel.Hub = *hub |
|
||||
} |
|
||||
if eventName != nil && *eventName != channel.EventName { |
|
||||
mutation["eventName"] = *eventName |
|
||||
channel.EventName = *eventName |
|
||||
} |
|
||||
if locationName != nil && *locationName != channel.LocationName { |
|
||||
mutation["locationName"] = *locationName |
|
||||
channel.LocationName = *locationName |
|
||||
} |
|
||||
|
|
||||
if len(mutation) == 0 { |
|
||||
return channel, nil |
|
||||
} |
|
||||
|
|
||||
err := collection.UpdateId(channel.Name, bson.M{"$set": mutation}) |
|
||||
if err != nil { |
|
||||
return models.Channel{}, err |
|
||||
} |
|
||||
|
|
||||
return channel, nil |
|
||||
} |
|
@ -1,33 +0,0 @@ |
|||||
package channels |
|
||||
|
|
||||
import ( |
|
||||
"strings" |
|
||||
|
|
||||
"github.com/globalsign/mgo" |
|
||||
|
|
||||
"git.aiterp.net/rpdata/api/models" |
|
||||
) |
|
||||
|
|
||||
// Ensure adds a channel if it doesn't exist. If logged is set and the found channel isn't logged,
|
|
||||
// that is changed.
|
|
||||
func Ensure(name string, logged bool) (models.Channel, error) { |
|
||||
if len(name) < 3 && !strings.HasPrefix(name, "#") { |
|
||||
return models.Channel{}, ErrInvalidName |
|
||||
} |
|
||||
|
|
||||
channel, err := FindName(name) |
|
||||
if err == mgo.ErrNotFound { |
|
||||
return Add(name, logged, false, "", "") |
|
||||
} else if err != nil { |
|
||||
return models.Channel{}, err |
|
||||
} |
|
||||
|
|
||||
if logged && !channel.Logged { |
|
||||
channel, err = Edit(channel, &logged, nil, nil, nil) |
|
||||
if err != nil { |
|
||||
return models.Channel{}, err |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return channel, nil |
|
||||
} |
|
@ -1,11 +0,0 @@ |
|||||
package channels |
|
||||
|
|
||||
import "git.aiterp.net/rpdata/api/models" |
|
||||
|
|
||||
// FindName finds a channel by its id (its name).
|
|
||||
func FindName(name string) (models.Channel, error) { |
|
||||
channel := models.Channel{} |
|
||||
err := collection.FindId(name).One(&channel) |
|
||||
|
|
||||
return channel, err |
|
||||
} |
|
@ -1,46 +0,0 @@ |
|||||
package channels |
|
||||
|
|
||||
import ( |
|
||||
"git.aiterp.net/rpdata/api/models" |
|
||||
"github.com/globalsign/mgo/bson" |
|
||||
) |
|
||||
|
|
||||
// Filter for searching
|
|
||||
type Filter struct { |
|
||||
Logged *bool `json:"logged"` |
|
||||
EventName string `json:"eventName"` |
|
||||
LocationName string `json:"locationName"` |
|
||||
} |
|
||||
|
|
||||
// List finds channels, if logged is true it will be limited to logged
|
|
||||
// channels
|
|
||||
func List(filter *Filter) ([]models.Channel, error) { |
|
||||
query := bson.M{} |
|
||||
|
|
||||
if filter != nil { |
|
||||
if filter.Logged != nil { |
|
||||
query["logged"] = *filter.Logged |
|
||||
} |
|
||||
if filter.EventName != "" { |
|
||||
query["eventName"] = filter.EventName |
|
||||
} |
|
||||
if filter.LocationName != "" { |
|
||||
query["locationName"] = filter.LocationName |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
channels := make([]models.Channel, 0, 128) |
|
||||
err := collection.Find(query).Sort("_id").All(&channels) |
|
||||
|
|
||||
return channels, err |
|
||||
} |
|
||||
|
|
||||
// ListNames finds channels by the names provided
|
|
||||
func ListNames(names ...string) ([]models.Channel, error) { |
|
||||
query := bson.M{"_id": bson.M{"$in": names}} |
|
||||
|
|
||||
channels := make([]models.Channel, 0, 32) |
|
||||
err := collection.Find(query).All(&channels) |
|
||||
|
|
||||
return channels, err |
|
||||
} |
|
@ -0,0 +1,22 @@ |
|||||
|
package models |
||||
|
|
||||
|
// 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 == "*" |
||||
|
} |
||||
|
|
||||
|
type KeyFilter struct { |
||||
|
UserID *string |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
package repositories |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/rpdata/api/models" |
||||
|
) |
||||
|
|
||||
|
type KeyRepository interface { |
||||
|
Find(ctx context.Context, id string) (*models.Key, error) |
||||
|
List(ctx context.Context, filter models.KeyFilter) ([]*models.Key, error) |
||||
|
Insert(ctx context.Context, key models.Key) (*models.Key, error) |
||||
|
Delete(ctx context.Context, key models.Key) error |
||||
|
} |
@ -0,0 +1,11 @@ |
|||||
|
package repositories |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/rpdata/api/models" |
||||
|
) |
||||
|
|
||||
|
type UserRepository interface { |
||||
|
Find(ctx context.Context, id string) (*models.User, error) |
||||
|
Insert(ctx context.Context, user models.User) (*models.User, error) |
||||
|
} |
@ -0,0 +1,331 @@ |
|||||
|
package services |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"crypto/rand" |
||||
|
"encoding/base64" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"git.aiterp.net/rpdata/api/models" |
||||
|
"git.aiterp.net/rpdata/api/repositories" |
||||
|
"github.com/dgrijalva/jwt-go" |
||||
|
"log" |
||||
|
"net/http" |
||||
|
"reflect" |
||||
|
"strings" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
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") |
||||
|
|
||||
|
// 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") |
||||
|
|
||||
|
// ErrUnauthenticated is returned when the user is not authenticated
|
||||
|
var ErrUnauthenticated = errors.New("you are not authenticated") |
||||
|
|
||||
|
// ErrUnauthorized is returned when the user doesn't have access to this resource
|
||||
|
var ErrUnauthorized = errors.New("you are not authorized to perform this action") |
||||
|
|
||||
|
// ErrInvalidOperation is returned for operations that should never be allowed
|
||||
|
var ErrInvalidOperation = errors.New("no permission exists for this operation") |
||||
|
|
||||
|
// AuthService is a service for handling the 'orizations and 'entications.
|
||||
|
type AuthService struct { |
||||
|
keys repositories.KeyRepository |
||||
|
users repositories.UserRepository |
||||
|
} |
||||
|
|
||||
|
// FindKey finds a key by id.
|
||||
|
func (s *AuthService) FindKey(ctx context.Context, id string) (*models.Key, error) { |
||||
|
return s.keys.Find(ctx, id) |
||||
|
} |
||||
|
|
||||
|
// FindKey finds a key by id.
|
||||
|
func (s *AuthService) FindUser(ctx context.Context, id string) (*models.User, error) { |
||||
|
return s.users.Find(ctx, id) |
||||
|
} |
||||
|
|
||||
|
// CreateKey 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 (s *AuthService) CreateKey(ctx context.Context, name, user string) (*models.Key, error) { |
||||
|
if user == "*" { |
||||
|
return nil, errors.New("auth: wildcard keys not allowed") |
||||
|
} |
||||
|
|
||||
|
secret, err := s.generateSecret() |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
key := &models.Key{ |
||||
|
Name: name, |
||||
|
User: user, |
||||
|
Secret: secret, |
||||
|
} |
||||
|
|
||||
|
key, err = s.keys.Insert(ctx, *key) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return key, nil |
||||
|
} |
||||
|
|
||||
|
// CheckPermission does some magic.
|
||||
|
func (s *AuthService) CheckPermission(ctx context.Context, op string, obj interface{}) error { |
||||
|
token := s.TokenFromContext(ctx) |
||||
|
if token == nil { |
||||
|
return ErrUnauthenticated |
||||
|
} |
||||
|
|
||||
|
if v := reflect.ValueOf(obj); v.Kind() == reflect.Struct { |
||||
|
ptr := reflect.PtrTo(v.Type()) |
||||
|
ptrValue := reflect.New(ptr.Elem()) |
||||
|
ptrValue.Elem().Set(v) |
||||
|
|
||||
|
obj = ptrValue.Interface() |
||||
|
} |
||||
|
|
||||
|
var authorized = false |
||||
|
|
||||
|
switch v := obj.(type) { |
||||
|
case *models.Channel: |
||||
|
authorized = token.Permitted("channel." + op) |
||||
|
case *models.Character: |
||||
|
authorized = token.PermittedUser(v.Author, "member", "character."+op) |
||||
|
case *models.Chapter: |
||||
|
authorized = token.PermittedUser(v.Author, "member", "chapter."+op) |
||||
|
case *models.Comment: |
||||
|
if op == "add" && v.Author != token.UserID { |
||||
|
return ErrInvalidOperation |
||||
|
} |
||||
|
|
||||
|
authorized = token.PermittedUser(v.Author, "member", "comment."+op) |
||||
|
case *models.File: |
||||
|
authorized = token.PermittedUser(v.Author, "member", "file."+op) |
||||
|
case *models.Log: |
||||
|
authorized = token.Permitted("log." + op) |
||||
|
case *models.Post: |
||||
|
authorized = token.Permitted("post." + op) |
||||
|
case *models.Story: |
||||
|
authorized = token.PermittedUser(v.Author, "member", "story."+op) |
||||
|
case *models.User: |
||||
|
authorized = token.Permitted("user." + op) |
||||
|
default: |
||||
|
log.Panicf("Invalid model %T: %#+v", v, v) |
||||
|
} |
||||
|
|
||||
|
if !authorized { |
||||
|
return ErrUnauthorized |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// TokenFromContext gets the token from context.
|
||||
|
func (s *AuthService) 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 (s *AuthService) RequestWithToken(r *http.Request) *http.Request { |
||||
|
header := r.Header.Get("Authorization") |
||||
|
if header == "" { |
||||
|
return r |
||||
|
} |
||||
|
|
||||
|
if !strings.HasPrefix(header, "Bearer ") { |
||||
|
return r |
||||
|
} |
||||
|
|
||||
|
token, err := s.CheckToken(r.Context(), 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 (s *AuthService) CheckToken(ctx context.Context, tokenString string) (token models.Token, err error) { |
||||
|
var key *models.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 = s.FindKey(ctx, kid) |
||||
|
if err != nil || key.ID == "" { |
||||
|
return nil, ErrKeyNotFound |
||||
|
} |
||||
|
|
||||
|
return []byte(key.Secret), nil |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return models.Token{}, err |
||||
|
} |
||||
|
|
||||
|
userid, permissions, err := s.parseClaims(jwtToken.Claims) |
||||
|
if err != nil { |
||||
|
return models.Token{}, err |
||||
|
} |
||||
|
|
||||
|
if !key.ValidForUser(userid) { |
||||
|
return models.Token{}, ErrWrongUser |
||||
|
} |
||||
|
|
||||
|
user, err := s.ensureUser(ctx, userid) |
||||
|
if err != nil { |
||||
|
return models.Token{}, ErrDeletedUser |
||||
|
} |
||||
|
|
||||
|
acceptedPermissions := make([]string, 0, len(user.Permissions)) |
||||
|
if len(permissions) > 0 { |
||||
|
for _, permission := range permissions { |
||||
|
found := false |
||||
|
|
||||
|
for _, userPermission := range user.Permissions { |
||||
|
if permission == userPermission { |
||||
|
found = true |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if found { |
||||
|
acceptedPermissions = append(acceptedPermissions, permission) |
||||
|
} |
||||
|
} |
||||
|
} else { |
||||
|
acceptedPermissions = append(acceptedPermissions, user.Permissions...) |
||||
|
} |
||||
|
|
||||
|
return models.Token{UserID: user.ID, Permissions: acceptedPermissions}, nil |
||||
|
} |
||||
|
|
||||
|
// AllPermissions gets all permissions and their purpose
|
||||
|
func (s *AuthService) AllPermissions() map[string]string { |
||||
|
return map[string]string{ |
||||
|
"member": "Can add/edit/remove own content", |
||||
|
"user.edit": "Can edit non-owned users", |
||||
|
"character.add": "Can add non-owned characters", |
||||
|
"character.edit": "Can edit non-owned characters", |
||||
|
"character.remove": "Can remove non-owned characters", |
||||
|
"channel.add": "Can add channels", |
||||
|
"channel.edit": "Can edit channels", |
||||
|
"channel.remove": "Can remove channels", |
||||
|
"comment.edit": "Can edit non-owned comments", |
||||
|
"comment.remove": "Can remove non-owned comments", |
||||
|
"log.add": "Can add logs", |
||||
|
"log.edit": "Can edit logs", |
||||
|
"log.remove": "Can remove logs", |
||||
|
"post.add": "Can add posts", |
||||
|
"post.edit": "Can edit posts", |
||||
|
"post.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.list": "Can list non-owned files", |
||||
|
"file.view": "Can view non-owned files", |
||||
|
"file.edit": "Can edit non-owned files", |
||||
|
"file.remove": "Can remove non-owned files", |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (s *AuthService) 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 |
||||
|
} |
||||
|
|
||||
|
func (s *AuthService) generateSecret() (string, error) { |
||||
|
var data [64]byte |
||||
|
|
||||
|
_, err := rand.Read(data[:]) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
|
||||
|
return base64.RawURLEncoding.EncodeToString(data[:]), nil |
||||
|
} |
||||
|
|
||||
|
func (s *AuthService) ensureUser(ctx context.Context, id string) (*models.User, error) { |
||||
|
user, err := s.users.Find(ctx, id) |
||||
|
if err == repositories.ErrNotFound { |
||||
|
user = &models.User{ |
||||
|
ID: id, |
||||
|
Nick: "", |
||||
|
Permissions: []string{ |
||||
|
"member", |
||||
|
"log.edit", |
||||
|
"post.edit", |
||||
|
"post.move", |
||||
|
"post.remove", |
||||
|
"file.upload", |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
user, err = s.users.Insert(ctx, *user) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
} else if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return user, err |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue