From c8ced52e1a6a556c87baadc4fa9a0e286526e3a6 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sat, 31 Aug 2019 22:46:55 +0200 Subject: [PATCH] File uploads, mostly. Also added extra behavior to addCharacter. --- cmd/rpdata-server/main.go | 22 +++++++++++++-- database/database.go | 1 + database/mongodb/characters.go | 31 +++++++++++++++++++++ graph2/resolvers/file.go | 9 ++++++ graph2/resolvers/resolvers.go | 1 + graph2/schema/root.gql | 8 +++++- graph2/schema/types/Change.gql | 2 ++ graph2/schema/types/File.gql | 14 +++++++++- internal/config/config.go | 1 + internal/generate/id.go | 5 ++++ models/change-model.go | 2 ++ models/change.go | 8 ++++++ repositories/character.go | 1 + services/characters.go | 7 ++++- services/files.go | 50 ++++++++++++++++++++++++++++++---- services/services.go | 13 +++++---- space/space.go | 25 ++++++++++------- 17 files changed, 175 insertions(+), 25 deletions(-) diff --git a/cmd/rpdata-server/main.go b/cmd/rpdata-server/main.go index c59ff0f..0b50e4b 100644 --- a/cmd/rpdata-server/main.go +++ b/cmd/rpdata-server/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "git.aiterp.net/rpdata/api/space" "log" "net/http" "runtime/debug" @@ -19,12 +20,27 @@ import ( ) func main() { - db, err := database.Init(config.Global().Database) + cfg := config.Global() + + db, err := database.Init(cfg.Database) if err != nil { log.Fatalln("Failed to init db:", err) } 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() @@ -83,6 +99,8 @@ func logListedChanges(changes *services.ChangeService) { func queryHandler(services *services.Bundle) http.HandlerFunc { handler := handler.GraphQL( graph2.New(services), + handler.UploadMaxSize(10485760), + handler.UploadMaxMemory(1048576), handler.RecoverFunc(func(ctx context.Context, err interface{}) error { log.Println(err) log.Println(string(debug.Stack())) diff --git a/database/database.go b/database/database.go index a719fcb..3c78f7f 100644 --- a/database/database.go +++ b/database/database.go @@ -24,6 +24,7 @@ type Database interface { Comments() repositories.CommentRepository Keys() repositories.KeyRepository Users() repositories.UserRepository + Files() repositories.FileRepository Close(ctx context.Context) error } diff --git a/database/mongodb/characters.go b/database/mongodb/characters.go index 04c6682..ac631a7 100644 --- a/database/mongodb/characters.go +++ b/database/mongodb/characters.go @@ -77,6 +77,37 @@ func (r *characterRepository) FindNick(ctx context.Context, nick string) (*model 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) { query := bson.M{} if filter.Author != nil { diff --git a/graph2/resolvers/file.go b/graph2/resolvers/file.go index 2e72649..9cbc8f3 100644 --- a/graph2/resolvers/file.go +++ b/graph2/resolvers/file.go @@ -2,9 +2,12 @@ package resolvers import ( "context" + "git.aiterp.net/rpdata/api/graph2/graphcore" "git.aiterp.net/rpdata/api/models" ) +// Queries + func (r *queryResolver) File(ctx context.Context, id string) (*models.File, error) { 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) } + +// 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) +} diff --git a/graph2/resolvers/resolvers.go b/graph2/resolvers/resolvers.go index 1013355..72c06d5 100644 --- a/graph2/resolvers/resolvers.go +++ b/graph2/resolvers/resolvers.go @@ -7,6 +7,7 @@ import ( type queryResolver struct{ s *services.Bundle } type mutationResolver struct{ s *services.Bundle } + type subscriptionResolver struct{ s *services.Bundle } // QueryResolver has all the queries diff --git a/graph2/schema/root.gql b/graph2/schema/root.gql index 97146d6..3c5e467 100644 --- a/graph2/schema/root.gql +++ b/graph2/schema/root.gql @@ -107,7 +107,7 @@ type Mutation { "Edit a comment in a chapter." editComment(input: CommentEditInput!): Comment! - "Remove a comemnt in a chapter." + "Remove a comment in a chapter." removeComment(input: CommentRemoveInput!): Comment! @@ -164,6 +164,10 @@ type Mutation { # Edit a channel editChannel(input: ChannelEditInput!): Channel! + + + "Upload a file" + uploadFile(input: FileUploadInput): File! } type Subscription { @@ -176,3 +180,5 @@ type Subscription { # A Time represents a RFC3339 encoded date with up to millisecond precision. scalar Time +"""A GraphQL file upload.""" +scalar Upload \ No newline at end of file diff --git a/graph2/schema/types/Change.gql b/graph2/schema/types/Change.gql index e702274..01012a6 100644 --- a/graph2/schema/types/Change.gql +++ b/graph2/schema/types/Change.gql @@ -64,6 +64,8 @@ enum ChangeModel { Story Tag Chapter + Comment + File } """ diff --git a/graph2/schema/types/File.gql b/graph2/schema/types/File.gql index ef38c97..0932e7d 100644 --- a/graph2/schema/types/File.gql +++ b/graph2/schema/types/File.gql @@ -38,9 +38,21 @@ input FilesFilter { 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 EditFileInput { - # The file's unique ID + # The file's unique ID id: String! # Whether the file is publicly listable. Someone with knowledge of the ID diff --git a/internal/config/config.go b/internal/config/config.go index edd9888..a3bbbf7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,7 @@ type Space struct { Bucket string `json:"bucket" yaml:"bucket"` MaxSize int64 `json:"maxSize" yaml:"maxSize"` Root string `json:"root" yaml:"root"` + URLRoot string `json:"urlRoot" yaml:"urlRoot"` } // Database is configuration for spaces. diff --git a/internal/generate/id.go b/internal/generate/id.go index 23b6787..a41297a 100644 --- a/internal/generate/id.go +++ b/internal/generate/id.go @@ -63,6 +63,11 @@ func FileID() string { return ID("F", 16) } +// FileID generates a file ID. +func FileUploadID() string { + return ID("U", 24) +} + func KeyID() string { return ID("K", 32) } diff --git a/models/change-model.go b/models/change-model.go index 74b6fde..5223611 100644 --- a/models/change-model.go +++ b/models/change-model.go @@ -26,6 +26,8 @@ const ( ChangeModelChapter ChangeModel = "Chapter" // ChangeModelComment is a value of ChangeModel 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. diff --git a/models/change.go b/models/change.go index 17eccbc..da6612e 100644 --- a/models/change.go +++ b/models/change.go @@ -23,6 +23,7 @@ type Change struct { Tags []*Tag `bson:"tags"` Chapters []*Chapter `bson:"chapters"` Comments []*Comment `bson:"comments"` + Files []*File `bson:"files"` } // 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) case []*Comment: change.Comments = append(change.Comments, object...) + case *File: + change.Files = append(change.Files, object) + case []*File: + change.Files = append(change.Files, object...) default: return false } @@ -103,6 +108,9 @@ func (change *Change) Objects() []interface{} { for _, comment := range change.Comments { data = append(data, comment) } + for _, file := range change.Files { + data = append(data, file) + } return data } diff --git a/repositories/character.go b/repositories/character.go index 6ff725a..0b7eb32 100644 --- a/repositories/character.go +++ b/repositories/character.go @@ -10,6 +10,7 @@ import ( type CharacterRepository interface { Find(ctx context.Context, id 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) Insert(ctx context.Context, character models.Character) (*models.Character, error) Update(ctx context.Context, character models.Character, update models.CharacterUpdate) (*models.Character, error) diff --git a/services/characters.go b/services/characters.go index fe7f627..b0e36ab 100644 --- a/services/characters.go +++ b/services/characters.go @@ -71,7 +71,12 @@ func (s *CharacterService) Create(ctx context.Context, nick, name, shortName, au } 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 == "" { diff --git a/services/files.go b/services/files.go index d3cf040..93f81f8 100644 --- a/services/files.go +++ b/services/files.go @@ -3,10 +3,15 @@ package services import ( "context" "errors" + "git.aiterp.net/rpdata/api/internal/generate" "git.aiterp.net/rpdata/api/models" + "git.aiterp.net/rpdata/api/models/changekeys" "git.aiterp.net/rpdata/api/repositories" + "git.aiterp.net/rpdata/api/space" "github.com/h2non/filetype" "io" + "log" + "time" ) 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. 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) { @@ -56,13 +63,18 @@ func (s *FileService) List(ctx context.Context, filter models.FileFilter) ([]*mo 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 == "" { return nil, ErrInvalidName } else if size < 320 || size > 16777216 { return nil, ErrInvalidFileSize } + token := s.authService.TokenFromContext(ctx) + if token == nil || !token.Permitted("file.upload", "member") { + return nil, ErrUnauthorized + } + head := make([]byte, 320) n, err := reader.Read(head) if err != nil || n < 320 { @@ -74,7 +86,35 @@ func (s *FileService) Upload(ctx context.Context, reader io.Reader, name string, 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) { @@ -122,7 +162,7 @@ func (r *concatReader) Read(p []byte) (n int, err error) { r.headPos = len(r.head) copy(p, remainder) - return len(p), nil + return len(remainder), nil } return r.body.Read(p) diff --git a/services/services.go b/services/services.go index c9bdb69..c100bb3 100644 --- a/services/services.go +++ b/services/services.go @@ -3,6 +3,7 @@ package services import ( "git.aiterp.net/rpdata/api/database" "git.aiterp.net/rpdata/api/services/loaders" + "git.aiterp.net/rpdata/api/space" ) // A Bundle contains all services. @@ -18,20 +19,22 @@ type Bundle struct { } // NewBundle creates a new bundle. -func NewBundle(db database.Database) *Bundle { +func NewBundle(db database.Database, spaceClient *space.Client) *Bundle { bundle := &Bundle{} bundle.Auth = &AuthService{ keys: db.Keys(), users: db.Users(), } - bundle.Files = &FileService{ - files: nil, - authService: bundle.Auth, - } bundle.Changes = &ChangeService{ changes: db.Changes(), authService: bundle.Auth, } + bundle.Files = &FileService{ + files: db.Files(), + authService: bundle.Auth, + changeService: bundle.Changes, + space: spaceClient, + } bundle.Tags = &TagService{tags: db.Tags()} bundle.Characters = &CharacterService{ characters: db.Characters(), diff --git a/space/space.go b/space/space.go index 9eff8d3..805eca8 100644 --- a/space/space.go +++ b/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 // 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 { - 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, UserMetadata: map[string]string{ "x-amz-acl": "public-read", }, }) 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 { - return "", err + return err } - return path, nil + return nil } // 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") } + urlRoot := cfg.URLRoot + if urlRoot == "" { + urlRoot = fmt.Sprintf("https://%s.%s/%s/", cfg.Bucket, cfg.Host, cfg.Root) + } else { + urlRoot += "/" + cfg.Root + "/" + } + return &Client{ bucket: cfg.Bucket, - urlRoot: fmt.Sprintf("https://%s.%s/%s/", cfg.Bucket, cfg.Host, cfg.Root), + urlRoot: urlRoot, spaceRoot: cfg.Root, maxSize: cfg.MaxSize, s3: client,