Gisle Aune
5 years ago
11 changed files with 394 additions and 1 deletions
-
1.idea/dictionaries/gisle.xml
-
12database/mongodb/db.go
-
105database/mongodb/files.go
-
1go.mod
-
2go.sum
-
2internal/auth/permissions.go
-
5internal/generate/id.go
-
15models/file.go
-
15repositories/file.go
-
144services/files.go
-
93space/space.go
@ -0,0 +1,105 @@ |
|||||
|
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 fileRepository struct { |
||||
|
files *mgo.Collection |
||||
|
} |
||||
|
|
||||
|
func (r *fileRepository) Find(ctx context.Context, id string) (*models.File, error) { |
||||
|
file := new(models.File) |
||||
|
err := r.files.FindId(id).One(file) |
||||
|
if err != nil { |
||||
|
if err == mgo.ErrNotFound { |
||||
|
return nil, repositories.ErrNotFound |
||||
|
} |
||||
|
|
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return file, nil |
||||
|
} |
||||
|
|
||||
|
func (r *fileRepository) List(ctx context.Context, filter models.FileFilter) ([]*models.File, error) { |
||||
|
query := bson.M{} |
||||
|
if filter.Author != nil { |
||||
|
query["author"] = *filter.Author |
||||
|
} |
||||
|
if filter.MimeTypes != nil { |
||||
|
query["mimeType"] = bson.M{"$in": filter.MimeTypes} |
||||
|
} |
||||
|
if filter.Public != nil { |
||||
|
query["public"] = *filter.Public |
||||
|
} |
||||
|
|
||||
|
files := make([]*models.File, 0, 16) |
||||
|
err := r.files.Find(query).Sort("-date").All(&files) |
||||
|
|
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return files, nil |
||||
|
} |
||||
|
|
||||
|
func (r *fileRepository) Insert(ctx context.Context, file models.File) (*models.File, error) { |
||||
|
file.ID = generate.FileID() |
||||
|
|
||||
|
err := r.files.Insert(&file) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &file, nil |
||||
|
} |
||||
|
|
||||
|
func (r *fileRepository) Update(ctx context.Context, file models.File, update models.FileUpdate) (*models.File, error) { |
||||
|
updateBson := bson.M{} |
||||
|
if update.Public != nil { |
||||
|
updateBson["public"] = *update.Public |
||||
|
file.Public = *update.Public |
||||
|
} |
||||
|
if update.Name != nil { |
||||
|
updateBson["name"] = *update.Name |
||||
|
file.Name = *update.Name |
||||
|
} |
||||
|
|
||||
|
err := r.files.UpdateId(file.ID, bson.M{"$set": updateBson}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &file, nil |
||||
|
} |
||||
|
|
||||
|
func (r *fileRepository) Delete(ctx context.Context, file models.File) error { |
||||
|
return r.files.RemoveId(file.ID) |
||||
|
} |
||||
|
|
||||
|
func newFileRepository(db *mgo.Database) (*fileRepository, error) { |
||||
|
files := db.C("file.headers") |
||||
|
|
||||
|
err := files.EnsureIndexKey("author") |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
err = files.EnsureIndexKey("mimeType") |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
err = files.EnsureIndexKey("public") |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &fileRepository{files: files}, nil |
||||
|
} |
@ -0,0 +1,15 @@ |
|||||
|
package repositories |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/rpdata/api/models" |
||||
|
) |
||||
|
|
||||
|
// FileRepository is an interface for database operations relating to file metadata.
|
||||
|
type FileRepository interface { |
||||
|
Find(ctx context.Context, id string) (*models.File, error) |
||||
|
List(ctx context.Context, filter models.FileFilter) ([]*models.File, error) |
||||
|
Insert(ctx context.Context, file models.File) (*models.File, error) |
||||
|
Update(ctx context.Context, file models.File, update models.FileUpdate) (*models.File, error) |
||||
|
Delete(ctx context.Context, file models.File) error |
||||
|
} |
@ -0,0 +1,144 @@ |
|||||
|
package services |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
"git.aiterp.net/rpdata/api/internal/auth" |
||||
|
"git.aiterp.net/rpdata/api/models" |
||||
|
"git.aiterp.net/rpdata/api/repositories" |
||||
|
"github.com/h2non/filetype" |
||||
|
"io" |
||||
|
) |
||||
|
|
||||
|
var ErrPrivateNoAuthor = errors.New("cannot search for private files without an author") |
||||
|
var ErrInvalidName = errors.New("invalid name") |
||||
|
var ErrInvalidFileSize = errors.New("file is not of a correct size (min: 320B, max: 16MB)") |
||||
|
var ErrCouldNotReadHead = errors.New("could not read file head") |
||||
|
var ErrInvalidFileType = errors.New("file type could not be recognized or is not allowed") |
||||
|
var ErrCouldNotUploadFile = errors.New("could not upload file") |
||||
|
|
||||
|
// FileService is a service for files.
|
||||
|
type FileService struct { |
||||
|
files repositories.FileRepository |
||||
|
} |
||||
|
|
||||
|
func (s *FileService) Find(ctx context.Context, id string) (*models.File, error) { |
||||
|
file, err := s.files.Find(ctx, id) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
if !file.Public { |
||||
|
err := auth.CheckPermission(ctx, "view", file) |
||||
|
if err != nil { |
||||
|
return nil, repositories.ErrNotFound |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return file, nil |
||||
|
} |
||||
|
|
||||
|
func (s *FileService) List(ctx context.Context, filter models.FileFilter) ([]*models.File, error) { |
||||
|
token := auth.TokenFromContext(ctx) |
||||
|
|
||||
|
if filter.Public != nil { |
||||
|
if *filter.Public == false { |
||||
|
if filter.Author == nil || *filter.Author == "" { |
||||
|
return nil, ErrPrivateNoAuthor |
||||
|
} |
||||
|
|
||||
|
if !token.PermittedUser(*filter.Author, "member", "file.list") { |
||||
|
return nil, auth.ErrUnauthorized |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return s.files.List(ctx, filter) |
||||
|
} |
||||
|
|
||||
|
func (s *FileService) Upload(ctx context.Context, reader io.Reader, name string, size int64) (*models.File, error) { |
||||
|
if name == "" { |
||||
|
return nil, ErrInvalidName |
||||
|
} else if size < 320 || size > 16777216 { |
||||
|
return nil, ErrInvalidFileSize |
||||
|
} |
||||
|
|
||||
|
head := make([]byte, 320) |
||||
|
n, err := reader.Read(head) |
||||
|
if err != nil || n < 320 { |
||||
|
return nil, ErrCouldNotReadHead |
||||
|
} |
||||
|
|
||||
|
fileType, err := filetype.Match(head) |
||||
|
if err != nil || !allowedMimeTypes[fileType.MIME.Value] { |
||||
|
return nil, ErrInvalidFileType |
||||
|
} |
||||
|
|
||||
|
panic("implement rest of me") |
||||
|
} |
||||
|
|
||||
|
func (s *FileService) Edit(ctx context.Context, id string, name *string, public *bool) (*models.File, error) { |
||||
|
file, err := s.files.Find(ctx, id) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
err = auth.CheckPermission(ctx, "edit", file) |
||||
|
if err != nil { |
||||
|
if !file.Public { |
||||
|
return nil, repositories.ErrNotFound |
||||
|
} |
||||
|
|
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return s.files.Update(ctx, *file, models.FileUpdate{ |
||||
|
Name: name, |
||||
|
Public: public, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// concatReader is a quick and dirty reader for reading the head and the file.
|
||||
|
type concatReader struct { |
||||
|
head []byte |
||||
|
headPos int |
||||
|
body io.Reader |
||||
|
} |
||||
|
|
||||
|
func (r *concatReader) Read(p []byte) (n int, err error) { |
||||
|
if len(p) == 0 { |
||||
|
return 0, io.ErrShortBuffer |
||||
|
} |
||||
|
|
||||
|
if r.headPos < len(r.head) { |
||||
|
remainder := r.head[r.headPos:] |
||||
|
if len(p) < len(remainder) { |
||||
|
r.headPos += len(p) |
||||
|
copy(p, remainder) |
||||
|
|
||||
|
return len(p), nil |
||||
|
} |
||||
|
|
||||
|
r.headPos = len(r.head) |
||||
|
copy(p, remainder) |
||||
|
|
||||
|
return len(p), nil |
||||
|
} |
||||
|
|
||||
|
return r.body.Read(p) |
||||
|
} |
||||
|
|
||||
|
var allowedMimeTypes = map[string]bool{ |
||||
|
"": false, |
||||
|
"image/jpeg": true, |
||||
|
"image/png": true, |
||||
|
"image/gif": true, |
||||
|
"image/tiff": true, |
||||
|
"image/tga": true, |
||||
|
"text/plain": true, |
||||
|
"application/json": true, |
||||
|
"application/pdf": false, |
||||
|
"binary/octet-stream": false, |
||||
|
"video/mp4": false, |
||||
|
"audio/mp3": false, |
||||
|
} |
@ -0,0 +1,93 @@ |
|||||
|
package space |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"git.aiterp.net/rpdata/api/internal/config" |
||||
|
"io" |
||||
|
|
||||
|
minio "github.com/minio/minio-go" |
||||
|
) |
||||
|
|
||||
|
type Client struct { |
||||
|
bucket string |
||||
|
urlRoot string |
||||
|
spaceRoot string |
||||
|
maxSize int64 |
||||
|
|
||||
|
s3 *minio.Client |
||||
|
} |
||||
|
|
||||
|
func (client *Client) MaxSize() int64 { |
||||
|
return client.maxSize |
||||
|
} |
||||
|
|
||||
|
// 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 |
||||
|
|
||||
|
if size > client.maxSize { |
||||
|
return "", errors.New("file is too big") |
||||
|
} |
||||
|
|
||||
|
_, err := client.s3.PutObjectWithContext(ctx, client.bucket, client.spaceRoot+"/"+path, reader, size, minio.PutObjectOptions{ |
||||
|
ContentType: mimeType, |
||||
|
UserMetadata: map[string]string{ |
||||
|
"x-amz-acl": "public-read", |
||||
|
}, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
|
||||
|
_, err = client.s3.StatObject(client.bucket, client.spaceRoot+"/"+path, minio.StatObjectOptions{}) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
|
||||
|
return path, nil |
||||
|
} |
||||
|
|
||||
|
// RemoveFile removes a file from the space
|
||||
|
func (client *Client) RemoveFile(folder string, name string) error { |
||||
|
path := folder + "/" + name |
||||
|
|
||||
|
return client.s3.RemoveObject(client.bucket, client.spaceRoot+"/"+path) |
||||
|
} |
||||
|
|
||||
|
// DownloadFile opens a file for download, using the same path format as the UploadFile function. Remember to Close it!
|
||||
|
// The returned stream also has `ReaderAt` and `Seeker`, but those are impl. details.
|
||||
|
func (client *Client) DownloadFile(ctx context.Context, path string) (io.ReadCloser, error) { |
||||
|
return client.s3.GetObjectWithContext(ctx, client.bucket, client.spaceRoot+"/"+path, minio.GetObjectOptions{}) |
||||
|
} |
||||
|
|
||||
|
// URLFromPath gets the URL from the path returned by UploadFile
|
||||
|
func (client *Client) URLFromPath(path string) string { |
||||
|
return client.urlRoot + path |
||||
|
} |
||||
|
|
||||
|
// Connect connects to a S3 space.
|
||||
|
func Connect(cfg config.Space) (*Client, error) { |
||||
|
client, err := minio.New(cfg.Host, cfg.AccessKey, cfg.SecretKey, true) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
exists, err := client.BucketExists(cfg.Bucket) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
if !exists { |
||||
|
return nil, errors.New("bucket not found") |
||||
|
} |
||||
|
|
||||
|
return &Client{ |
||||
|
bucket: cfg.Bucket, |
||||
|
urlRoot: fmt.Sprintf("https://%s.%s/%s/", cfg.Bucket, cfg.Host, cfg.Root), |
||||
|
spaceRoot: cfg.Root, |
||||
|
maxSize: cfg.MaxSize, |
||||
|
s3: client, |
||||
|
}, nil |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue