Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84dea8ea5d |
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2 v1.43.7
|
github.com/aws/aws-sdk-go-v2 v1.43.7
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.37
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.37
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3
|
||||||
|
github.com/aws/smithy-go v1.27.8
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/go-chi/chi/v5 v5.3.1
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -27,7 +28,6 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect
|
||||||
github.com/aws/smithy-go v1.27.8 // indirect
|
|
||||||
github.com/gorilla/websocket v1.4.2 // indirect
|
github.com/gorilla/websocket v1.4.2 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
+18
-3
@@ -8,9 +8,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
"github.com/aws/smithy-go/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Uploader stores public avatar objects.
|
// Uploader stores public avatar objects.
|
||||||
@@ -78,13 +80,26 @@ func NewSpaces(cfg SpacesConfig) Uploader {
|
|||||||
return Disabled{}
|
return Disabled{}
|
||||||
}
|
}
|
||||||
client := s3.New(s3.Options{
|
client := s3.New(s3.Options{
|
||||||
Region: cfg.Region,
|
Region: cfg.Region,
|
||||||
Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""),
|
Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""),
|
||||||
BaseEndpoint: aws.String(cfg.Endpoint),
|
BaseEndpoint: aws.String(cfg.Endpoint),
|
||||||
|
RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
|
||||||
|
APIOptions: []func(*middleware.Stack) error{
|
||||||
|
spacesUnsignedPayload,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return &spaces{client: client, cfg: cfg}
|
return &spaces{client: client, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spacesUnsignedPayload signs Spaces PUTs as UNSIGNED-PAYLOAD so the client
|
||||||
|
// can stream the body without hashing it first.
|
||||||
|
func spacesUnsignedPayload(stack *middleware.Stack) error {
|
||||||
|
if err := v4.SwapComputePayloadSHA256ForUnsignedPayloadMiddleware(stack); err != nil {
|
||||||
|
return v4.AddUnsignedPayloadMiddleware(stack)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *spaces) Enabled() bool { return true }
|
func (s *spaces) Enabled() bool { return true }
|
||||||
|
|
||||||
func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) {
|
func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) {
|
||||||
|
|||||||
+22
-16
@@ -190,13 +190,14 @@ func (s *Server) uploadPostMedia(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return store.PostImage{}, "", err
|
return store.PostImage{}, "", err
|
||||||
}
|
}
|
||||||
|
defer prepared.body.Close()
|
||||||
mediaID := uuid.NewString()
|
mediaID := uuid.NewString()
|
||||||
objectKey := path.Join("post-videos", postID, mediaID+prepared.extension)
|
objectKey := path.Join("post-videos", postID, mediaID+prepared.extension)
|
||||||
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
|
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
|
||||||
Key: objectKey,
|
Key: objectKey,
|
||||||
Body: bytes.NewReader(prepared.body),
|
Body: prepared.body,
|
||||||
ContentType: prepared.contentType,
|
ContentType: prepared.contentType,
|
||||||
Size: int64(len(prepared.body)),
|
Size: prepared.size,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.PostImage{}, "", &postImageRequestError{
|
return store.PostImage{}, "", &postImageRequestError{
|
||||||
@@ -405,7 +406,8 @@ func isWebM(raw []byte) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type preparedPostVideo struct {
|
type preparedPostVideo struct {
|
||||||
body []byte
|
body io.ReadCloser
|
||||||
|
size int64
|
||||||
extension string
|
extension string
|
||||||
contentType string
|
contentType string
|
||||||
}
|
}
|
||||||
@@ -414,6 +416,9 @@ func preparePostVideo(header *multipart.FileHeader) (preparedPostVideo, error) {
|
|||||||
if header == nil {
|
if header == nil {
|
||||||
return preparedPostVideo{}, invalidPostImage("Select a valid video.", nil)
|
return preparedPostVideo{}, invalidPostImage("Select a valid video.", nil)
|
||||||
}
|
}
|
||||||
|
if header.Size == 0 {
|
||||||
|
return preparedPostVideo{}, invalidPostImage("Videos cannot be empty.", nil)
|
||||||
|
}
|
||||||
if header.Size > postVideoMaxFileBytes {
|
if header.Size > postVideoMaxFileBytes {
|
||||||
return preparedPostVideo{}, &postImageRequestError{
|
return preparedPostVideo{}, &postImageRequestError{
|
||||||
status: http.StatusRequestEntityTooLarge,
|
status: http.StatusRequestEntityTooLarge,
|
||||||
@@ -424,27 +429,28 @@ func preparePostVideo(header *multipart.FileHeader) (preparedPostVideo, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
|
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
peek := make([]byte, 512)
|
||||||
raw, err := io.ReadAll(io.LimitReader(file, postVideoMaxFileBytes+1))
|
n, err := io.ReadFull(file, peek)
|
||||||
if err != nil {
|
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||||||
|
file.Close()
|
||||||
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
|
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
|
||||||
}
|
}
|
||||||
if len(raw) == 0 {
|
if n == 0 {
|
||||||
|
file.Close()
|
||||||
return preparedPostVideo{}, invalidPostImage("Videos cannot be empty.", nil)
|
return preparedPostVideo{}, invalidPostImage("Videos cannot be empty.", nil)
|
||||||
}
|
}
|
||||||
if int64(len(raw)) > postVideoMaxFileBytes {
|
switch mediaKindFromBytes(peek[:n]) {
|
||||||
return preparedPostVideo{}, &postImageRequestError{
|
|
||||||
status: http.StatusRequestEntityTooLarge,
|
|
||||||
message: "Each video must be 25 MB or smaller.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch mediaKindFromBytes(raw) {
|
|
||||||
case store.MediaKindVideo:
|
case store.MediaKindVideo:
|
||||||
default:
|
default:
|
||||||
|
file.Close()
|
||||||
return preparedPostVideo{}, invalidPostImage("Videos must be MP4 or WebM.", nil)
|
return preparedPostVideo{}, invalidPostImage("Videos must be MP4 or WebM.", nil)
|
||||||
}
|
}
|
||||||
result := preparedPostVideo{body: raw}
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||||
if isWebM(raw) {
|
file.Close()
|
||||||
|
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
|
||||||
|
}
|
||||||
|
result := preparedPostVideo{body: file, size: header.Size}
|
||||||
|
if isWebM(peek[:n]) {
|
||||||
result.extension = ".webm"
|
result.extension = ".webm"
|
||||||
result.contentType = "video/webm"
|
result.contentType = "video/webm"
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -60,20 +60,34 @@ func TestPreparePostImage(t *testing.T) {
|
|||||||
func TestPreparePostVideo(t *testing.T) {
|
func TestPreparePostVideo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
prepared, err := preparePostVideoHeader(t, "clip.mp4", tinyMP4())
|
mp4 := tinyMP4()
|
||||||
|
prepared, err := preparePostVideoHeader(t, "clip.mp4", mp4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if prepared.extension != ".mp4" || prepared.contentType != "video/mp4" || len(prepared.body) == 0 {
|
defer prepared.body.Close()
|
||||||
t.Fatalf("prepared MP4 = %+v", prepared)
|
got, err := io.ReadAll(prepared.body)
|
||||||
}
|
|
||||||
prepared, err = preparePostVideoHeader(t, "clip.webm", tinyWebM())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if prepared.extension != ".webm" || prepared.contentType != "video/webm" {
|
if prepared.extension != ".mp4" || prepared.contentType != "video/mp4" ||
|
||||||
|
prepared.size != int64(len(mp4)) || !bytes.Equal(got, mp4) {
|
||||||
|
t.Fatalf("prepared MP4 = %+v len(body)=%d", prepared, len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
webm := tinyWebM()
|
||||||
|
prepared, err = preparePostVideoHeader(t, "clip.webm", webm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer prepared.body.Close()
|
||||||
|
if prepared.extension != ".webm" || prepared.contentType != "video/webm" ||
|
||||||
|
prepared.size != int64(len(webm)) {
|
||||||
t.Fatalf("prepared WebM = %+v", prepared)
|
t.Fatalf("prepared WebM = %+v", prepared)
|
||||||
}
|
}
|
||||||
|
if _, err := preparePostVideoHeader(t, "empty.mp4", nil); err == nil {
|
||||||
|
t.Fatal("empty video unexpectedly succeeded")
|
||||||
|
}
|
||||||
if _, err := preparePostVideoHeader(t, "notes.txt", []byte("not a video")); err == nil {
|
if _, err := preparePostVideoHeader(t, "notes.txt", []byte("not a video")); err == nil {
|
||||||
t.Fatal("text video upload unexpectedly succeeded")
|
t.Fatal("text video upload unexpectedly succeeded")
|
||||||
}
|
}
|
||||||
@@ -269,6 +283,17 @@ func TestPostVideoMultipartLifecycle(t *testing.T) {
|
|||||||
!strings.HasPrefix(root.Images[1].ObjectKey, "post-videos/") {
|
!strings.HasPrefix(root.Images[1].ObjectKey, "post-videos/") {
|
||||||
t.Fatalf("root media = %+v", root.Images)
|
t.Fatalf("root media = %+v", root.Images)
|
||||||
}
|
}
|
||||||
|
clip := tinyMP4()
|
||||||
|
var streamed recordedImageUpload
|
||||||
|
for _, upload := range blobs.recordedUploads() {
|
||||||
|
if strings.HasPrefix(upload.key, "post-videos/") {
|
||||||
|
streamed = upload
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if streamed.size != int64(len(clip)) || !bytes.Equal(streamed.body, clip) {
|
||||||
|
t.Fatalf("streamed video upload = %+v", streamed)
|
||||||
|
}
|
||||||
|
|
||||||
rec = multipartPost(t, handler, "/posts/"+root.ID+"/edit", map[string][]string{
|
rec = multipartPost(t, handler, "/posts/"+root.ID+"/edit", map[string][]string{
|
||||||
"_csrf": {csrf},
|
"_csrf": {csrf},
|
||||||
@@ -513,6 +538,7 @@ func solidJPEG(t *testing.T, width, height int) []byte {
|
|||||||
type recordedImageUpload struct {
|
type recordedImageUpload struct {
|
||||||
key string
|
key string
|
||||||
contentType string
|
contentType string
|
||||||
|
size int64
|
||||||
body []byte
|
body []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +566,7 @@ func (b *recordingImageBlob) Upload(_ context.Context, object blob.FileUpload) (
|
|||||||
b.uploads = append(b.uploads, recordedImageUpload{
|
b.uploads = append(b.uploads, recordedImageUpload{
|
||||||
key: object.Key,
|
key: object.Key,
|
||||||
contentType: object.ContentType,
|
contentType: object.ContentType,
|
||||||
|
size: object.Size,
|
||||||
body: body,
|
body: body,
|
||||||
})
|
})
|
||||||
return "https://cdn.example/" + object.Key, nil
|
return "https://cdn.example/" + object.Key, nil
|
||||||
@@ -553,9 +580,13 @@ func (b *recordingImageBlob) Delete(_ context.Context, key string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *recordingImageBlob) uploadCount() int {
|
func (b *recordingImageBlob) uploadCount() int {
|
||||||
|
return len(b.recordedUploads())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *recordingImageBlob) recordedUploads() []recordedImageUpload {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
return len(b.uploads)
|
return append([]recordedImageUpload(nil), b.uploads...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *recordingImageBlob) deletedKeys() []string {
|
func (b *recordingImageBlob) deletedKeys() []string {
|
||||||
|
|||||||
Reference in New Issue
Block a user