@synopackageland/templates 0.0.0-stage → 0.9.0

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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -2
  3. package/package.json +11 -4
  4. package/templates/_shared/AGENTS.md +146 -0
  5. package/templates/_shared/CLAUDE.md +1 -0
  6. package/templates/_shared/GO-LICENSE +27 -0
  7. package/templates/_shared/GO-PATENTS +22 -0
  8. package/templates/_shared/LICENSES/TEMPLATE.txt +21 -0
  9. package/templates/_shared/third-party-notices.mjs +55 -0
  10. package/templates/binary/LICENSES/GO-LICENSE +27 -0
  11. package/templates/binary/LICENSES/GO-PATENTS +22 -0
  12. package/templates/binary/dsm-package.yaml +46 -0
  13. package/templates/binary/example/main.go +16 -0
  14. package/templates/binary/gitignore +2 -0
  15. package/templates/binary/i18n/messages/de.json +4 -0
  16. package/templates/binary/i18n/messages/en.json +4 -0
  17. package/templates/binary/i18n/messages/es.json +4 -0
  18. package/templates/binary/i18n/messages/fr.json +4 -0
  19. package/templates/binary/i18n/messages/it.json +4 -0
  20. package/templates/binary/i18n/messages/ja.json +4 -0
  21. package/templates/binary/i18n/messages/ko.json +4 -0
  22. package/templates/binary/i18n/messages/zh-CN.json +4 -0
  23. package/templates/binary/i18n/messages/zh-TW.json +4 -0
  24. package/templates/binary/ui/app.css +4 -0
  25. package/templates/binary/ui/app.js +32 -0
  26. package/templates/compose/compose.yaml +8 -0
  27. package/templates/compose/synology-app.yaml +16 -0
  28. package/templates/native-go/README.md +368 -0
  29. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +53 -0
  30. package/templates/native-go/backend/cmd/app/main.go +101 -0
  31. package/templates/native-go/backend/cmd/background-worker/main.go +38 -0
  32. package/templates/native-go/backend/go.mod +9 -0
  33. package/templates/native-go/backend/go.sum +4 -0
  34. package/templates/native-go/backend/internal/api/permissions.go +26 -0
  35. package/templates/native-go/backend/internal/api/permissions.json +5 -0
  36. package/templates/native-go/backend/internal/api/permissions_test.go +30 -0
  37. package/templates/native-go/backend/internal/app/background.go +249 -0
  38. package/templates/native-go/backend/internal/app/background_none.go +8 -0
  39. package/templates/native-go/backend/internal/app/background_owner.go +38 -0
  40. package/templates/native-go/backend/internal/app/background_owner_test.go +163 -0
  41. package/templates/native-go/backend/internal/app/background_schedule.go +69 -0
  42. package/templates/native-go/backend/internal/app/background_schedule_test.go +62 -0
  43. package/templates/native-go/backend/internal/app/background_test.go +320 -0
  44. package/templates/native-go/backend/internal/app/delegate_config.go +57 -0
  45. package/templates/native-go/backend/internal/app/delegate_config_test.go +28 -0
  46. package/templates/native-go/backend/internal/app/delegate_none.go +5 -0
  47. package/templates/native-go/backend/internal/app/identity.go +20 -0
  48. package/templates/native-go/backend/internal/app/identity_test.go +15 -0
  49. package/templates/native-go/backend/internal/app/migrations.go +41 -0
  50. package/templates/native-go/backend/internal/app/migrations_test.go +41 -0
  51. package/templates/native-go/backend/internal/app/portal.go +30 -0
  52. package/templates/native-go/backend/internal/app/portal_test.go +55 -0
  53. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  54. package/templates/native-go/backend/internal/app/routes.go +137 -0
  55. package/templates/native-go/backend/internal/app/routes_test.go +197 -0
  56. package/templates/native-go/backend/internal/app/session_files.go +9 -0
  57. package/templates/native-go/backend/internal/app/session_files_test.go +14 -0
  58. package/templates/native-go/backend/internal/app/shared_folder.go +144 -0
  59. package/templates/native-go/backend/internal/app/shared_folder_test.go +158 -0
  60. package/templates/native-go/dsm-package.yaml +71 -0
  61. package/templates/native-go/gitignore +1 -0
  62. package/templates/native-go/i18n/glossary.json +663 -0
  63. package/templates/native-go/i18n/messages/de.json +29 -0
  64. package/templates/native-go/i18n/messages/en.json +29 -0
  65. package/templates/native-go/i18n/messages/es.json +29 -0
  66. package/templates/native-go/i18n/messages/fr.json +29 -0
  67. package/templates/native-go/i18n/messages/it.json +29 -0
  68. package/templates/native-go/i18n/messages/ja.json +29 -0
  69. package/templates/native-go/i18n/messages/ko.json +29 -0
  70. package/templates/native-go/i18n/messages/zh-CN.json +29 -0
  71. package/templates/native-go/i18n/messages/zh-TW.json +29 -0
  72. package/templates/native-go/ui/app.css +45 -0
  73. package/templates/native-go/ui/app.js +111 -0
  74. package/templates/native-go-delegate/backend/cmd/background-worker/main.go +49 -0
  75. package/templates/native-go-delegate/backend/internal/app/delegate.go +25 -0
  76. package/templates/native-go-delegate/backend/internal/app/delegate_on.go +5 -0
  77. package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +31 -0
  78. package/templates/native-go-delegate/frontend/src/CapabilityPanels.vue +32 -0
  79. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +77 -0
  80. package/templates/native-go-jobs/backend/internal/app/background_on.go +7 -0
  81. package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +87 -0
  82. package/templates/native-go-jobs/frontend/test/background-page.test.ts +20 -0
  83. package/templates/native-go-root-ops/backend/internal/app/executor.go +140 -0
  84. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +118 -0
  85. package/templates/native-go-root-ops/root/whoami.sh +3 -0
  86. package/templates/native-go-vue/README.md +51 -0
  87. package/templates/native-go-vue/frontend/DESIGN.md +61 -0
  88. package/templates/native-go-vue/frontend/package-lock.json +2550 -0
  89. package/templates/native-go-vue/frontend/package.json +23 -0
  90. package/templates/native-go-vue/frontend/src/App.vue +90 -0
  91. package/templates/native-go-vue/frontend/src/CapabilityPanels.vue +6 -0
  92. package/templates/native-go-vue/frontend/src/i18n/de.json +20 -0
  93. package/templates/native-go-vue/frontend/src/i18n/en.json +20 -0
  94. package/templates/native-go-vue/frontend/src/i18n/es.json +20 -0
  95. package/templates/native-go-vue/frontend/src/i18n/fr.json +20 -0
  96. package/templates/native-go-vue/frontend/src/i18n/it.json +20 -0
  97. package/templates/native-go-vue/frontend/src/i18n/ja.json +20 -0
  98. package/templates/native-go-vue/frontend/src/i18n/ko.json +20 -0
  99. package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +20 -0
  100. package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +20 -0
  101. package/templates/native-go-vue/frontend/src/i18n.ts +13 -0
  102. package/templates/native-go-vue/frontend/src/main.ts +33 -0
  103. package/templates/native-go-vue/frontend/src/style.css +27 -0
  104. package/templates/native-go-vue/frontend/test/apps.test.ts +57 -0
  105. package/templates/native-go-vue/frontend/test/native-controls.test.ts +27 -0
  106. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +50 -0
  107. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  108. package/templates/native-go-vue/frontend/vite.config.ts +11 -0
  109. package/templates/native-go-vue/gitignore +2 -0
