-
Notifications
You must be signed in to change notification settings - Fork 2
/
base.go
356 lines (310 loc) · 8.31 KB
/
base.go
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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"strconv"
"strings"
"sync"
)
const (
EnvURL = "SYNOLOGY_URL"
EnvUser = "SYNOLOGY_USER"
EnvPass = "SYNOLOGY_PASSWORD" //nolint:gosec
)
var ErrBadStatus = errors.New("bad response status")
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type HTTPClientFunc func(req *http.Request) (*http.Response, error)
func (hf HTTPClientFunc) Do(req *http.Request) (*http.Response, error) {
return hf(req)
}
type Config struct {
Client HTTPClient // HTTP client to perform requests, default is http.DefaultClient
User string // User name
Password string // User password
URL string // Synology url, default is http://localhost:5000
}
// Default client based on env variables.
func Default() *Client {
return New(FromEnv(nil))
}
// FromEnv creates config based on standard environment variables. If envFunc not defined,
// os.Getenv will be used.
func FromEnv(envFunc func(string) string) Config {
if envFunc == nil {
envFunc = os.Getenv
}
return Config{
User: envFunc(EnvUser),
Password: envFunc(EnvPass),
URL: envFunc(EnvURL),
}
}
// New instance of Synology API client.
func New(cfg Config) *Client {
if cfg.Client == nil {
cfg.Client = http.DefaultClient
}
if cfg.URL == "" {
cfg.URL = "http://localhost:500"
} else {
cfg.URL = strings.TrimRight(cfg.URL, "/")
}
return &Client{
client: cfg.Client,
user: cfg.User,
password: cfg.Password,
baseURL: cfg.URL,
}
}
type Client struct {
client HTTPClient
user string
password string
baseURL string
authLock sync.Mutex
token string
sid string
versionLock sync.Mutex
versions map[string]API
}
// WithClient returns copy of Synology client with custom HTTP client.
func (cl *Client) WithClient(client HTTPClient) *Client {
cl.versionLock.Lock()
defer cl.versionLock.Unlock()
cl.authLock.Lock()
defer cl.authLock.Unlock()
return &Client{
client: client,
user: cl.user,
password: cl.password,
baseURL: cl.baseURL,
token: cl.token,
sid: cl.sid,
versions: cl.versions,
}
}
// APIVersion returns max version for specific API. It queries Synology for all APIs and caches result.
func (cl *Client) APIVersion(ctx context.Context, apiName string) (API, error) {
if m := cl.versions; m != nil {
return m[apiName], nil
}
cl.versionLock.Lock()
defer cl.versionLock.Unlock()
if m := cl.versions; m != nil {
return m[apiName], nil
}
err := cl.doPost(ctx, "/webapi/query.cgi", nil, map[string]interface{}{
"method": "query",
"api": "SYNO.API.Info",
"version": 1,
}, &cl.versions)
if err != nil {
return API{}, fmt.Errorf("invoke api: %w", err)
}
return cl.versions[apiName], nil
}
// Login to Synology and get token. Token will be cached. If token already obtained, API call will not be executed.
func (cl *Client) Login(ctx context.Context) error {
if cl.token != "" {
return nil
}
cl.authLock.Lock()
defer cl.authLock.Unlock()
if cl.token != "" {
return nil
}
var response struct {
Sid string `json:"sid"`
Token string `json:"synotoken"`
}
err := cl.callAPI(ctx, "SYNO.API.Auth", "login", map[string]interface{}{
"enable_syno_token": "yes",
"account": cl.user,
"passwd": cl.password,
}, &response)
if err != nil {
return fmt.Errorf("invoke api: %w", err)
}
cl.token = response.Token
cl.sid = response.Sid
return nil
}
func (cl *Client) callAPI(ctx context.Context, apiName, method string, params map[string]interface{}, out interface{}) error {
info, err := cl.APIVersion(ctx, apiName)
if err != nil {
return fmt.Errorf("get API %s version: %w", apiName, err)
}
var queryParams = map[string]interface{}{
"method": method,
"api": apiName,
"version": info.MaxVersion,
"_sid": cl.sid,
}
// if it's not upload, we can merge transport params into payload
if !needStreaming(params) {
if params == nil {
params = queryParams
} else {
for k, v := range queryParams {
params[k] = v
}
queryParams = nil
}
}
return cl.doPost(ctx, "/webapi/"+info.Path, queryParams, params, out)
}
func (cl *Client) doPost(ctx context.Context, path string, queryParams map[string]interface{}, params map[string]interface{}, out interface{}) error {
var contentType string
var content io.ReadCloser
if needStreaming(params) {
contentType, content = streamData(params)
} else {
contentType, content = plainData(params)
}
defer content.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cl.baseURL+path+"?"+joinParams(queryParams), content)
if err != nil {
return fmt.Errorf("prepare request: %w", err)
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("X-Syno-Token", cl.token)
res, err := cl.client.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("status %d: %w", res.StatusCode, ErrBadStatus)
}
var rawResponse apiResponse
err = json.NewDecoder(res.Body).Decode(&rawResponse)
if err != nil {
return fmt.Errorf("decode response: %w", err)
}
if err := rawResponse.Error; err != nil {
return fmt.Errorf("response: %w", err)
}
err = json.Unmarshal(rawResponse.RawData, out)
if err != nil {
return fmt.Errorf("decode payload: %w", err)
}
return nil
}
func plainData(params map[string]interface{}) (string, io.ReadCloser) {
return "application/x-www-form-urlencoded", io.NopCloser(strings.NewReader(joinParams(params)))
}
func streamData(params map[string]interface{}) (string, io.ReadCloser) {
reader, writer := io.Pipe()
mp := multipart.NewWriter(writer)
go func() {
err := streamMultipart(params, mp)
if err == nil {
err = mp.Close()
}
_ = writer.CloseWithError(err)
}()
return "multipart/form-data; boundary=" + mp.Boundary(), reader
}
func joinParams(params map[string]interface{}) string {
var buffer bytes.Buffer
for key, value := range params {
if buffer.Len() > 0 {
buffer.WriteRune('&')
}
buffer.WriteString(url.QueryEscape(key))
buffer.WriteRune('=')
buffer.WriteString(url.QueryEscape(fmt.Sprint(value)))
}
return buffer.String()
}
func streamMultipart(params map[string]interface{}, w *multipart.Writer) error {
for key, value := range params {
var dest io.Writer
var source io.Reader
switch v := value.(type) {
case io.Reader:
out, err := w.CreateFormField(key)
if err != nil {
return fmt.Errorf("create part for %s: %w", key, err)
}
dest = out
source = v
case fileAttachment:
out, err := w.CreateFormFile(key, v.FileName)
if err != nil {
return fmt.Errorf("create part for %s: %w", key, err)
}
dest = out
source = v.Reader
case []byte:
out, err := w.CreateFormField(key)
if err != nil {
return fmt.Errorf("create part for %s: %w", key, err)
}
dest = out
source = bytes.NewReader(v)
case string:
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name=`+strconv.Quote(key))
h.Set("Content-Type", "text/plain")
out, err := w.CreatePart(h)
if err != nil {
return fmt.Errorf("create part for %s: %w", key, err)
}
dest = out
source = strings.NewReader(v)
default:
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name=`+strconv.Quote(key))
out, err := w.CreatePart(h)
if err != nil {
return fmt.Errorf("create part for %s: %w", key, err)
}
dest = out
source = strings.NewReader(fmt.Sprint(v))
}
if _, err := io.Copy(dest, source); err != nil {
return fmt.Errorf("copy content for part %s: %w", key, err)
}
}
return nil
}
func needStreaming(params map[string]interface{}) bool {
for _, v := range params {
switch v.(type) {
case io.Reader, *fileAttachment, fileAttachment:
return true
}
}
return false
}
type fileAttachment struct {
FileName string
Reader io.Reader
}
type apiResponse struct {
Success bool `json:"success"`
Error *RemoteError `json:"error,omitempty"`
RawData json.RawMessage `json:"data"`
}
type API struct {
MaxVersion int64 `json:"maxVersion"`
Path string `json:"path"`
}
type RemoteError struct {
Code int64 `json:"code"`
}
func (e *RemoteError) Error() string {
return "API error code: " + strconv.FormatInt(e.Code, 10) //nolint:gomnd
}