@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.
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/package.json +11 -4
- package/templates/_shared/AGENTS.md +146 -0
- package/templates/_shared/CLAUDE.md +1 -0
- package/templates/_shared/GO-LICENSE +27 -0
- package/templates/_shared/GO-PATENTS +22 -0
- package/templates/_shared/LICENSES/TEMPLATE.txt +21 -0
- package/templates/_shared/third-party-notices.mjs +55 -0
- package/templates/binary/LICENSES/GO-LICENSE +27 -0
- package/templates/binary/LICENSES/GO-PATENTS +22 -0
- package/templates/binary/dsm-package.yaml +46 -0
- package/templates/binary/example/main.go +16 -0
- package/templates/binary/gitignore +2 -0
- package/templates/binary/i18n/messages/de.json +4 -0
- package/templates/binary/i18n/messages/en.json +4 -0
- package/templates/binary/i18n/messages/es.json +4 -0
- package/templates/binary/i18n/messages/fr.json +4 -0
- package/templates/binary/i18n/messages/it.json +4 -0
- package/templates/binary/i18n/messages/ja.json +4 -0
- package/templates/binary/i18n/messages/ko.json +4 -0
- package/templates/binary/i18n/messages/zh-CN.json +4 -0
- package/templates/binary/i18n/messages/zh-TW.json +4 -0
- package/templates/binary/ui/app.css +4 -0
- package/templates/binary/ui/app.js +32 -0
- package/templates/compose/compose.yaml +8 -0
- package/templates/compose/synology-app.yaml +16 -0
- package/templates/native-go/README.md +368 -0
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +53 -0
- package/templates/native-go/backend/cmd/app/main.go +101 -0
- package/templates/native-go/backend/cmd/background-worker/main.go +38 -0
- package/templates/native-go/backend/go.mod +9 -0
- package/templates/native-go/backend/go.sum +4 -0
- package/templates/native-go/backend/internal/api/permissions.go +26 -0
- package/templates/native-go/backend/internal/api/permissions.json +5 -0
- package/templates/native-go/backend/internal/api/permissions_test.go +30 -0
- package/templates/native-go/backend/internal/app/background.go +249 -0
- package/templates/native-go/backend/internal/app/background_none.go +8 -0
- package/templates/native-go/backend/internal/app/background_owner.go +38 -0
- package/templates/native-go/backend/internal/app/background_owner_test.go +163 -0
- package/templates/native-go/backend/internal/app/background_schedule.go +69 -0
- package/templates/native-go/backend/internal/app/background_schedule_test.go +62 -0
- package/templates/native-go/backend/internal/app/background_test.go +320 -0
- package/templates/native-go/backend/internal/app/delegate_config.go +57 -0
- package/templates/native-go/backend/internal/app/delegate_config_test.go +28 -0
- package/templates/native-go/backend/internal/app/delegate_none.go +5 -0
- package/templates/native-go/backend/internal/app/identity.go +20 -0
- package/templates/native-go/backend/internal/app/identity_test.go +15 -0
- package/templates/native-go/backend/internal/app/migrations.go +41 -0
- package/templates/native-go/backend/internal/app/migrations_test.go +41 -0
- package/templates/native-go/backend/internal/app/portal.go +30 -0
- package/templates/native-go/backend/internal/app/portal_test.go +55 -0
- package/templates/native-go/backend/internal/app/root_none.go +9 -0
- package/templates/native-go/backend/internal/app/routes.go +137 -0
- package/templates/native-go/backend/internal/app/routes_test.go +197 -0
- package/templates/native-go/backend/internal/app/session_files.go +9 -0
- package/templates/native-go/backend/internal/app/session_files_test.go +14 -0
- package/templates/native-go/backend/internal/app/shared_folder.go +144 -0
- package/templates/native-go/backend/internal/app/shared_folder_test.go +158 -0
- package/templates/native-go/dsm-package.yaml +71 -0
- package/templates/native-go/gitignore +1 -0
- package/templates/native-go/i18n/glossary.json +663 -0
- package/templates/native-go/i18n/messages/de.json +29 -0
- package/templates/native-go/i18n/messages/en.json +29 -0
- package/templates/native-go/i18n/messages/es.json +29 -0
- package/templates/native-go/i18n/messages/fr.json +29 -0
- package/templates/native-go/i18n/messages/it.json +29 -0
- package/templates/native-go/i18n/messages/ja.json +29 -0
- package/templates/native-go/i18n/messages/ko.json +29 -0
- package/templates/native-go/i18n/messages/zh-CN.json +29 -0
- package/templates/native-go/i18n/messages/zh-TW.json +29 -0
- package/templates/native-go/ui/app.css +45 -0
- package/templates/native-go/ui/app.js +111 -0
- package/templates/native-go-delegate/backend/cmd/background-worker/main.go +49 -0
- package/templates/native-go-delegate/backend/internal/app/delegate.go +25 -0
- package/templates/native-go-delegate/backend/internal/app/delegate_on.go +5 -0
- package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +31 -0
- package/templates/native-go-delegate/frontend/src/CapabilityPanels.vue +32 -0
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +77 -0
- package/templates/native-go-jobs/backend/internal/app/background_on.go +7 -0
- package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +87 -0
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +20 -0
- package/templates/native-go-root-ops/backend/internal/app/executor.go +140 -0
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +118 -0
- package/templates/native-go-root-ops/root/whoami.sh +3 -0
- package/templates/native-go-vue/README.md +51 -0
- package/templates/native-go-vue/frontend/DESIGN.md +61 -0
- package/templates/native-go-vue/frontend/package-lock.json +2550 -0
- package/templates/native-go-vue/frontend/package.json +23 -0
- package/templates/native-go-vue/frontend/src/App.vue +90 -0
- package/templates/native-go-vue/frontend/src/CapabilityPanels.vue +6 -0
- package/templates/native-go-vue/frontend/src/i18n/de.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/en.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/es.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/fr.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/it.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/ja.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/ko.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n.ts +13 -0
- package/templates/native-go-vue/frontend/src/main.ts +33 -0
- package/templates/native-go-vue/frontend/src/style.css +27 -0
- package/templates/native-go-vue/frontend/test/apps.test.ts +57 -0
- package/templates/native-go-vue/frontend/test/native-controls.test.ts +27 -0
- package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +50 -0
- package/templates/native-go-vue/frontend/tsconfig.json +1 -0
- package/templates/native-go-vue/frontend/vite.config.ts +11 -0
- 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
|
+
}
|