-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathuploads.go
More file actions
213 lines (182 loc) · 6.69 KB
/
uploads.go
File metadata and controls
213 lines (182 loc) · 6.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
package maxigo
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
)
// GetVideoDetails returns detailed information about a video attachment.
// Corresponds to GET /videos/{videoToken}.
func (c *Client) GetVideoDetails(ctx context.Context, videoToken string) (*VideoAttachmentDetails, error) {
var result VideoAttachmentDetails
path := fmt.Sprintf("/videos/%s", url.PathEscape(videoToken))
if err := c.do(ctx, "GetVideoDetails", http.MethodGet, path, nil, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
// GetUploadURL returns a URL to upload a file of the given type.
// Corresponds to POST /uploads.
func (c *Client) GetUploadURL(ctx context.Context, uploadType UploadType) (*UploadEndpoint, error) {
q := make(url.Values)
q.Set("type", string(uploadType))
var result UploadEndpoint
if err := c.do(ctx, "GetUploadURL", http.MethodPost, "/uploads", q, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
// UploadPhoto uploads an image and returns photo tokens.
// This is a two-step operation: get upload URL, then upload the file.
func (c *Client) UploadPhoto(ctx context.Context, filename string, reader io.Reader) (*PhotoTokens, error) {
endpoint, err := c.GetUploadURL(ctx, UploadImage)
if err != nil {
return nil, err
}
body, err := c.doUpload(ctx, "UploadPhoto", endpoint.URL, filename, reader)
if err != nil {
return nil, err
}
var result PhotoTokens
if err := json.Unmarshal(body, &result); err != nil {
return nil, decodeError("UploadPhoto", fmt.Errorf("unmarshal upload response: %w", err))
}
return &result, nil
}
// UploadMedia uploads a video, audio, or file and returns the token.
// This is a two-step operation: get upload URL, then upload the file.
func (c *Client) UploadMedia(ctx context.Context, uploadType UploadType, filename string, reader io.Reader) (*UploadedInfo, error) {
endpoint, err := c.GetUploadURL(ctx, uploadType)
if err != nil {
return nil, err
}
body, err := c.doUpload(ctx, "UploadMedia", endpoint.URL, filename, reader)
if err != nil {
return nil, err
}
var result UploadedInfo
if err := json.Unmarshal(body, &result); err != nil {
return nil, decodeError("UploadMedia", fmt.Errorf("unmarshal upload response: %w", err))
}
return &result, nil
}
// UploadPhotoFromFile opens a local file and uploads it as a photo.
func (c *Client) UploadPhotoFromFile(ctx context.Context, filePath string) (*PhotoTokens, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, &Error{Kind: ErrFetch, Op: "UploadPhotoFromFile", Message: err.Error(), Err: err}
}
defer func() { _ = f.Close() }()
return c.UploadPhoto(ctx, filepath.Base(filePath), f)
}
// UploadMediaFromFile opens a local file and uploads it as the given media type.
func (c *Client) UploadMediaFromFile(ctx context.Context, uploadType UploadType, filePath string) (*UploadedInfo, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, &Error{Kind: ErrFetch, Op: "UploadMediaFromFile", Message: err.Error(), Err: err}
}
defer func() { _ = f.Close() }()
return c.UploadMedia(ctx, uploadType, filepath.Base(filePath), f)
}
// UploadPhotoFromURL fetches an image from a URL and uploads it as a photo.
// Only http and https schemes are allowed.
//
// Security: do not pass untrusted user input directly as imageURL
// without validation — this could allow SSRF attacks against internal networks.
func (c *Client) UploadPhotoFromURL(ctx context.Context, imageURL string) (*PhotoTokens, error) {
ctx, cancel := c.ensureTimeout(ctx)
defer cancel()
body, filename, err := c.fetchURL(ctx, "UploadPhotoFromURL", imageURL)
if err != nil {
return nil, err
}
defer func() { _ = body.Close() }()
if filename == "" {
filename = "photo"
}
return c.UploadPhoto(ctx, filename, body)
}
// UploadMediaFromURL fetches a file from a URL and uploads it as the given media type.
// Only http and https schemes are allowed.
//
// Security: do not pass untrusted user input directly as fileURL
// without validation — this could allow SSRF attacks against internal networks.
func (c *Client) UploadMediaFromURL(ctx context.Context, uploadType UploadType, fileURL string) (*UploadedInfo, error) {
ctx, cancel := c.ensureTimeout(ctx)
defer cancel()
body, filename, err := c.fetchURL(ctx, "UploadMediaFromURL", fileURL)
if err != nil {
return nil, err
}
defer func() { _ = body.Close() }()
if filename == "" {
filename = "file"
}
return c.UploadMedia(ctx, uploadType, filename, body)
}
// maxFetchSize is the maximum number of bytes fetchURL will read (50 MB).
const maxFetchSize = 50 << 20
// fetchURL downloads content from the URL. Caller must close the body.
// Only http and https schemes are allowed.
func (c *Client) fetchURL(ctx context.Context, op, rawURL string) (io.ReadCloser, string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, "", networkError(op, fmt.Errorf("parse URL: %w", err))
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", networkError(op, fmt.Errorf("unsupported URL scheme %q: only http and https are allowed", u.Scheme))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", networkError(op, fmt.Errorf("create request: %w", err))
}
resp, err := c.httpClient.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, "", timeoutError(op, ctx.Err())
}
if isTimeout(err) {
return nil, "", timeoutError(op, err)
}
return nil, "", networkError(op, err)
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
return nil, "", fetchError(op, resp.StatusCode, fmt.Sprintf("fetch %s: %s", rawURL, http.StatusText(resp.StatusCode)))
}
limited := io.NopCloser(io.LimitReader(resp.Body, maxFetchSize))
body := readCloser{limited, resp.Body}
return body, extractFilename(resp, rawURL), nil
}
// readCloser combines a limited reader with the original closer.
type readCloser struct {
io.ReadCloser // limited reader (reads up to maxFetchSize)
underlying io.Closer // original resp.Body
}
func (rc readCloser) Close() error {
return errors.Join(rc.ReadCloser.Close(), rc.underlying.Close())
}
// extractFilename gets name from Content-Disposition header or URL path.
// Filenames are sanitized with filepath.Base to prevent path traversal.
func extractFilename(resp *http.Response, rawURL string) string {
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
if _, params, err := mime.ParseMediaType(cd); err == nil {
if name := filepath.Base(params["filename"]); name != "" && name != "." {
return name
}
}
}
if u, err := url.Parse(rawURL); err == nil {
if base := path.Base(u.Path); base != "." && base != "/" {
return base
}
}
return ""
}