Browse Source

File uploads, mostly. Also added extra behavior to addCharacter.

thegreatrefactor
Gisle Aune 5 years ago
parent
commit
c8ced52e1a
  1. 22
      cmd/rpdata-server/main.go
  2. 1
      database/database.go
  3. 31
      database/mongodb/characters.go
  4. 9
      graph2/resolvers/file.go
  5. 1
      graph2/resolvers/resolvers.go
  6. 8
      graph2/schema/root.gql
  7. 2
      graph2/schema/types/Change.gql
  8. 14
      graph2/schema/types/File.gql
  9. 1
      internal/config/config.go
  10. 5
      internal/generate/id.go
  11. 2
      models/change-model.go
  12. 8
      models/change.go
  13. 1
      repositories/character.go
  14. 7
      services/characters.go
  15. 50
      services/files.go
  16. 13
      services/services.go
  17. 25
      space/space.go

22
cmd/rpdata-server/main.go

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"git.aiterp.net/rpdata/api/space"
"log" "log"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
@ -19,12 +20,27 @@ import (
) )
func main() { func main() {
db, err := database.Init(config.Global().Database)
cfg := config.Global()
db, err := database.Init(cfg.Database)
if err != nil { if err != nil {
log.Fatalln("Failed to init db:", err) log.Fatalln("Failed to init db:", err)
} }
defer db.Close(context.Background()) defer db.Close(context.Background())
serviceBundle := services.NewBundle(db)
var spaceClient *space.Client
if cfg.Space.Enabled {
spaceClient, err = space.Connect(cfg.Space)
if err != nil {
log.Fatalln("Failed to init space:", err)
}
log.Println("Space loaded")
} else {
log.Println("Space is disabled, file upload will not work!")
}
serviceBundle := services.NewBundle(db, spaceClient)
instrumentation.Register() instrumentation.Register()
@ -83,6 +99,8 @@ func logListedChanges(changes *services.ChangeService) {
func queryHandler(services *services.Bundle) http.HandlerFunc { func queryHandler(services *services.Bundle) http.HandlerFunc {
handler := handler.GraphQL( handler := handler.GraphQL(
graph2.New(services), graph2.New(services),
handler.UploadMaxSize(10485760),
handler.UploadMaxMemory(1048576),
handler.RecoverFunc(func(ctx context.Context, err interface{}) error { handler.RecoverFunc(func(ctx context.Context, err interface{}) error {
log.Println(err) log.Println(err)
log.Println(string(debug.Stack())) log.Println(string(debug.Stack()))

1
database/database.go

@ -24,6 +24,7 @@ type Database interface {
Comments() repositories.CommentRepository Comments() repositories.CommentRepository
Keys() repositories.KeyRepository Keys() repositories.KeyRepository
Users() repositories.UserRepository Users() repositories.UserRepository
Files() repositories.FileRepository
Close(ctx context.Context) error Close(ctx context.Context) error
} }

31
database/mongodb/characters.go

@ -77,6 +77,37 @@ func (r *characterRepository) FindNick(ctx context.Context, nick string) (*model
return character, nil return character, nil
} }
func (r *characterRepository) FindName(ctx context.Context, name string) (*models.Character, error) {
query := bson.M{
"$or": []bson.M{
{"shortName": name},
{"name": name},
},
}
// Look for all characters matching query
characters := make([]*models.Character, 0, 8)
err := r.characters.Find(query).All(&characters)
if err != nil {
if err == mgo.ErrNotFound {
return nil, repositories.ErrNotFound
}
return nil, err
} else if len(characters) == 0 {
return nil, repositories.ErrNotFound
}
// Prioritize exact match
for _, character := range characters {
if character.Name == name {
return character, nil
}
}
return characters[0], nil
}
func (r *characterRepository) List(ctx context.Context, filter models.CharacterFilter) ([]*models.Character, error) { func (r *characterRepository) List(ctx context.Context, filter models.CharacterFilter) ([]*models.Character, error) {
query := bson.M{} query := bson.M{}
if filter.Author != nil { if filter.Author != nil {

9
graph2/resolvers/file.go

@ -2,9 +2,12 @@ package resolvers
import ( import (
"context" "context"
"git.aiterp.net/rpdata/api/graph2/graphcore"
"git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/models"
) )
// Queries
func (r *queryResolver) File(ctx context.Context, id string) (*models.File, error) { func (r *queryResolver) File(ctx context.Context, id string) (*models.File, error) {
return r.s.Files.Find(ctx, id) return r.s.Files.Find(ctx, id)
} }
@ -20,3 +23,9 @@ func (r *queryResolver) Files(ctx context.Context, filter *models.FileFilter) ([
return r.s.Files.List(ctx, *filter) return r.s.Files.List(ctx, *filter)
} }
// Mutations
func (r *mutationResolver) UploadFile(ctx context.Context, input *graphcore.FileUploadInput) (*models.File, error) {
return r.s.Files.Upload(ctx, input.File.File, input.Name, input.Public, input.File.Size)
}

1
graph2/resolvers/resolvers.go

@ -7,6 +7,7 @@ import (
type queryResolver struct{ s *services.Bundle } type queryResolver struct{ s *services.Bundle }
type mutationResolver struct{ s *services.Bundle } type mutationResolver struct{ s *services.Bundle }
type subscriptionResolver struct{ s *services.Bundle } type subscriptionResolver struct{ s *services.Bundle }
// QueryResolver has all the queries // QueryResolver has all the queries

8
graph2/schema/root.gql

@ -107,7 +107,7 @@ type Mutation {
"Edit a comment in a chapter." "Edit a comment in a chapter."
editComment(input: CommentEditInput!): Comment! editComment(input: CommentEditInput!): Comment!
"Remove a comemnt in a chapter."
"Remove a comment in a chapter."
removeComment(input: CommentRemoveInput!): Comment! removeComment(input: CommentRemoveInput!): Comment!
@ -164,6 +164,10 @@ type Mutation {
# Edit a channel # Edit a channel
editChannel(input: ChannelEditInput!): Channel! editChannel(input: ChannelEditInput!): Channel!
"Upload a file"
uploadFile(input: FileUploadInput): File!
} }
type Subscription { type Subscription {
@ -176,3 +180,5 @@ type Subscription {
# A Time represents a RFC3339 encoded date with up to millisecond precision. # A Time represents a RFC3339 encoded date with up to millisecond precision.
scalar Time scalar Time
"""A GraphQL file upload."""
scalar Upload

2
graph2/schema/types/Change.gql

@ -64,6 +64,8 @@ enum ChangeModel {
Story Story
Tag Tag
Chapter Chapter
Comment
File
} }
""" """

14
graph2/schema/types/File.gql

@ -38,9 +38,21 @@ input FilesFilter {
mimeTypes: [String!] mimeTypes: [String!]
} }
# Input for uploadFile mutation
input FileUploadInput {
"The file name"
name: String!
# Whether the file should be public
public: Boolean!
# The actual file upload
file: Upload!
}
# Input for editFile mutation # Input for editFile mutation
input EditFileInput { input EditFileInput {
# The file's unique ID
# The file's unique ID
id: String! id: String!
# Whether the file is publicly listable. Someone with knowledge of the ID # Whether the file is publicly listable. Someone with knowledge of the ID

1
internal/config/config.go

@ -30,6 +30,7 @@ type Space struct {
Bucket string `json:"bucket" yaml:"bucket"` Bucket string `json:"bucket" yaml:"bucket"`
MaxSize int64 `json:"maxSize" yaml:"maxSize"` MaxSize int64 `json:"maxSize" yaml:"maxSize"`
Root string `json:"root" yaml:"root"` Root string `json:"root" yaml:"root"`
URLRoot string `json:"urlRoot" yaml:"urlRoot"`
} }
// Database is configuration for spaces. // Database is configuration for spaces.

5
internal/generate/id.go

@ -63,6 +63,11 @@ func FileID() string {
return ID("F", 16) return ID("F", 16)
} }
// FileID generates a file ID.
func FileUploadID() string {
return ID("U", 24)
}
func KeyID() string { func KeyID() string {
return ID("K", 32) return ID("K", 32)
} }

2
models/change-model.go

@ -26,6 +26,8 @@ const (
ChangeModelChapter ChangeModel = "Chapter" ChangeModelChapter ChangeModel = "Chapter"
// ChangeModelComment is a value of ChangeModel // ChangeModelComment is a value of ChangeModel
ChangeModelComment ChangeModel = "Comment" ChangeModelComment ChangeModel = "Comment"
// ChangeModelFile is a value of ChangeModel
ChangeModelFile ChangeModel = "File"
) )
// IsValid returns true if the underlying string is one of the correct values. // IsValid returns true if the underlying string is one of the correct values.

8
models/change.go

@ -23,6 +23,7 @@ type Change struct {
Tags []*Tag `bson:"tags"` Tags []*Tag `bson:"tags"`
Chapters []*Chapter `bson:"chapters"` Chapters []*Chapter `bson:"chapters"`
Comments []*Comment `bson:"comments"` Comments []*Comment `bson:"comments"`
Files []*File `bson:"files"`
} }
// AddObject adds the model into the appropriate array. // AddObject adds the model into the appropriate array.
@ -68,6 +69,10 @@ func (change *Change) AddObject(object interface{}) bool {
change.Comments = append(change.Comments, object) change.Comments = append(change.Comments, object)
case []*Comment: case []*Comment:
change.Comments = append(change.Comments, object...) change.Comments = append(change.Comments, object...)
case *File:
change.Files = append(change.Files, object)
case []*File:
change.Files = append(change.Files, object...)
default: default:
return false return false
} }
@ -103,6 +108,9 @@ func (change *Change) Objects() []interface{} {
for _, comment := range change.Comments { for _, comment := range change.Comments {
data = append(data, comment) data = append(data, comment)
} }
for _, file := range change.Files {
data = append(data, file)
}
return data return data
} }

1
repositories/character.go

@ -10,6 +10,7 @@ import (
type CharacterRepository interface { type CharacterRepository interface {
Find(ctx context.Context, id string) (*models.Character, error) Find(ctx context.Context, id string) (*models.Character, error)
FindNick(ctx context.Context, nick string) (*models.Character, error) FindNick(ctx context.Context, nick string) (*models.Character, error)
FindName(ctx context.Context, name string) (*models.Character, error)
List(ctx context.Context, filter models.CharacterFilter) ([]*models.Character, error) List(ctx context.Context, filter models.CharacterFilter) ([]*models.Character, error)
Insert(ctx context.Context, character models.Character) (*models.Character, error) Insert(ctx context.Context, character models.Character) (*models.Character, error)
Update(ctx context.Context, character models.Character, update models.CharacterUpdate) (*models.Character, error) Update(ctx context.Context, character models.Character, update models.CharacterUpdate) (*models.Character, error)

7
services/characters.go

@ -71,7 +71,12 @@ func (s *CharacterService) Create(ctx context.Context, nick, name, shortName, au
} }
if name == "" { if name == "" {
return nil, errors.New("Name cannot be empty")
return nil, errors.New("name cannot be empty")
}
// Insert nick into existing if character already exists.
if character, err := s.characters.FindName(ctx, name); err == nil && character.Name == name {
return s.AddNick(ctx, character.ID, nick)
} }
if author == "" { if author == "" {

50
services/files.go

@ -3,10 +3,15 @@ package services
import ( import (
"context" "context"
"errors" "errors"
"git.aiterp.net/rpdata/api/internal/generate"
"git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/models"
"git.aiterp.net/rpdata/api/models/changekeys"
"git.aiterp.net/rpdata/api/repositories" "git.aiterp.net/rpdata/api/repositories"
"git.aiterp.net/rpdata/api/space"
"github.com/h2non/filetype" "github.com/h2non/filetype"
"io" "io"
"log"
"time"
) )
var ErrPrivateNoAuthor = errors.New("cannot search for private files without an author") var ErrPrivateNoAuthor = errors.New("cannot search for private files without an author")
@ -18,8 +23,10 @@ var ErrCouldNotUploadFile = errors.New("could not upload file")
// FileService is a service for files. // FileService is a service for files.
type FileService struct { type FileService struct {
files repositories.FileRepository
authService *AuthService
files repositories.FileRepository
authService *AuthService
changeService *ChangeService
space *space.Client
} }
func (s *FileService) Find(ctx context.Context, id string) (*models.File, error) { func (s *FileService) Find(ctx context.Context, id string) (*models.File, error) {
@ -56,13 +63,18 @@ func (s *FileService) List(ctx context.Context, filter models.FileFilter) ([]*mo
return s.files.List(ctx, filter) return s.files.List(ctx, filter)
} }
func (s *FileService) Upload(ctx context.Context, reader io.Reader, name string, size int64) (*models.File, error) {
func (s *FileService) Upload(ctx context.Context, reader io.Reader, name string, public bool, size int64) (*models.File, error) {
if name == "" { if name == "" {
return nil, ErrInvalidName return nil, ErrInvalidName
} else if size < 320 || size > 16777216 { } else if size < 320 || size > 16777216 {
return nil, ErrInvalidFileSize return nil, ErrInvalidFileSize
} }
token := s.authService.TokenFromContext(ctx)
if token == nil || !token.Permitted("file.upload", "member") {
return nil, ErrUnauthorized
}
head := make([]byte, 320) head := make([]byte, 320)
n, err := reader.Read(head) n, err := reader.Read(head)
if err != nil || n < 320 { if err != nil || n < 320 {
@ -74,7 +86,35 @@ func (s *FileService) Upload(ctx context.Context, reader io.Reader, name string,
return nil, ErrInvalidFileType return nil, ErrInvalidFileType
} }
panic("implement rest of me")
reader2 := &concatReader{head: head, body: reader}
path := generate.FileUploadID() + "." + fileType.Extension
err = s.space.UploadFile(ctx, path, fileType.MIME.Value, reader2, size)
if err != nil || !allowedMimeTypes[fileType.MIME.Value] {
log.Println("File upload failed:", err)
return nil, ErrCouldNotUploadFile
}
file := &models.File{
Size: size,
Name: name,
URL: s.space.URLFromPath(path),
Time: time.Now(),
Author: token.UserID,
Kind: "upload",
MimeType: fileType.MIME.Value,
Public: public,
}
file, err = s.files.Insert(ctx, *file)
if err != nil {
return nil, err
}
s.changeService.Submit(ctx, models.ChangeModelFile, "upload", file.Public, changekeys.Listed(file), file)
return file, nil
} }
func (s *FileService) Edit(ctx context.Context, id string, name *string, public *bool) (*models.File, error) { func (s *FileService) Edit(ctx context.Context, id string, name *string, public *bool) (*models.File, error) {
@ -122,7 +162,7 @@ func (r *concatReader) Read(p []byte) (n int, err error) {
r.headPos = len(r.head) r.headPos = len(r.head)
copy(p, remainder) copy(p, remainder)
return len(p), nil
return len(remainder), nil
} }
return r.body.Read(p) return r.body.Read(p)

13
services/services.go

@ -3,6 +3,7 @@ package services
import ( import (
"git.aiterp.net/rpdata/api/database" "git.aiterp.net/rpdata/api/database"
"git.aiterp.net/rpdata/api/services/loaders" "git.aiterp.net/rpdata/api/services/loaders"
"git.aiterp.net/rpdata/api/space"
) )
// A Bundle contains all services. // A Bundle contains all services.
@ -18,20 +19,22 @@ type Bundle struct {
} }
// NewBundle creates a new bundle. // NewBundle creates a new bundle.
func NewBundle(db database.Database) *Bundle {
func NewBundle(db database.Database, spaceClient *space.Client) *Bundle {
bundle := &Bundle{} bundle := &Bundle{}
bundle.Auth = &AuthService{ bundle.Auth = &AuthService{
keys: db.Keys(), keys: db.Keys(),
users: db.Users(), users: db.Users(),
} }
bundle.Files = &FileService{
files: nil,
authService: bundle.Auth,
}
bundle.Changes = &ChangeService{ bundle.Changes = &ChangeService{
changes: db.Changes(), changes: db.Changes(),
authService: bundle.Auth, authService: bundle.Auth,
} }
bundle.Files = &FileService{
files: db.Files(),
authService: bundle.Auth,
changeService: bundle.Changes,
space: spaceClient,
}
bundle.Tags = &TagService{tags: db.Tags()} bundle.Tags = &TagService{tags: db.Tags()}
bundle.Characters = &CharacterService{ bundle.Characters = &CharacterService{
characters: db.Characters(), characters: db.Characters(),

25
space/space.go

@ -25,29 +25,27 @@ func (client *Client) MaxSize() int64 {
// UploadFile uploads the file to the space. This does not do any checks on it, so the endpoints should // UploadFile uploads the file to the space. This does not do any checks on it, so the endpoints should
// ensure that's all okay. // ensure that's all okay.
func (client *Client) UploadFile(ctx context.Context, folder string, name string, mimeType string, reader io.Reader, size int64) (string, error) {
path := folder + "/" + name
func (client *Client) UploadFile(ctx context.Context, name string, mimeType string, reader io.Reader, size int64) error {
if size > client.maxSize { if size > client.maxSize {
return "", errors.New("file is too big")
return errors.New("file is too big")
} }
_, err := client.s3.PutObjectWithContext(ctx, client.bucket, client.spaceRoot+"/"+path, reader, size, minio.PutObjectOptions{
_, err := client.s3.PutObjectWithContext(ctx, client.bucket, client.spaceRoot+"/"+name, reader, size, minio.PutObjectOptions{
ContentType: mimeType, ContentType: mimeType,
UserMetadata: map[string]string{ UserMetadata: map[string]string{
"x-amz-acl": "public-read", "x-amz-acl": "public-read",
}, },
}) })
if err != nil { if err != nil {
return "", err
return err
} }
_, err = client.s3.StatObject(client.bucket, client.spaceRoot+"/"+path, minio.StatObjectOptions{})
_, err = client.s3.StatObject(client.bucket, client.spaceRoot+"/"+name, minio.StatObjectOptions{})
if err != nil { if err != nil {
return "", err
return err
} }
return path, nil
return nil
} }
// RemoveFile removes a file from the space // RemoveFile removes a file from the space
@ -83,9 +81,16 @@ func Connect(cfg config.Space) (*Client, error) {
return nil, errors.New("bucket not found") return nil, errors.New("bucket not found")
} }
urlRoot := cfg.URLRoot
if urlRoot == "" {
urlRoot = fmt.Sprintf("https://%s.%s/%s/", cfg.Bucket, cfg.Host, cfg.Root)
} else {
urlRoot += "/" + cfg.Root + "/"
}
return &Client{ return &Client{
bucket: cfg.Bucket, bucket: cfg.Bucket,
urlRoot: fmt.Sprintf("https://%s.%s/%s/", cfg.Bucket, cfg.Host, cfg.Root),
urlRoot: urlRoot,
spaceRoot: cfg.Root, spaceRoot: cfg.Root,
maxSize: cfg.MaxSize, maxSize: cfg.MaxSize,
s3: client, s3: client,

Loading…
Cancel
Save