add web/file2
This commit is contained in:
parent
32f0d84427
commit
b5a323aeb4
|
@ -0,0 +1,13 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FileInfo interface {
|
||||
fs.FileInfo
|
||||
ID() uuid.UUID
|
||||
ContentType() string
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
Package file abstracts non-hierarchical file stores. Each file consists of a
|
||||
name, a MIME type, a UUID, and data. File names may be duplicate.
|
||||
|
||||
TODO: think about name vs. UUID again—should the ``name'' really be the filename
|
||||
and not the UUID?
|
||||
*/
|
||||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// An FS is a file store.
|
||||
type FS interface {
|
||||
// Store stores a new file with the given UUID, name, and MIME type.
|
||||
// Its data is taken from the provided Reader. If it encounters an
|
||||
// error, it does nothing. Any existing file with the same UUID is
|
||||
// overwritten.
|
||||
Store(id uuid.UUID, name, contentType string, data io.Reader) error
|
||||
// RemoveUUID deletes a file.
|
||||
RemoveUUID(id uuid.UUID) error
|
||||
// Open opens a file by its name. If multiple files have the same name,
|
||||
// it is unspecified which one is opened. This may very well be very
|
||||
// slow. This is bad. Go away.
|
||||
Open(name string) (fs.File, error)
|
||||
// OpenUUID opens a file by its UUID.
|
||||
OpenUUID(id uuid.UUID) (fs.File, error)
|
||||
// Check checks the health of the file store. If the file store is not
|
||||
// healthy, it returns a descriptive error. Otherwise, the file store
|
||||
// should be usable.
|
||||
Check() error
|
||||
}
|
||||
|
||||
// StoreFromHTTP stores the first file with given form key from an HTTP
|
||||
// multipart/form-data request. Its Content-Type header is ignored; the type is
|
||||
// detected. The file name is the last part of the provided file name not
|
||||
// containing any slashes or backslashes.
|
||||
//
|
||||
// TODO: store all files with the given key instead of just the first one
|
||||
func StoreFromHTTP(fs FS, r *http.Request, key string) error {
|
||||
file, fileHeader, err := r.FormFile(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get file from request: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, err := file.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
contentType := http.DetectContentType(buf[:n])
|
||||
|
||||
i := strings.LastIndexAny(fileHeader.Filename, "/\\")
|
||||
// if i == -1 { i = -1 }
|
||||
|
||||
return fs.Store(uuid.New(), fileHeader.Filename[i+1:], contentType, file)
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// File is a file handle with its associated ID.
|
||||
type File struct {
|
||||
*os.File
|
||||
fs *FS
|
||||
id uuid.UUID
|
||||
}
|
||||
|
||||
func (f File) Stat() (fs.FileInfo, error) {
|
||||
fi := FileInfo{id: f.id}
|
||||
var err error
|
||||
fi.FileInfo, err = f.File.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("os stat: %w", err)
|
||||
}
|
||||
name, err := os.ReadFile(path.Join(f.fs.Root, f.id.String(), "name"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading name: %w", err)
|
||||
}
|
||||
fi.name = string(name)
|
||||
contentType, err := os.ReadFile(path.Join(f.fs.Root, f.id.String(), "type"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading type: %w", err)
|
||||
}
|
||||
fi.contentType = string(contentType)
|
||||
return fi, nil
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FileInfo struct {
|
||||
id uuid.UUID
|
||||
name string
|
||||
contentType string
|
||||
os.FileInfo
|
||||
}
|
||||
|
||||
func (fi FileInfo) ID() uuid.UUID { return fi.id }
|
||||
func (fi FileInfo) ContentType() string { return fi.contentType }
|
||||
func (fi FileInfo) Name() string { return fi.name }
|
||||
func (fi FileInfo) Sys() interface{} { return fi.FileInfo }
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
Package fs implements a non-hierarchical file store using the underlying (disk)
|
||||
file system.
|
||||
|
||||
A file store contains directories named after UUIDs, each containing the
|
||||
following files:
|
||||
name the file name (``basename'')
|
||||
type the file's MIME type
|
||||
data the file data
|
||||
|
||||
This is not meant for anything for which the word ``scale'' plays any role at
|
||||
all, ever, anywhere.
|
||||
|
||||
TODO: should io/fs ever support writable file systems, use one of those instead
|
||||
of the ``disk'' (os.Open & Co.) (see https://github.com/golang/go/issues/45757)
|
||||
*/
|
||||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FS is an on-disk file store.
|
||||
type FS struct {
|
||||
// Root is the path of the file store, relative to the process's working
|
||||
// directory or absolute.
|
||||
Root string
|
||||
}
|
||||
|
||||
func (fs *FS) Store(id uuid.UUID, name, contentType string, data io.Reader) error {
|
||||
p := path.Join(fs.Root, id.String())
|
||||
|
||||
_, err := os.Stat(p)
|
||||
if err == nil {
|
||||
err = os.RemoveAll(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove old %v: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = os.Mkdir(p, 0o750)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", id.String(), err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
os.RemoveAll(p)
|
||||
}
|
||||
}()
|
||||
|
||||
err = os.WriteFile(path.Join(p, "name"), []byte(name), 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write name: %w", err)
|
||||
}
|
||||
err = os.WriteFile(path.Join(p, "type"), []byte(contentType), 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write type: %w", err)
|
||||
}
|
||||
f, err := os.Create(path.Join(p, "data"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create data file: %w", err)
|
||||
}
|
||||
_, err = io.Copy(f, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *FS) RemoveUUID(id uuid.UUID) error {
|
||||
return os.RemoveAll(path.Join(fs.Root, id.String()))
|
||||
}
|
||||
|
||||
// OpenUUID opens the file with the given UUID.
|
||||
func (fs *FS) OpenUUID(id uuid.UUID) (fs.File, error) {
|
||||
f, err := os.Open(path.Join(fs.Root, id.String(), "data"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open data: %w", err)
|
||||
}
|
||||
return File{File: f, fs: fs, id: uuid.MustParse(id.String())}, nil
|
||||
}
|
||||
|
||||
// Open searches for and opens the file with the given name.
|
||||
func (fs *FS) Open(name string) (fs.File, error) {
|
||||
files, err := os.ReadDir(fs.Root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range files {
|
||||
id := v.Name()
|
||||
entryName, err := os.ReadFile(path.Join(fs.Root, id, "name"))
|
||||
if err != nil || string(entryName) != name {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(path.Join(fs.Root, id, "data"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open data: %w", err)
|
||||
}
|
||||
return File{File: f, fs: fs, id: uuid.MustParse(id)}, nil
|
||||
}
|
||||
return nil, errors.New("no such file")
|
||||
}
|
||||
|
||||
// Check checks whether the file store is operable, ie. whether fs.Root exists
|
||||
// and is a directory.
|
||||
func (fs *FS) Check() error {
|
||||
fi, err := os.Stat(fs.Root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return errors.New("file store is not a directory")
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"dev.sum7.eu/genofire/golang-lib/web/file2"
|
||||
)
|
||||
|
||||
func TestOpenStat(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
var fs file.FS = &FS{Root: "./testdata"}
|
||||
assert.NoError(fs.Check())
|
||||
|
||||
f, err := fs.Open("glenda")
|
||||
assert.NoError(err)
|
||||
assert.NotNil(f)
|
||||
|
||||
fi, err := f.Stat()
|
||||
assert.NoError(err)
|
||||
assert.NotNil(fi)
|
||||
|
||||
assert.Equal(uuid.MustParse("d2750ced-4bdc-41d0-8c2f-5b9de44b84ef"), fi.(file.FileInfo).ID())
|
||||
assert.Equal("text/plain", fi.(file.FileInfo).ContentType())
|
||||
assert.Equal("glenda", fi.Name())
|
||||
}
|
||||
|
||||
func TestCreateOpenUUIDRead(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
var fs file.FS = &FS{Root: "./testdata"}
|
||||
assert.NoError(fs.Check())
|
||||
|
||||
err := fs.Store(uuid.MustParse("f9375ccb-ee09-4ecf-917e-b88725efcb68"), "$name", "text/plain", strings.NewReader("hello, world\n"))
|
||||
assert.NoError(err)
|
||||
|
||||
f, err := fs.OpenUUID(uuid.MustParse("f9375ccb-ee09-4ecf-917e-b88725efcb68"))
|
||||
assert.NoError(err)
|
||||
assert.NotNil(f)
|
||||
|
||||
buf, err := io.ReadAll(f)
|
||||
assert.NoError(err)
|
||||
assert.Equal("hello, world\n", string(buf))
|
||||
|
||||
os.RemoveAll("./testdata/f9375ccb-ee09-4ecf-917e-b88725efcb68")
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
Small ‘hand-drwawn’ Glenda by ems:
|
||||
|
||||
(\(\
|
||||
¸". ..
|
||||
( . .)
|
||||
| ° ¡
|
||||
¿ ;
|
||||
c?".UJ"
|
|
@ -0,0 +1 @@
|
|||
glenda
|
|
@ -0,0 +1 @@
|
|||
text/plain
|
|
@ -0,0 +1,47 @@
|
|||
package file_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"dev.sum7.eu/genofire/golang-lib/web/file2"
|
||||
)
|
||||
|
||||
var fs file.FS
|
||||
|
||||
func ExampleFS() {
|
||||
// generate the UUID for the new file
|
||||
id := uuid.New()
|
||||
|
||||
// store a file
|
||||
{
|
||||
f, _ := os.Open("glenda.png")
|
||||
fs.Store(id, "glenda.png", "image/png", f)
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// copy back to a local file
|
||||
{
|
||||
r, _ := fs.OpenUUID(id)
|
||||
w, _ := os.Create("glenda.png")
|
||||
io.Copy(w, r)
|
||||
r.Close()
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleStoreFromHTTP() {
|
||||
http.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := file.StoreFromHTTP(fs, r, "file"); err != nil {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(fmt.Sprintf(`{"message":"%v"}`, err)))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package s3
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
*minio.Object
|
||||
}
|
||||
|
||||
func (f File) Stat() (fs.FileInfo, error) {
|
||||
var fi FileInfo
|
||||
var err error
|
||||
fi.ObjectInfo, err = f.Object.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fi, nil
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package s3
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
type FileInfo struct {
|
||||
minio.ObjectInfo
|
||||
}
|
||||
|
||||
func (fi FileInfo) Name() string { return fi.UserMetadata["filename"] }
|
||||
func (fi FileInfo) Size() int64 { return fi.ObjectInfo.Size }
|
||||
|
||||
// TODO: try to map s3 permissions to these, somehow
|
||||
func (fi FileInfo) Mode() fs.FileMode { return 0o640 }
|
||||
func (fi FileInfo) ModTime() time.Time { return fi.LastModified }
|
||||
func (fi FileInfo) IsDir() bool { return false }
|
||||
func (fi FileInfo) Sys() interface{} { return fi.ObjectInfo }
|
||||
func (fi FileInfo) ID() uuid.UUID { return uuid.MustParse(fi.Key) }
|
||||
func (fi FileInfo) ContentType() string { return fi.ObjectInfo.ContentType }
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
Package s3 implements a non-hierarchical file store using Amazon s3. A file
|
||||
store uses a single bucket. Each file is an object with its UUID as the name.
|
||||
The file name is stored in the user-defined object metadata x-amz-meta-filename.
|
||||
*/
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type FS struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
// New ``connects'' to an s3 endpoint and creates a file store using the
|
||||
// specified bucket. The bucket is created if it doesn't exist.
|
||||
func New(endpoint string, secure bool, id, secret, bucket, location string) (*FS, error) {
|
||||
var fs FS
|
||||
var err error
|
||||
fs.client, err = minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(id, secret, ""),
|
||||
Secure: secure,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx := context.TODO()
|
||||
|
||||
err = fs.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{
|
||||
Region: location,
|
||||
})
|
||||
if err != nil {
|
||||
if exists, err := fs.client.BucketExists(ctx, bucket); err != nil || !exists {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
fs.bucket = bucket
|
||||
|
||||
return &fs, nil
|
||||
}
|
||||
|
||||
func (fs *FS) Store(id uuid.UUID, name, contentType string, data io.Reader) error {
|
||||
ctx := context.TODO()
|
||||
_, err := fs.client.PutObject(ctx, fs.bucket, id.String(), data, -1, minio.PutObjectOptions{
|
||||
UserMetadata: map[string]string{
|
||||
"filename": name,
|
||||
},
|
||||
ContentType: contentType,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (fs *FS) RemoveUUID(id uuid.UUID) error {
|
||||
ctx := context.TODO()
|
||||
return fs.client.RemoveObject(ctx, fs.bucket, id.String(), minio.RemoveObjectOptions{})
|
||||
}
|
||||
|
||||
// TODO: implement
|
||||
func (fs *FS) Open(name string) (fs.File, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (fs *FS) OpenUUID(id uuid.UUID) (fs.File, error) {
|
||||
ctx := context.TODO()
|
||||
|
||||
object, err := fs.client.GetObject(ctx, fs.bucket, id.String(), minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return File{Object: object}, nil
|
||||
}
|
||||
|
||||
// TODO: do some checking
|
||||
func (fs *FS) Check() error {
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package s3
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"dev.sum7.eu/genofire/golang-lib/web/file2"
|
||||
)
|
||||
|
||||
// TODO: actually test, either using little dummies or using sth like play.min.io
|
||||
|
||||
func TestTypes(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
var fs file.FS
|
||||
fs, err := New("127.0.0.1", false, "", "", "", "")
|
||||
_ = fs
|
||||
assert.Error(err)
|
||||
}
|
||||
|
||||
func ExampleNew() {
|
||||
New("play.min.io", true, "Q3AM3UQ867SPQQA43P2F", "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG", "file-store", "")
|
||||
}
|
Loading…
Reference in New Issue