@@ -0,0 +1,249 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "log"
8
+ "net/http"
9
+ "os/user"
10
+ "path"
11
+ "path/filepath"
12
+ "time"
13
+
14
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
15
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
16
+ "github.com/synopackageland/syno-pkg-land-sdk/filestation"
17
+ )
18
+
19
+ // workerFresh is how recent the worker heartbeat must be to count as running.
20
+ const workerFresh = 15 * time.Second
21
+
22
+ // lookupSubmitterUID resolves the authenticated DSM username on every request.
23
+ // Unknown UIDs fail closed for owners and submission.
24
+ var lookupSubmitterUID = osLookupUID
25
+
26
+ func osLookupUID(name string) string {
27
+ u, err := user.Lookup(name)
28
+ if err != nil {
29
+ return ""
30
+ }
31
+ return u.Uid
32
+ }
33
+
34
+ type backgroundFiles interface {
35
+ Browse(ctx context.Context, c api.Caller, r *http.Request, folder string, offset int) (filestation.Page, error)
36
+ ReadFile(ctx context.Context, c api.Caller, r *http.Request, folder, rel string) ([]byte, error)
37
+ }
38
+
39
+ type backgroundAPI struct {
40
+ store *background.Store
41
+ files backgroundFiles
42
+ }
43
+
44
+ // BackgroundRoutes is the production assembly of the opt-in background jobs
45
+ // API. `synopkgland init --with jobs` wires it in through background.go's
46
+ // replacement of background_none.go; nothing else references it.
47
+ func BackgroundRoutes(dataDir string) []api.Route {
48
+ return newBackgroundRoutes(background.NewRequestStore(filepath.Join(dataDir, "background")), filestation.Local(dataDir))
49
+ }
50
+
51
+ // Every request authenticates against DSM afresh. Owner routes are available
52
+ // to signed-in users; schedule reapproval and owner cleanup are admin-only.
53
+ func newBackgroundRoutes(store *background.Store, files backgroundFiles) []api.Route {
54
+ b := backgroundAPI{store: store, files: files}
55
+ return []api.Route{
56
+ api.HandleRequest("bg.status", b.owner(b.status)),
57
+ api.HandleRequest("bg.schedules.list", b.owner(b.schedules)),
58
+ api.HandleRequest("bg.schedules.create", b.owner(b.createSchedule)),
59
+ api.HandleRequest("bg.schedules.disable", b.owner(b.disableSchedule)),
60
+ api.HandleRequest("bg.schedules.delete", b.owner(b.deleteSchedule)),
61
+ api.HandleRequest("bg.schedules.enable", b.admin(b.enableSchedule)),
62
+ api.HandleRequest("bg.jobs.purge", b.admin(b.purge)),
63
+ api.HandleRequest("bg.browse", b.owner(b.browse)),
64
+ api.HandleRequest("bg.jobs.submit", b.owner(b.submit)),
65
+ api.HandleRequest("bg.jobs.list", b.owner(b.list)),
66
+ api.HandleRequest("bg.jobs.get", b.owner(b.get)),
67
+ api.HandleRequest("bg.jobs.cancel", b.owner(b.cancel)),
68
+ api.HandleRequest("bg.jobs.result", b.owner(b.result)),
69
+ }
70
+ }
71
+
72
+ type backgroundHandler func(ctx context.Context, c api.Caller, r *http.Request, params json.RawMessage) (any, error)
73
+
74
+ func (backgroundAPI) admin(h backgroundHandler) api.RequestHandler {
75
+ return func(ctx context.Context, c api.Caller, r *http.Request, params json.RawMessage) (any, error) {
76
+ if !c.Admin() {
77
+ return nil, &api.Error{Code: "unsupported", Message: "background jobs need a DSM administrator"}
78
+ }
79
+ return h(ctx, c, r, params)
80
+ }
81
+ }
82
+
83
+ type idParams struct {
84
+ ID string `json:"id"`
85
+ }
86
+
87
+ type jobView struct {
88
+ Job background.Job `json:"job"`
89
+ }
90
+
91
+ func (b backgroundAPI) status(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
92
+ if err := decodeParams(params, &struct{}{}); err != nil {
93
+ return nil, err
94
+ }
95
+ st, err := b.store.StatusFor(ctx, accessFrom(ctx))
96
+ if err != nil {
97
+ return nil, backgroundError(err)
98
+ }
99
+ var worker map[string]any
100
+ if st.Worker != nil {
101
+ worker = map[string]any{"pid": st.Worker.PID, "uid": st.Worker.UID, "username": st.Worker.Username,
102
+ "revision": st.Worker.Revision, "at": st.Worker.At, "alive": workerAlive(*st.Worker, time.Now())}
103
+ }
104
+ return map[string]any{
105
+ "owner": accessFrom(ctx).Owner, "supported": true, "admin": accessFrom(ctx).Admin, "algorithm": st.Algorithm, "revision": st.Revision,
106
+ "queued": st.Queued, "running": st.Running, "worker": worker,
107
+ "limits": map[string]any{"maxInputBytes": background.MaxInputBytes, "maxQueued": background.MaxQueued,
108
+ "maxSchedules": background.MaxSchedules, "intervalSeconds": []int{3600, 86400, 604800}, "maxTotalBytes": background.MaxTotalBytes, "maxOwnerPending": background.MaxOwnerPending, "maxOwnerBytes": background.MaxOwnerBytes, "jobTimeoutSeconds": int(background.JobTimeout / time.Second)},
109
+ }, nil
110
+ }
111
+
112
+ func workerAlive(w background.WorkerInfo, now time.Time) bool {
113
+ return now.Sub(w.At) <= workerFresh
114
+ }
115
+
116
+ // browse lists what the caller's own File Station session can see, so
117
+ // the panel can pick a file instead of typing a path. It grants nothing:
118
+ // submit still reads through the same session.
119
+ func (b backgroundAPI) browse(ctx context.Context, c api.Caller, r *http.Request, params json.RawMessage) (any, error) {
120
+ var in struct {
121
+ Folder string `json:"folder"`
122
+ Offset int `json:"offset"`
123
+ }
124
+ if err := decodeParams(params, &in); err != nil {
125
+ return nil, err
126
+ }
127
+ if in.Offset < 0 {
128
+ return nil, api.BadRequest("offset must not be negative")
129
+ }
130
+ return b.files.Browse(ctx, c, r, in.Folder, in.Offset)
131
+ }
132
+
133
+ func (b backgroundAPI) submit(ctx context.Context, c api.Caller, r *http.Request, params json.RawMessage) (any, error) {
134
+ var in struct {
135
+ Key string `json:"key"`
136
+ Folder string `json:"folder"`
137
+ Relative string `json:"relative"`
138
+ }
139
+ if err := decodeParams(params, &in); err != nil {
140
+ return nil, err
141
+ }
142
+ if in.Key == "" || in.Folder == "" || in.Relative == "" {
143
+ return nil, api.BadRequest("key, folder and relative are required")
144
+ }
145
+ access := accessFrom(ctx)
146
+ if !access.Owner.Valid() {
147
+ return nil, &api.Error{Code: "identity_unavailable", Message: "DSM user UID could not be resolved"}
148
+ }
149
+ data, err := b.files.ReadFile(ctx, c, r, in.Folder, in.Relative)
150
+ if err != nil {
151
+ return nil, err
152
+ }
153
+ job, duplicate, err := b.store.Submit(ctx, background.SubmitRequest{
154
+ Key: in.Key, Name: path.Base(in.Relative), Data: data, Submitter: access.Owner,
155
+ })
156
+ if err != nil {
157
+ return nil, backgroundError(err)
158
+ }
159
+ return map[string]any{"job": job, "duplicate": duplicate}, nil
160
+ }
161
+
162
+ func (b backgroundAPI) list(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
163
+ if err := decodeParams(params, &struct{}{}); err != nil {
164
+ return nil, err
165
+ }
166
+ jobs, err := b.store.ListFor(ctx, accessFrom(ctx))
167
+ if err != nil {
168
+ return nil, backgroundError(err)
169
+ }
170
+ newestFirst := make([]background.Job, len(jobs))
171
+ for i, j := range jobs {
172
+ newestFirst[len(jobs)-1-i] = j
173
+ }
174
+ return map[string]any{"jobs": newestFirst}, nil
175
+ }
176
+
177
+ func (b backgroundAPI) get(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
178
+ var in idParams
179
+ if err := decodeParams(params, &in); err != nil {
180
+ return nil, err
181
+ }
182
+ job, err := b.store.GetFor(ctx, in.ID, accessFrom(ctx))
183
+ return jobOrError(job, err)
184
+ }
185
+
186
+ func (b backgroundAPI) cancel(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
187
+ var in idParams
188
+ if err := decodeParams(params, &in); err != nil {
189
+ return nil, err
190
+ }
191
+ job, err := b.store.CancelFor(ctx, in.ID, accessFrom(ctx))
192
+ return jobOrError(job, err)
193
+ }
194
+
195
+ func jobOrError(job background.Job, err error) (any, error) {
196
+ if err != nil {
197
+ return nil, backgroundError(err)
198
+ }
199
+ return jobView{Job: job}, nil
200
+ }
201
+
202
+ func (b backgroundAPI) result(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
203
+ var in idParams
204
+ if err := decodeParams(params, &in); err != nil {
205
+ return nil, err
206
+ }
207
+ job, err := b.store.GetFor(ctx, in.ID, accessFrom(ctx))
208
+ if err != nil {
209
+ return nil, backgroundError(err)
210
+ }
211
+ if job.State != background.StateSucceeded || job.Result == nil {
212
+ return nil, &api.Error{Code: "not_ready", Message: "the job has no result"}
213
+ }
214
+ return map[string]any{"job": job, "result": map[string]any{
215
+ "jobId": job.ID, "worker": job.Worker, "workerRevision": job.WorkerRevision, "algorithm": "sha256",
216
+ "sha256": job.Result.SHA256, "bytes": job.Result.Bytes, "inputDigest": job.InputDigest, "name": job.Name,
217
+ }}, nil
218
+ }
219
+
220
+ func backgroundError(err error) error {
221
+ switch {
222
+ case errors.Is(err, background.ErrBusy):
223
+ return &api.Error{Code: "jobs_busy", Message: "background jobs are busy; retry later"}
224
+ case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
225
+ return err
226
+ case errors.Is(err, background.ErrScheduleFull):
227
+ return &api.Error{Code: "schedule-full", Message: "the schedule limit has been reached"}
228
+ case errors.Is(err, background.ErrInputUnavailable):
229
+ return &api.Error{Code: "input-unavailable", Message: "import the private copy again"}
230
+ case errors.Is(err, background.ErrOwnerQueueFull):
231
+ return &api.Error{Code: "owner-queue-full", Message: "your pending job limit has been reached"}
232
+ case errors.Is(err, background.ErrOwnerQuota):
233
+ return &api.Error{Code: "owner-quota-exceeded", Message: "your background storage limit has been reached"}
234
+ case errors.Is(err, background.ErrInvalid):
235
+ return api.BadRequest("invalid background request")
236
+ case errors.Is(err, background.ErrTooLarge):
237
+ return &api.Error{Code: "limit-exceeded", Message: "the file is larger than the background limit"}
238
+ case errors.Is(err, background.ErrQueueFull):
239
+ return &api.Error{Code: "queue-full", Message: "the background queue is full"}
240
+ case errors.Is(err, background.ErrQuota):
241
+ return &api.Error{Code: "quota-exceeded", Message: "the background spool is full"}
242
+ case errors.Is(err, background.ErrKeyConflict), errors.Is(err, background.ErrNotCancelable):
243
+ return &api.Error{Code: "conflict", Message: "the request conflicts with the job's current state"}
244
+ case errors.Is(err, background.ErrNotFound):
245
+ return &api.Error{Code: "not_found", Message: "job not found"}
246
+ }
247
+ log.Printf("background spool error: %v", err)
248
+ return &api.Error{Code: "unavailable", Message: "the background spool is unavailable"}
249
+ }
@@ -0,0 +1,8 @@
1
+ package app
2
+
3
+ import "github.com/synopackageland/syno-pkg-land-sdk/api"
4
+
5
+ // backgroundRoutes is empty: this package did not opt in to background jobs.
6
+ // `synopkgland init --background` replaces this file with background_on.go,
7
+ // which serves BackgroundRoutes for the package-user worker unit.
8
+ func (Config) backgroundRoutes() []api.Route { return nil }
@@ -0,0 +1,38 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
7
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
8
+ "net/http"
9
+ )
10
+
11
+ type backgroundAccessKey struct{}
12
+
13
+ func accessFrom(ctx context.Context) background.Access {
14
+ return ctx.Value(backgroundAccessKey{}).(background.Access)
15
+ }
16
+ func (b backgroundAPI) owner(h backgroundHandler) api.RequestHandler {
17
+ return func(ctx context.Context, c api.Caller, r *http.Request, params json.RawMessage) (any, error) {
18
+ access := background.Access{Admin: c.Admin()}
19
+ access.Owner = background.Submitter{User: c.User(), UID: lookupSubmitterUID(c.User())}
20
+ if !access.Admin && !access.Owner.Valid() {
21
+ return nil, &api.Error{Code: "identity_unavailable", Message: "DSM user UID could not be resolved"}
22
+ }
23
+ return h(context.WithValue(ctx, backgroundAccessKey{}, access), c, r, params)
24
+ }
25
+ }
26
+ func (b backgroundAPI) purge(ctx context.Context, c api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
27
+ var in struct {
28
+ Owner *background.Submitter `json:"owner"`
29
+ }
30
+ if err := decodeParams(params, &in); err != nil || in.Owner == nil {
31
+ return nil, api.BadRequest("exact owner is required")
32
+ }
33
+ audit, err := b.store.PurgeOwner(ctx, *in.Owner, c.User())
34
+ if err != nil {
35
+ return nil, backgroundError(err)
36
+ }
37
+ return audit, nil
38
+ }
@@ -0,0 +1,163 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
6
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ func ordinaryEnv(t *testing.T) *bgEnv {
12
+ e := newBgEnv(t)
13
+ lookupSubmitterUID = func(name string) string {
14
+ switch name {
15
+ case "alice":
16
+ return "1024"
17
+ case "bob":
18
+ return "1025"
19
+ case "carol":
20
+ return "1026"
21
+ }
22
+ return ""
23
+ }
24
+ return e
25
+ }
26
+
27
+ var carol = stubAuth{identity: api.Identity{User: "carol"}}
28
+
29
+ func ownerSubmit(t *testing.T, e *bgEnv, a api.Authenticator, key string) string {
30
+ t.Helper()
31
+ code, out := e.call(a, "bg.jobs.submit", `{"key":"`+key+`","folder":"/share","relative":"a.txt"}`)
32
+ if code != "" {
33
+ t.Fatalf("submit %s: %s %v", key, code, out)
34
+ }
35
+ return data(out)["job"].(map[string]any)["id"].(string)
36
+ }
37
+ func TestBackgroundOwnersAreIsolatedIncludingKeysAndCounts(t *testing.T) {
38
+ e := ordinaryEnv(t)
39
+ a := ownerSubmit(t, e, alice, "same")
40
+ b := ownerSubmit(t, e, bob, "same")
41
+ c := ownerSubmit(t, e, carol, "same")
42
+ if a == b || b == c {
43
+ t.Fatal("idempotency key leaked across owners")
44
+ }
45
+ for _, auth := range []api.Authenticator{bob, carol} {
46
+ code, out := e.call(auth, "bg.jobs.list", `{}`)
47
+ if code != "" || len(data(out)["jobs"].([]any)) != 1 {
48
+ t.Fatalf("list: %s %v", code, out)
49
+ }
50
+ _, out = e.call(auth, "bg.status", `{}`)
51
+ if data(out)["queued"] != float64(1) || data(out)["admin"] != false {
52
+ t.Fatalf("status leaks: %v", out)
53
+ }
54
+ }
55
+ for _, method := range []string{"bg.jobs.get", "bg.jobs.cancel", "bg.jobs.result"} {
56
+ for _, id := range []string{a, c, strings.Repeat("0", 32)} {
57
+ if code, out := e.call(bob, method, `{"id":"`+id+`"}`); code != "not_found" {
58
+ t.Fatalf("%s %s: %s %v", method, id, code, out)
59
+ }
60
+ }
61
+ }
62
+ _, out := e.call(alice, "bg.jobs.list", `{}`)
63
+ if len(data(out)["jobs"].([]any)) != 3 {
64
+ t.Fatal("admin must see all")
65
+ }
66
+ if code, _ := e.call(alice, "bg.jobs.cancel", `{"id":"`+b+`"}`); code != "" {
67
+ t.Fatal(code)
68
+ }
69
+ }
70
+ func TestBackgroundOwnerResultAndUIDChangeFailClosed(t *testing.T) {
71
+ e := ordinaryEnv(t)
72
+ id := ownerSubmit(t, e, bob, "one")
73
+ ctx := context.Background()
74
+ _, _ = e.store.Claim(ctx)
75
+ if err := e.store.Finish(ctx, id, background.Outcome{State: background.StateSucceeded, Result: &background.Result{SHA256: "digest", Bytes: 5}}); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ if code, _ := e.call(bob, "bg.jobs.result", `{"id":"`+id+`"}`); code != "" {
79
+ t.Fatal(code)
80
+ }
81
+ if code, _ := e.call(carol, "bg.jobs.result", `{"id":"`+id+`"}`); code != "not_found" {
82
+ t.Fatal(code)
83
+ }
84
+ lookupSubmitterUID = func(string) string { return "2000" }
85
+ _, out := e.call(bob, "bg.jobs.list", `{}`)
86
+ if len(data(out)["jobs"].([]any)) != 0 {
87
+ t.Fatal("same name with new UID saw old jobs")
88
+ }
89
+ if code, _ := e.call(bob, "bg.jobs.get", `{"id":"`+id+`"}`); code != "not_found" {
90
+ t.Fatal(code)
91
+ }
92
+ lookupSubmitterUID = func(string) string { return "" }
93
+ if code, _ := e.call(bob, "bg.jobs.list", `{}`); code != "identity_unavailable" {
94
+ t.Fatal(code)
95
+ }
96
+ }
97
+ func TestBackgroundPersonalQuotaAndAdminPurge(t *testing.T) {
98
+ e := ordinaryEnv(t)
99
+ for _, method := range []string{"bg.jobs.purge"} {
100
+ if code, _ := e.call(bob, method, `{}`); code != "admin_required" {
101
+ t.Fatalf("%s: %s", method, code)
102
+ }
103
+ }
104
+ ownerSubmit(t, e, bob, "one")
105
+ ownerSubmit(t, e, bob, "two")
106
+ if code, _ := e.call(bob, "bg.jobs.submit", `{"key":"three","folder":"/share","relative":"a.txt"}`); code != "owner-queue-full" {
107
+ t.Fatal(code)
108
+ }
109
+ ownerSubmit(t, e, carol, "one")
110
+
111
+ }
112
+ func TestBackgroundPurgeRequiresAdminAndExactOwner(t *testing.T) {
113
+ e := ordinaryEnv(t)
114
+ id := ownerSubmit(t, e, bob, "one")
115
+ ownerSubmit(t, e, carol, "one")
116
+ code, out := e.call(alice, "bg.jobs.purge", `{"owner":{"user":"bob","uid":"1025"}}`)
117
+ if code != "" || data(out)["removed"] != float64(1) {
118
+ t.Fatalf("purge: %s %v", code, out)
119
+ }
120
+ if code, _ := e.call(alice, "bg.jobs.get", `{"id":"`+id+`"}`); code != "not_found" {
121
+ t.Fatal(code)
122
+ }
123
+ _, out = e.call(alice, "bg.jobs.list", `{}`)
124
+ if len(data(out)["jobs"].([]any)) != 1 {
125
+ t.Fatal("purge touched another owner")
126
+ }
127
+ }
128
+
129
+ func TestFreshOrdinaryUserJobsNeedNoAdminSetup(t *testing.T) {
130
+ e := newBgEnv(t)
131
+ lookupSubmitterUID = func(name string) string {
132
+ if name == "bob" {
133
+ return "1025"
134
+ }
135
+ return "1026"
136
+ }
137
+ code, out := e.call(bob, "bg.status", `{}`)
138
+ if code != "" {
139
+ t.Fatalf("fresh status: %s %v", code, out)
140
+ }
141
+ for _, key := range []string{"enabled", "allowOrdinaryUsers"} {
142
+ if _, exists := data(out)[key]; exists {
143
+ t.Fatalf("obsolete status switch %s", key)
144
+ }
145
+ }
146
+ id := ownerSubmit(t, e, bob, "fresh")
147
+ for _, method := range []string{"bg.jobs.get", "bg.jobs.cancel"} {
148
+ if code, _ := e.call(carol, method, `{"id":"`+id+`"}`); code != "not_found" {
149
+ t.Fatalf("other owner %s: %s", method, code)
150
+ }
151
+ }
152
+ if code, _ := e.call(bob, "bg.jobs.get", `{"id":"`+id+`"}`); code != "" {
153
+ t.Fatal(code)
154
+ }
155
+ if code, _ := e.call(bob, "bg.jobs.cancel", `{"id":"`+id+`"}`); code != "" {
156
+ t.Fatal(code)
157
+ }
158
+ for _, method := range []string{"bg.mode.set", "bg.users.set"} {
159
+ if code, _ := e.call(alice, method, `{}`); code != "unknown_method" {
160
+ t.Fatalf("obsolete route %s: %s", method, code)
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,69 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
7
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
8
+ "net/http"
9
+ )
10
+
11
+ func (b backgroundAPI) schedules(ctx context.Context, _ api.Caller, _ *http.Request, p json.RawMessage) (any, error) {
12
+ if err := decodeParams(p, &struct{}{}); err != nil {
13
+ return nil, err
14
+ }
15
+ list, err := b.store.Schedules(ctx, accessFrom(ctx))
16
+ if err != nil {
17
+ return nil, backgroundError(err)
18
+ }
19
+ return map[string]any{"schedules": list}, nil
20
+ }
21
+ func (b backgroundAPI) createSchedule(ctx context.Context, _ api.Caller, _ *http.Request, p json.RawMessage) (any, error) {
22
+ var in background.ScheduleRequest
23
+ if err := decodeParams(p, &in); err != nil {
24
+ return nil, err
25
+ }
26
+ if !accessFrom(ctx).Owner.Valid() {
27
+ return nil, &api.Error{Code: "identity_unavailable", Message: "DSM user UID could not be resolved"}
28
+ }
29
+ sc, dup, err := b.store.CreateSchedule(ctx, in, accessFrom(ctx))
30
+ if err != nil {
31
+ return nil, backgroundError(err)
32
+ }
33
+ return map[string]any{"schedule": sc, "duplicate": dup}, nil
34
+ }
35
+ func (b backgroundAPI) disableSchedule(ctx context.Context, _ api.Caller, _ *http.Request, p json.RawMessage) (any, error) {
36
+ var in idParams
37
+ if err := decodeParams(p, &in); err != nil {
38
+ return nil, err
39
+ }
40
+ sc, err := b.store.SetSchedule(ctx, in.ID, false, accessFrom(ctx))
41
+ if err != nil {
42
+ return nil, backgroundError(err)
43
+ }
44
+ return map[string]any{"schedule": sc}, nil
45
+ }
46
+ func (b backgroundAPI) enableSchedule(ctx context.Context, c api.Caller, _ *http.Request, p json.RawMessage) (any, error) {
47
+ var in struct {
48
+ ID string `json:"id"`
49
+ Consent bool `json:"consent"`
50
+ }
51
+ if err := decodeParams(p, &in); err != nil || !in.Consent {
52
+ return nil, api.BadRequest("explicit consent is required")
53
+ }
54
+ sc, err := b.store.SetSchedule(ctx, in.ID, true, background.Access{Admin: true, Owner: background.Submitter{User: c.User(), UID: lookupSubmitterUID(c.User())}})
55
+ if err != nil {
56
+ return nil, backgroundError(err)
57
+ }
58
+ return map[string]any{"schedule": sc}, nil
59
+ }
60
+ func (b backgroundAPI) deleteSchedule(ctx context.Context, _ api.Caller, _ *http.Request, p json.RawMessage) (any, error) {
61
+ var in idParams
62
+ if err := decodeParams(p, &in); err != nil {
63
+ return nil, err
64
+ }
65
+ if err := b.store.DeleteSchedule(ctx, in.ID, accessFrom(ctx)); err != nil {
66
+ return nil, backgroundError(err)
67
+ }
68
+ return map[string]bool{"deleted": true}, nil
69
+ }
@@ -0,0 +1,62 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
7
+ "testing"
8
+ )
9
+
10
+ func TestBackgroundScheduleRoutesConsentOwnerAndAdmin(t *testing.T) {
11
+ e := ordinaryEnv(t)
12
+ id := ownerSubmit(t, e, bob, "copy")
13
+ ctx := context.Background()
14
+ _, _ = e.store.Claim(ctx)
15
+ _ = e.store.Finish(ctx, id, background.Outcome{State: background.StateSucceeded, Result: &background.Result{Bytes: 5}})
16
+ params := fmt.Sprintf(`{"key":"s","jobId":%q,"intervalSeconds":3600,"consent":true}`, id)
17
+ if code, _ := e.call(carol, "bg.schedules.create", params); code != "not_found" {
18
+ t.Fatal(code)
19
+ }
20
+ for _, p := range []string{fmt.Sprintf(`{"key":"s","jobId":%q,"intervalSeconds":3600}`, id), fmt.Sprintf(`{"key":"s","jobId":%q,"intervalSeconds":3600,"consent":true,"owner":"carol"}`, id)} {
21
+ if code, _ := e.call(bob, "bg.schedules.create", p); code != "bad_request" {
22
+ t.Fatal(code)
23
+ }
24
+ }
25
+ code, out := e.call(bob, "bg.schedules.create", params)
26
+ if code != "" {
27
+ t.Fatalf("create %s %v", code, out)
28
+ }
29
+ sid := data(out)["schedule"].(map[string]any)["id"].(string)
30
+ _, out = e.call(carol, "bg.schedules.list", `{}`)
31
+ if len(data(out)["schedules"].([]any)) != 0 {
32
+ t.Fatal("list leak")
33
+ }
34
+ _, out = e.call(alice, "bg.schedules.list", `{}`)
35
+ if len(data(out)["schedules"].([]any)) != 1 {
36
+ t.Fatal("admin list")
37
+ }
38
+ for _, m := range []string{"bg.schedules.disable", "bg.schedules.delete"} {
39
+ if code, _ := e.call(carol, m, fmt.Sprintf(`{"id":%q}`, sid)); code != "not_found" {
40
+ t.Fatal(code)
41
+ }
42
+ }
43
+ if code, _ := e.call(bob, "bg.schedules.enable", fmt.Sprintf(`{"id":%q,"consent":true}`, sid)); code != "admin_required" {
44
+ t.Fatal(code)
45
+ }
46
+ if code, _ := e.call(alice, "bg.schedules.disable", fmt.Sprintf(`{"id":%q}`, sid)); code != "" {
47
+ t.Fatal(code)
48
+ }
49
+ if code, _ := e.call(alice, "bg.schedules.enable", fmt.Sprintf(`{"id":%q}`, sid)); code != "bad_request" {
50
+ t.Fatal(code)
51
+ }
52
+ if code, _ := e.call(alice, "bg.schedules.enable", fmt.Sprintf(`{"id":%q,"consent":true}`, sid)); code != "" {
53
+ t.Fatal(code)
54
+ }
55
+ if code, _ := e.call(bob, "bg.schedules.delete", fmt.Sprintf(`{"id":%q}`, sid)); code != "" {
56
+ t.Fatal(code)
57
+ }
58
+ // No scheduling route calls File Station, uses a root grant or stores a session.
59
+ if e.files.calls != 1 {
60
+ t.Fatalf("schedule read File Station %d", e.files.calls)
61
+ }
62
+ }