A lot of delete and failed scream tests.
the build was successful

This commit is contained in:
2019-08-26 22:14:59 +02:00
parent 0e70e37038
commit e690e7ab97
41 changed files with 617 additions and 1240 deletions
-109
View File
@@ -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
}
-33
View File
@@ -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",
}
}
-60
View File
@@ -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
}
-167
View File
@@ -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
}
-55
View File
@@ -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")
})
}
+4
View File
@@ -62,3 +62,7 @@ func CommentID() string {
func FileID() string {
return ID("F", 16)
}
func KeyID() string {
return ID("K", 32)
}
-78
View File
@@ -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
}
-146
View File
@@ -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
}
-80
View File
@@ -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()
}
-73
View File
@@ -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
}
-39
View File
@@ -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
}
-97
View File
@@ -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
}