uvako-cli 0.5.0 → 0.6.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/README.md +5 -0
- package/package.json +6 -1
- package/src/api.js +7 -4
- package/src/check.js +78 -4
- package/src/files.js +2 -1
- package/src/init.js +97 -6
- package/src/locales/en.js +76 -8
- package/src/main.js +34 -13
- package/src/remote.js +80 -7
- package/src/rules.generated.js +452 -0
- package/src/sections.js +333 -0
- package/src/svg.js +152 -0
- package/templates/app/README.md +26 -0
package/src/sections.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { rules } from "./rules.generated.js";
|
|
2
|
+
import { t } from "./i18n.js";
|
|
3
|
+
|
|
4
|
+
// "engine": "sections" paketlerinin denetimi. Sunucudaki karşılıkları: theme/sections.go
|
|
5
|
+
// (şema ve sayfa şablonu), theme/render (şablon sandbox'ı). Listeler ve sınırlar ortak
|
|
6
|
+
// rules.json'dan gelir (src/rules.generated.js). Son karar her zaman sunucudadır; `uvako theme
|
|
7
|
+
// check` giriş yapılmışsa sunucunun kuru denetimini de çalıştırır.
|
|
8
|
+
|
|
9
|
+
const actionMark = "\x1a";
|
|
10
|
+
const namePattern = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
|
|
11
|
+
const sectionId = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
12
|
+
const settingId = /^[a-z][a-z0-9_]{0,63}$/;
|
|
13
|
+
const colorPattern = /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?(?:[0-9a-fA-F]{2})?$/;
|
|
14
|
+
const scriptPatterns = [/<script\b/i, /javascript:/i, /\son[a-z]+\s*=/i, /<iframe\b|<object\b|<embed\b|<foreignObject\b/i];
|
|
15
|
+
const denied = { elements: new Set(rules.deniedElements), attributes: new Set(rules.deniedAttributes), urls: new Set(rules.urlAttributes), schemes: new Set(rules.urlSchemes) };
|
|
16
|
+
const functions = new Set(rules.functions);
|
|
17
|
+
const keywords = new Set(["if", "else", "end", "range", "with", "define", "template", "nil", "true", "false"]);
|
|
18
|
+
|
|
19
|
+
/** Paket bölüm dosyası mı: templates/<sayfa>.json (index dışı), sections/, snippets/, layout/. */
|
|
20
|
+
export function sectionFile(rel) {
|
|
21
|
+
const parts = rel.split("/");
|
|
22
|
+
if (parts.length !== 2 && rel !== "layout/theme.html.tmpl") return "";
|
|
23
|
+
if (rel === "layout/theme.html.tmpl") return "source";
|
|
24
|
+
if (parts[0] === "templates" && parts[1].endsWith(".json") && parts[1] !== "index.json" && rules.templates.includes(parts[1].slice(0, -5))) return "template";
|
|
25
|
+
if (parts[0] === "sections" && parts[1].endsWith(".schema.json")) return "schema";
|
|
26
|
+
if ((parts[0] === "sections" || parts[0] === "snippets") && parts[1].endsWith(".html.tmpl")) return "source";
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── şablon: {{ … }} aksiyonları ve statik HTML ──────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function actionEnd(s) {
|
|
33
|
+
const body = s.replace(/^-/, "").replace(/^[ \t\r\n]+/, "");
|
|
34
|
+
if (body.startsWith("/*")) {
|
|
35
|
+
const offset = s.length - body.length;
|
|
36
|
+
const close = body.indexOf("*/");
|
|
37
|
+
const end = close >= 0 ? body.slice(close).indexOf("}}") : -1;
|
|
38
|
+
if (end < 0) throw new Error(t("kapanmamış şablon aksiyonu"));
|
|
39
|
+
return { end: offset + close + end + 2, comment: true };
|
|
40
|
+
}
|
|
41
|
+
for (let i = 0; i < s.length; i++) {
|
|
42
|
+
const c = s[i];
|
|
43
|
+
if (c === '"' || c === "'") {
|
|
44
|
+
for (i++; i < s.length && s[i] !== c; i++) {
|
|
45
|
+
if (s[i] === "\\") i++;
|
|
46
|
+
else if (s[i] === "\n") throw new Error(t("kapanmamış şablon aksiyonu"));
|
|
47
|
+
}
|
|
48
|
+
if (i >= s.length) throw new Error(t("kapanmamış şablon aksiyonu"));
|
|
49
|
+
} else if (c === "`") {
|
|
50
|
+
const close = s.indexOf("`", i + 1);
|
|
51
|
+
if (close < 0) throw new Error(t("kapanmamış şablon aksiyonu"));
|
|
52
|
+
i = close;
|
|
53
|
+
} else if (c === "}" && s[i + 1] === "}") {
|
|
54
|
+
return { end: i + 2, comment: false };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new Error(t("kapanmamış şablon aksiyonu"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Aksiyonları işaretle değiştirir; aksiyon gövdelerini (yorumlar hariç) döndürür. */
|
|
61
|
+
export function maskActions(source) {
|
|
62
|
+
let masked = "";
|
|
63
|
+
const actions = [];
|
|
64
|
+
let rest = source;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const start = rest.indexOf("{{");
|
|
67
|
+
if (start < 0) return { masked: masked + rest, actions };
|
|
68
|
+
masked += rest.slice(0, start) + actionMark;
|
|
69
|
+
const { end, comment } = actionEnd(rest.slice(start + 2));
|
|
70
|
+
if (!comment) actions.push(rest.slice(start + 2, start + end).replace(/^-/, "").replace(/-?}}$/, "").trim());
|
|
71
|
+
rest = rest.slice(start + 2 + end);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isLetter = (c) => /[A-Za-z]/.test(c ?? "");
|
|
76
|
+
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f";
|
|
77
|
+
const strip = (value) => [...value].filter((c) => c > " " && c !== "\x7f").join("");
|
|
78
|
+
const localName = (name) => (name !== "xlink:href" && name.includes(":") ? name.slice(name.lastIndexOf(":") + 1) : name);
|
|
79
|
+
|
|
80
|
+
function decode(value) {
|
|
81
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);?/gi, (whole, ref) => {
|
|
82
|
+
if (ref[0] === "#") {
|
|
83
|
+
const code = ref[1] === "x" || ref[1] === "X" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
|
|
84
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
|
85
|
+
}
|
|
86
|
+
return { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", colon: ":", tab: "\t", newline: "\n" }[ref.toLowerCase()] ?? whole;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function allowedURL(value) {
|
|
91
|
+
const compact = strip(value).toLowerCase();
|
|
92
|
+
const colon = compact.indexOf(":");
|
|
93
|
+
if (colon < 0 || /[/?#]/.test(compact.slice(0, colon))) return true;
|
|
94
|
+
return denied.schemes.has(compact.slice(0, colon));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function checkAttribute(name, value) {
|
|
98
|
+
const local = localName(name);
|
|
99
|
+
if (local.startsWith("on") || denied.attributes.has(local)) return t("izin verilmeyen öznitelik: {0}", name);
|
|
100
|
+
const text = decode(value).replaceAll(actionMark, "x");
|
|
101
|
+
if (denied.urls.has(name) || denied.urls.has(local)) {
|
|
102
|
+
if (!allowedURL(text)) return t("{0} içinde izin verilmeyen adres şeması", name);
|
|
103
|
+
} else if (local === "srcset") {
|
|
104
|
+
if (text.split(",").some((candidate) => candidate.trim() && !allowedURL(candidate.trim().split(/\s+/)[0]))) return t("srcset içinde izin verilmeyen adres şeması");
|
|
105
|
+
} else if (local === "style") {
|
|
106
|
+
const compact = strip(text).toLowerCase();
|
|
107
|
+
const bad = ["expression(", "javascript:", "vbscript:", "@import", "behavior:", "-moz-binding", "\\"].find((item) => compact.includes(item));
|
|
108
|
+
if (bad) return t("style özniteliğinde izin verilmeyen ifade: {0}", bad);
|
|
109
|
+
}
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** theme/render/html.go → checkHTML ile aynı kurallar (maskelenmiş şablon). */
|
|
114
|
+
export function checkHTML(source) {
|
|
115
|
+
for (let i = 0; i < source.length;) {
|
|
116
|
+
const lt = source.indexOf("<", i);
|
|
117
|
+
if (lt < 0) return "";
|
|
118
|
+
i = lt + 1;
|
|
119
|
+
let rest = source.slice(i);
|
|
120
|
+
if (rest.startsWith("!--")) {
|
|
121
|
+
const end = rest.indexOf("-->", 3);
|
|
122
|
+
if (end < 0) return t("kapanmamış HTML yorumu");
|
|
123
|
+
i += end + 3;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (rest.startsWith("!") || rest.startsWith("?")) {
|
|
127
|
+
if (!rest.toLowerCase().startsWith("!doctype html>")) return t("işaretleme bildirimi kullanılamaz");
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const closing = rest.startsWith("/");
|
|
131
|
+
if (closing) {
|
|
132
|
+
i++;
|
|
133
|
+
rest = rest.slice(1);
|
|
134
|
+
}
|
|
135
|
+
if (!rest) return "";
|
|
136
|
+
if (rest.startsWith(actionMark)) return t("şablon aksiyonu etiket adı oluşturamaz");
|
|
137
|
+
if (!isLetter(rest[0])) continue;
|
|
138
|
+
let n = 0;
|
|
139
|
+
while (n < rest.length && !isSpace(rest[n]) && rest[n] !== ">" && rest[n] !== "/") n++;
|
|
140
|
+
const name = rest.slice(0, n).toLowerCase();
|
|
141
|
+
if (name.includes(actionMark)) return t("şablon aksiyonu etiket adı oluşturamaz");
|
|
142
|
+
if (denied.elements.has(name) || denied.elements.has(localName(name))) return t("izin verilmeyen etiket: {0}", name);
|
|
143
|
+
i += n;
|
|
144
|
+
if (closing) {
|
|
145
|
+
const end = source.indexOf(">", i);
|
|
146
|
+
if (end < 0) return t("kapanmamış etiket: {0}", name);
|
|
147
|
+
i = end + 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (;;) {
|
|
151
|
+
while (i < source.length && (isSpace(source[i]) || source[i] === "/")) i++;
|
|
152
|
+
if (i >= source.length) return t("kapanmamış etiket: {0}", name);
|
|
153
|
+
if (source[i] === ">") {
|
|
154
|
+
i++;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
const start = i;
|
|
158
|
+
while (i < source.length && !isSpace(source[i]) && !"=>/".includes(source[i])) i++;
|
|
159
|
+
const attribute = source.slice(start, i).toLowerCase();
|
|
160
|
+
if (attribute.includes(actionMark)) return t("şablon aksiyonu öznitelik adı oluşturamaz");
|
|
161
|
+
while (i < source.length && isSpace(source[i])) i++;
|
|
162
|
+
let value = "";
|
|
163
|
+
if (source[i] === "=") {
|
|
164
|
+
i++;
|
|
165
|
+
while (i < source.length && isSpace(source[i])) i++;
|
|
166
|
+
if (source[i] === '"' || source[i] === "'") {
|
|
167
|
+
const end = source.indexOf(source[i], i + 1);
|
|
168
|
+
if (end < 0) return t("{0} etiketinde kapanmamış öznitelik değeri", name);
|
|
169
|
+
value = source.slice(i + 1, end);
|
|
170
|
+
i = end + 1;
|
|
171
|
+
} else {
|
|
172
|
+
const start = i;
|
|
173
|
+
while (i < source.length && !isSpace(source[i]) && source[i] !== ">") i++;
|
|
174
|
+
value = source.slice(start, i);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const problem = checkAttribute(attribute, value);
|
|
178
|
+
if (problem) return `${name}: ${problem}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Aksiyonlardaki fonksiyon, action, include ve define adlarını denetler. */
|
|
185
|
+
function checkActions(actions, snippets, defines) {
|
|
186
|
+
const problems = [];
|
|
187
|
+
for (const action of actions) {
|
|
188
|
+
const bare = action.replace(/"(?:[^"\\]|\\.)*"|`[^`]*`|'(?:[^'\\]|\\.)*'/g, (literal) => (literal[0] === '"' ? `"${literal.slice(1, -1).replace(/\s/g, "_")}"` : '""'));
|
|
189
|
+
const words = [...bare.matchAll(/(^|[\s(|,])([A-Za-z_][A-Za-z0-9_]*)/g)].map((match) => match[2]);
|
|
190
|
+
const head = words[0];
|
|
191
|
+
if (head === "block" || head === "break" || head === "continue") problems.push(t("izin verilmeyen aksiyon: {0}", head));
|
|
192
|
+
for (const word of words) {
|
|
193
|
+
if (keywords.has(word) || functions.has(word) || word === "block" || word === "break" || word === "continue") continue;
|
|
194
|
+
problems.push(t("bilinmeyen fonksiyon: {0}", word));
|
|
195
|
+
}
|
|
196
|
+
const named = /^(template|define)\s+"([^"]*)"/.exec(action);
|
|
197
|
+
if (named) {
|
|
198
|
+
const [, kind, name] = named;
|
|
199
|
+
if (!name.startsWith("snippets/") || !namePattern.test(name.slice("snippets/".length))) problems.push(t("{0} yalnızca paketin snippets/ şablonlarını adlandırabilir: {1}", kind, name));
|
|
200
|
+
else if (kind === "template" && !snippets.has(name) && !defines.has(name)) problems.push(t("snippet bulunamadı: {0}", name));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return problems;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Bölüm/snippet/layout şablonları: yol, boyut, aksiyonlar ve statik HTML. */
|
|
207
|
+
export function checkSources(sources, report) {
|
|
208
|
+
const snippets = new Set(Object.keys(sources).filter((rel) => rel.startsWith("snippets/")).map((rel) => rel.slice(0, -".html.tmpl".length)));
|
|
209
|
+
const defines = new Set();
|
|
210
|
+
for (const source of Object.values(sources)) for (const match of source.matchAll(/\{\{-?\s*define\s+"([^"]*)"/g)) defines.add(match[1]);
|
|
211
|
+
if (Object.keys(sources).length > 128) report.errors.push(t("Pakette en çok 128 bölüm şablonu olabilir."));
|
|
212
|
+
for (const [rel, source] of Object.entries(sources)) {
|
|
213
|
+
const handle = rel.split("/")[1]?.replace(/\.html\.tmpl$/, "") ?? "";
|
|
214
|
+
if (rel !== "layout/theme.html.tmpl" && !namePattern.test(handle)) report.errors.push(t("{0}: şablon adında yalnızca küçük harf, rakam, tire ve alt çizgi olabilir.", rel));
|
|
215
|
+
if (rel.startsWith("sections/") && handle in rules.platformSchemas) report.errors.push(t("{0}: platform bölümünün adı kullanılamaz; başka bir ad verin.", rel));
|
|
216
|
+
if (Buffer.byteLength(source) > rules.limits.templateBytes) {
|
|
217
|
+
report.errors.push(t("{0}: {1} KB sınırını aşıyor.", rel, rules.limits.templateBytes / 1024));
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
let masked;
|
|
221
|
+
try {
|
|
222
|
+
masked = maskActions(source);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
report.errors.push(`${rel}: ${error.message}`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const html = checkHTML(masked.masked);
|
|
228
|
+
if (html) report.errors.push(`${rel}: ${html}`);
|
|
229
|
+
for (const problem of checkActions(masked.actions, snippets, defines)) report.errors.push(`${rel}: ${problem}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** {{ t "Metin" }} çağrılarındaki metinler: locales/ dosyalarında karşılığı aranır. */
|
|
234
|
+
export function templateTexts(sources) {
|
|
235
|
+
const out = new Set();
|
|
236
|
+
for (const source of Object.values(sources)) {
|
|
237
|
+
for (const match of source.matchAll(/\{\{-?\s*t\s+"((?:[^"\\]|\\.)*)"/g)) {
|
|
238
|
+
try { out.add(JSON.parse(`"${match[1]}"`)); } catch { /* bozuk dize derlemede yakalanır */ }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── bölüm ayar şeması (theme/sections.go → validateSectionSchema) ──────────────────────────
|
|
245
|
+
|
|
246
|
+
const safeText = (value) => {
|
|
247
|
+
if (typeof value === "string") return value.length <= 16384 && !scriptPatterns.some((pattern) => pattern.test(value));
|
|
248
|
+
return value === null || value === undefined || typeof value === "boolean" || typeof value === "number";
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
function validValue(setting, value) {
|
|
252
|
+
if (!safeText(value)) return false;
|
|
253
|
+
switch (setting.type) {
|
|
254
|
+
case "checkbox": return typeof value === "boolean";
|
|
255
|
+
case "number":
|
|
256
|
+
case "range": return typeof value === "number" && Number.isFinite(value) && (setting.min === undefined || value >= setting.min) && (setting.max === undefined || value <= setting.max);
|
|
257
|
+
default: {
|
|
258
|
+
if (typeof value !== "string") return false;
|
|
259
|
+
if (setting.type === "color") return value === "" || colorPattern.test(value);
|
|
260
|
+
if (setting.type === "url" || setting.type === "image") {
|
|
261
|
+
if (value === "") return true;
|
|
262
|
+
if (/[\0\r\n\t\\ ]/.test(value)) return false;
|
|
263
|
+
if (value.startsWith("/")) return !value.startsWith("//") && !value.includes("..");
|
|
264
|
+
try {
|
|
265
|
+
const url = new URL(value);
|
|
266
|
+
return url.protocol === "https:" && Boolean(url.host) && !url.username && !url.password;
|
|
267
|
+
} catch { return false; }
|
|
268
|
+
}
|
|
269
|
+
if (setting.type === "select") return (setting.options ?? []).some((option) => option.value === value);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const finite = (value) => value === undefined || (typeof value === "number" && Number.isFinite(value));
|
|
276
|
+
|
|
277
|
+
export function checkSectionSchema(rel, schema, report) {
|
|
278
|
+
const fail = (text) => report.errors.push(t("{0}: bölüm ayar şeması geçersiz ({1}).", rel, text));
|
|
279
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return fail(t("nesne olmalı"));
|
|
280
|
+
if (typeof schema.name !== "string" || !schema.name.trim() || schema.name.length > 100) fail(t("\"name\" gerekli, en çok 100 karakter"));
|
|
281
|
+
const settings = schema.settings ?? [];
|
|
282
|
+
if (!Array.isArray(settings) || settings.length > rules.limits.sectionSettings) return fail(t("\"settings\" en çok {0} ayarlık bir liste olmalı", rules.limits.sectionSettings));
|
|
283
|
+
if (schema.fallback !== undefined && typeof schema.fallback !== "string") fail(t("\"fallback\" bir bölüm türü olmalı"));
|
|
284
|
+
const seen = new Set();
|
|
285
|
+
for (const setting of settings) {
|
|
286
|
+
const id = setting?.id;
|
|
287
|
+
if (!settingId.test(String(id ?? "")) || seen.has(id)) { fail(t("\"{0}\" ayar adı geçersiz ya da tekrarlı", String(id ?? ""))); continue; }
|
|
288
|
+
seen.add(id);
|
|
289
|
+
if (typeof setting.label !== "string" || !setting.label.trim() || setting.label.length > 150) fail(t("\"{0}\" ayarının \"label\" alanı gerekli", id));
|
|
290
|
+
if (!rules.settingTypes.includes(setting.type)) fail(t("\"{0}\" ayarının türü ({1}) bilinmiyor; kullanılabilir: {2}", id, String(setting.type), rules.settingTypes.join(", ")));
|
|
291
|
+
if (!finite(setting.min) || !finite(setting.max) || (setting.min !== undefined && setting.max !== undefined && setting.min > setting.max)) fail(t("\"{0}\" ayarının min/max değerleri geçersiz", id));
|
|
292
|
+
if (setting.step !== undefined && !(typeof setting.step === "number" && Number.isFinite(setting.step) && setting.step > 0)) fail(t("\"{0}\" ayarının step değeri geçersiz", id));
|
|
293
|
+
if (setting.type === "range" && (setting.min === undefined || setting.max === undefined)) fail(t("\"{0}\" (range) için min ve max gerekli", id));
|
|
294
|
+
if (setting.type === "select" && (!Array.isArray(setting.options) || setting.options.length === 0 || setting.options.length > 50)) fail(t("\"{0}\" (select) için 1-50 seçenek gerekli", id));
|
|
295
|
+
const values = new Set();
|
|
296
|
+
for (const option of Array.isArray(setting.options) ? setting.options : []) {
|
|
297
|
+
if (!option?.value || !option?.label || values.has(option.value)) fail(t("\"{0}\" ayarının seçenekleri geçersiz", id));
|
|
298
|
+
values.add(option?.value);
|
|
299
|
+
}
|
|
300
|
+
if (setting.default !== undefined && setting.default !== null && !validValue(setting, setting.default)) fail(t("\"{0}\" ayarının varsayılan değeri türüne uymuyor", id));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── sayfa şablonu (theme/sections.go → validatePageTemplate) ──────────────────────────────
|
|
305
|
+
|
|
306
|
+
/** known.schemas: tür → şema (platform + paket), known.legacy: temel temanın bölüm türleri (bilinmiyorsa null). */
|
|
307
|
+
export function checkPageTemplate(rel, tpl, known, report) {
|
|
308
|
+
const fail = (text) => report.errors.push(`${rel}: ${text}`);
|
|
309
|
+
if (!tpl || typeof tpl !== "object" || !tpl.sections || typeof tpl.sections !== "object" || !Array.isArray(tpl.order ?? [])) return fail(t("geçerli bir şablon değil ({\"order\": […], \"sections\": {…}})."));
|
|
310
|
+
const order = tpl.order ?? [];
|
|
311
|
+
if (order.length > 100 || Object.keys(tpl.sections).length > 100) fail(t("en çok 100 bölüm olabilir."));
|
|
312
|
+
const seen = new Set();
|
|
313
|
+
for (const id of order) {
|
|
314
|
+
if (!sectionId.test(String(id)) || seen.has(id)) fail(t("\"{0}\" bölüm kimliği geçersiz ya da tekrarlı.", String(id)));
|
|
315
|
+
else if (!(id in tpl.sections)) fail(t("\"order\" içindeki \"{0}\" bölümü \"sections\" altında yok.", id));
|
|
316
|
+
seen.add(id);
|
|
317
|
+
}
|
|
318
|
+
for (const [id, section] of Object.entries(tpl.sections)) {
|
|
319
|
+
if (!sectionId.test(id)) fail(t("\"{0}\" bölüm kimliği geçersiz.", id));
|
|
320
|
+
const type = String(section?.type ?? "");
|
|
321
|
+
const schema = known.schemas.get(type);
|
|
322
|
+
if (!schema && !type.startsWith("app:") && known.legacy && !known.legacy.has(type)) fail(t("\"{0}\" bölümünün türü ({1}) ne platformda ne pakette ne de temel temada var.", id, type));
|
|
323
|
+
if (!schema && !type.startsWith("app:") && !known.legacy && type.startsWith("pkg:")) fail(t("\"{0}\" bölümünün türü ({1}) pakette yok: sections/{2}.html.tmpl ekleyin.", id, type, type.slice(4)));
|
|
324
|
+
const defs = new Map((schema?.settings ?? []).map((setting) => [setting.id, setting]));
|
|
325
|
+
for (const [key, value] of Object.entries(section?.settings ?? {})) {
|
|
326
|
+
if (!safeText(value)) { fail(t("\"{0}\" bölümünün \"{1}\" ayarı betik ya da izin verilmeyen bir değer içeriyor.", id, key)); continue; }
|
|
327
|
+
if (schema && (!defs.has(key) || !validValue(defs.get(key), value))) fail(t("\"{0}\" bölümünün \"{1}\" ayarı şemada yok ya da türüne uymuyor.", id, key));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Platform bölümleri ve ayar şemaları: sunucuyla aynı rules.json'dan. */
|
|
333
|
+
export const platformSchemas = new Map(Object.entries(rules.platformSchemas));
|
package/src/svg.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import sax from "sax";
|
|
2
|
+
|
|
3
|
+
const svgNS = "http://www.w3.org/2000/svg";
|
|
4
|
+
const xlinkNS = "http://www.w3.org/1999/xlink";
|
|
5
|
+
const xmlNS = "http://www.w3.org/XML/1998/namespace";
|
|
6
|
+
const maxBytes = 5 * 1024 * 1024;
|
|
7
|
+
const names = (text) => new Set(text.split(/\s+/).filter(Boolean));
|
|
8
|
+
|
|
9
|
+
// This static drawing profile mirrors backend/internal/theme/svg.go. Both implementations
|
|
10
|
+
// run backend/internal/theme/testdata/svg.json to keep the upload and CLI decisions aligned.
|
|
11
|
+
const elements = Object.fromEntries(Object.entries({
|
|
12
|
+
svg: "x y width height viewBox preserveAspectRatio version baseProfile",
|
|
13
|
+
g: "", defs: "", title: "", desc: "",
|
|
14
|
+
symbol: "viewBox preserveAspectRatio x y width height", use: "href x y width height",
|
|
15
|
+
image: "href x y width height preserveAspectRatio", path: "d pathLength",
|
|
16
|
+
rect: "x y width height rx ry pathLength", circle: "cx cy r pathLength", ellipse: "cx cy rx ry pathLength",
|
|
17
|
+
line: "x1 y1 x2 y2 pathLength", polyline: "points pathLength", polygon: "points pathLength",
|
|
18
|
+
text: "x y dx dy rotate textLength lengthAdjust", tspan: "x y dx dy rotate textLength lengthAdjust",
|
|
19
|
+
textPath: "href startOffset method spacing side textLength lengthAdjust",
|
|
20
|
+
linearGradient: "href x1 y1 x2 y2 gradientUnits gradientTransform spreadMethod",
|
|
21
|
+
radialGradient: "href cx cy r fx fy fr gradientUnits gradientTransform spreadMethod", stop: "offset",
|
|
22
|
+
clipPath: "clipPathUnits", mask: "x y width height maskUnits maskContentUnits mask-type",
|
|
23
|
+
pattern: "href x y width height patternUnits patternContentUnits patternTransform viewBox preserveAspectRatio",
|
|
24
|
+
marker: "viewBox preserveAspectRatio refX refY markerWidth markerHeight markerUnits orient",
|
|
25
|
+
filter: "x y width height filterUnits primitiveUnits", feBlend: "in in2 mode result x y width height",
|
|
26
|
+
feColorMatrix: "in type values result x y width height", feComponentTransfer: "in result x y width height",
|
|
27
|
+
feComposite: "in in2 operator k1 k2 k3 k4 result x y width height",
|
|
28
|
+
feConvolveMatrix: "in order kernelMatrix divisor bias targetX targetY edgeMode kernelUnitLength preserveAlpha result x y width height",
|
|
29
|
+
feDiffuseLighting: "in surfaceScale diffuseConstant kernelUnitLength result x y width height",
|
|
30
|
+
feDisplacementMap: "in in2 scale xChannelSelector yChannelSelector result x y width height",
|
|
31
|
+
feDistantLight: "azimuth elevation", feDropShadow: "in dx dy stdDeviation result x y width height",
|
|
32
|
+
feFlood: "result x y width height",
|
|
33
|
+
feFuncA: "type tableValues slope intercept amplitude exponent offset", feFuncB: "type tableValues slope intercept amplitude exponent offset",
|
|
34
|
+
feFuncG: "type tableValues slope intercept amplitude exponent offset", feFuncR: "type tableValues slope intercept amplitude exponent offset",
|
|
35
|
+
feGaussianBlur: "in stdDeviation edgeMode result x y width height", feImage: "href preserveAspectRatio result x y width height",
|
|
36
|
+
feMerge: "result x y width height", feMergeNode: "in", feMorphology: "in operator radius result x y width height",
|
|
37
|
+
feOffset: "in dx dy result x y width height", fePointLight: "x y z",
|
|
38
|
+
feSpecularLighting: "in surfaceScale specularConstant specularExponent kernelUnitLength result x y width height",
|
|
39
|
+
feSpotLight: "x y z pointsAtX pointsAtY pointsAtZ specularExponent limitingConeAngle",
|
|
40
|
+
feTile: "in result x y width height", feTurbulence: "baseFrequency numOctaves seed stitchTiles type result x y width height",
|
|
41
|
+
}).map(([tag, attributes]) => [tag, names(attributes)]));
|
|
42
|
+
|
|
43
|
+
const globalAttributes = names(`id class role aria-label aria-labelledby aria-describedby aria-hidden
|
|
44
|
+
fill fill-rule fill-opacity stroke stroke-width stroke-linecap stroke-linejoin stroke-miterlimit stroke-dasharray stroke-dashoffset stroke-opacity
|
|
45
|
+
opacity color stop-color stop-opacity flood-color flood-opacity lighting-color
|
|
46
|
+
clip-path clip-rule mask filter marker-start marker-mid marker-end
|
|
47
|
+
color-interpolation color-interpolation-filters color-rendering shape-rendering text-rendering image-rendering
|
|
48
|
+
display visibility overflow vector-effect paint-order pointer-events
|
|
49
|
+
font-family font-size font-style font-weight font-stretch text-anchor dominant-baseline alignment-baseline baseline-shift
|
|
50
|
+
letter-spacing word-spacing writing-mode direction unicode-bidi text-decoration transform`);
|
|
51
|
+
|
|
52
|
+
const fragment = /^#[A-Za-z_][A-Za-z0-9_.:-]*$/;
|
|
53
|
+
const localURL = /^url\([ \t\r\n]*(?:#[A-Za-z_][A-Za-z0-9_.:-]*|"#[A-Za-z_][A-Za-z0-9_.:-]*"|'#[A-Za-z_][A-Za-z0-9_.:-]*')[ \t\r\n]*\)$/i;
|
|
54
|
+
const color = /^(?:#[0-9a-f]{3}|#[0-9a-f]{4}|#[0-9a-f]{6}|#[0-9a-f]{8}|[a-z]+|(?:rgb|rgba|hsl|hsla)\([0-9+.,% /\t\r\n-]+\))$/i;
|
|
55
|
+
const declaration = /^version[ \t\r\n]*=[ \t\r\n]*(?:"1\.0"|'1\.0')(?:[ \t\r\n]+encoding[ \t\r\n]*=[ \t\r\n]*(?:"[Uu][Tt][Ff]-8"|'[Uu][Tt][Ff]-8'))?(?:[ \t\r\n]+standalone[ \t\r\n]*=[ \t\r\n]*(?:"(?:yes|no)"|'(?:yes|no)'))?[ \t\r\n]*$/;
|
|
56
|
+
const whitespace = (text) => /^[ \t\r\n]*$/.test(text);
|
|
57
|
+
const trim = (text) => text.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
|
|
58
|
+
const reject = (message) => { throw new Error(message); };
|
|
59
|
+
|
|
60
|
+
function validXMLText(text) {
|
|
61
|
+
for (const char of text) {
|
|
62
|
+
const code = char.codePointAt(0);
|
|
63
|
+
if (!(code === 9 || code === 10 || code === 13 || (code >= 0x20 && code <= 0xD7FF) || (code >= 0xE000 && code <= 0xFFFD) || (code >= 0x10000 && code <= 0x10FFFF))) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateValue(name, raw) {
|
|
69
|
+
if (!validXMLText(raw)) reject("SVG geçersiz XML karakteri içeriyor.");
|
|
70
|
+
const value = trim(raw);
|
|
71
|
+
if (/[\\<>`]/.test(value) || /javascript:|expression\(|-moz-binding|behavior:/i.test(value)) reject(`${name} özniteliği güvenli bir SVG değeri değil.`);
|
|
72
|
+
if (name === "href") {
|
|
73
|
+
if (!fragment.test(value)) reject("SVG href yalnızca yerel #kimlik bağlantısı olabilir.");
|
|
74
|
+
} else if (["fill", "stroke"].includes(name)) {
|
|
75
|
+
if (!color.test(value) && !localURL.test(value)) reject(`SVG ${name} yalnızca renk veya yerel url(#kimlik) olabilir.`);
|
|
76
|
+
} else if (["color", "stop-color", "flood-color", "lighting-color"].includes(name)) {
|
|
77
|
+
if (!color.test(value)) reject(`SVG ${name} geçerli bir renk olmalıdır.`);
|
|
78
|
+
} else if (["clip-path", "mask", "filter", "marker-start", "marker-mid", "marker-end"].includes(name)) {
|
|
79
|
+
if (value !== "none" && !localURL.test(value)) reject(`SVG ${name} yalnızca yerel url(#kimlik) olabilir.`);
|
|
80
|
+
} else if (/url[ \t\r\n]*\(/i.test(value)) reject(`SVG ${name} url() içeremez.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Returns an upload-compatible validation message, or an empty string for a static SVG. */
|
|
84
|
+
export function checkSVG(input) {
|
|
85
|
+
try {
|
|
86
|
+
if (Buffer.byteLength(input) > maxBytes) reject("SVG dosya boyutu sınırını aşıyor.");
|
|
87
|
+
const source = (typeof input === "string" ? input : new TextDecoder("utf-8", { fatal: true }).decode(input)).replace(/^\uFEFF/, "");
|
|
88
|
+
if (!validXMLText(source)) reject("SVG geçersiz XML karakteri içeriyor.");
|
|
89
|
+
// Input is already bounded; large path attributes can exceed sax's default 64 KB buffer.
|
|
90
|
+
sax.MAX_BUFFER_LENGTH = maxBytes;
|
|
91
|
+
const parser = sax.parser(true, { xmlns: true, strictEntities: true });
|
|
92
|
+
let depth = 0;
|
|
93
|
+
let count = 0;
|
|
94
|
+
let root = false;
|
|
95
|
+
let declared = false;
|
|
96
|
+
let attributes = [];
|
|
97
|
+
let seen = new Set();
|
|
98
|
+
parser.onerror = (error) => { throw error; };
|
|
99
|
+
parser.ondoctype = parser.onsgmldeclaration = () => reject("SVG DOCTYPE veya varlık bildirimi içeremez.");
|
|
100
|
+
parser.onprocessinginstruction = (pi) => {
|
|
101
|
+
if (declared || !/^<\?xml[ \t\r\n]/.test(source) || pi.name !== "xml" || !declaration.test(pi.body)) reject("SVG işlem yönergesi içeremez.");
|
|
102
|
+
declared = true;
|
|
103
|
+
};
|
|
104
|
+
parser.onopentagstart = () => { attributes = []; seen = new Set(); };
|
|
105
|
+
parser.onattribute = (attr) => {
|
|
106
|
+
const key = `${attr.uri}\0${attr.local}`;
|
|
107
|
+
if (seen.has(key)) reject("SVG yinelenen öznitelik içeriyor.");
|
|
108
|
+
seen.add(key);
|
|
109
|
+
attributes.push(attr);
|
|
110
|
+
if (attributes.length > 64) reject("SVG yapı sınırını aşıyor.");
|
|
111
|
+
};
|
|
112
|
+
parser.onopentag = (node) => {
|
|
113
|
+
if (depth === 0) {
|
|
114
|
+
if (root || node.local !== "svg") reject("SVG tek bir svg kökü içermelidir.");
|
|
115
|
+
root = true;
|
|
116
|
+
}
|
|
117
|
+
if (++depth > 64 || ++count > 10000) reject("SVG yapı sınırını aşıyor.");
|
|
118
|
+
if (node.uri !== "" && node.uri !== svgNS) reject("SVG dışında ad alanı kullanılamaz.");
|
|
119
|
+
const allowed = elements[node.local];
|
|
120
|
+
if (!allowed || node.name.split(":").length > 2) reject(`<${node.local}> etiketi kullanılamaz.`);
|
|
121
|
+
for (const attr of attributes) {
|
|
122
|
+
if (attr.name.split(":").length > 2) reject("SVG öznitelik adı geçersiz.");
|
|
123
|
+
if (attr.prefix === "xmlns") {
|
|
124
|
+
const prefix = attr.name === "xmlns" ? "" : attr.local;
|
|
125
|
+
if (prefix === "xmlns" || (prefix === "xml" && attr.value !== xmlNS) ||
|
|
126
|
+
(attr.value !== svgNS && attr.value !== xlinkNS && !(prefix === "xml" && attr.value === xmlNS) && !(prefix === "" && attr.value === ""))) reject("SVG ad alanı bildirimi geçersiz.");
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!attr.uri) {
|
|
130
|
+
if (!globalAttributes.has(attr.local) && !allowed.has(attr.local)) reject(`${attr.local} özniteliği kullanılamaz.`);
|
|
131
|
+
} else if (attr.uri === xlinkNS) {
|
|
132
|
+
if (attr.local !== "href" || !allowed.has("href")) reject("xlink özniteliği kullanılamaz.");
|
|
133
|
+
} else if (attr.uri === xmlNS) {
|
|
134
|
+
if (!["lang", "space"].includes(attr.local)) reject("xml özniteliği kullanılamaz.");
|
|
135
|
+
} else reject("SVG özniteliğinin ad alanı geçersiz.");
|
|
136
|
+
validateValue(attr.local, attr.value);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
parser.onclosetag = () => { depth--; };
|
|
140
|
+
parser.ontext = (text) => {
|
|
141
|
+
if (!validXMLText(text) || (depth === 0 && !whitespace(text))) reject("SVG kökü dışında metin veya geçersiz XML karakteri kullanılamaz.");
|
|
142
|
+
};
|
|
143
|
+
parser.oncdata = (text) => {
|
|
144
|
+
if (depth === 0 || !validXMLText(text)) reject("SVG kökü dışında CDATA kullanılamaz.");
|
|
145
|
+
};
|
|
146
|
+
parser.write(source).close();
|
|
147
|
+
if (!root || depth !== 0) reject("geçerli bir SVG kökü yok.");
|
|
148
|
+
return "";
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return error.message;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/templates/app/README.md
CHANGED
|
@@ -30,6 +30,32 @@ uvako app submit # herkese açmak için Uvako incelemesine gönderir
|
|
|
30
30
|
|
|
31
31
|
Client secret bu klasöre yazılmaz. Sunucunuzda `UVAKO_CLIENT_SECRET` ortam değişkeniyle verin.
|
|
32
32
|
|
|
33
|
+
## Panelde açılma (gömülü uygulama)
|
|
34
|
+
|
|
35
|
+
`"embedded": true` olduğu için uygulama mağaza panelinin içinde, bir iframe'de açılır:
|
|
36
|
+
|
|
37
|
+
- Sayfa köprüyü mağazadan yükler: `<script src="https://<mağaza>/admin/app-bridge.js">`, sonra
|
|
38
|
+
`UvakoAppBridge.createApp()`. Bildirim, başlık, panel içi yönlendirme ve onay penceresi köprüdendir.
|
|
39
|
+
- Sunucuya istekler `app.fetch(...)` ile gider: `Authorization: Bearer <oturum belirteci>`. Belirteç bir
|
|
40
|
+
dakikalık bir JWT'dir; `server.js` → `verifySessionToken` imzayı (client secret), `aud`, `exp` ve mağazayı
|
|
41
|
+
(`dest`) doğrular. Çerez kullanılmaz.
|
|
42
|
+
- Sayfa `Content-Security-Policy: frame-ancestors https://<mağaza>` gönderir: başka sitelerde
|
|
43
|
+
çerçevelenemez.
|
|
44
|
+
|
|
45
|
+
## Vitrin bloğu
|
|
46
|
+
|
|
47
|
+
`blocks/yildiz-puani/` bir vitrin bloğu örneğidir: `block.json` (ad ve ayarlar), `block.html` (şablon),
|
|
48
|
+
`block.css`. `uvako app deploy` blokları da gönderir. Mağaza bloğu tema editöründe **Uygulama bloğu ekle**
|
|
49
|
+
ile ana sayfaya ekler. Şablonda kod çalışmaz: `{{ .settings.ad }}`, `if/else` ve `eq/ne/not/and/or`
|
|
50
|
+
kullanılabilir.
|
|
51
|
+
|
|
52
|
+
## Ücret alma
|
|
53
|
+
|
|
54
|
+
**Pro plana geç** düğmesi `POST /api/billing` ile aylık bir ücret oluşturur ve mağazayı panelin onay
|
|
55
|
+
sayfasına gönderir. Onaydan sonra panel `/billing/return?charge_id=…` adresine döner. Örnek varsayılan
|
|
56
|
+
olarak test ücreti oluşturur (`UVAKO_TEST_CHARGES=0` ile gerçek ücret). Test ücreti yalnızca kendi
|
|
57
|
+
geliştirme mağazalarınızda çalışır.
|
|
58
|
+
|
|
33
59
|
## Güvenlik
|
|
34
60
|
|
|
35
61
|
- Kurulum dönüşünde `hmac` ve `state` doğrulanır; `shop` yalnızca `*.uvoo.store` olabilir.
|