@synopackageland/templates 0.9.9 → 0.10.1
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/README.md +29 -29
- package/package.json +2 -2
- package/templates/_shared/AGENTS.md +53 -15
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
- package/templates/native-go/backend/cmd/app/main.go +15 -1
- package/templates/native-go/backend/cmd/app/main_test.go +33 -0
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/go.sum +2 -2
- package/templates/native-go/i18n/messages/de.json +4 -0
- package/templates/native-go/i18n/messages/en.json +4 -0
- package/templates/native-go/i18n/messages/es.json +4 -0
- package/templates/native-go/i18n/messages/fr.json +4 -0
- package/templates/native-go/i18n/messages/it.json +4 -0
- package/templates/native-go/i18n/messages/ja.json +4 -0
- package/templates/native-go/i18n/messages/ko.json +4 -0
- package/templates/native-go/i18n/messages/zh-CN.json +4 -0
- package/templates/native-go/i18n/messages/zh-TW.json +4 -0
- package/templates/native-go/ui/app.js +2 -1
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +24 -3
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
- package/templates/native-go-root-ops/backend/internal/app/executor.go +2 -65
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +10 -82
- package/templates/native-go-root-ops/frontend/src/ExecutorGate.vue +16 -0
- package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +60 -0
- package/templates/native-go-vue/README.md +24 -8
- package/templates/native-go-vue/frontend/package-lock.json +9 -9
- package/templates/native-go-vue/frontend/package.json +2 -2
- package/templates/native-go-vue/frontend/src/App.vue +36 -36
- package/templates/native-go-vue/frontend/src/ExecutorGate.vue +10 -0
- package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
- package/templates/native-go-vue/frontend/test/context.ts +8 -0
- package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
- package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
- package/templates/native-node/README.md +153 -0
- package/templates/native-node/backend/app/package.json +4 -0
- package/templates/native-node/backend/app/permissions.json +5 -0
- package/templates/native-node/backend/package-lock.json +59 -0
- package/templates/native-node/backend/package.json +17 -0
- package/templates/native-node/backend/src/delegate.ts +5 -0
- package/templates/native-node/backend/src/jobs.ts +5 -0
- package/templates/native-node/backend/src/main.ts +4 -0
- package/templates/native-node/backend/src/params.ts +11 -0
- package/templates/native-node/backend/src/root-ops.ts +5 -0
- package/templates/native-node/backend/src/routes.ts +97 -0
- package/templates/native-node/backend/src/service.ts +22 -0
- package/templates/native-node/backend/test/routes.test.ts +75 -0
- package/templates/native-node/backend/test/support.ts +67 -0
- package/templates/native-node/backend/tsconfig.build.json +8 -0
- package/templates/native-node/backend/tsconfig.json +18 -0
- package/templates/native-node/dsm-package.yaml +80 -0
- package/templates/native-node/gitignore +4 -0
- package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
- package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
- package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
- package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
- package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
- package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
- package/templates/native-node-vue/README.md +56 -0
- package/templates/native-node-vue/gitignore +4 -0
- package/templates/native-python/README.md +150 -0
- package/templates/native-python/backend/app/delegate.py +5 -0
- package/templates/native-python/backend/app/jobs.py +5 -0
- package/templates/native-python/backend/app/main.py +5 -0
- package/templates/native-python/backend/app/params.py +10 -0
- package/templates/native-python/backend/app/permissions.json +5 -0
- package/templates/native-python/backend/app/root_ops.py +5 -0
- package/templates/native-python/backend/app/routes.py +107 -0
- package/templates/native-python/backend/tests/support.py +72 -0
- package/templates/native-python/backend/tests/test_routes.py +73 -0
- package/templates/native-python/dsm-package.yaml +77 -0
- package/templates/native-python/gitignore +2 -0
- package/templates/native-python-delegate/backend/app/delegate.py +42 -0
- package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
- package/templates/native-python-jobs/backend/app/jobs.py +40 -0
- package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
- package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
- package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
- package/templates/native-python-vue/README.md +56 -0
- package/templates/native-python-vue/gitignore +3 -0
- package/templates/native-rust/README.md +74 -296
- package/templates/native-rust/backend/Cargo.lock +6 -6
- package/templates/native-rust/backend/Cargo.toml +2 -10
- package/templates/native-rust/backend/src/delegate.rs +6 -0
- package/templates/native-rust/backend/src/jobs.rs +6 -0
- package/templates/native-rust/backend/src/main.rs +12 -0
- package/templates/native-rust/backend/src/root_ops.rs +6 -0
- package/templates/native-rust/backend/src/routes.rs +352 -0
- package/templates/native-rust/backend/src/test_support.rs +91 -0
- package/templates/native-rust/dsm-package.yaml +10 -21
- package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
- package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
- package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
- package/templates/native-rust-vue/README.md +35 -34
- package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
- package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
- package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
- package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
- package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
- package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
- package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
- package/templates/native-rust/backend/src/app/background/work.rs +0 -78
- package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
- package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
- package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
- package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
- package/templates/native-rust/backend/src/app/identity.rs +0 -56
- package/templates/native-rust/backend/src/app/migrations.rs +0 -79
- package/templates/native-rust/backend/src/app/mod.rs +0 -30
- package/templates/native-rust/backend/src/app/portal.rs +0 -317
- package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
- package/templates/native-rust/backend/src/app/routes.rs +0 -315
- package/templates/native-rust/backend/src/app/session_files.rs +0 -19
- package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
- package/templates/native-rust/backend/src/app/test_support.rs +0 -202
- package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
- package/templates/native-rust/backend/src/bin/api.rs +0 -72
- package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
- package/templates/native-rust/backend/src/build_env.rs +0 -155
- package/templates/native-rust/backend/src/lib.rs +0 -6
- package/templates/native-rust/backend/src/permissions.rs +0 -54
- package/templates/native-rust/backend/src/process.rs +0 -91
- package/templates/native-rust/i18n/glossary.json +0 -665
- package/templates/native-rust/i18n/messages/de.json +0 -32
- package/templates/native-rust/i18n/messages/en.json +0 -32
- package/templates/native-rust/i18n/messages/es.json +0 -32
- package/templates/native-rust/i18n/messages/fr.json +0 -32
- package/templates/native-rust/i18n/messages/it.json +0 -32
- package/templates/native-rust/i18n/messages/ja.json +0 -32
- package/templates/native-rust/i18n/messages/ko.json +0 -32
- package/templates/native-rust/i18n/messages/zh-CN.json +0 -32
- package/templates/native-rust/i18n/messages/zh-TW.json +0 -32
- package/templates/native-rust/ui/app.css +0 -45
- package/templates/native-rust/ui/app.js +0 -111
- package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
- package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
- package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
- package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +0 -475
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
//! The App's methods. Register a method in `build` and list it in
|
|
2
|
+
//! dsm-package.yaml's `backend.methods`; give it a rule in permissions.json
|
|
3
|
+
//! unless only administrators may call it. The runtime has checked the DSM
|
|
4
|
+
//! session, the entry gate and that rule before a handler runs.
|
|
5
|
+
|
|
6
|
+
use serde::{Deserialize, Serialize};
|
|
7
|
+
use serde_json::{Value, json};
|
|
8
|
+
use std::fs;
|
|
9
|
+
use std::io::{ErrorKind, Write};
|
|
10
|
+
use std::path::{Path, PathBuf};
|
|
11
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
12
|
+
use syno_pkg_land_app::{App, Context, Error, Params};
|
|
13
|
+
|
|
14
|
+
/// Where the App's state lives; the runtime sets the environment.
|
|
15
|
+
#[derive(Clone)]
|
|
16
|
+
pub struct Config {
|
|
17
|
+
pub package: String,
|
|
18
|
+
/// The package's own data directory (/var/packages/<package>/var), kept across upgrades.
|
|
19
|
+
pub data_dir: PathBuf,
|
|
20
|
+
pub packages_root: PathBuf,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl Config {
|
|
24
|
+
pub fn from_env() -> Self {
|
|
25
|
+
let var = |name| std::env::var_os(name).unwrap_or_default();
|
|
26
|
+
Config {
|
|
27
|
+
package: var("SYNOPKGLAND_PACKAGE").to_string_lossy().into_owned(),
|
|
28
|
+
data_dir: var("SYNOPKGLAND_DATA_DIR").into(),
|
|
29
|
+
packages_root: "/var/packages".into(),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub fn build(cfg: Config) -> App {
|
|
35
|
+
let mut app = App::new();
|
|
36
|
+
let c = cfg.clone();
|
|
37
|
+
app.method("app.status", move |ctx, _| Ok(status(&c, ctx)));
|
|
38
|
+
let c = cfg.clone();
|
|
39
|
+
app.method("greeting.get", move |_, _| get_greeting(&c));
|
|
40
|
+
let c = cfg.clone();
|
|
41
|
+
app.method("greeting.set", move |ctx, params| {
|
|
42
|
+
set_greeting(&c, ctx, params)
|
|
43
|
+
});
|
|
44
|
+
// Each capability module is a stub until `synopkgland init --with` adds its example.
|
|
45
|
+
crate::jobs::register(&mut app, &cfg);
|
|
46
|
+
crate::root_ops::register(&mut app, &cfg);
|
|
47
|
+
crate::delegate::register(&mut app, &cfg);
|
|
48
|
+
app
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fn status(cfg: &Config, ctx: &Context) -> Value {
|
|
52
|
+
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
|
|
53
|
+
let mut status = json!({
|
|
54
|
+
"package": cfg.package,
|
|
55
|
+
"version": installed_version(cfg),
|
|
56
|
+
"user": ctx.user,
|
|
57
|
+
"admin": ctx.admin,
|
|
58
|
+
"process": {"uid": uid, "gid": gid},
|
|
59
|
+
"language": "Rust",
|
|
60
|
+
"platform": format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH),
|
|
61
|
+
"time": utc_now(),
|
|
62
|
+
});
|
|
63
|
+
// Served only when dsm-package.yaml declares a portal.
|
|
64
|
+
if let Ok(portal) = ctx.runtime::<Value>("portal.status", &()) {
|
|
65
|
+
status["portal"] = portal;
|
|
66
|
+
}
|
|
67
|
+
status
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// The version in the package's INFO, or "" outside DSM.
|
|
71
|
+
fn installed_version(cfg: &Config) -> String {
|
|
72
|
+
let info =
|
|
73
|
+
fs::read_to_string(cfg.packages_root.join(&cfg.package).join("INFO")).unwrap_or_default();
|
|
74
|
+
info.lines()
|
|
75
|
+
.find_map(|line| line.strip_prefix("version="))
|
|
76
|
+
.map(|v| v.trim_matches('"').to_string())
|
|
77
|
+
.unwrap_or_default()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[derive(Serialize, Deserialize)]
|
|
81
|
+
struct Greeting {
|
|
82
|
+
text: String,
|
|
83
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
84
|
+
updated_by: Option<String>,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#[derive(Deserialize)]
|
|
88
|
+
#[serde(deny_unknown_fields)]
|
|
89
|
+
struct SetGreeting {
|
|
90
|
+
text: String,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fn greeting_path(cfg: &Config) -> PathBuf {
|
|
94
|
+
cfg.data_dir.join("greeting.json")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn get_greeting(cfg: &Config) -> Result<Greeting, Error> {
|
|
98
|
+
match fs::read(greeting_path(cfg)) {
|
|
99
|
+
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Greeting {
|
|
100
|
+
text: "Hello".into(),
|
|
101
|
+
updated_by: None,
|
|
102
|
+
}),
|
|
103
|
+
Err(e) => Err(internal(e)),
|
|
104
|
+
Ok(data) => serde_json::from_slice(&data).map_err(internal),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn set_greeting(cfg: &Config, ctx: &Context, params: &Params) -> Result<Greeting, Error> {
|
|
109
|
+
let SetGreeting { text } = params.decode()?;
|
|
110
|
+
if !(1..=200).contains(&text.chars().count()) {
|
|
111
|
+
return Err(Error::bad_request("text must be 1 to 200 characters"));
|
|
112
|
+
}
|
|
113
|
+
let greeting = Greeting {
|
|
114
|
+
text,
|
|
115
|
+
updated_by: Some(ctx.user.clone()),
|
|
116
|
+
};
|
|
117
|
+
write_file_atomic(
|
|
118
|
+
&greeting_path(cfg),
|
|
119
|
+
&serde_json::to_vec(&greeting).map_err(internal)?,
|
|
120
|
+
)
|
|
121
|
+
.map_err(internal)?;
|
|
122
|
+
Ok(greeting)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// An unexpected failure: logged to stderr (the package's log), `internal` for the browser.
|
|
126
|
+
pub fn internal(e: impl std::fmt::Display) -> Error {
|
|
127
|
+
eprintln!("{e}");
|
|
128
|
+
Error::new("internal", "internal error")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Replaces path in one rename, so readers never see half a file.
|
|
132
|
+
pub fn write_file_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
|
133
|
+
let nanos = SystemTime::now()
|
|
134
|
+
.duration_since(UNIX_EPOCH)
|
|
135
|
+
.map_or(0, |d| d.subsec_nanos());
|
|
136
|
+
let tmp = path.with_file_name(format!(".tmp-{}-{nanos}", std::process::id()));
|
|
137
|
+
let written = fs::File::create_new(&tmp)
|
|
138
|
+
.and_then(|mut f| f.write_all(data))
|
|
139
|
+
.and_then(|_| fs::rename(&tmp, path));
|
|
140
|
+
if written.is_err() {
|
|
141
|
+
let _ = fs::remove_file(&tmp);
|
|
142
|
+
}
|
|
143
|
+
written
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// Now as 2006-01-02T15:04:05Z.
|
|
147
|
+
pub fn utc_now() -> String {
|
|
148
|
+
let secs = SystemTime::now()
|
|
149
|
+
.duration_since(UNIX_EPOCH)
|
|
150
|
+
.map_or(0, |d| d.as_secs());
|
|
151
|
+
let (days, rest) = (secs / 86400, secs % 86400);
|
|
152
|
+
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
|
153
|
+
let z = days as i64 + 719_468;
|
|
154
|
+
let era = z.div_euclid(146_097);
|
|
155
|
+
let doe = z - era * 146_097;
|
|
156
|
+
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
157
|
+
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
158
|
+
let mp = (5 * doy + 2) / 153;
|
|
159
|
+
let day = doy - (153 * mp + 2) / 5 + 1;
|
|
160
|
+
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
161
|
+
let year = yoe + era * 400 + i64::from(month <= 2);
|
|
162
|
+
format!(
|
|
163
|
+
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
|
|
164
|
+
rest / 3600,
|
|
165
|
+
rest % 3600 / 60,
|
|
166
|
+
rest % 60
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#[cfg(test)]
|
|
171
|
+
mod tests {
|
|
172
|
+
use super::*;
|
|
173
|
+
use crate::test_support::{Services, TempDir, call};
|
|
174
|
+
use serde_json::{Value, json};
|
|
175
|
+
use std::fs;
|
|
176
|
+
|
|
177
|
+
fn setup() -> (TempDir, Config) {
|
|
178
|
+
let dir = TempDir::new();
|
|
179
|
+
let cfg = Config {
|
|
180
|
+
package: "ExampleApp".into(),
|
|
181
|
+
data_dir: dir.path().join("var"),
|
|
182
|
+
packages_root: dir.path().join("packages"),
|
|
183
|
+
};
|
|
184
|
+
fs::create_dir(&cfg.data_dir).unwrap();
|
|
185
|
+
(dir, cfg)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fn files(cfg: &Config) -> Vec<String> {
|
|
189
|
+
fs::read_dir(&cfg.data_dir)
|
|
190
|
+
.unwrap()
|
|
191
|
+
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
|
192
|
+
.collect()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#[test]
|
|
196
|
+
fn status_reports_the_caller_and_the_installed_version() {
|
|
197
|
+
let (_dir, cfg) = setup();
|
|
198
|
+
fs::create_dir_all(cfg.packages_root.join("ExampleApp")).unwrap();
|
|
199
|
+
fs::write(
|
|
200
|
+
cfg.packages_root.join("ExampleApp/INFO"),
|
|
201
|
+
"package=\"ExampleApp\"\nversion=\"0.1.0-0001\"\n",
|
|
202
|
+
)
|
|
203
|
+
.unwrap();
|
|
204
|
+
let reply = call(
|
|
205
|
+
build(cfg),
|
|
206
|
+
"app.status",
|
|
207
|
+
Value::Null,
|
|
208
|
+
"alice",
|
|
209
|
+
&Services::new(),
|
|
210
|
+
);
|
|
211
|
+
assert_eq!(reply.code(), "");
|
|
212
|
+
let s = reply.data;
|
|
213
|
+
assert_eq!(
|
|
214
|
+
(&s["package"], &s["version"], &s["user"], &s["admin"]),
|
|
215
|
+
(
|
|
216
|
+
&json!("ExampleApp"),
|
|
217
|
+
&json!("0.1.0-0001"),
|
|
218
|
+
&json!("alice"),
|
|
219
|
+
&json!(false)
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
|
|
223
|
+
assert_eq!(s["process"], json!({"uid": uid, "gid": gid}));
|
|
224
|
+
assert_eq!(s["language"], "Rust");
|
|
225
|
+
assert_eq!(
|
|
226
|
+
s["platform"],
|
|
227
|
+
format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH)
|
|
228
|
+
);
|
|
229
|
+
let time = s["time"].as_str().unwrap();
|
|
230
|
+
assert!(
|
|
231
|
+
time.len() == 20 && time.ends_with('Z') && &time[10..11] == "T",
|
|
232
|
+
"{time}"
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#[test]
|
|
237
|
+
fn status_without_info_has_no_version() {
|
|
238
|
+
let (_dir, cfg) = setup();
|
|
239
|
+
let reply = call(
|
|
240
|
+
build(cfg),
|
|
241
|
+
"app.status",
|
|
242
|
+
Value::Null,
|
|
243
|
+
"admin",
|
|
244
|
+
&Services::new(),
|
|
245
|
+
);
|
|
246
|
+
assert_eq!(reply.data["version"], "");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#[test]
|
|
250
|
+
fn status_shows_the_portal_when_the_runtime_offers_it() {
|
|
251
|
+
let (_dir, cfg) = setup();
|
|
252
|
+
let portal = json!({"enabled": true, "alias": "example"});
|
|
253
|
+
let answer = portal.clone();
|
|
254
|
+
let services: Services = [(
|
|
255
|
+
"portal.status",
|
|
256
|
+
Box::new(move |_: &Value| Ok(answer.clone())) as _,
|
|
257
|
+
)]
|
|
258
|
+
.into();
|
|
259
|
+
let reply = call(build(cfg), "app.status", Value::Null, "admin", &services);
|
|
260
|
+
assert_eq!(reply.data["portal"], portal);
|
|
261
|
+
assert_eq!(
|
|
262
|
+
reply.services,
|
|
263
|
+
vec![("portal.status".to_string(), Value::Null)]
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#[test]
|
|
268
|
+
fn status_without_a_portal_leaves_it_out() {
|
|
269
|
+
let (_dir, cfg) = setup();
|
|
270
|
+
let reply = call(
|
|
271
|
+
build(cfg),
|
|
272
|
+
"app.status",
|
|
273
|
+
Value::Null,
|
|
274
|
+
"admin",
|
|
275
|
+
&Services::new(),
|
|
276
|
+
);
|
|
277
|
+
assert_eq!(reply.code(), "");
|
|
278
|
+
assert!(reply.data.get("portal").is_none());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
fn greeting_defaults_to_hello() {
|
|
283
|
+
let (_dir, cfg) = setup();
|
|
284
|
+
let reply = call(
|
|
285
|
+
build(cfg),
|
|
286
|
+
"greeting.get",
|
|
287
|
+
Value::Null,
|
|
288
|
+
"admin",
|
|
289
|
+
&Services::new(),
|
|
290
|
+
);
|
|
291
|
+
assert_eq!(reply.data, json!({"text": "Hello"}));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#[test]
|
|
295
|
+
fn greeting_set_stores_the_text_and_who_set_it() {
|
|
296
|
+
let (_dir, cfg) = setup();
|
|
297
|
+
let reply = call(
|
|
298
|
+
build(cfg.clone()),
|
|
299
|
+
"greeting.set",
|
|
300
|
+
json!({"text": "Hi 👋"}),
|
|
301
|
+
"alice",
|
|
302
|
+
&Services::new(),
|
|
303
|
+
);
|
|
304
|
+
assert_eq!(reply.data, json!({"text": "Hi 👋", "updated_by": "alice"}));
|
|
305
|
+
let reply = call(
|
|
306
|
+
build(cfg.clone()),
|
|
307
|
+
"greeting.get",
|
|
308
|
+
Value::Null,
|
|
309
|
+
"admin",
|
|
310
|
+
&Services::new(),
|
|
311
|
+
);
|
|
312
|
+
assert_eq!(reply.data, json!({"text": "Hi 👋", "updated_by": "alice"}));
|
|
313
|
+
assert_eq!(files(&cfg), ["greeting.json"]);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#[test]
|
|
317
|
+
fn greeting_counts_characters_not_bytes() {
|
|
318
|
+
let (_dir, cfg) = setup();
|
|
319
|
+
let reply = call(
|
|
320
|
+
build(cfg),
|
|
321
|
+
"greeting.set",
|
|
322
|
+
json!({"text": "é".repeat(200)}),
|
|
323
|
+
"admin",
|
|
324
|
+
&Services::new(),
|
|
325
|
+
);
|
|
326
|
+
assert_eq!(reply.code(), "");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn greeting_set_refuses_invalid_params_and_keeps_the_old_text() {
|
|
331
|
+
let (_dir, cfg) = setup();
|
|
332
|
+
for params in [
|
|
333
|
+
Value::Null,
|
|
334
|
+
json!([]),
|
|
335
|
+
json!({}),
|
|
336
|
+
json!({"text": ""}),
|
|
337
|
+
json!({"text": "x".repeat(201)}),
|
|
338
|
+
json!({"text": 5}),
|
|
339
|
+
json!({"text": "hi", "extra": 1}),
|
|
340
|
+
] {
|
|
341
|
+
let reply = call(
|
|
342
|
+
build(cfg.clone()),
|
|
343
|
+
"greeting.set",
|
|
344
|
+
params.clone(),
|
|
345
|
+
"admin",
|
|
346
|
+
&Services::new(),
|
|
347
|
+
);
|
|
348
|
+
assert_eq!(reply.code(), "bad_request", "{params}");
|
|
349
|
+
}
|
|
350
|
+
assert!(files(&cfg).is_empty());
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
//! Calls an App method the way the runtime does: over a channel, with the
|
|
2
|
+
//! hello, the call, the services the method asks for and its result.
|
|
3
|
+
|
|
4
|
+
use serde_json::{Value, json};
|
|
5
|
+
use std::collections::HashMap;
|
|
6
|
+
use std::io::{BufRead, BufReader, Write};
|
|
7
|
+
use std::os::unix::net::UnixStream;
|
|
8
|
+
use std::path::{Path, PathBuf};
|
|
9
|
+
use std::sync::atomic::{AtomicU32, Ordering};
|
|
10
|
+
use std::thread;
|
|
11
|
+
use syno_pkg_land_app::{App, Error};
|
|
12
|
+
|
|
13
|
+
/// Each op the method may ask for: its result, or a refusal. An op it lacks is `unknown_service`.
|
|
14
|
+
pub type Services = HashMap<&'static str, Box<dyn Fn(&Value) -> Result<Value, Error>>>;
|
|
15
|
+
|
|
16
|
+
pub struct Reply {
|
|
17
|
+
pub data: Value,
|
|
18
|
+
pub error: Option<Error>,
|
|
19
|
+
/// The services the method asked for, in order.
|
|
20
|
+
pub services: Vec<(String, Value)>,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl Reply {
|
|
24
|
+
pub fn code(&self) -> &str {
|
|
25
|
+
self.error.as_ref().map_or("", |e| e.code.as_str())
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub fn call(app: App, method: &str, params: Value, user: &str, services: &Services) -> Reply {
|
|
30
|
+
let (runtime, app_end) = UnixStream::pair().unwrap();
|
|
31
|
+
let w = app_end.try_clone().unwrap();
|
|
32
|
+
let serving = thread::spawn(move || app.serve(app_end, w));
|
|
33
|
+
let mut out = runtime.try_clone().unwrap();
|
|
34
|
+
let mut send = |frame: Value| out.write_all(format!("{frame}\n").as_bytes()).unwrap();
|
|
35
|
+
let mut lines = BufReader::new(runtime.try_clone().unwrap()).lines();
|
|
36
|
+
send(json!({"t": "hello", "versions": [1]}));
|
|
37
|
+
lines.next();
|
|
38
|
+
let ctx = json!({"user": user, "admin": user == "admin", "locale": "en", "request_id": "0".repeat(32), "deadline_ms": 30000});
|
|
39
|
+
send(json!({"t": "call", "id": "1", "method": method, "params": params, "ctx": ctx}));
|
|
40
|
+
let mut asked = Vec::new();
|
|
41
|
+
for line in lines {
|
|
42
|
+
let frame: Value = serde_json::from_str(&line.unwrap()).unwrap();
|
|
43
|
+
if frame["t"] == "result" {
|
|
44
|
+
runtime.shutdown(std::net::Shutdown::Both).unwrap();
|
|
45
|
+
serving.join().unwrap();
|
|
46
|
+
let error = (frame["ok"] == false)
|
|
47
|
+
.then(|| serde_json::from_value(frame["error"].clone()).unwrap());
|
|
48
|
+
return Reply {
|
|
49
|
+
data: frame["data"].clone(),
|
|
50
|
+
error,
|
|
51
|
+
services: asked,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
let op = frame["op"].as_str().unwrap().to_string();
|
|
55
|
+
asked.push((op.clone(), frame["params"].clone()));
|
|
56
|
+
let answer = match services.get(op.as_str()) {
|
|
57
|
+
None => Err(Error::new("unknown_service", &format!("no service {op}"))),
|
|
58
|
+
Some(service) => service(&frame["params"]),
|
|
59
|
+
};
|
|
60
|
+
send(match answer {
|
|
61
|
+
Ok(result) => {
|
|
62
|
+
json!({"t": "svc_result", "id": frame["id"], "ok": true, "result": result})
|
|
63
|
+
}
|
|
64
|
+
Err(e) => json!({"t": "svc_result", "id": frame["id"], "ok": false, "error": e}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
panic!("the App ended the channel without a result");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// A directory removed when the test ends.
|
|
71
|
+
pub struct TempDir(PathBuf);
|
|
72
|
+
|
|
73
|
+
impl TempDir {
|
|
74
|
+
pub fn new() -> Self {
|
|
75
|
+
static NEXT: AtomicU32 = AtomicU32::new(0);
|
|
76
|
+
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
|
77
|
+
let dir = std::env::temp_dir().join(format!("app-test-{}-{n}", std::process::id()));
|
|
78
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
79
|
+
TempDir(dir)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pub fn path(&self) -> &Path {
|
|
83
|
+
&self.0
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
impl Drop for TempDir {
|
|
88
|
+
fn drop(&mut self) {
|
|
89
|
+
let _ = std::fs::remove_dir_all(&self.0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -35,13 +35,20 @@ maintainer: Your Name
|
|
|
35
35
|
os_min_ver: "7.1-42661"
|
|
36
36
|
|
|
37
37
|
# One SPK per architecture. Supported: x86_64, aarch64, armv7 (add it here to
|
|
38
|
-
# build it; armv7
|
|
38
|
+
# build it; armv7 is built but not NAS-verified).
|
|
39
39
|
arches: [x86_64, aarch64]
|
|
40
40
|
|
|
41
|
+
# The SDK's Go runtime is the CGI: it checks the DSM session, the entry gate
|
|
42
|
+
# and permissions.json, then runs the App binary as the package account, once
|
|
43
|
+
# per call. The build cross-compiles the Cargo project in backend (cargo
|
|
44
|
+
# zigbuild) and reads the SDK release from Cargo.lock. A cross-compiled binary
|
|
45
|
+
# cannot run on the build machine, so methods lists every method the App
|
|
46
|
+
# registers, as `synopkgland check` compares them with the App's hello.
|
|
41
47
|
backend:
|
|
42
|
-
|
|
48
|
+
runtime: rust
|
|
43
49
|
dir: backend
|
|
44
|
-
|
|
50
|
+
entry: app
|
|
51
|
+
methods: [app.status, greeting.get, greeting.set]
|
|
45
52
|
|
|
46
53
|
ui:
|
|
47
54
|
# Static files served at /webman/3rdparty/<package>/app/. app.js and app.css
|
|
@@ -69,21 +76,3 @@ i18n:
|
|
|
69
76
|
|
|
70
77
|
# Optional SDK capabilities; delegate/rootOps share administrator-approved executor setup.
|
|
71
78
|
capabilities: {}
|
|
72
|
-
|
|
73
|
-
# Optional uninstall hook. DSM runs `exec args` as the package account on a
|
|
74
|
-
# real uninstall (not an upgrade), before the executor is removed, with
|
|
75
|
-
# SYNOPKGLAND_DELETE_STATE=0|1 and SYNOPKGLAND_REMOVE_DATA=0|1 from the wizard.
|
|
76
|
-
# A failure or timeout is logged (syslog tag synopkgland-uninstall, output in
|
|
77
|
-
# var/uninstall-hook.log) and never blocks the uninstall. The backend's
|
|
78
|
-
# `uninstall` mode deletes the background job spool when the admin ticks
|
|
79
|
-
# "delete state"; add uninstall.state.jobs to i18n/messages in every language.
|
|
80
|
-
# uninstall:
|
|
81
|
-
# purge:
|
|
82
|
-
# exec: ui/cgi/api.cgi
|
|
83
|
-
# args: [uninstall]
|
|
84
|
-
# timeout: 60 # seconds, 2..120
|
|
85
|
-
# # root: purge # rootOps only: root/purge.sh runs as root at
|
|
86
|
-
# # # uninstall, and only while its approval stands
|
|
87
|
-
# state:
|
|
88
|
-
# items:
|
|
89
|
-
# - key: jobs # i18n key uninstall.state.jobs
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
//! Delegated writes: save a report into a folder the caller chose. The
|
|
2
|
+
//! package writes the report into its executor staging directory; the
|
|
3
|
+
//! executor moves it to the caller's destination with the caller's own
|
|
4
|
+
//! permission, so the package never needs write access to that folder.
|
|
5
|
+
|
|
6
|
+
use crate::routes::{Config, internal, utc_now};
|
|
7
|
+
use serde::Deserialize;
|
|
8
|
+
use serde_json::{Value, json};
|
|
9
|
+
use std::fs::OpenOptions;
|
|
10
|
+
use std::io::{ErrorKind, Write};
|
|
11
|
+
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
|
12
|
+
use syno_pkg_land_app::{App, Context, Error, Params};
|
|
13
|
+
|
|
14
|
+
#[derive(Deserialize)]
|
|
15
|
+
#[serde(deny_unknown_fields)]
|
|
16
|
+
struct SaveReport {
|
|
17
|
+
delegation_id: String,
|
|
18
|
+
job_id: String,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
pub fn register(app: &mut App, cfg: &Config) {
|
|
22
|
+
let cfg = cfg.clone();
|
|
23
|
+
app.method("report.save", move |ctx, params| save(&cfg, ctx, params));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// A job id names one file: a letter or digit, then up to 127 of them or `._-`.
|
|
27
|
+
fn valid_job_id(id: &str) -> bool {
|
|
28
|
+
let ok = |c: char| c.is_ascii_alphanumeric() || "._-".contains(c);
|
|
29
|
+
id.len() <= 128 && id.starts_with(|c: char| c.is_ascii_alphanumeric()) && id.chars().all(ok)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn save(cfg: &Config, ctx: &Context, params: &Params) -> Result<Value, Error> {
|
|
33
|
+
let p: SaveReport = params.decode()?;
|
|
34
|
+
if !valid_job_id(&p.job_id) {
|
|
35
|
+
return Err(Error::bad_request(
|
|
36
|
+
"job_id must be 1 to 128 letters, digits, '.', '_' or '-'",
|
|
37
|
+
));
|
|
38
|
+
}
|
|
39
|
+
let staging = cfg.data_dir.join("executor-staging");
|
|
40
|
+
std::fs::DirBuilder::new()
|
|
41
|
+
.recursive(true)
|
|
42
|
+
.mode(0o700)
|
|
43
|
+
.create(&staging)
|
|
44
|
+
.map_err(internal)?;
|
|
45
|
+
let name = format!("report-{}.txt", p.job_id);
|
|
46
|
+
let path = staging.join(&name);
|
|
47
|
+
// A retried save sends the file it wrote the first time.
|
|
48
|
+
match OpenOptions::new()
|
|
49
|
+
.write(true)
|
|
50
|
+
.create_new(true)
|
|
51
|
+
.mode(0o600)
|
|
52
|
+
.open(&path)
|
|
53
|
+
{
|
|
54
|
+
Ok(mut file) => {
|
|
55
|
+
let report = format!("Report for {}, {}\n", ctx.user, utc_now());
|
|
56
|
+
file.write_all(report.as_bytes()).map_err(internal)?;
|
|
57
|
+
}
|
|
58
|
+
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
|
|
59
|
+
Err(e) => return Err(internal(e)),
|
|
60
|
+
}
|
|
61
|
+
let size = std::fs::metadata(&path).map_err(internal)?.len();
|
|
62
|
+
ctx.runtime(
|
|
63
|
+
"delegate.move_from_staging",
|
|
64
|
+
&json!({"delegation_id": p.delegation_id, "job_id": p.job_id, "staging": name, "name": name, "size": size}),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[cfg(test)]
|
|
69
|
+
mod tests {
|
|
70
|
+
use crate::routes::{Config, build};
|
|
71
|
+
use crate::test_support::{Services, TempDir, call};
|
|
72
|
+
use serde_json::{Value, json};
|
|
73
|
+
use std::os::unix::fs::PermissionsExt;
|
|
74
|
+
use syno_pkg_land_app::Error;
|
|
75
|
+
|
|
76
|
+
fn setup() -> (TempDir, Config) {
|
|
77
|
+
let dir = TempDir::new();
|
|
78
|
+
let cfg = Config {
|
|
79
|
+
package: "ExampleApp".into(),
|
|
80
|
+
data_dir: dir.path().into(),
|
|
81
|
+
packages_root: dir.path().into(),
|
|
82
|
+
};
|
|
83
|
+
(dir, cfg)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fn moved() -> Services {
|
|
87
|
+
[(
|
|
88
|
+
"delegate.move_from_staging",
|
|
89
|
+
Box::new(|_: &Value| Ok(json!({"state": "queued"}))) as _,
|
|
90
|
+
)]
|
|
91
|
+
.into()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn save(cfg: &Config, user: &str, services: &Services) -> crate::test_support::Reply {
|
|
95
|
+
call(
|
|
96
|
+
build(cfg.clone()),
|
|
97
|
+
"report.save",
|
|
98
|
+
json!({"delegation_id": "d1", "job_id": "job-1"}),
|
|
99
|
+
user,
|
|
100
|
+
services,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[test]
|
|
105
|
+
fn save_moves_a_private_report_to_the_callers_destination() {
|
|
106
|
+
let (_dir, cfg) = setup();
|
|
107
|
+
let reply = save(&cfg, "alice", &moved());
|
|
108
|
+
assert_eq!(reply.data, json!({"state": "queued"}));
|
|
109
|
+
let path = cfg.data_dir.join("executor-staging/report-job-1.txt");
|
|
110
|
+
assert!(std::fs::read_to_string(&path).unwrap().contains("alice"));
|
|
111
|
+
let meta = std::fs::metadata(&path).unwrap();
|
|
112
|
+
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
|
|
113
|
+
let params = json!({"delegation_id": "d1", "job_id": "job-1", "staging": "report-job-1.txt", "name": "report-job-1.txt", "size": meta.len()});
|
|
114
|
+
assert_eq!(
|
|
115
|
+
reply.services,
|
|
116
|
+
vec![("delegate.move_from_staging".to_string(), params)]
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#[test]
|
|
121
|
+
fn a_repeated_save_sends_the_same_file_again() {
|
|
122
|
+
let (_dir, cfg) = setup();
|
|
123
|
+
let first = save(&cfg, "alice", &moved()).services;
|
|
124
|
+
let path = cfg.data_dir.join("executor-staging/report-job-1.txt");
|
|
125
|
+
let report = std::fs::read_to_string(&path).unwrap();
|
|
126
|
+
assert_eq!(save(&cfg, "bob", &moved()).services, first);
|
|
127
|
+
assert_eq!(std::fs::read_to_string(&path).unwrap(), report);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[test]
|
|
131
|
+
fn save_passes_on_the_executors_refusal() {
|
|
132
|
+
let (_dir, cfg) = setup();
|
|
133
|
+
let services: Services = [(
|
|
134
|
+
"delegate.move_from_staging",
|
|
135
|
+
Box::new(|_: &Value| {
|
|
136
|
+
Err(Error::new(
|
|
137
|
+
"DELEGATION_DENIED",
|
|
138
|
+
"The destination is no longer allowed.",
|
|
139
|
+
))
|
|
140
|
+
}) as _,
|
|
141
|
+
)]
|
|
142
|
+
.into();
|
|
143
|
+
assert_eq!(save(&cfg, "alice", &services).code(), "DELEGATION_DENIED");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[test]
|
|
147
|
+
fn save_refuses_invalid_params_before_writing() {
|
|
148
|
+
let (_dir, cfg) = setup();
|
|
149
|
+
for params in [
|
|
150
|
+
Value::Null,
|
|
151
|
+
json!({"delegation_id": "d1"}),
|
|
152
|
+
json!({"delegation_id": "d1", "job_id": "../x"}),
|
|
153
|
+
json!({"delegation_id": "d1", "job_id": ""}),
|
|
154
|
+
json!({"delegation_id": 1, "job_id": "j"}),
|
|
155
|
+
] {
|
|
156
|
+
let reply = call(
|
|
157
|
+
build(cfg.clone()),
|
|
158
|
+
"report.save",
|
|
159
|
+
params.clone(),
|
|
160
|
+
"alice",
|
|
161
|
+
&Services::new(),
|
|
162
|
+
);
|
|
163
|
+
assert_eq!(
|
|
164
|
+
(reply.code(), reply.services.len()),
|
|
165
|
+
("bad_request", 0),
|
|
166
|
+
"{params}"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
assert!(!cfg.data_dir.join("executor-staging").exists());
|
|
170
|
+
}
|
|
171
|
+
}
|