@synopackageland/cli 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +301 -1
- package/assets/default-icon/README.md +19 -0
- package/assets/default-icon/default-icon-small.svg +9 -0
- package/assets/default-icon/default-icon.svg +10 -0
- package/assets/default-icon/icon_16.png +0 -0
- package/assets/default-icon/icon_24.png +0 -0
- package/assets/default-icon/icon_256.png +0 -0
- package/assets/default-icon/icon_32.png +0 -0
- package/assets/default-icon/icon_48.png +0 -0
- package/assets/default-icon/icon_64.png +0 -0
- package/assets/default-icon/icon_72.png +0 -0
- package/dist/add.d.ts +4 -1
- package/dist/add.js +14 -9
- package/dist/build-spk/dsm-icons.d.ts +31 -2
- package/dist/build-spk/dsm-icons.js +135 -56
- package/dist/build-spk/dsm-platforms.json +4 -1
- package/dist/build-spk/gateway.d.ts +1 -1
- package/dist/build-spk/gateway.js +8 -4
- package/dist/build-spk/generator.js +17 -3
- package/dist/build-spk/go-toolchain.d.ts +20 -0
- package/dist/build-spk/go-toolchain.js +44 -0
- package/dist/build-spk/png.d.ts +14 -0
- package/dist/build-spk/png.js +161 -0
- package/dist/build-spk/ui-texts.d.ts +20 -0
- package/dist/build-spk/ui-texts.js +42 -0
- package/dist/cli.js +39 -9
- package/dist/contract/i18n.d.ts +5 -0
- package/dist/contract/i18n.js +45 -0
- package/dist/contract/icon.d.ts +5 -0
- package/dist/contract/icon.js +27 -0
- package/dist/contract/validate.js +4 -0
- package/dist/contract/web-launch.js +6 -2
- package/dist/deploy/native-install-values.d.ts +17 -0
- package/dist/deploy/native-install-values.js +32 -0
- package/dist/deploy/plan.d.ts +6 -2
- package/dist/deploy/plan.js +57 -6
- package/dist/error-registry.js +15 -6
- package/dist/host/dsm-client.js +53 -17
- package/dist/host/dsm-http.js +24 -2
- package/dist/host/types.d.ts +1 -0
- package/dist/info.js +28 -9
- package/dist/init.js +1 -1
- package/dist/runtime/dsm-version.d.ts +6 -0
- package/dist/runtime/dsm-version.js +8 -0
- package/dist/runtime/executor-build.d.ts +1 -1
- package/dist/runtime/executor-build.js +8 -5
- package/dist/standalone/daemon.d.ts +2 -1
- package/dist/standalone/daemon.js +10 -6
- package/dist/standalone/dsm-window.d.ts +2 -0
- package/dist/standalone/dsm-window.js +26 -40
- package/dist/standalone/i18n.d.ts +12 -0
- package/dist/standalone/i18n.js +30 -0
- package/dist/standalone/inspect-spk.d.ts +5 -0
- package/dist/standalone/inspect-spk.js +41 -2
- package/dist/standalone/manifest.d.ts +5 -6
- package/dist/standalone/manifest.js +21 -34
- package/dist/standalone/native-authorization-window.d.ts +1 -1
- package/dist/standalone/native-authorization-window.js +7 -1
- package/dist/standalone/native-layout.d.ts +2 -0
- package/dist/standalone/native-layout.js +22 -1
- package/dist/standalone/native-root-window.d.ts +4 -2
- package/dist/standalone/native-root-window.js +33 -18
- package/dist/standalone/root-window.d.ts +4 -1
- package/dist/standalone/root-window.js +143 -33
- package/dist/standalone/spk.js +7 -4
- package/dist/templates/_daemon/go.mod +1 -1
- package/dist/templates/_daemon/main.go +6 -3
- package/dist/templates/_daemon/main_test.go +21 -1
- package/dist/templates/_daemon/testdata/child.go +3 -0
- package/dist/templates/_executor/README.md +4 -1
- package/dist/templates/_executor/anchor.go +10 -0
- package/dist/templates/_executor/anchor_test.go +40 -5
- package/dist/templates/_executor/digest_review_test.go +2 -2
- package/dist/templates/_executor/dsm.go +40 -3
- package/dist/templates/_executor/go.mod +2 -0
- package/dist/templates/_executor/main.go +11 -5
- package/dist/templates/_executor/managed.go +3 -3
- package/dist/templates/_executor/managed_test.go +3 -3
- package/dist/templates/_executor/path_test.go +18 -0
- package/dist/templates/_executor/pid.go +37 -0
- package/dist/templates/_executor/pid_test.go +113 -0
- package/dist/templates/_executor/platform_other.go +8 -0
- package/dist/templates/_executor/purge_log.go +18 -0
- package/dist/templates/_executor/tasks.go +45 -7
- package/dist/templates/_executor/tasks_dsm71_test.go +114 -0
- package/dist/templates/_executor/tasks_test.go +58 -1
- package/dist/templates/_executor/upgrade_watch_test.go +13 -5
- package/dist/templates/_gateway/go.mod +2 -0
- package/dist/templates/binary/dsm-package.yaml +3 -0
- package/dist/templates/compose/synology-app.yaml +3 -0
- package/dist/templates/native-go/backend/go.mod +1 -1
- package/dist/templates/native-go/backend/internal/filestation/client.go +39 -3
- package/dist/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
- package/dist/templates/native-go/backend/internal/identity/README.md +5 -1
- package/dist/templates/native-go/backend/internal/identity/dsm.go +24 -5
- package/dist/templates/native-go/backend/internal/identity/http.go +1 -1
- package/dist/templates/native-go/backend/internal/identity/identity_test.go +48 -1
- package/dist/templates/native-go/backend/internal/identity/service.go +1 -1
- package/dist/templates/native-go/dsm-package.yaml +3 -0
- package/dist/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
- package/dist/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
- package/dist/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
- package/dist/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
- package/package.json +2 -1
- package/schema/synology-app.schema.json +22 -0
- package/templates/_daemon/go.mod +1 -1
- package/templates/_daemon/main.go +6 -3
- package/templates/_daemon/main_test.go +21 -1
- package/templates/_daemon/testdata/child.go +3 -0
- package/templates/_executor/README.md +4 -1
- package/templates/_executor/anchor.go +10 -0
- package/templates/_executor/anchor_test.go +40 -5
- package/templates/_executor/digest_review_test.go +2 -2
- package/templates/_executor/dsm.go +40 -3
- package/templates/_executor/go.mod +2 -0
- package/templates/_executor/main.go +11 -5
- package/templates/_executor/managed.go +3 -3
- package/templates/_executor/managed_test.go +3 -3
- package/templates/_executor/path_test.go +18 -0
- package/templates/_executor/pid.go +37 -0
- package/templates/_executor/pid_test.go +113 -0
- package/templates/_executor/platform_other.go +8 -0
- package/templates/_executor/purge_log.go +18 -0
- package/templates/_executor/tasks.go +45 -7
- package/templates/_executor/tasks_dsm71_test.go +114 -0
- package/templates/_executor/tasks_test.go +58 -1
- package/templates/_executor/upgrade_watch_test.go +13 -5
- package/templates/_gateway/go.mod +2 -0
- package/templates/binary/dsm-package.yaml +3 -0
- package/templates/compose/synology-app.yaml +3 -0
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/internal/filestation/client.go +39 -3
- package/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
- package/templates/native-go/backend/internal/identity/README.md +5 -1
- package/templates/native-go/backend/internal/identity/dsm.go +24 -5
- package/templates/native-go/backend/internal/identity/http.go +1 -1
- package/templates/native-go/backend/internal/identity/identity_test.go +48 -1
- package/templates/native-go/backend/internal/identity/service.go +1 -1
- package/templates/native-go/dsm-package.yaml +3 -0
- package/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
- package/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
- package/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
- package/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
- package/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
- package/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
|
@@ -6,10 +6,12 @@ import (
|
|
|
6
6
|
"encoding/hex"
|
|
7
7
|
"encoding/json"
|
|
8
8
|
"errors"
|
|
9
|
+
"fmt"
|
|
9
10
|
"os"
|
|
10
11
|
"path/filepath"
|
|
11
12
|
"strconv"
|
|
12
13
|
"synopkgland/executor/internal/taskscript"
|
|
14
|
+
"time"
|
|
13
15
|
)
|
|
14
16
|
|
|
15
17
|
type approvedTask struct {
|
|
@@ -51,8 +53,7 @@ func taskReference(ctx context.Context, d *liveDSM, task approvedTask, expected
|
|
|
51
53
|
boot := task.Kind == "boot"
|
|
52
54
|
if !boot {
|
|
53
55
|
api = "SYNO.Core.TaskScheduler"
|
|
54
|
-
|
|
55
|
-
b, err := d.call(ctx, api, "list", "3", "", map[string]any{"start": 0, "limit": 500})
|
|
56
|
+
b, err := listControlTasks(ctx, d)
|
|
56
57
|
if err != nil {
|
|
57
58
|
return "", "", nil, err
|
|
58
59
|
}
|
|
@@ -83,7 +84,14 @@ func taskReference(ctx context.Context, d *liveDSM, task approvedTask, expected
|
|
|
83
84
|
}
|
|
84
85
|
ref = map[string]any{"id": id, "real_owner": "root"}
|
|
85
86
|
}
|
|
86
|
-
b
|
|
87
|
+
var b json.RawMessage
|
|
88
|
+
var err error
|
|
89
|
+
if boot {
|
|
90
|
+
b, err = d.call(ctx, api, "get", version, "", ref)
|
|
91
|
+
} else {
|
|
92
|
+
b, err = onUnsupportedVersion(func() (json.RawMessage, error) { return d.call(ctx, api, "get", "4", "", ref) },
|
|
93
|
+
func() (json.RawMessage, error) { return d.call(ctx, api, "get", "3", "", ref) })
|
|
94
|
+
}
|
|
87
95
|
if err != nil {
|
|
88
96
|
return "", "", nil, err
|
|
89
97
|
}
|
|
@@ -225,20 +233,24 @@ func purgeTasks(ctx context.Context, d *liveDSM, pkg string, inv taskInventory,
|
|
|
225
233
|
expected = canonical.Script
|
|
226
234
|
}
|
|
227
235
|
}
|
|
236
|
+
started := time.Now()
|
|
228
237
|
api, version, ref, err := taskReference(ctx, d, task, expected)
|
|
229
238
|
if err != nil {
|
|
230
239
|
log("task cleanup skipped: " + task.Kind + " definition mismatch or unavailable")
|
|
231
240
|
continue
|
|
232
241
|
}
|
|
233
|
-
|
|
242
|
+
looked := time.Now()
|
|
243
|
+
if err = d.mutate(ctx, api, "delete", version, ref); err != nil {
|
|
234
244
|
log("task cleanup unconfirmed: " + task.Kind)
|
|
235
245
|
}
|
|
246
|
+
deleted := time.Now()
|
|
236
247
|
// The scheduler has no conditional delete. Accept the admin edit window,
|
|
237
248
|
// delete exactly once, and observe the name afterward without another delete.
|
|
238
249
|
if taskRemains(ctx, d, task) {
|
|
239
250
|
log("task cleanup: " + task.Kind + " same-named task remains after delete")
|
|
240
251
|
}
|
|
241
|
-
|
|
252
|
+
log(fmt.Sprintf("task cleanup timing: package=%s kind=%s lookup_ms=%d delete_ms=%d verify_ms=%d", pkg, task.Kind,
|
|
253
|
+
looked.Sub(started).Milliseconds(), deleted.Sub(looked).Milliseconds(), time.Since(deleted).Milliseconds()))
|
|
242
254
|
}
|
|
243
255
|
}
|
|
244
256
|
|
|
@@ -246,8 +258,12 @@ func purgeWithInventory(ctx context.Context, d *liveDSM, pkg string, inv taskInv
|
|
|
246
258
|
if runtime != filepath.Join(state, "runtime") {
|
|
247
259
|
return errors.New("runtime outside package protected state")
|
|
248
260
|
}
|
|
261
|
+
started := time.Now()
|
|
249
262
|
purgeTasks(ctx, d, pkg, inv, log)
|
|
250
|
-
|
|
263
|
+
cleaned := time.Now()
|
|
264
|
+
err := finishControl(state, runtime, socket, true)
|
|
265
|
+
log(fmt.Sprintf("purge timing: package=%s tasks_ms=%d files_ms=%d", pkg, cleaned.Sub(started).Milliseconds(), time.Since(cleaned).Milliseconds()))
|
|
266
|
+
return err
|
|
251
267
|
}
|
|
252
268
|
|
|
253
269
|
func taskRemains(ctx context.Context, d *liveDSM, task approvedTask) bool {
|
|
@@ -261,7 +277,7 @@ func taskRemains(ctx context.Context, d *liveDSM, task approvedTask) bool {
|
|
|
261
277
|
}
|
|
262
278
|
return json.Unmarshal(b, &got) == nil && got.Name == task.Name
|
|
263
279
|
}
|
|
264
|
-
b, err :=
|
|
280
|
+
b, err := listControlTasks(ctx, d)
|
|
265
281
|
if err != nil {
|
|
266
282
|
return false
|
|
267
283
|
}
|
|
@@ -280,3 +296,25 @@ func taskRemains(ctx context.Context, d *liveDSM, task approvedTask) bool {
|
|
|
280
296
|
}
|
|
281
297
|
return false
|
|
282
298
|
}
|
|
299
|
+
|
|
300
|
+
// DSM 7.1 has TaskScheduler list v2 (offset) and get v3 only. A read falls back
|
|
301
|
+
// solely when DSM reports the method version unsupported; any other refusal stands.
|
|
302
|
+
func onUnsupportedVersion(current, legacy func() (json.RawMessage, error)) (json.RawMessage, error) {
|
|
303
|
+
b, err := current()
|
|
304
|
+
var refused *dsmError
|
|
305
|
+
if errors.As(err, &refused) && versionUnsupported(refused.Code) {
|
|
306
|
+
return legacy()
|
|
307
|
+
}
|
|
308
|
+
return b, err
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// DSM 7.1.1 returns 104 for a version above maxVersion (get v4) and 103 for a
|
|
312
|
+
// method absent at an existing version (list v3).
|
|
313
|
+
func versionUnsupported(code int) bool { return code == 103 || code == 104 }
|
|
314
|
+
func listControlTasks(ctx context.Context, d *liveDSM) (json.RawMessage, error) {
|
|
315
|
+
return onUnsupportedVersion(func() (json.RawMessage, error) {
|
|
316
|
+
return d.call(ctx, "SYNO.Core.TaskScheduler", "list", "3", "", map[string]any{"start": 0, "limit": 500})
|
|
317
|
+
}, func() (json.RawMessage, error) {
|
|
318
|
+
return d.call(ctx, "SYNO.Core.TaskScheduler", "list", "2", "", map[string]any{"offset": 0, "limit": 500})
|
|
319
|
+
})
|
|
320
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// dsm71Scheduler answers like DSM 7.1.1-42962 (vt-62): TaskScheduler stops at
|
|
12
|
+
// list v2 (offset/limit) and get v3. t-0234 measured the refusals: a version
|
|
13
|
+
// above maxVersion 3 returns 104; a method absent at an existing version, 103.
|
|
14
|
+
type dsm71Scheduler struct {
|
|
15
|
+
recordingScheduler
|
|
16
|
+
refusal string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (s *dsm71Scheduler) Exec(ctx context.Context, path string, args []string) ([]byte, error) {
|
|
20
|
+
api, method, version := schedulerCall(args)
|
|
21
|
+
s.calls = append(s.calls, api+" "+method+" v"+version)
|
|
22
|
+
if api == "SYNO.Core.TaskScheduler" {
|
|
23
|
+
if method == "list" && version == "3" && s.refusal != "" {
|
|
24
|
+
return []byte(`{"success":false,"error":{"code":` + s.refusal + `}}`), nil
|
|
25
|
+
}
|
|
26
|
+
supported := map[string]string{"list": "2", "get": "3", "delete": "2", "run": "2"}
|
|
27
|
+
if supported[method] != version {
|
|
28
|
+
if version > "3" {
|
|
29
|
+
return []byte(`{"success":false,"error":{"code":104}}`), nil
|
|
30
|
+
}
|
|
31
|
+
return []byte(`{"success":false,"error":{"code":103}}`), nil
|
|
32
|
+
}
|
|
33
|
+
if method == "list" && (!contains(args, "offset=0") || !contains(args, "limit=500")) {
|
|
34
|
+
return []byte(`{"success":false,"error":{"code":101}}`), nil
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return s.schedulerFixture.Exec(ctx, path, args)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func purgeWith(t *testing.T, cmd Command) {
|
|
41
|
+
t.Helper()
|
|
42
|
+
inventory := taskInventory{Package: "TestApp", PolicyDigest: strings.Repeat("b", 64), Tasks: []approvedTask{{Kind: "boot", Name: "TestApp executor boot", ScriptDigest: canonicalTaskDigest(t, "boot")}, {Kind: "control", Name: "TestApp executor control", ScriptDigest: canonicalTaskDigest(t, "control")}}}
|
|
43
|
+
state := filepath.Join(t.TempDir(), "state")
|
|
44
|
+
socket := filepath.Join(t.TempDir(), "socket")
|
|
45
|
+
os.MkdirAll(filepath.Join(state, "runtime"), 0700)
|
|
46
|
+
os.MkdirAll(socket, 0700)
|
|
47
|
+
if err := purgeWithInventory(context.Background(), &liveDSM{Command: cmd}, "TestApp", inventory, state, filepath.Join(state, "runtime"), socket, func(string) {}); err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestPurgeFindsAndDeletesBothTasksOnDSM71(t *testing.T) {
|
|
53
|
+
cmd := &dsm71Scheduler{}
|
|
54
|
+
purgeWith(t, cmd)
|
|
55
|
+
if strings.Join(cmd.deleted, ",") != "SYNO.Core.EventScheduler,SYNO.Core.TaskScheduler" {
|
|
56
|
+
t.Fatal("DSM 7.1 tasks left behind", cmd.deleted, cmd.calls)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestSchedulerReadFallsBackOnlyOnUnsupportedVersion(t *testing.T) {
|
|
61
|
+
for _, code := range []string{"105", "119", "102", "3121"} {
|
|
62
|
+
t.Run(code, func(t *testing.T) {
|
|
63
|
+
cmd := &dsm71Scheduler{refusal: code}
|
|
64
|
+
purgeWith(t, cmd)
|
|
65
|
+
for _, call := range cmd.calls {
|
|
66
|
+
if call == "SYNO.Core.TaskScheduler list v2" || call == "SYNO.Core.TaskScheduler get v3" {
|
|
67
|
+
t.Fatal("fell back after code", code, cmd.calls)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if contains(cmd.deleted, "SYNO.Core.TaskScheduler") {
|
|
71
|
+
t.Fatal("deleted without exact readback", cmd.deleted)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func TestModernSchedulerNeverUsesLegacyVersions(t *testing.T) {
|
|
78
|
+
cmd := &recordingScheduler{}
|
|
79
|
+
purgeWith(t, cmd)
|
|
80
|
+
for _, call := range cmd.calls {
|
|
81
|
+
if call == "SYNO.Core.TaskScheduler list v2" || call == "SYNO.Core.TaskScheduler get v3" {
|
|
82
|
+
t.Fatal("modern DSM used a legacy version", cmd.calls)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if len(cmd.deleted) != 2 {
|
|
86
|
+
t.Fatal(cmd.deleted)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type recordingScheduler struct {
|
|
91
|
+
schedulerFixture
|
|
92
|
+
calls []string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func (s *recordingScheduler) Exec(ctx context.Context, path string, args []string) ([]byte, error) {
|
|
96
|
+
api, method, version := schedulerCall(args)
|
|
97
|
+
s.calls = append(s.calls, api+" "+method+" v"+version)
|
|
98
|
+
return s.schedulerFixture.Exec(ctx, path, args)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func schedulerCall(args []string) (api, method, version string) {
|
|
102
|
+
for _, a := range args {
|
|
103
|
+
k, v, _ := strings.Cut(a, "=")
|
|
104
|
+
switch k {
|
|
105
|
+
case "api":
|
|
106
|
+
api = v
|
|
107
|
+
case "method":
|
|
108
|
+
method = v
|
|
109
|
+
case "version":
|
|
110
|
+
version = v
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return
|
|
114
|
+
}
|
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"errors"
|
|
7
7
|
"os"
|
|
8
8
|
"path/filepath"
|
|
9
|
+
"regexp"
|
|
9
10
|
"strings"
|
|
10
11
|
"synopkgland/executor/internal/taskscript"
|
|
11
12
|
"testing"
|
|
@@ -14,7 +15,9 @@ import (
|
|
|
14
15
|
type schedulerFixture struct {
|
|
15
16
|
changed, foreignOwner, duplicate bool
|
|
16
17
|
script string
|
|
17
|
-
|
|
18
|
+
// deleteReply replaces the delete envelope, as DSM sends it.
|
|
19
|
+
deleteReply string
|
|
20
|
+
deleted []string
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
func (s *schedulerFixture) Exec(_ context.Context, _ string, args []string) ([]byte, error) {
|
|
@@ -48,6 +51,9 @@ func (s *schedulerFixture) Exec(_ context.Context, _ string, args []string) ([]b
|
|
|
48
51
|
if api == "SYNO.Core.TaskScheduler" && !contains(args, `tasks=[{"id":7,"real_owner":"root"}]`) {
|
|
49
52
|
return nil, errors.New("wrong delete reference")
|
|
50
53
|
}
|
|
54
|
+
if s.deleteReply != "" {
|
|
55
|
+
return []byte(s.deleteReply), nil
|
|
56
|
+
}
|
|
51
57
|
data = map[string]any{}
|
|
52
58
|
} else if api == "SYNO.Core.EventScheduler" {
|
|
53
59
|
data = map[string]any{"task_name": "TestApp executor boot", "owner": map[string]string{"0": owner}, "operation": script, "operation_type": "script", "event": "bootup", "enable": true}
|
|
@@ -164,3 +170,54 @@ func canonicalTaskDigest(t *testing.T, kind string) string {
|
|
|
164
170
|
t.Fatal("task kind")
|
|
165
171
|
return ""
|
|
166
172
|
}
|
|
173
|
+
|
|
174
|
+
// t-0229: preuninst took 15-23 s; time each scheduler phase and the file
|
|
175
|
+
// removal separately, naming the package, so a device log shows which waits.
|
|
176
|
+
func TestPurgeLogsPhaseTimingWithPackageName(t *testing.T) {
|
|
177
|
+
cmd := &schedulerFixture{}
|
|
178
|
+
inventory := taskInventory{Package: "TestApp", PolicyDigest: strings.Repeat("b", 64), Tasks: []approvedTask{{Kind: "boot", Name: "TestApp executor boot", ScriptDigest: canonicalTaskDigest(t, "boot")}, {Kind: "control", Name: "TestApp executor control", ScriptDigest: canonicalTaskDigest(t, "control")}}}
|
|
179
|
+
state := filepath.Join(t.TempDir(), "state")
|
|
180
|
+
socket := filepath.Join(t.TempDir(), "socket")
|
|
181
|
+
os.MkdirAll(filepath.Join(state, "runtime"), 0700)
|
|
182
|
+
os.MkdirAll(socket, 0700)
|
|
183
|
+
logs := []string{}
|
|
184
|
+
if err := purgeWithInventory(context.Background(), &liveDSM{Command: cmd}, "TestApp", inventory, state, filepath.Join(state, "runtime"), socket, func(s string) { logs = append(logs, s) }); err != nil {
|
|
185
|
+
t.Fatal(err)
|
|
186
|
+
}
|
|
187
|
+
task := regexp.MustCompile(`^task cleanup timing: package=TestApp kind=(boot|control) lookup_ms=\d+ delete_ms=\d+ verify_ms=\d+$`)
|
|
188
|
+
total := regexp.MustCompile(`^purge timing: package=TestApp tasks_ms=\d+ files_ms=\d+$`)
|
|
189
|
+
kinds := []string{}
|
|
190
|
+
for _, line := range logs {
|
|
191
|
+
if m := task.FindStringSubmatch(line); m != nil {
|
|
192
|
+
kinds = append(kinds, m[1])
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if strings.Join(kinds, ",") != "boot,control" || len(logs) == 0 || !total.MatchString(logs[len(logs)-1]) {
|
|
196
|
+
t.Fatal(logs)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// t-0237: EventScheduler delete v1 and TaskScheduler delete v2 answer a success
|
|
201
|
+
// without data; that is a confirmed delete, while a refusal stays unconfirmed.
|
|
202
|
+
func TestPurgeConfirmsDatalessDeleteSuccess(t *testing.T) {
|
|
203
|
+
for reply, unconfirmed := range map[string]bool{
|
|
204
|
+
`{"success":true}`: false,
|
|
205
|
+
`{"httpd_restart":false,"success":true}`: false,
|
|
206
|
+
`{"success":false,"error":{"code":4800}}`: true,
|
|
207
|
+
`{"success":true,"error":{"code":4800}}`: true,
|
|
208
|
+
} {
|
|
209
|
+
cmd := &schedulerFixture{deleteReply: reply}
|
|
210
|
+
inventory := taskInventory{Package: "TestApp", PolicyDigest: strings.Repeat("b", 64), Tasks: []approvedTask{{Kind: "boot", Name: "TestApp executor boot", ScriptDigest: canonicalTaskDigest(t, "boot")}, {Kind: "control", Name: "TestApp executor control", ScriptDigest: canonicalTaskDigest(t, "control")}}}
|
|
211
|
+
logs := []string{}
|
|
212
|
+
purgeTasks(context.Background(), &liveDSM{Command: cmd}, "TestApp", inventory, func(s string) { logs = append(logs, s) })
|
|
213
|
+
found := 0
|
|
214
|
+
for _, line := range logs {
|
|
215
|
+
if strings.HasPrefix(line, "task cleanup unconfirmed: ") {
|
|
216
|
+
found++
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (found == 2) != unconfirmed || (found != 0 && found != 2) || len(cmd.deleted) != 2 {
|
|
220
|
+
t.Fatal(reply, logs)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -2,6 +2,7 @@ package main
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"errors"
|
|
5
6
|
"net"
|
|
6
7
|
"os"
|
|
7
8
|
"path/filepath"
|
|
@@ -70,13 +71,20 @@ func TestUpgradeGapPreservesRuntimeAndResumesOnlyApprovedPins(t *testing.T) {
|
|
|
70
71
|
}
|
|
71
72
|
opened.Add(1)
|
|
72
73
|
return func() { listener.Close(); closed.Add(1) }, nil
|
|
73
|
-
}, func(ctx context.Context) bool {
|
|
74
|
+
}, func(ctx context.Context) bool {
|
|
75
|
+
// The production decision: only differing pins ask for approval.
|
|
76
|
+
pins := packagePins(ctx, target, info, func(os.FileInfo) bool { return true }, ed, pd)
|
|
77
|
+
return approvedPins(pins, func() error { return errors.New("no anchored upgrade here") }, func() error { return nil }, func(message string) {
|
|
78
|
+
if strings.Contains(message, "administrator approval") {
|
|
79
|
+
select {
|
|
80
|
+
case approval <- struct{}{}:
|
|
81
|
+
default:
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}, func() error {
|
|
74
86
|
purged.Add(1)
|
|
75
87
|
return finishControl(state, filepath.Join(state, "runtime"), sockDir, true)
|
|
76
|
-
}, func(message string) {
|
|
77
|
-
if strings.Contains(message, "administrator approval") {
|
|
78
|
-
approval <- struct{}{}
|
|
79
|
-
}
|
|
80
88
|
})
|
|
81
89
|
}()
|
|
82
90
|
t.Cleanup(func() {
|
|
@@ -5,6 +5,9 @@ version: 0.1.0
|
|
|
5
5
|
build: 1
|
|
6
6
|
displayname: __DISPLAY_NAME__
|
|
7
7
|
description: A DSM package with a prebuilt binary CGI and DSM window UI.
|
|
8
|
+
# Package Center and DSM window icon: a square PNG of at least 256x256, or a
|
|
9
|
+
# folder of icon_<size>.png. Omit to use the SDK default icon.
|
|
10
|
+
# icon: assets/icon.png
|
|
8
11
|
maintainer: Your Name
|
|
9
12
|
# Optional INFO/website metadata. Omit fields you do not need.
|
|
10
13
|
# metadata:
|
|
@@ -3,6 +3,9 @@ package: com.synology.hello.files
|
|
|
3
3
|
version: 0.1.0
|
|
4
4
|
displayname: Hello Files
|
|
5
5
|
description: A minimal headless app with a Shared Folder mount.
|
|
6
|
+
# Package Center and DSM window icon: a square PNG of at least 256x256, or a
|
|
7
|
+
# folder of icon_<size>.png. Omit to use the SDK default icon.
|
|
8
|
+
# icon: assets/icon.png
|
|
6
9
|
maintainer: Syno Package Land
|
|
7
10
|
os_min_ver: "7.2.1-69057"
|
|
8
11
|
|
|
@@ -21,6 +21,9 @@ const MaxBytes = 512 * 1024
|
|
|
21
21
|
const maxMetadata = 1024 * 1024
|
|
22
22
|
const PageSize = 100
|
|
23
23
|
|
|
24
|
+
// downloadPeek bounds how much of a DSM 7.1 download is inspected for an error envelope.
|
|
25
|
+
const downloadPeek = 4096
|
|
26
|
+
|
|
24
27
|
// Client has no cookie jar or credentials. The destination is startup-owned,
|
|
25
28
|
// literal loopback only; proxies, redirects and automatic retries are disabled.
|
|
26
29
|
type Client struct {
|
|
@@ -269,15 +272,48 @@ func (c *Client) DownloadStream(ctx context.Context, r *http.Request, p string)
|
|
|
269
272
|
if e != nil {
|
|
270
273
|
return Download{}, e
|
|
271
274
|
}
|
|
272
|
-
|
|
273
|
-
|
|
275
|
+
body := resp.Body
|
|
276
|
+
raw := resp.Header.Values("Content-Disposition")
|
|
277
|
+
if len(raw) == 0 {
|
|
278
|
+
// DSM 7.1 sends no Content-Disposition at all. Only a short body that is
|
|
279
|
+
// a complete DSM envelope is an error; everything else is the file.
|
|
280
|
+
if body, e = unlessEnvelope(resp); e != nil {
|
|
281
|
+
return Download{}, e
|
|
282
|
+
}
|
|
283
|
+
} else if disposition, _, _ := mime.ParseMediaType(raw[0]); len(raw) != 1 || disposition != "attachment" {
|
|
274
284
|
e = decode(resp, nil)
|
|
275
285
|
if e == nil {
|
|
276
286
|
e = fail("unavailable")
|
|
277
287
|
}
|
|
278
288
|
return Download{}, e
|
|
279
289
|
}
|
|
280
|
-
return Download{Body:
|
|
290
|
+
return Download{Body: body, ContentType: resp.Header.Get("Content-Type"), ContentDisposition: resp.Header.Get("Content-Disposition"), ContentLength: resp.ContentLength}, nil
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// unlessEnvelope returns the whole body as a stream unless it fits in the peek
|
|
294
|
+
// and decodes as a DSM envelope, which is refused and never becomes content.
|
|
295
|
+
func unlessEnvelope(resp *http.Response) (io.ReadCloser, error) {
|
|
296
|
+
head, e := io.ReadAll(io.LimitReader(resp.Body, downloadPeek+1))
|
|
297
|
+
if e != nil {
|
|
298
|
+
resp.Body.Close()
|
|
299
|
+
return nil, fail("read_failed")
|
|
300
|
+
}
|
|
301
|
+
if len(head) <= downloadPeek {
|
|
302
|
+
var envelope struct {
|
|
303
|
+
Success *bool `json:"success"`
|
|
304
|
+
}
|
|
305
|
+
if json.Unmarshal(head, &envelope) == nil && envelope.Success != nil {
|
|
306
|
+
resp.Body = io.NopCloser(bytes.NewReader(head))
|
|
307
|
+
if e = decode(resp, nil); e == nil {
|
|
308
|
+
e = fail("unavailable")
|
|
309
|
+
}
|
|
310
|
+
return nil, e
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return struct {
|
|
314
|
+
io.Reader
|
|
315
|
+
io.Closer
|
|
316
|
+
}{io.MultiReader(bytes.NewReader(head), resp.Body), resp.Body}, nil
|
|
281
317
|
}
|
|
282
318
|
|
|
283
319
|
// Read is the bounded package-private job import, using the same stream.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package filestation
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"net/http"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// DSM 7.1.1-42962 (vt-62, t-0231) answers Download v2 mode=download with the
|
|
13
|
+
// file bytes and no Content-Disposition header. Its Content-Type was not
|
|
14
|
+
// recorded, so every plausible type is covered.
|
|
15
|
+
func dsm71Download(contentType, body string) http.HandlerFunc {
|
|
16
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
17
|
+
if contentType != "" {
|
|
18
|
+
w.Header().Set("Content-Type", contentType)
|
|
19
|
+
}
|
|
20
|
+
fmt.Fprint(w, body)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func TestDSM71DownloadWithoutDispositionReturnsFileBytes(t *testing.T) {
|
|
25
|
+
for _, tt := range []struct{ contentType, body string }{
|
|
26
|
+
{"text/plain", "t0231 fixture a.txt"},
|
|
27
|
+
{"application/octet-stream", "\x00\x01binary"},
|
|
28
|
+
{"", "no type"},
|
|
29
|
+
{"application/json", `{"name":"a user file"}`},
|
|
30
|
+
{"application/json", `[{"success":false}]`},
|
|
31
|
+
{"text/plain", strings.Repeat("y", downloadPeek+7)},
|
|
32
|
+
} {
|
|
33
|
+
t.Run(tt.contentType, func(t *testing.T) {
|
|
34
|
+
c := fakeClient(t, dsm71Download(tt.contentType, tt.body))
|
|
35
|
+
b, err := c.Read(context.Background(), sessionRequest(), "/home/a")
|
|
36
|
+
if err != nil || string(b) != tt.body {
|
|
37
|
+
t.Fatalf("%v %q", err, b)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func TestDSM71DownloadStreamsBeyondThePeek(t *testing.T) {
|
|
44
|
+
body := strings.Repeat("z", MaxBytes*3)
|
|
45
|
+
c := fakeClient(t, dsm71Download("application/octet-stream", body))
|
|
46
|
+
d, err := c.DownloadStream(context.Background(), sessionRequest(), "/home/a")
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
defer d.Body.Close()
|
|
51
|
+
b, err := io.ReadAll(d.Body)
|
|
52
|
+
if err != nil || string(b) != body || d.ContentType != "application/octet-stream" {
|
|
53
|
+
t.Fatal(err, len(b))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestDSM71DownloadEnvelopeWithoutDispositionIsNeverContent(t *testing.T) {
|
|
58
|
+
for _, tt := range []struct{ contentType, body, want string }{
|
|
59
|
+
{"application/json; charset=utf-8", `{"success":false,"error":{"code":408}}`, "denied"},
|
|
60
|
+
{"text/plain", `{"success":false,"error":{"code":119}}`, "session_required"},
|
|
61
|
+
{"", ` {"error":{"code":407},"success":false}` + "\n", "denied"},
|
|
62
|
+
{"application/json", `{"success":true,"data":{}}`, "unavailable"},
|
|
63
|
+
} {
|
|
64
|
+
t.Run(tt.want, func(t *testing.T) {
|
|
65
|
+
c := fakeClient(t, dsm71Download(tt.contentType, tt.body))
|
|
66
|
+
b, err := c.Read(context.Background(), sessionRequest(), "/home/a")
|
|
67
|
+
if err == nil || len(b) != 0 || !strings.Contains(err.Error(), tt.want) {
|
|
68
|
+
t.Fatalf("%v %q", err, b)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestNonAttachmentDispositionStaysRefused(t *testing.T) {
|
|
75
|
+
c := fakeClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
76
|
+
w.Header().Set("Content-Disposition", `inline; filename="a"`)
|
|
77
|
+
fmt.Fprint(w, "abc")
|
|
78
|
+
})
|
|
79
|
+
if b, err := c.Read(context.Background(), sessionRequest(), "/home/a"); err == nil || len(b) != 0 {
|
|
80
|
+
t.Fatal("inline disposition admitted")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -16,7 +16,11 @@ The transport accepts same-origin JSON POST requests:
|
|
|
16
16
|
- `identity.logout {}` removes the app session and clears the cookie.
|
|
17
17
|
|
|
18
18
|
NormalUser.get is the only DSM cookie identity check; anonymous Initdata success
|
|
19
|
-
cannot establish identity.
|
|
19
|
+
cannot establish identity. DSM web logins enable SynoToken, and DSM refuses
|
|
20
|
+
their cookie alone (119), so the check also forwards the request's single
|
|
21
|
+
`X-SYNO-TOKEN` header, which the DSM window's `ctx.call` sends. Sessions without
|
|
22
|
+
SynoToken still verify with the cookie alone. The token is never put in a URL,
|
|
23
|
+
logged or stored. Local `user.Lookup` binds the username to its UID.
|
|
20
24
|
Auth login creates a temporary SID, then immediately logs it out before issuing
|
|
21
25
|
an app session. A logout failure refuses the app login. No password, OTP, DSM SID
|
|
22
26
|
or DSM cookie is logged or persisted. Only hashes of app bearer IDs are stored.
|
|
@@ -9,6 +9,7 @@ import (
|
|
|
9
9
|
"net/url"
|
|
10
10
|
"strings"
|
|
11
11
|
"time"
|
|
12
|
+
"unicode"
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
type dsmReply struct {
|
|
@@ -25,7 +26,9 @@ type dsmReply struct {
|
|
|
25
26
|
} `json:"error"`
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
// dsmCall sends token only as X-SYNO-TOKEN: DSM web logins enable SynoToken,
|
|
30
|
+
// and their cookie alone is refused with 119.
|
|
31
|
+
func (s *Service) dsmCall(ctx context.Context, form url.Values, cookie, token string) (dsmReply, error) {
|
|
29
32
|
var out dsmReply
|
|
30
33
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
31
34
|
defer cancel()
|
|
@@ -37,6 +40,9 @@ func (s *Service) dsmCall(ctx context.Context, form url.Values, cookie string) (
|
|
|
37
40
|
if cookie != "" {
|
|
38
41
|
req.AddCookie(&http.Cookie{Name: "id", Value: cookie})
|
|
39
42
|
}
|
|
43
|
+
if token != "" {
|
|
44
|
+
req.Header.Set("X-SYNO-TOKEN", token)
|
|
45
|
+
}
|
|
40
46
|
res, err := s.cfg.Client.Do(req)
|
|
41
47
|
if err != nil {
|
|
42
48
|
return out, errors.New("DSM unavailable")
|
|
@@ -48,19 +54,32 @@ func (s *Service) dsmCall(ctx context.Context, form url.Values, cookie string) (
|
|
|
48
54
|
}
|
|
49
55
|
return out, nil
|
|
50
56
|
}
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
|
|
58
|
+
// verify checks the caller's DSM session with the request's cookie and, when the
|
|
59
|
+
// browser sent one, its SynoToken. Neither is stored or logged.
|
|
60
|
+
func (s *Service) verify(r *http.Request, cookie string) (Subject, error) {
|
|
61
|
+
token := synoToken(r)
|
|
62
|
+
out, err := s.dsmCall(r.Context(), url.Values{"api": {"SYNO.Core.NormalUser"}, "method": {"get"}, "version": {"1"}}, cookie, token)
|
|
53
63
|
if err != nil || !out.Success {
|
|
54
64
|
return Subject{}, errors.New("DSM session refused")
|
|
55
65
|
}
|
|
56
66
|
return s.resolve(out.Data.Username)
|
|
57
67
|
}
|
|
68
|
+
|
|
69
|
+
// synoToken returns the single, well-formed X-SYNO-TOKEN header or "".
|
|
70
|
+
func synoToken(r *http.Request) string {
|
|
71
|
+
values := r.Header.Values("X-SYNO-TOKEN")
|
|
72
|
+
if len(values) != 1 || len(values[0]) > 4096 || strings.ContainsFunc(values[0], unicode.IsControl) {
|
|
73
|
+
return ""
|
|
74
|
+
}
|
|
75
|
+
return values[0]
|
|
76
|
+
}
|
|
58
77
|
func (s *Service) login(ctx context.Context, account, password, otp string) (Subject, string) {
|
|
59
78
|
form := url.Values{"api": {"SYNO.API.Auth"}, "method": {"login"}, "version": {"7"}, "account": {account}, "passwd": {password}, "format": {"sid"}}
|
|
60
79
|
if otp != "" {
|
|
61
80
|
form.Set("otp_code", otp)
|
|
62
81
|
}
|
|
63
|
-
out, err := s.dsmCall(ctx, form, "")
|
|
82
|
+
out, err := s.dsmCall(ctx, form, "", "")
|
|
64
83
|
if err != nil {
|
|
65
84
|
return Subject{}, "auth_unavailable"
|
|
66
85
|
}
|
|
@@ -84,7 +103,7 @@ func (s *Service) login(ctx context.Context, account, password, otp string) (Sub
|
|
|
84
103
|
}
|
|
85
104
|
// The temporary DSM session exists only to prove identity. Dispose of it
|
|
86
105
|
// before issuing an application session, including when local lookup fails.
|
|
87
|
-
loggedOut, err := s.dsmCall(context.WithoutCancel(ctx), url.Values{"api": {"SYNO.API.Auth"}, "method": {"logout"}, "version": {"7"}, "_sid": {out.Data.SID}}, "")
|
|
106
|
+
loggedOut, err := s.dsmCall(context.WithoutCancel(ctx), url.Values{"api": {"SYNO.API.Auth"}, "method": {"logout"}, "version": {"7"}, "_sid": {out.Data.SID}}, "", "")
|
|
88
107
|
if err != nil || !loggedOut.Success {
|
|
89
108
|
return Subject{}, "auth_unavailable"
|
|
90
109
|
}
|
|
@@ -237,7 +237,7 @@ func (s *Service) handle(w http.ResponseWriter, r *http.Request, req request) {
|
|
|
237
237
|
code = "session_required"
|
|
238
238
|
return nil
|
|
239
239
|
}
|
|
240
|
-
sub, err := s.verify(r
|
|
240
|
+
sub, err := s.verify(r, dc.Value)
|
|
241
241
|
if err != nil {
|
|
242
242
|
code = "session_required"
|
|
243
243
|
return nil
|