@yunsoft/yuncms-core 0.1.2 → 0.1.3
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/package.json +1 -1
- package/src/bootstrap.js +6 -0
- package/src/index.js +3 -1
- package/src/migrations/0008-studio-logo-file.js +10 -0
- package/src/migrations/0009-schema-display-names.js +19 -0
- package/src/migrations/0010-studio-favicon-file.js +12 -0
- package/src/schema-key.js +54 -0
- package/src/schema-metadata-repository.js +20 -8
- package/src/services/collections-service.js +14 -2
- package/src/services/core-services.js +2 -0
- package/src/services/fields-service.js +11 -2
- package/src/services/studio-settings-service.js +83 -11
- package/src/services/system-collection-fields-service.js +122 -0
package/package.json
CHANGED
package/src/bootstrap.js
CHANGED
|
@@ -7,6 +7,9 @@ import { authActionTokensMigration } from './migrations/0004-auth-action-tokens.
|
|
|
7
7
|
import { defaultPublicRoleMigration } from './migrations/0005-default-public-role.js';
|
|
8
8
|
import { studioSettingsMigration } from './migrations/0006-studio-settings.js';
|
|
9
9
|
import { systemPermissionResourcesMigration } from './migrations/0007-system-permission-resources.js';
|
|
10
|
+
import { studioLogoFileMigration } from './migrations/0008-studio-logo-file.js';
|
|
11
|
+
import { schemaDisplayNamesMigration } from './migrations/0009-schema-display-names.js';
|
|
12
|
+
import { studioFaviconFileMigration } from './migrations/0010-studio-favicon-file.js';
|
|
10
13
|
import { readSchemaVersion } from './schema-version.js';
|
|
11
14
|
import { ensurePublicRole } from './setup.js';
|
|
12
15
|
|
|
@@ -18,6 +21,9 @@ export const CORE_MIGRATIONS = Object.freeze([
|
|
|
18
21
|
defaultPublicRoleMigration,
|
|
19
22
|
studioSettingsMigration,
|
|
20
23
|
systemPermissionResourcesMigration,
|
|
24
|
+
studioLogoFileMigration,
|
|
25
|
+
schemaDisplayNamesMigration,
|
|
26
|
+
studioFaviconFileMigration,
|
|
21
27
|
]);
|
|
22
28
|
|
|
23
29
|
export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { DEFAULT_SERVER_PORT, loadConfig, loadEnvFileIfPresent } from './config.
|
|
|
2
2
|
export { createDatabasePool, pingDatabase, closeDatabasePool } from './database.js';
|
|
3
3
|
export { withTransaction, withConnectionTransaction } from './transaction.js';
|
|
4
4
|
export { assertIdentifier, quoteIdentifier } from './identifier.js';
|
|
5
|
+
export { normalizeDisplayName, normalizeSchemaKey, resolveSchemaName } from './schema-key.js';
|
|
5
6
|
export { YunCmsDatabaseError, normalizeDatabaseError, isRetryableDatabaseError } from './errors.js';
|
|
6
7
|
export { withDatabaseRetry } from './retry.js';
|
|
7
8
|
export {
|
|
@@ -41,6 +42,7 @@ export { AuditService, redactAuditValue } from './services/audit-service.js';
|
|
|
41
42
|
export { ItemsService } from './services/items-service.js';
|
|
42
43
|
export { CollectionsService } from './services/collections-service.js';
|
|
43
44
|
export { FieldsService } from './services/fields-service.js';
|
|
45
|
+
export { SystemCollectionFieldsService } from './services/system-collection-fields-service.js';
|
|
44
46
|
export { RelationsService } from './services/relations-service.js';
|
|
45
47
|
export { UsersService } from './services/users-service.js';
|
|
46
48
|
export { RolesService } from './services/roles-service.js';
|
|
@@ -85,4 +87,4 @@ export {
|
|
|
85
87
|
REQUIRED_CORE_MIGRATION_IDS,
|
|
86
88
|
bootstrapDatabase,
|
|
87
89
|
assertDatabaseCompatible,
|
|
88
|
-
} from './bootstrap.js';
|
|
90
|
+
} from './bootstrap.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const studioLogoFileMigration = {
|
|
2
|
+
id: '0008-studio-logo-file',
|
|
3
|
+
statements: [
|
|
4
|
+
`ALTER TABLE yuncms_studio_settings
|
|
5
|
+
ADD COLUMN logo_file CHAR(36) NULL AFTER logo_url,
|
|
6
|
+
ADD KEY idx_yuncms_studio_settings_logo_file (logo_file),
|
|
7
|
+
ADD CONSTRAINT fk_yuncms_studio_settings_logo_file
|
|
8
|
+
FOREIGN KEY (logo_file) REFERENCES yuncms_files (id) ON DELETE SET NULL`,
|
|
9
|
+
],
|
|
10
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const schemaDisplayNamesMigration = {
|
|
2
|
+
id: '0009-schema-display-names',
|
|
3
|
+
statements: [
|
|
4
|
+
`ALTER TABLE yuncms_collections
|
|
5
|
+
ADD COLUMN name VARCHAR(255) NULL AFTER collection`,
|
|
6
|
+
`UPDATE yuncms_collections
|
|
7
|
+
SET name = collection
|
|
8
|
+
WHERE name IS NULL OR TRIM(name) = ''`,
|
|
9
|
+
`ALTER TABLE yuncms_collections
|
|
10
|
+
MODIFY COLUMN name VARCHAR(255) NOT NULL`,
|
|
11
|
+
`ALTER TABLE yuncms_fields
|
|
12
|
+
ADD COLUMN name VARCHAR(255) NULL AFTER field`,
|
|
13
|
+
`UPDATE yuncms_fields
|
|
14
|
+
SET name = field
|
|
15
|
+
WHERE name IS NULL OR TRIM(name) = ''`,
|
|
16
|
+
`ALTER TABLE yuncms_fields
|
|
17
|
+
MODIFY COLUMN name VARCHAR(255) NOT NULL`,
|
|
18
|
+
],
|
|
19
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const studioFaviconFileMigration = {
|
|
2
|
+
id: '0010-studio-favicon-file',
|
|
3
|
+
statements: [
|
|
4
|
+
`ALTER TABLE yuncms_studio_settings
|
|
5
|
+
ADD COLUMN favicon_file CHAR(36) NULL AFTER logo_file`,
|
|
6
|
+
`ALTER TABLE yuncms_studio_settings
|
|
7
|
+
ADD KEY idx_yuncms_studio_settings_favicon_file (favicon_file)`,
|
|
8
|
+
`ALTER TABLE yuncms_studio_settings
|
|
9
|
+
ADD CONSTRAINT fk_yuncms_studio_settings_favicon_file
|
|
10
|
+
FOREIGN KEY (favicon_file) REFERENCES yuncms_files (id) ON DELETE SET NULL`,
|
|
11
|
+
],
|
|
12
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const TURKISH_ASCII = Object.freeze({
|
|
2
|
+
ç: 'c',
|
|
3
|
+
Ç: 'c',
|
|
4
|
+
ğ: 'g',
|
|
5
|
+
Ğ: 'g',
|
|
6
|
+
ı: 'i',
|
|
7
|
+
İ: 'i',
|
|
8
|
+
ö: 'o',
|
|
9
|
+
Ö: 'o',
|
|
10
|
+
ş: 's',
|
|
11
|
+
Ş: 's',
|
|
12
|
+
ü: 'u',
|
|
13
|
+
Ü: 'u',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function schemaNameError(message) {
|
|
17
|
+
const error = new Error(message);
|
|
18
|
+
error.code = 'INVALID_SCHEMA_NAME';
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeDisplayName(value, { fallback = null, maxLength = 255 } = {}) {
|
|
23
|
+
const candidate = typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : '';
|
|
24
|
+
const normalized = candidate || (typeof fallback === 'string' ? fallback.trim().replace(/\s+/g, ' ') : '');
|
|
25
|
+
if (!normalized) throw schemaNameError('A display name is required');
|
|
26
|
+
if (normalized.length > maxLength) throw schemaNameError(`Display name cannot exceed ${maxLength} characters`);
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeSchemaKey(value, { prefix = 'field', maxLength = 64 } = {}) {
|
|
31
|
+
const source = normalizeDisplayName(value, { maxLength: 512 });
|
|
32
|
+
const transliterated = [...source].map((character) => TURKISH_ASCII[character] ?? character).join('');
|
|
33
|
+
let key = transliterated
|
|
34
|
+
.normalize('NFKD')
|
|
35
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
38
|
+
.replace(/^_+|_+$/g, '')
|
|
39
|
+
.replace(/_+/g, '_');
|
|
40
|
+
|
|
41
|
+
if (!key) throw schemaNameError('Name must contain at least one letter or number');
|
|
42
|
+
if (/^[0-9]/.test(key)) key = `${prefix}_${key}`;
|
|
43
|
+
if (key.length > maxLength) key = key.slice(0, maxLength).replace(/_+$/g, '');
|
|
44
|
+
if (!key) throw schemaNameError('Name cannot be normalized to a schema key');
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveSchemaName({ displayName, key, prefix = 'field' } = {}) {
|
|
49
|
+
const name = normalizeDisplayName(displayName ?? key);
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
key: normalizeSchemaKey(key ?? name, { prefix }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -12,7 +12,7 @@ export class SchemaMetadataRepository {
|
|
|
12
12
|
|
|
13
13
|
async listCollections() {
|
|
14
14
|
const [rows] = await this.database.query(
|
|
15
|
-
`SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
15
|
+
`SELECT collection, name, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
16
16
|
FROM yuncms_collections
|
|
17
17
|
ORDER BY collection ASC`,
|
|
18
18
|
);
|
|
@@ -21,7 +21,7 @@ export class SchemaMetadataRepository {
|
|
|
21
21
|
|
|
22
22
|
async readCollection(collection) {
|
|
23
23
|
const [rows] = await this.database.query(
|
|
24
|
-
`SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
24
|
+
`SELECT collection, name, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
25
25
|
FROM yuncms_collections
|
|
26
26
|
WHERE collection = ?
|
|
27
27
|
LIMIT 1`,
|
|
@@ -32,6 +32,7 @@ export class SchemaMetadataRepository {
|
|
|
32
32
|
|
|
33
33
|
async createCollection({
|
|
34
34
|
collection,
|
|
35
|
+
name = collection,
|
|
35
36
|
primaryKey = 'id',
|
|
36
37
|
note = null,
|
|
37
38
|
singleton = false,
|
|
@@ -41,10 +42,11 @@ export class SchemaMetadataRepository {
|
|
|
41
42
|
}) {
|
|
42
43
|
await this.database.query(
|
|
43
44
|
`INSERT INTO yuncms_collections
|
|
44
|
-
(collection, primary_key, note, singleton, hidden, \`system\`, metadata)
|
|
45
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
45
|
+
(collection, name, primary_key, note, singleton, hidden, \`system\`, metadata)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
46
47
|
[
|
|
47
48
|
collection,
|
|
49
|
+
name,
|
|
48
50
|
primaryKey,
|
|
49
51
|
note,
|
|
50
52
|
singleton ? 1 : 0,
|
|
@@ -60,6 +62,10 @@ export class SchemaMetadataRepository {
|
|
|
60
62
|
const assignments = [];
|
|
61
63
|
const params = [];
|
|
62
64
|
|
|
65
|
+
if (Object.hasOwn(patch, 'name')) {
|
|
66
|
+
assignments.push('name = ?');
|
|
67
|
+
params.push(patch.name);
|
|
68
|
+
}
|
|
63
69
|
if (Object.hasOwn(patch, 'note')) {
|
|
64
70
|
assignments.push('note = ?');
|
|
65
71
|
params.push(patch.note ?? null);
|
|
@@ -96,7 +102,7 @@ export class SchemaMetadataRepository {
|
|
|
96
102
|
|
|
97
103
|
async listFields(collection) {
|
|
98
104
|
const [rows] = await this.database.query(
|
|
99
|
-
`SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
105
|
+
`SELECT id, collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
100
106
|
created_at, updated_at
|
|
101
107
|
FROM yuncms_fields
|
|
102
108
|
WHERE collection = ?
|
|
@@ -108,7 +114,7 @@ export class SchemaMetadataRepository {
|
|
|
108
114
|
|
|
109
115
|
async readField(collection, field) {
|
|
110
116
|
const [rows] = await this.database.query(
|
|
111
|
-
`SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
117
|
+
`SELECT id, collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
112
118
|
created_at, updated_at
|
|
113
119
|
FROM yuncms_fields
|
|
114
120
|
WHERE collection = ? AND field = ?
|
|
@@ -121,6 +127,7 @@ export class SchemaMetadataRepository {
|
|
|
121
127
|
async createField({
|
|
122
128
|
collection,
|
|
123
129
|
field,
|
|
130
|
+
name = field,
|
|
124
131
|
type,
|
|
125
132
|
required = false,
|
|
126
133
|
readonly = false,
|
|
@@ -137,11 +144,12 @@ export class SchemaMetadataRepository {
|
|
|
137
144
|
}
|
|
138
145
|
await this.database.query(
|
|
139
146
|
`INSERT INTO yuncms_fields
|
|
140
|
-
(collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata)
|
|
141
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
147
|
+
(collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
142
149
|
[
|
|
143
150
|
collection,
|
|
144
151
|
field,
|
|
152
|
+
name,
|
|
145
153
|
type,
|
|
146
154
|
required ? 1 : 0,
|
|
147
155
|
readonly ? 1 : 0,
|
|
@@ -169,6 +177,10 @@ export class SchemaMetadataRepository {
|
|
|
169
177
|
}
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
if (Object.hasOwn(patch, 'name')) {
|
|
181
|
+
assignments.push('name = ?');
|
|
182
|
+
params.push(patch.name);
|
|
183
|
+
}
|
|
172
184
|
if (Object.hasOwn(patch, 'readonly')) {
|
|
173
185
|
assignments.push('readonly = ?');
|
|
174
186
|
params.push(patch.readonly ? 1 : 0);
|
|
@@ -3,13 +3,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { withAdvisoryLock } from '../advisory-lock.js';
|
|
4
4
|
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
5
5
|
import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
|
|
6
|
+
import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
|
|
6
7
|
import { incrementSchemaVersion } from '../schema-version.js';
|
|
7
8
|
import { compileCollectionSystemFields, normalizeCollectionSystemFields } from '../system-fields.js';
|
|
8
9
|
import { withConnectionTransaction } from '../transaction.js';
|
|
9
10
|
import { BaseService } from './base-service.js';
|
|
10
11
|
import { assertSchemaManager } from './schema-access.js';
|
|
11
12
|
|
|
12
|
-
const COLLECTION_METADATA_KEYS = new Set(['note', 'singleton', 'hidden', 'metadata']);
|
|
13
|
+
const COLLECTION_METADATA_KEYS = new Set(['name', 'note', 'singleton', 'hidden', 'metadata']);
|
|
13
14
|
|
|
14
15
|
function assertUserCollectionName(collection) {
|
|
15
16
|
assertIdentifier(collection, 'collection name');
|
|
@@ -47,6 +48,9 @@ function assertCollectionMetadataPatch(patch) {
|
|
|
47
48
|
throw invalidSchemaPayload(`Collection ${key} must be a boolean`);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
if (Object.hasOwn(patch, 'name')) {
|
|
52
|
+
patch.name = normalizeDisplayName(patch.name);
|
|
53
|
+
}
|
|
50
54
|
if (Object.hasOwn(patch, 'note') && patch.note != null && typeof patch.note !== 'string') {
|
|
51
55
|
throw invalidSchemaPayload('Collection note must be a string or null');
|
|
52
56
|
}
|
|
@@ -93,7 +97,13 @@ export class CollectionsService extends BaseService {
|
|
|
93
97
|
async createOne(input = {}) {
|
|
94
98
|
assertSchemaManager(this.accountability);
|
|
95
99
|
assertCollectionCreateMetadata(input);
|
|
96
|
-
const
|
|
100
|
+
const resolvedName = resolveSchemaName({
|
|
101
|
+
displayName: input.name ?? input.collection,
|
|
102
|
+
key: input.collection,
|
|
103
|
+
prefix: 'collection',
|
|
104
|
+
});
|
|
105
|
+
const collection = assertUserCollectionName(resolvedName.key);
|
|
106
|
+
const name = resolvedName.name;
|
|
97
107
|
const primaryKey = input.primaryKey ?? 'id';
|
|
98
108
|
|
|
99
109
|
if (primaryKey !== 'id') {
|
|
@@ -132,6 +142,7 @@ export class CollectionsService extends BaseService {
|
|
|
132
142
|
return await withConnectionTransaction(connection, async () => {
|
|
133
143
|
const created = await metadata.createCollection({
|
|
134
144
|
collection,
|
|
145
|
+
name,
|
|
135
146
|
primaryKey,
|
|
136
147
|
note: input.note ?? null,
|
|
137
148
|
singleton: input.singleton === true,
|
|
@@ -145,6 +156,7 @@ export class CollectionsService extends BaseService {
|
|
|
145
156
|
await metadata.createField({
|
|
146
157
|
collection,
|
|
147
158
|
field: 'id',
|
|
159
|
+
name: 'ID',
|
|
148
160
|
type: 'uuid',
|
|
149
161
|
required: true,
|
|
150
162
|
readonly: true,
|
|
@@ -11,6 +11,7 @@ import { PermissionsService } from './permissions-service.js';
|
|
|
11
11
|
import { RelationsService } from './relations-service.js';
|
|
12
12
|
import { RolesService } from './roles-service.js';
|
|
13
13
|
import { StudioSettingsService } from './studio-settings-service.js';
|
|
14
|
+
import { SystemCollectionFieldsService } from './system-collection-fields-service.js';
|
|
14
15
|
import { UsersService } from './users-service.js';
|
|
15
16
|
import { createServiceRegistry } from './service-registry.js';
|
|
16
17
|
|
|
@@ -23,6 +24,7 @@ export function createCoreServiceRegistry() {
|
|
|
23
24
|
ItemsService,
|
|
24
25
|
CollectionsService,
|
|
25
26
|
FieldsService,
|
|
27
|
+
SystemCollectionFieldsService,
|
|
26
28
|
RelationsService,
|
|
27
29
|
UsersService,
|
|
28
30
|
RolesService,
|
|
@@ -4,12 +4,13 @@ import { withAdvisoryLock } from '../advisory-lock.js';
|
|
|
4
4
|
import { compileFieldColumn } from '../field-types.js';
|
|
5
5
|
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
6
6
|
import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
|
|
7
|
+
import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
|
|
7
8
|
import { incrementSchemaVersion } from '../schema-version.js';
|
|
8
9
|
import { withConnectionTransaction } from '../transaction.js';
|
|
9
10
|
import { BaseService } from './base-service.js';
|
|
10
11
|
import { assertSchemaManager } from './schema-access.js';
|
|
11
12
|
|
|
12
|
-
const FIELD_METADATA_KEYS = new Set(['readonly', 'hidden', 'sort', 'interface', 'options']);
|
|
13
|
+
const FIELD_METADATA_KEYS = new Set(['name', 'readonly', 'hidden', 'sort', 'interface', 'options']);
|
|
13
14
|
const FIELD_PHYSICAL_KEYS = new Set([
|
|
14
15
|
'required',
|
|
15
16
|
'defaultValue',
|
|
@@ -46,6 +47,7 @@ function assertFieldMetadataPatch(patch) {
|
|
|
46
47
|
if (Object.keys(patch).length === 0) {
|
|
47
48
|
throw invalidSchemaPayload('Field metadata patch cannot be empty');
|
|
48
49
|
}
|
|
50
|
+
if (Object.hasOwn(patch, 'name')) patch.name = normalizeDisplayName(patch.name);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
function assertPhysicalPatch(patch) {
|
|
@@ -150,7 +152,13 @@ export class FieldsService extends BaseService {
|
|
|
150
152
|
async createOne(collection, input = {}) {
|
|
151
153
|
assertSchemaManager(this.accountability);
|
|
152
154
|
assertIdentifier(collection, 'collection name');
|
|
153
|
-
const
|
|
155
|
+
const resolvedName = resolveSchemaName({
|
|
156
|
+
displayName: input.name ?? input.field,
|
|
157
|
+
key: input.field,
|
|
158
|
+
prefix: 'field',
|
|
159
|
+
});
|
|
160
|
+
const field = assertFieldName(resolvedName.key);
|
|
161
|
+
const name = resolvedName.name;
|
|
154
162
|
|
|
155
163
|
if (field === 'id') {
|
|
156
164
|
const error = new Error('The id field is created with the collection and cannot be added again');
|
|
@@ -196,6 +204,7 @@ export class FieldsService extends BaseService {
|
|
|
196
204
|
const created = await metadata.createField({
|
|
197
205
|
collection,
|
|
198
206
|
field,
|
|
207
|
+
name,
|
|
199
208
|
type: input.type,
|
|
200
209
|
required: input.required === true,
|
|
201
210
|
readonly: input.readonly === true,
|
|
@@ -3,6 +3,11 @@ import { BaseService } from './base-service.js';
|
|
|
3
3
|
const THEMES = new Set(['system', 'light', 'dark']);
|
|
4
4
|
const LOCALES = new Set(['en', 'tr']);
|
|
5
5
|
const ACCENT_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
6
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
const YUNSOFT_LOGOS = new Set([
|
|
8
|
+
'https://yunsoft.com/light-logo.png',
|
|
9
|
+
'https://yunsoft.com/dark-logo.png',
|
|
10
|
+
]);
|
|
6
11
|
|
|
7
12
|
function invalid(message) {
|
|
8
13
|
const error = new Error(message);
|
|
@@ -10,6 +15,12 @@ function invalid(message) {
|
|
|
10
15
|
return error;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
function notFound(message) {
|
|
19
|
+
const error = new Error(message);
|
|
20
|
+
error.code = 'NOT_FOUND';
|
|
21
|
+
return error;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
function assertManager(accountability) {
|
|
14
25
|
if (accountability.admin === true || accountability.system === true) return;
|
|
15
26
|
const error = new Error('Studio settings require administrator accountability');
|
|
@@ -25,16 +36,17 @@ function normalizeBrandName(value) {
|
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
function normalizeLogoUrl(value) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
let url;
|
|
32
|
-
try {
|
|
33
|
-
url = new URL(normalized);
|
|
34
|
-
} catch {
|
|
35
|
-
throw invalid('Logo URL must be a valid URL');
|
|
39
|
+
const normalized = String(value ?? '').trim();
|
|
40
|
+
if (!YUNSOFT_LOGOS.has(normalized)) {
|
|
41
|
+
throw invalid('External logo URLs are not supported; choose an image from Files');
|
|
36
42
|
}
|
|
37
|
-
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeImageFile(value, label) {
|
|
47
|
+
if (value == null || value === '') return null;
|
|
48
|
+
const normalized = String(value).trim();
|
|
49
|
+
if (!UUID_PATTERN.test(normalized)) throw invalid(`${label} file id must be a UUID`);
|
|
38
50
|
return normalized;
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -60,6 +72,8 @@ function publicSettings(row) {
|
|
|
60
72
|
return {
|
|
61
73
|
brand_name: row.brand_name,
|
|
62
74
|
logo_url: row.logo_url,
|
|
75
|
+
logo_file: row.logo_file ?? null,
|
|
76
|
+
favicon_file: row.favicon_file ?? null,
|
|
63
77
|
accent_color: row.accent_color,
|
|
64
78
|
theme: row.theme,
|
|
65
79
|
default_locale: row.default_locale,
|
|
@@ -70,7 +84,7 @@ function publicSettings(row) {
|
|
|
70
84
|
export class StudioSettingsService extends BaseService {
|
|
71
85
|
async readPublic() {
|
|
72
86
|
const [rows] = await this.database.query(
|
|
73
|
-
`SELECT brand_name, logo_url, accent_color, theme, default_locale, updated_at
|
|
87
|
+
`SELECT brand_name, logo_url, logo_file, favicon_file, accent_color, theme, default_locale, updated_at
|
|
74
88
|
FROM yuncms_studio_settings
|
|
75
89
|
WHERE id = 1
|
|
76
90
|
LIMIT 1`,
|
|
@@ -89,12 +103,56 @@ export class StudioSettingsService extends BaseService {
|
|
|
89
103
|
return this.readPublic();
|
|
90
104
|
}
|
|
91
105
|
|
|
106
|
+
async readImageAssetContent(settingKey, label) {
|
|
107
|
+
const settings = await this.readPublic();
|
|
108
|
+
const fileId = settings[settingKey];
|
|
109
|
+
if (!fileId) throw notFound(`No file-backed Studio ${label.toLowerCase()} is configured`);
|
|
110
|
+
if (!this.storage) throw new Error(`StudioSettingsService requires storage to read a file-backed ${label.toLowerCase()}`);
|
|
111
|
+
|
|
112
|
+
const [rows] = await this.database.query(
|
|
113
|
+
`SELECT id, storage, filename_disk, mimetype, filesize
|
|
114
|
+
FROM yuncms_files
|
|
115
|
+
WHERE id = ?
|
|
116
|
+
LIMIT 1`,
|
|
117
|
+
[fileId],
|
|
118
|
+
);
|
|
119
|
+
const file = rows[0];
|
|
120
|
+
if (!file) throw notFound(`Configured Studio ${label.toLowerCase()} file does not exist`);
|
|
121
|
+
if (!String(file.mimetype || '').toLowerCase().startsWith('image/')) {
|
|
122
|
+
throw invalid(`Configured Studio ${label.toLowerCase()} must be an image`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const driver = this.storage.get(file.storage);
|
|
126
|
+
const contents = await driver.get(file.filename_disk);
|
|
127
|
+
return { file, contents };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async readLogoContent() {
|
|
131
|
+
return this.readImageAssetContent('logo_file', 'Logo');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async readFaviconContent() {
|
|
135
|
+
return this.readImageAssetContent('favicon_file', 'Favicon');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async validateSelectedImage(fileId, label) {
|
|
139
|
+
if (!fileId) return;
|
|
140
|
+
const [files] = await this.database.query(
|
|
141
|
+
'SELECT id, mimetype FROM yuncms_files WHERE id = ? LIMIT 1',
|
|
142
|
+
[fileId],
|
|
143
|
+
);
|
|
144
|
+
if (!files[0]) throw invalid(`Selected ${label.toLowerCase()} file does not exist`);
|
|
145
|
+
if (!String(files[0].mimetype || '').toLowerCase().startsWith('image/')) {
|
|
146
|
+
throw invalid(`Selected ${label.toLowerCase()} file must be an image`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
92
150
|
async updateOne(patch = {}) {
|
|
93
151
|
assertManager(this.accountability);
|
|
94
152
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid('Studio settings patch must be an object');
|
|
95
153
|
|
|
96
154
|
const keys = Object.keys(patch);
|
|
97
|
-
const allowed = new Set(['brand_name', 'logo_url', 'accent_color', 'theme', 'default_locale']);
|
|
155
|
+
const allowed = new Set(['brand_name', 'logo_url', 'logo_file', 'favicon_file', 'accent_color', 'theme', 'default_locale']);
|
|
98
156
|
if (keys.length === 0 || keys.some((key) => !allowed.has(key))) {
|
|
99
157
|
throw invalid('Studio settings patch contains unsupported properties');
|
|
100
158
|
}
|
|
@@ -109,6 +167,18 @@ export class StudioSettingsService extends BaseService {
|
|
|
109
167
|
assignments.push('logo_url = ?');
|
|
110
168
|
params.push(normalizeLogoUrl(patch.logo_url));
|
|
111
169
|
}
|
|
170
|
+
if (Object.hasOwn(patch, 'logo_file')) {
|
|
171
|
+
const fileId = normalizeImageFile(patch.logo_file, 'Logo');
|
|
172
|
+
await this.validateSelectedImage(fileId, 'Logo');
|
|
173
|
+
assignments.push('logo_file = ?');
|
|
174
|
+
params.push(fileId);
|
|
175
|
+
}
|
|
176
|
+
if (Object.hasOwn(patch, 'favicon_file')) {
|
|
177
|
+
const fileId = normalizeImageFile(patch.favicon_file, 'Favicon');
|
|
178
|
+
await this.validateSelectedImage(fileId, 'Favicon');
|
|
179
|
+
assignments.push('favicon_file = ?');
|
|
180
|
+
params.push(fileId);
|
|
181
|
+
}
|
|
112
182
|
if (Object.hasOwn(patch, 'accent_color')) {
|
|
113
183
|
assignments.push('accent_color = ?');
|
|
114
184
|
params.push(normalizeAccent(patch.accent_color));
|
|
@@ -134,6 +204,8 @@ export class StudioSettingsService extends BaseService {
|
|
|
134
204
|
export const STUDIO_SETTING_DEFAULTS = Object.freeze({
|
|
135
205
|
brand_name: 'YunCMS',
|
|
136
206
|
logo_url: 'https://yunsoft.com/light-logo.png',
|
|
207
|
+
logo_file: null,
|
|
208
|
+
favicon_file: null,
|
|
137
209
|
accent_color: '#2563eb',
|
|
138
210
|
theme: 'system',
|
|
139
211
|
default_locale: 'en',
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { withAdvisoryLock } from '../advisory-lock.js';
|
|
2
|
+
import { compileFieldColumn } from '../field-types.js';
|
|
3
|
+
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
4
|
+
import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
|
|
5
|
+
import { resolveSchemaName } from '../schema-key.js';
|
|
6
|
+
import { incrementSchemaVersion } from '../schema-version.js';
|
|
7
|
+
import { isPermissionManagedSystemResource } from '../system-permissions.js';
|
|
8
|
+
import { withConnectionTransaction } from '../transaction.js';
|
|
9
|
+
import { BaseService } from './base-service.js';
|
|
10
|
+
import { assertSchemaManager } from './schema-access.js';
|
|
11
|
+
|
|
12
|
+
function fieldName(value) {
|
|
13
|
+
assertIdentifier(value, 'field name');
|
|
14
|
+
if (value.length > 64) {
|
|
15
|
+
const error = new Error('Field name cannot exceed 64 characters');
|
|
16
|
+
error.code = 'INVALID_SCHEMA_PAYLOAD';
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
if (value === 'id') {
|
|
20
|
+
const error = new Error('The id field cannot be replaced');
|
|
21
|
+
error.code = 'FIELD_EXISTS';
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertExtensibleSystemCollection(collection) {
|
|
28
|
+
if (!collection?.system || !isPermissionManagedSystemResource(collection)) {
|
|
29
|
+
const error = new Error(`System collection is not extensible: ${collection?.collection || 'unknown'}`);
|
|
30
|
+
error.code = 'SYSTEM_SCHEMA_READ_ONLY';
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertSystemExtensionInput(input = {}) {
|
|
36
|
+
if (input.required === true) {
|
|
37
|
+
const error = new Error('Custom system collection fields must be optional in V1');
|
|
38
|
+
error.code = 'SYSTEM_EXTENSION_REQUIRED_UNSUPPORTED';
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class SystemCollectionFieldsService extends BaseService {
|
|
44
|
+
async createOne(collectionName, input = {}) {
|
|
45
|
+
assertSchemaManager(this.accountability);
|
|
46
|
+
assertIdentifier(collectionName, 'collection name');
|
|
47
|
+
assertSystemExtensionInput(input);
|
|
48
|
+
const resolvedName = resolveSchemaName({
|
|
49
|
+
displayName: input.name ?? input.field,
|
|
50
|
+
key: input.field,
|
|
51
|
+
prefix: 'field',
|
|
52
|
+
});
|
|
53
|
+
const field = fieldName(resolvedName.key);
|
|
54
|
+
const name = resolvedName.name;
|
|
55
|
+
const compiled = compileFieldColumn({ ...input, required: false });
|
|
56
|
+
|
|
57
|
+
return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
|
|
58
|
+
const metadata = new SchemaMetadataRepository(connection);
|
|
59
|
+
const collection = await metadata.readCollection(collectionName);
|
|
60
|
+
if (!collection) {
|
|
61
|
+
const error = new Error(`Unknown collection: ${collectionName}`);
|
|
62
|
+
error.code = 'COLLECTION_NOT_FOUND';
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
assertExtensibleSystemCollection(collection);
|
|
66
|
+
|
|
67
|
+
const existing = await metadata.readField(collectionName, field);
|
|
68
|
+
if (existing) {
|
|
69
|
+
const error = new Error(`Field already exists: ${collectionName}.${field}`);
|
|
70
|
+
error.code = 'FIELD_EXISTS';
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const table = quoteIdentifier(collectionName, 'collection name');
|
|
75
|
+
const column = quoteIdentifier(field, 'field name');
|
|
76
|
+
let physicalCreated = false;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await connection.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${compiled.sql}`, compiled.params);
|
|
80
|
+
physicalCreated = true;
|
|
81
|
+
|
|
82
|
+
return await withConnectionTransaction(connection, async () => {
|
|
83
|
+
const created = await metadata.createField({
|
|
84
|
+
collection: collectionName,
|
|
85
|
+
field,
|
|
86
|
+
name,
|
|
87
|
+
type: input.type,
|
|
88
|
+
required: false,
|
|
89
|
+
readonly: input.readonly === true,
|
|
90
|
+
hidden: input.hidden === true,
|
|
91
|
+
sort: input.sort ?? null,
|
|
92
|
+
interface: input.interface ?? null,
|
|
93
|
+
options: input.options ?? null,
|
|
94
|
+
schemaMetadata: {
|
|
95
|
+
...compiled.schemaMetadata,
|
|
96
|
+
systemExtension: true,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const schemaVersion = await incrementSchemaVersion(connection);
|
|
100
|
+
return { ...created, schemaVersion };
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
try {
|
|
104
|
+
await metadata.deleteField(collectionName, field);
|
|
105
|
+
} catch (cleanupError) {
|
|
106
|
+
error.cleanupMetadataError = cleanupError;
|
|
107
|
+
}
|
|
108
|
+
if (physicalCreated) {
|
|
109
|
+
try {
|
|
110
|
+
await connection.query(`ALTER TABLE ${table} DROP COLUMN ${column}`);
|
|
111
|
+
} catch (cleanupError) {
|
|
112
|
+
error.cleanupPhysicalError = cleanupError;
|
|
113
|
+
error.code ||= 'SCHEMA_PARTIAL_FAILURE';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { assertExtensibleSystemCollection, assertSystemExtensionInput };
|