@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,137 @@
|
|
|
1
|
+
// Package app holds this package's business operations. Add a route here and
|
|
2
|
+
// register its handler here. App permissions live in internal/api/permissions.json; SDK capability routes
|
|
3
|
+
// use the immutable SDK policy.
|
|
4
|
+
// unlisted methods require membership in DSM's administrators group.
|
|
5
|
+
//
|
|
6
|
+
//line __PACKAGE__/internal/app/routes.go:1
|
|
7
|
+
package app
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"bytes"
|
|
11
|
+
"context"
|
|
12
|
+
"encoding/json"
|
|
13
|
+
"errors"
|
|
14
|
+
"io/fs"
|
|
15
|
+
"os"
|
|
16
|
+
"path/filepath"
|
|
17
|
+
"runtime"
|
|
18
|
+
"time"
|
|
19
|
+
"unicode/utf8"
|
|
20
|
+
|
|
21
|
+
"github.com/synopackageland/syno-pkg-land-sdk/api"
|
|
22
|
+
"github.com/synopackageland/syno-pkg-land-sdk/filestation"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// Config is fixed at build time and startup, never taken from a request.
|
|
26
|
+
type Config struct {
|
|
27
|
+
DelegateScope string
|
|
28
|
+
DelegateOperationsJSON, DelegateDestinationsJSON, DelegateLifetime string
|
|
29
|
+
ExecutorDigest, PolicyDigest string
|
|
30
|
+
|
|
31
|
+
// SessionFiles is the interactive File Station adapter; nil keeps legacy refusal.
|
|
32
|
+
SessionFiles *filestation.Service
|
|
33
|
+
PackageID string
|
|
34
|
+
Version string
|
|
35
|
+
// DataDir is the package's persistent, package-owned directory
|
|
36
|
+
// (/var/packages/<package>/var). It survives upgrades.
|
|
37
|
+
DataDir string
|
|
38
|
+
// VolumeRoot is where /volumeN lives; empty means "/". Tests set it.
|
|
39
|
+
VolumeRoot string
|
|
40
|
+
// Portal serves portal.status; PortalConfig overrides DSM's saved
|
|
41
|
+
// AppPortal.json path in tests.
|
|
42
|
+
Portal bool
|
|
43
|
+
PortalConfig string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func Routes(cfg Config) []api.Route {
|
|
47
|
+
routes := cfg.delegateRoutes()
|
|
48
|
+
routes = append(routes, []api.Route{
|
|
49
|
+
api.Handle("app.status", cfg.status),
|
|
50
|
+
api.Handle("greeting.get", cfg.getGreeting),
|
|
51
|
+
api.Handle("greeting.set", cfg.setGreeting),
|
|
52
|
+
}...)
|
|
53
|
+
routes = append(routes, cfg.SessionFiles.Routes()...)
|
|
54
|
+
routes = append(routes, cfg.backgroundRoutes()...)
|
|
55
|
+
routes = append(routes, cfg.portalRoutes()...)
|
|
56
|
+
return append(routes, cfg.rootRoutes()...)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func (cfg Config) status(_ context.Context, caller api.Caller, _ json.RawMessage) (any, error) {
|
|
60
|
+
return cfg.withSharedFolders(map[string]any{
|
|
61
|
+
"package": cfg.PackageID,
|
|
62
|
+
"version": cfg.Version,
|
|
63
|
+
"user": caller.User(),
|
|
64
|
+
"admin": caller.Admin(),
|
|
65
|
+
"process": map[string]int{"uid": os.Getuid(), "gid": os.Getgid()},
|
|
66
|
+
"go_version": runtime.Version(),
|
|
67
|
+
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
|
68
|
+
"time": time.Now().UTC().Format(time.RFC3339),
|
|
69
|
+
}), nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type greeting struct {
|
|
73
|
+
Text string `json:"text"`
|
|
74
|
+
UpdatedBy string `json:"updated_by,omitempty"`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func (cfg Config) greetingPath() string { return filepath.Join(cfg.DataDir, "greeting.json") }
|
|
78
|
+
|
|
79
|
+
func (cfg Config) getGreeting(context.Context, api.Caller, json.RawMessage) (any, error) {
|
|
80
|
+
data, err := os.ReadFile(cfg.greetingPath())
|
|
81
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
82
|
+
return greeting{Text: "Hello"}, nil
|
|
83
|
+
}
|
|
84
|
+
if err != nil {
|
|
85
|
+
return nil, err
|
|
86
|
+
}
|
|
87
|
+
var g greeting
|
|
88
|
+
return g, json.Unmarshal(data, &g)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func (cfg Config) setGreeting(_ context.Context, caller api.Caller, params json.RawMessage) (any, error) {
|
|
92
|
+
var in struct {
|
|
93
|
+
Text string `json:"text"`
|
|
94
|
+
}
|
|
95
|
+
if err := decodeParams(params, &in); err != nil {
|
|
96
|
+
return nil, err
|
|
97
|
+
}
|
|
98
|
+
if n := utf8.RuneCountInString(in.Text); n < 1 || n > 200 {
|
|
99
|
+
return nil, api.BadRequest("text must be 1 to 200 characters")
|
|
100
|
+
}
|
|
101
|
+
g := greeting{Text: in.Text, UpdatedBy: caller.User()}
|
|
102
|
+
data, err := json.Marshal(g)
|
|
103
|
+
if err != nil {
|
|
104
|
+
return nil, err
|
|
105
|
+
}
|
|
106
|
+
return g, writeFileAtomic(cfg.greetingPath(), data)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// decodeParams decodes params strictly: unknown fields are an error.
|
|
110
|
+
func decodeParams(params json.RawMessage, into any) error {
|
|
111
|
+
if len(params) == 0 {
|
|
112
|
+
params = json.RawMessage(`{}`)
|
|
113
|
+
}
|
|
114
|
+
dec := json.NewDecoder(bytes.NewReader(params))
|
|
115
|
+
dec.DisallowUnknownFields()
|
|
116
|
+
if err := dec.Decode(into); err != nil {
|
|
117
|
+
return api.BadRequest("invalid params: " + err.Error())
|
|
118
|
+
}
|
|
119
|
+
return nil
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// writeFileAtomic replaces path in one rename, so readers never see half a file.
|
|
123
|
+
func writeFileAtomic(path string, data []byte) error {
|
|
124
|
+
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
|
|
125
|
+
if err != nil {
|
|
126
|
+
return err
|
|
127
|
+
}
|
|
128
|
+
defer os.Remove(tmp.Name())
|
|
129
|
+
if _, err := tmp.Write(data); err != nil {
|
|
130
|
+
tmp.Close()
|
|
131
|
+
return err
|
|
132
|
+
}
|
|
133
|
+
if err := tmp.Close(); err != nil {
|
|
134
|
+
return err
|
|
135
|
+
}
|
|
136
|
+
return os.Rename(tmp.Name(), path)
|
|
137
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"os"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"github.com/synopackageland/syno-pkg-land-sdk/api"
|
|
12
|
+
|
|
13
|
+
appapi "__PACKAGE__/internal/api"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type stubAuth struct {
|
|
17
|
+
identity api.Identity
|
|
18
|
+
err error
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func (s stubAuth) Authenticate(*http.Request) (api.Identity, error) { return s.identity, s.err }
|
|
22
|
+
|
|
23
|
+
func testConfig(t *testing.T) Config {
|
|
24
|
+
return Config{PackageID: "HelloGo", Version: "0.1.0-0001", DataDir: t.TempDir()}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// rule is the access rule the server applies to r: the App policy for App
|
|
28
|
+
// methods, the SDK policy (reported by the route) for SDK methods.
|
|
29
|
+
func rule(t *testing.T, r api.Route) string {
|
|
30
|
+
t.Helper()
|
|
31
|
+
raw, err := appapi.Permissions()
|
|
32
|
+
if err != nil {
|
|
33
|
+
t.Fatal(err)
|
|
34
|
+
}
|
|
35
|
+
var policy struct{ Methods map[string]string }
|
|
36
|
+
if err := json.Unmarshal(raw, &policy); err != nil {
|
|
37
|
+
t.Fatal(err)
|
|
38
|
+
}
|
|
39
|
+
switch value, ok := policy.Methods[r.Method()]; {
|
|
40
|
+
case ok:
|
|
41
|
+
return value
|
|
42
|
+
case r.Public():
|
|
43
|
+
return "public"
|
|
44
|
+
case r.AdminOnly():
|
|
45
|
+
return api.AdministratorPermission
|
|
46
|
+
}
|
|
47
|
+
return "authenticated"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// call returns the envelope's error code ("" on success) and the envelope.
|
|
51
|
+
func call(t *testing.T, cfg Config, auth api.Authenticator, method, params string, permissionJSON ...[]byte) (string, map[string]any) {
|
|
52
|
+
t.Helper()
|
|
53
|
+
previous := lookupSubmitterUID
|
|
54
|
+
lookupSubmitterUID = func(string) string { return "1024" }
|
|
55
|
+
defer func() { lookupSubmitterUID = previous }()
|
|
56
|
+
permissions, err := appapi.Permissions()
|
|
57
|
+
if err != nil {
|
|
58
|
+
t.Fatal(err)
|
|
59
|
+
}
|
|
60
|
+
if len(permissionJSON) != 0 {
|
|
61
|
+
permissions = permissionJSON[0]
|
|
62
|
+
}
|
|
63
|
+
srv, err := api.NewServerWithPermissions(auth, permissions, Routes(cfg)...)
|
|
64
|
+
if err != nil {
|
|
65
|
+
t.Fatal(err)
|
|
66
|
+
}
|
|
67
|
+
body := `{"method":"` + method + `","params":` + params + `}`
|
|
68
|
+
req := httptest.NewRequest(http.MethodPost, "/api.cgi", strings.NewReader(body))
|
|
69
|
+
req.Header.Set("Content-Type", "application/json")
|
|
70
|
+
rec := httptest.NewRecorder()
|
|
71
|
+
srv.ServeHTTP(rec, req)
|
|
72
|
+
var out map[string]any
|
|
73
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
74
|
+
t.Fatalf("not JSON: %s", rec.Body.String())
|
|
75
|
+
}
|
|
76
|
+
if rec.Code != http.StatusOK {
|
|
77
|
+
t.Fatalf("HTTP %d: DSM replaces non-200 CGI bodies", rec.Code)
|
|
78
|
+
}
|
|
79
|
+
code := ""
|
|
80
|
+
if e, ok := out["error"].(map[string]any); ok {
|
|
81
|
+
code, _ = e["code"].(string)
|
|
82
|
+
}
|
|
83
|
+
return code, out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var (
|
|
87
|
+
alice = stubAuth{identity: api.Identity{User: "alice", Admin: true}}
|
|
88
|
+
bob = stubAuth{identity: api.Identity{User: "bob"}}
|
|
89
|
+
nobody = stubAuth{err: api.ErrNoSession}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// sampleParams holds valid params for every route, so the denial test below
|
|
93
|
+
// proves the refusal came from the policy and not from bad input.
|
|
94
|
+
// Expected domain errors for routes an overlay adds (fresh store, no DSM cookie).
|
|
95
|
+
var sampleOutcome = map[string]string{}
|
|
96
|
+
|
|
97
|
+
var sampleParams = map[string]string{
|
|
98
|
+
"app.status": `{}`,
|
|
99
|
+
"greeting.get": `{}`,
|
|
100
|
+
"greeting.set": `{"text":"Hi"}`,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func TestEveryRouteHasSampleParamsAndNoneIsPublic(t *testing.T) {
|
|
104
|
+
routes := Routes(testConfig(t))
|
|
105
|
+
if len(routes) != len(sampleParams) {
|
|
106
|
+
t.Fatalf("%d routes but %d sample params: add the new route to sampleParams", len(routes), len(sampleParams))
|
|
107
|
+
}
|
|
108
|
+
for _, r := range routes {
|
|
109
|
+
if _, ok := sampleParams[r.Method()]; !ok {
|
|
110
|
+
t.Errorf("route %s has no sample params", r.Method())
|
|
111
|
+
}
|
|
112
|
+
if rule(t, r) == "public" {
|
|
113
|
+
t.Errorf("route %s is public; this template ships none", r.Method())
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func TestEveryRouteRefusesCallersWithoutASessionAndChangesNothing(t *testing.T) {
|
|
119
|
+
for _, r := range Routes(testConfig(t)) {
|
|
120
|
+
cfg := testConfig(t)
|
|
121
|
+
code, _ := call(t, cfg, nobody, r.Method(), sampleParams[r.Method()])
|
|
122
|
+
if code != "session_required" {
|
|
123
|
+
t.Errorf("%s: %q, want session_required", r.Method(), code)
|
|
124
|
+
}
|
|
125
|
+
assertDataDirEmpty(t, cfg)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func TestAdministratorRoutesRefuseOrdinaryUsersAndChangeNothing(t *testing.T) {
|
|
130
|
+
for _, r := range Routes(testConfig(t)) {
|
|
131
|
+
if rule(t, r) != api.AdministratorPermission {
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
cfg := testConfig(t)
|
|
135
|
+
code, _ := call(t, cfg, bob, r.Method(), sampleParams[r.Method()])
|
|
136
|
+
if code != "admin_required" {
|
|
137
|
+
t.Errorf("%s: %q, want admin_required", r.Method(), code)
|
|
138
|
+
}
|
|
139
|
+
assertDataDirEmpty(t, cfg)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func TestEveryRouteSucceedsForAnAdmin(t *testing.T) {
|
|
144
|
+
for _, r := range Routes(testConfig(t)) {
|
|
145
|
+
code, out := call(t, testConfig(t), alice, r.Method(), sampleParams[r.Method()])
|
|
146
|
+
want := sampleOutcome[r.Method()]
|
|
147
|
+
if strings.HasPrefix(r.Method(), "jobs.") {
|
|
148
|
+
want = "unavailable"
|
|
149
|
+
}
|
|
150
|
+
if code != want {
|
|
151
|
+
t.Errorf("%s: %q %v", r.Method(), code, out)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func TestStatusReportsPackageAndCaller(t *testing.T) {
|
|
157
|
+
code, out := call(t, testConfig(t), alice, "app.status", `{}`)
|
|
158
|
+
data := out["data"].(map[string]any)
|
|
159
|
+
if code != "" || data["package"] != "HelloGo" || data["version"] != "0.1.0-0001" || data["user"] != "alice" || data["admin"] != true {
|
|
160
|
+
t.Fatalf("got %q %v", code, out)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func TestGreetingDefaultsAndAdminCanChangeIt(t *testing.T) {
|
|
165
|
+
cfg := testConfig(t)
|
|
166
|
+
_, out := call(t, cfg, alice, "greeting.get", `{}`)
|
|
167
|
+
if got := out["data"].(map[string]any)["text"]; got != "Hello" {
|
|
168
|
+
t.Fatalf("default greeting = %v", got)
|
|
169
|
+
}
|
|
170
|
+
if code, out := call(t, cfg, alice, "greeting.set", `{"text":"Hi from DSM"}`); code != "" {
|
|
171
|
+
t.Fatalf("set: %q %v", code, out)
|
|
172
|
+
}
|
|
173
|
+
_, out = call(t, cfg, alice, "greeting.get", `{}`)
|
|
174
|
+
data := out["data"].(map[string]any)
|
|
175
|
+
if data["text"] != "Hi from DSM" || data["updated_by"] != "alice" {
|
|
176
|
+
t.Fatalf("after set: %v", data)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func TestGreetingSetValidatesText(t *testing.T) {
|
|
181
|
+
for _, params := range []string{`{}`, `{"text":""}`, `{"text":"` + strings.Repeat("x", 201) + `"}`, `{"text":7}`, `{"text":"ok","extra":1}`} {
|
|
182
|
+
cfg := testConfig(t)
|
|
183
|
+
code, _ := call(t, cfg, alice, "greeting.set", params)
|
|
184
|
+
if code != "bad_request" {
|
|
185
|
+
t.Errorf("%s: %q, want bad_request", params, code)
|
|
186
|
+
}
|
|
187
|
+
assertDataDirEmpty(t, cfg)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func assertDataDirEmpty(t *testing.T, cfg Config) {
|
|
192
|
+
t.Helper()
|
|
193
|
+
entries, err := os.ReadDir(cfg.DataDir)
|
|
194
|
+
if err != nil || len(entries) != 0 {
|
|
195
|
+
t.Fatalf("data dir changed: %v %v", entries, err)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
// FileStationSession selects the interactive File Station adapter using the caller’s DSM session.
|
|
4
|
+
const FileStationSession = "session"
|
|
5
|
+
|
|
6
|
+
// SessionFilesEnabled accepts only the fixed build-time session setting.
|
|
7
|
+
func SessionFilesEnabled(fileStation string) bool {
|
|
8
|
+
return fileStation == FileStationSession
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestSessionFilesEnabledByTrustedBuildSetting(t *testing.T) {
|
|
6
|
+
for _, tc := range []struct {
|
|
7
|
+
value string
|
|
8
|
+
want bool
|
|
9
|
+
}{{"", false}, {"session", true}, {"yes", false}} {
|
|
10
|
+
if got := SessionFilesEnabled(tc.value); got != tc.want {
|
|
11
|
+
t.Errorf("%q: got %v, want %v", tc.value, got, tc.want)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io/fs"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"sort"
|
|
11
|
+
"strings"
|
|
12
|
+
"syscall"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// The install wizard stores the chosen Shared Folder's *name* in
|
|
16
|
+
// <DataDir>/config.json (see dsm-package.yaml install.sharedFolder). The path
|
|
17
|
+
// is resolved on every call, so a Shared Folder moved to another volume keeps
|
|
18
|
+
// working. config.json is written by the package's postinst/postupgrade, not
|
|
19
|
+
// by the app.
|
|
20
|
+
|
|
21
|
+
var (
|
|
22
|
+
ErrSharedFolderNotFound = errors.New("shared folder not found on any volume")
|
|
23
|
+
ErrSharedFolderAmbiguous = errors.New("shared folder name matches more than one volume")
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// SharedFolder says where one configured Shared Folder is right now and what
|
|
27
|
+
// the package account may do there.
|
|
28
|
+
type SharedFolder struct {
|
|
29
|
+
Key string `json:"key"`
|
|
30
|
+
Name string `json:"name"`
|
|
31
|
+
Path string `json:"path,omitempty"`
|
|
32
|
+
Exists bool `json:"exists"`
|
|
33
|
+
Readable bool `json:"readable"`
|
|
34
|
+
Writable bool `json:"writable"`
|
|
35
|
+
Error string `json:"error,omitempty"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func validShareName(name string) bool {
|
|
39
|
+
return name != "" && !strings.ContainsAny(name, "/\\\x00") && !strings.Contains(name, "..") &&
|
|
40
|
+
!strings.HasPrefix(name, "@") && !strings.HasPrefix(name, ".")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ReadSharedFolders returns the Shared Folder names the installer stored, by
|
|
44
|
+
// key. A package without config.json has none.
|
|
45
|
+
func ReadSharedFolders(dataDir string) (map[string]string, error) {
|
|
46
|
+
data, err := os.ReadFile(filepath.Join(dataDir, "config.json"))
|
|
47
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
48
|
+
return nil, nil
|
|
49
|
+
}
|
|
50
|
+
if err != nil {
|
|
51
|
+
return nil, err
|
|
52
|
+
}
|
|
53
|
+
var cfg struct {
|
|
54
|
+
Schema int `json:"schema"`
|
|
55
|
+
SharedFolders map[string]string `json:"shared_folders"`
|
|
56
|
+
}
|
|
57
|
+
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
58
|
+
return nil, fmt.Errorf("config.json: %w", err)
|
|
59
|
+
}
|
|
60
|
+
if cfg.Schema != 1 {
|
|
61
|
+
return nil, fmt.Errorf("config.json: unsupported schema %d", cfg.Schema)
|
|
62
|
+
}
|
|
63
|
+
for key, name := range cfg.SharedFolders {
|
|
64
|
+
if !validShareName(name) {
|
|
65
|
+
return nil, fmt.Errorf("config.json: invalid shared folder name for %q", key)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return cfg.SharedFolders, nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ResolveSharedFolder finds /volumeN/<name> under root ("/" on a NAS).
|
|
72
|
+
func ResolveSharedFolder(root, name string) (string, error) {
|
|
73
|
+
if !validShareName(name) {
|
|
74
|
+
return "", fmt.Errorf("invalid shared folder name %q", name)
|
|
75
|
+
}
|
|
76
|
+
volumes, err := filepath.Glob(filepath.Join(root, "volume[0-9]*"))
|
|
77
|
+
if err != nil {
|
|
78
|
+
return "", err
|
|
79
|
+
}
|
|
80
|
+
var found []string
|
|
81
|
+
for _, volume := range volumes {
|
|
82
|
+
path := filepath.Join(volume, name)
|
|
83
|
+
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
|
84
|
+
found = append(found, path)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
switch len(found) {
|
|
88
|
+
case 0:
|
|
89
|
+
return "", ErrSharedFolderNotFound
|
|
90
|
+
case 1:
|
|
91
|
+
return found[0], nil
|
|
92
|
+
}
|
|
93
|
+
return "", ErrSharedFolderAmbiguous
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const (
|
|
97
|
+
accessRead = 0x4 // R_OK
|
|
98
|
+
accessWrite = 0x2 // W_OK
|
|
99
|
+
accessExec = 0x1 // X_OK
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// SharedFolders reports every configured Shared Folder, sorted by key. The
|
|
103
|
+
// error is a corrupt config.json.
|
|
104
|
+
func (cfg Config) SharedFolders() ([]SharedFolder, error) {
|
|
105
|
+
names, err := ReadSharedFolders(cfg.DataDir)
|
|
106
|
+
if err != nil {
|
|
107
|
+
return nil, err
|
|
108
|
+
}
|
|
109
|
+
root := cfg.VolumeRoot
|
|
110
|
+
if root == "" {
|
|
111
|
+
root = "/"
|
|
112
|
+
}
|
|
113
|
+
keys := make([]string, 0, len(names))
|
|
114
|
+
for key := range names {
|
|
115
|
+
keys = append(keys, key)
|
|
116
|
+
}
|
|
117
|
+
sort.Strings(keys)
|
|
118
|
+
folders := make([]SharedFolder, 0, len(keys))
|
|
119
|
+
for _, key := range keys {
|
|
120
|
+
folder := SharedFolder{Key: key, Name: names[key]}
|
|
121
|
+
path, err := ResolveSharedFolder(root, folder.Name)
|
|
122
|
+
if err != nil {
|
|
123
|
+
folder.Error = err.Error()
|
|
124
|
+
} else {
|
|
125
|
+
folder.Path, folder.Exists = path, true
|
|
126
|
+
folder.Readable = syscall.Access(path, accessRead|accessExec) == nil
|
|
127
|
+
folder.Writable = syscall.Access(path, accessWrite|accessExec) == nil
|
|
128
|
+
}
|
|
129
|
+
folders = append(folders, folder)
|
|
130
|
+
}
|
|
131
|
+
return folders, nil
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// withSharedFolders adds the configured Shared Folders to an app.status
|
|
135
|
+
// answer; a package that configures none is answered exactly as before.
|
|
136
|
+
func (cfg Config) withSharedFolders(status map[string]any) map[string]any {
|
|
137
|
+
folders, err := cfg.SharedFolders()
|
|
138
|
+
if err != nil {
|
|
139
|
+
status["shared_folders_error"] = err.Error()
|
|
140
|
+
} else if len(folders) > 0 {
|
|
141
|
+
status["shared_folders"] = folders
|
|
142
|
+
}
|
|
143
|
+
return status
|
|
144
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func writeConfig(t *testing.T, dir, body string) {
|
|
11
|
+
t.Helper()
|
|
12
|
+
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(body), 0o600); err != nil {
|
|
13
|
+
t.Fatal(err)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func TestReadSharedFoldersWithoutConfigIsEmpty(t *testing.T) {
|
|
18
|
+
got, err := ReadSharedFolders(t.TempDir())
|
|
19
|
+
if err != nil || len(got) != 0 {
|
|
20
|
+
t.Fatalf("got %v %v", got, err)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func TestReadSharedFoldersReturnsNamesByKey(t *testing.T) {
|
|
25
|
+
dir := t.TempDir()
|
|
26
|
+
writeConfig(t, dir, `{"schema":1,"shared_folders":{"media":"Family Photos"}}`)
|
|
27
|
+
got, err := ReadSharedFolders(dir)
|
|
28
|
+
if err != nil || got["media"] != "Family Photos" || len(got) != 1 {
|
|
29
|
+
t.Fatalf("got %v %v", got, err)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestReadSharedFoldersRefusesUnknownSchemaAndBadNames(t *testing.T) {
|
|
34
|
+
for _, body := range []string{
|
|
35
|
+
`{"schema":2,"shared_folders":{"media":"x"}}`,
|
|
36
|
+
`{"schema":1,"shared_folders":{"media":"a/b"}}`,
|
|
37
|
+
`{"schema":1,"shared_folders":{"media":".."}}`,
|
|
38
|
+
`{"schema":1,"shared_folders":{"media":"@appdata"}}`,
|
|
39
|
+
`{"schema":1,"shared_folders":{"media":""}}`,
|
|
40
|
+
`not json`,
|
|
41
|
+
} {
|
|
42
|
+
dir := t.TempDir()
|
|
43
|
+
writeConfig(t, dir, body)
|
|
44
|
+
if _, err := ReadSharedFolders(dir); err == nil {
|
|
45
|
+
t.Errorf("%s: want an error", body)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestResolveSharedFolderFindsTheNameOnItsVolume(t *testing.T) {
|
|
51
|
+
root := t.TempDir()
|
|
52
|
+
for _, d := range []string{"volume1/other", "volume2/Photos", "volume2/@appdata"} {
|
|
53
|
+
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
got, err := ResolveSharedFolder(root, "Photos")
|
|
58
|
+
if err != nil || got != filepath.Join(root, "volume2", "Photos") {
|
|
59
|
+
t.Fatalf("got %q %v", got, err)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestResolveSharedFolderFollowsAMovedShare(t *testing.T) {
|
|
64
|
+
root := t.TempDir()
|
|
65
|
+
os.MkdirAll(filepath.Join(root, "volume1", "Photos"), 0o755)
|
|
66
|
+
first, _ := ResolveSharedFolder(root, "Photos")
|
|
67
|
+
os.Rename(filepath.Join(root, "volume1", "Photos"), filepath.Join(root, "Photos.tmp"))
|
|
68
|
+
os.MkdirAll(filepath.Join(root, "volume2"), 0o755)
|
|
69
|
+
os.Rename(filepath.Join(root, "Photos.tmp"), filepath.Join(root, "volume2", "Photos"))
|
|
70
|
+
second, err := ResolveSharedFolder(root, "Photos")
|
|
71
|
+
if err != nil || first == second || second != filepath.Join(root, "volume2", "Photos") {
|
|
72
|
+
t.Fatalf("%q -> %q %v", first, second, err)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestResolveSharedFolderErrors(t *testing.T) {
|
|
77
|
+
root := t.TempDir()
|
|
78
|
+
if _, err := ResolveSharedFolder(root, "Missing"); !errors.Is(err, ErrSharedFolderNotFound) {
|
|
79
|
+
t.Fatalf("missing: %v", err)
|
|
80
|
+
}
|
|
81
|
+
os.MkdirAll(filepath.Join(root, "volume1", "Dup"), 0o755)
|
|
82
|
+
os.MkdirAll(filepath.Join(root, "volume2", "Dup"), 0o755)
|
|
83
|
+
if _, err := ResolveSharedFolder(root, "Dup"); !errors.Is(err, ErrSharedFolderAmbiguous) {
|
|
84
|
+
t.Fatalf("ambiguous: %v", err)
|
|
85
|
+
}
|
|
86
|
+
for _, bad := range []string{"", "..", "a/b", "@appdata", ".x"} {
|
|
87
|
+
if _, err := ResolveSharedFolder(root, bad); err == nil {
|
|
88
|
+
t.Errorf("%q: want an error", bad)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func TestResolveSharedFolderIgnoresAFileOfThatName(t *testing.T) {
|
|
94
|
+
root := t.TempDir()
|
|
95
|
+
os.MkdirAll(filepath.Join(root, "volume1"), 0o755)
|
|
96
|
+
os.WriteFile(filepath.Join(root, "volume1", "Notes"), nil, 0o644)
|
|
97
|
+
if _, err := ResolveSharedFolder(root, "Notes"); !errors.Is(err, ErrSharedFolderNotFound) {
|
|
98
|
+
t.Fatalf("got %v", err)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func TestStatusOmitsSharedFoldersWhenNoneConfigured(t *testing.T) {
|
|
103
|
+
_, out := call(t, testConfig(t), alice, "app.status", `{}`)
|
|
104
|
+
if _, present := out["data"].(map[string]any)["shared_folders"]; present {
|
|
105
|
+
t.Fatalf("unexpected shared_folders: %v", out)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestStatusReportsEachConfiguredSharedFolder(t *testing.T) {
|
|
110
|
+
cfg := testConfig(t)
|
|
111
|
+
cfg.VolumeRoot = t.TempDir()
|
|
112
|
+
os.MkdirAll(filepath.Join(cfg.VolumeRoot, "volume1", "Photos"), 0o755)
|
|
113
|
+
writeConfig(t, cfg.DataDir, `{"schema":1,"shared_folders":{"media":"Photos","gone":"Missing"}}`)
|
|
114
|
+
_, out := call(t, cfg, alice, "app.status", `{}`)
|
|
115
|
+
rows := out["data"].(map[string]any)["shared_folders"].([]any)
|
|
116
|
+
if len(rows) != 2 {
|
|
117
|
+
t.Fatalf("rows: %v", rows)
|
|
118
|
+
}
|
|
119
|
+
byKey := map[string]map[string]any{}
|
|
120
|
+
for _, r := range rows {
|
|
121
|
+
m := r.(map[string]any)
|
|
122
|
+
byKey[m["key"].(string)] = m
|
|
123
|
+
}
|
|
124
|
+
media, gone := byKey["media"], byKey["gone"]
|
|
125
|
+
if media["name"] != "Photos" || media["exists"] != true || media["readable"] != true || media["writable"] != true || media["path"] != filepath.Join(cfg.VolumeRoot, "volume1", "Photos") {
|
|
126
|
+
t.Fatalf("media: %v", media)
|
|
127
|
+
}
|
|
128
|
+
if gone["exists"] != false || gone["readable"] != false || gone["writable"] != false || gone["error"] == nil || gone["path"] != nil {
|
|
129
|
+
t.Fatalf("gone: %v", gone)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func TestStatusReportsAFolderThePackageAccountCannotWrite(t *testing.T) {
|
|
134
|
+
if os.Geteuid() == 0 {
|
|
135
|
+
t.Skip("root ignores directory modes")
|
|
136
|
+
}
|
|
137
|
+
cfg := testConfig(t)
|
|
138
|
+
cfg.VolumeRoot = t.TempDir()
|
|
139
|
+
dir := filepath.Join(cfg.VolumeRoot, "volume1", "Locked")
|
|
140
|
+
os.MkdirAll(dir, 0o755)
|
|
141
|
+
os.Chmod(dir, 0o555)
|
|
142
|
+
t.Cleanup(func() { os.Chmod(dir, 0o755) })
|
|
143
|
+
writeConfig(t, cfg.DataDir, `{"schema":1,"shared_folders":{"media":"Locked"}}`)
|
|
144
|
+
_, out := call(t, cfg, alice, "app.status", `{}`)
|
|
145
|
+
row := out["data"].(map[string]any)["shared_folders"].([]any)[0].(map[string]any)
|
|
146
|
+
if row["exists"] != true || row["readable"] != true || row["writable"] != false {
|
|
147
|
+
t.Fatalf("row: %v", row)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func TestStatusReportsACorruptConfigWithoutFailing(t *testing.T) {
|
|
152
|
+
cfg := testConfig(t)
|
|
153
|
+
writeConfig(t, cfg.DataDir, `not json`)
|
|
154
|
+
code, out := call(t, cfg, alice, "app.status", `{}`)
|
|
155
|
+
if code != "" || out["data"].(map[string]any)["shared_folders_error"] == nil {
|
|
156
|
+
t.Fatalf("%q %v", code, out)
|
|
157
|
+
}
|
|
158
|
+
}
|