@xleddyl/nuxt-cms 0.2.1 → 0.2.2
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/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/runtime/app/components/cms/BrandMark.vue +2 -3
- package/dist/runtime/app/components/cms/ConfirmModal.vue +14 -1
- package/dist/runtime/app/components/cms/MediaGallery.vue +89 -11
- package/dist/runtime/app/components/cms/Modal.vue +0 -2
- package/dist/runtime/app/composables/cms-confirm.d.ts +16 -7
- package/dist/runtime/app/composables/cms-confirm.js +1 -0
- package/dist/runtime/app/composables/cms-media-library.d.ts +1 -2
- package/dist/runtime/app/composables/cms-media-library.js +12 -19
- package/dist/runtime/app/utils/ui.d.ts +0 -2
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/seed.js +11 -1
- package/dist/runtime/server/api/collection.post.js +20 -14
- package/dist/runtime/server/api/collection.put.js +12 -12
- package/dist/runtime/server/api/item.put.js +14 -13
- package/dist/runtime/server/api/media-folder.patch.js +10 -16
- package/dist/runtime/server/api/media-move.post.js +12 -8
- package/dist/runtime/server/api/media.delete.js +1 -1
- package/dist/runtime/server/api/media.post.js +2 -1
- package/dist/runtime/server/api/media.put.js +2 -1
- package/dist/runtime/server/plugins/migrate-sqlite.js +2 -2
- package/dist/runtime/server/routes/auth/login.post.js +28 -22
- package/dist/runtime/server/utils/db-d1.d.ts +0 -1
- package/dist/runtime/server/utils/db-d1.js +3 -4
- package/dist/runtime/server/utils/db-libsql.d.ts +0 -1
- package/dist/runtime/server/utils/db-libsql.js +3 -4
- package/dist/runtime/server/utils/db-postgres.d.ts +0 -1
- package/dist/runtime/server/utils/db-postgres.js +1 -4
- package/dist/runtime/server/utils/db-sqlite.d.ts +0 -1
- package/dist/runtime/server/utils/db-sqlite.js +4 -10
- package/dist/runtime/server/utils/graphql.js +2 -1
- package/dist/runtime/server/utils/libsql-migrations.d.ts +1 -0
- package/dist/runtime/server/utils/libsql-migrations.js +1 -1
- package/dist/runtime/server/utils/media-folders.js +14 -23
- package/dist/runtime/server/utils/media.js +2 -0
- package/dist/runtime/server/utils/migrate.d.ts +1 -0
- package/dist/runtime/server/utils/migrate.js +32 -0
- package/dist/runtime/server/utils/registry.d.ts +3 -0
- package/dist/runtime/server/utils/registry.js +10 -1
- package/dist/runtime/server/utils/relations.d.ts +1 -1
- package/dist/runtime/server/utils/relations.js +6 -9
- package/dist/runtime/server/utils/timestamps.d.ts +12 -0
- package/dist/runtime/server/utils/timestamps.js +48 -0
- package/dist/runtime/server/utils/users.js +3 -2
- package/dist/runtime/server/utils/write-statements.d.ts +33 -0
- package/dist/runtime/server/utils/write-statements.js +75 -0
- package/dist/runtime/shared/index.d.ts +14 -0
- package/dist/runtime/shared/index.js +13 -0
- package/dist/runtime/shared/timestamps.d.ts +4 -0
- package/dist/runtime/shared/timestamps.js +24 -0
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { useDb } from "#cms-db";
|
|
5
5
|
import { cms_media } from "#cms-tables";
|
|
6
6
|
import { normalizeMediaFolder } from "../../shared/index.js";
|
|
7
|
+
import { nowTimestamp } from "../../shared/timestamps.js";
|
|
7
8
|
import { objectKeySchema } from "../../shared/validation.js";
|
|
8
9
|
import { requireMediaFilePath } from "../utils/media-fs.js";
|
|
9
10
|
import {
|
|
@@ -41,6 +42,6 @@ export default defineEventHandler(async (event) => {
|
|
|
41
42
|
alt: body.alt ?? null,
|
|
42
43
|
folder: normalizeMediaFolder(body.folder)
|
|
43
44
|
};
|
|
44
|
-
const [row] = await useDb().insert(cms_media).values(values).onConflictDoUpdate({ target: cms_media.key, set: values }).returning();
|
|
45
|
+
const [row] = await useDb().insert(cms_media).values({ ...values, createdAt: nowTimestamp() }).onConflictDoUpdate({ target: cms_media.key, set: values }).returning();
|
|
45
46
|
return toMediaItem(row, publicUrl);
|
|
46
47
|
});
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { useDb } from "#cms-db";
|
|
5
5
|
import { cms_media } from "#cms-tables";
|
|
6
6
|
import { normalizeMediaFolder } from "../../shared/index.js";
|
|
7
|
+
import { nowTimestamp } from "../../shared/timestamps.js";
|
|
7
8
|
import { objectKeySchema } from "../../shared/validation.js";
|
|
8
9
|
import { useMediaIndex } from "../utils/media-index.js";
|
|
9
10
|
import { assertMediaWritable, toMediaItem, useMediaConfig } from "../utils/media.js";
|
|
@@ -29,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
|
|
29
30
|
throw createError({ statusCode: 404, statusMessage: "Media not found" });
|
|
30
31
|
}
|
|
31
32
|
const alt = body.alt ?? null;
|
|
32
|
-
await useDb().insert(cms_media).values({ key: body.key, alt }).onConflictDoUpdate({ target: cms_media.key, set: { alt } });
|
|
33
|
+
await useDb().insert(cms_media).values({ key: body.key, alt, createdAt: nowTimestamp() }).onConflictDoUpdate({ target: cms_media.key, set: { alt } });
|
|
33
34
|
return toMediaItem({ ...file, alt, createdAt: null }, publicUrl);
|
|
34
35
|
}
|
|
35
36
|
assertMediaWritable(media);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useDb } from "../utils/db-sqlite.js";
|
|
2
|
-
import {
|
|
2
|
+
import { runSqliteMigrations } from "../utils/migrate.js";
|
|
3
3
|
export default async () => {
|
|
4
|
-
await
|
|
4
|
+
await runSqliteMigrations(useDb());
|
|
5
5
|
};
|
|
@@ -12,25 +12,27 @@ const credentialsSchema = z.object({
|
|
|
12
12
|
email: z.string().trim().min(1).max(254),
|
|
13
13
|
password: z.string().min(1).max(256)
|
|
14
14
|
});
|
|
15
|
-
const
|
|
16
|
-
const RATE_MAX_FAILURES =
|
|
17
|
-
const RATE_GLOBAL_MAX_FAILURES = 100;
|
|
15
|
+
const RATE_LOCK_MS = 5 * 6e4;
|
|
16
|
+
const RATE_MAX_FAILURES = 5;
|
|
18
17
|
const RATE_PRUNE_EVERY = 200;
|
|
19
|
-
const GLOBAL_KEY = "global";
|
|
20
18
|
let writesSincePrune = 0;
|
|
21
19
|
function rateStorage() {
|
|
22
20
|
return useStorage("cms:login-rate");
|
|
23
21
|
}
|
|
24
|
-
function rateKey(
|
|
25
|
-
return
|
|
22
|
+
function rateKey(kind, value) {
|
|
23
|
+
return `${kind}:${value.replace(/[^a-z0-9]/gi, "-")}`;
|
|
26
24
|
}
|
|
27
25
|
async function readEntry(key, now) {
|
|
28
26
|
const entry = await rateStorage().getItem(key);
|
|
29
27
|
return entry && entry.resetAt > now ? entry : null;
|
|
30
28
|
}
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
function isLocked(entry) {
|
|
30
|
+
return !!entry && entry.count >= RATE_MAX_FAILURES;
|
|
31
|
+
}
|
|
32
|
+
async function recordAttempt(key, now) {
|
|
33
|
+
const current = await readEntry(key, now) ?? { count: 0, resetAt: now + RATE_LOCK_MS };
|
|
33
34
|
current.count++;
|
|
35
|
+
if (current.count >= RATE_MAX_FAILURES) current.resetAt = now + RATE_LOCK_MS;
|
|
34
36
|
await rateStorage().setItem(key, current, {
|
|
35
37
|
ttl: Math.ceil((current.resetAt - now) / 1e3)
|
|
36
38
|
});
|
|
@@ -47,6 +49,15 @@ async function prune(now) {
|
|
|
47
49
|
})
|
|
48
50
|
);
|
|
49
51
|
}
|
|
52
|
+
async function clearAttempts(keys) {
|
|
53
|
+
await Promise.all(keys.map((key) => rateStorage().removeItem(key)));
|
|
54
|
+
}
|
|
55
|
+
function tooManyAttempts() {
|
|
56
|
+
return createError({
|
|
57
|
+
statusCode: 429,
|
|
58
|
+
statusMessage: "Too many failed attempts, try again in 5 minutes"
|
|
59
|
+
});
|
|
60
|
+
}
|
|
50
61
|
function safeEqual(a, b) {
|
|
51
62
|
const hashA = createHash("sha256").update(a).digest();
|
|
52
63
|
const hashB = createHash("sha256").update(b).digest();
|
|
@@ -54,34 +65,29 @@ function safeEqual(a, b) {
|
|
|
54
65
|
}
|
|
55
66
|
export default defineEventHandler(async (event) => {
|
|
56
67
|
assertSameOrigin(event);
|
|
57
|
-
const ip = rateKey(getRequestIP(event, { xForwardedFor: true }) ?? "unknown");
|
|
58
68
|
const now = Date.now();
|
|
59
|
-
const [attempts, globalFailures] = await Promise.all([
|
|
60
|
-
readEntry(ip, now),
|
|
61
|
-
readEntry(GLOBAL_KEY, now)
|
|
62
|
-
]);
|
|
63
|
-
if (globalFailures && globalFailures.count >= RATE_GLOBAL_MAX_FAILURES || attempts && attempts.count >= RATE_MAX_FAILURES) {
|
|
64
|
-
throw createError({
|
|
65
|
-
statusCode: 429,
|
|
66
|
-
statusMessage: "Too many failed attempts, try again later"
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
69
|
const body = await readValidatedBody(event, credentialsSchema.parse);
|
|
70
70
|
const email = body.email.toLowerCase();
|
|
71
|
+
const keys = [
|
|
72
|
+
rateKey("ip", getRequestIP(event, { xForwardedFor: true }) ?? "unknown"),
|
|
73
|
+
rateKey("email", email)
|
|
74
|
+
];
|
|
75
|
+
const entries = await Promise.all(keys.map((key) => readEntry(key, now)));
|
|
76
|
+
if (entries.some(isLocked)) throw tooManyAttempts();
|
|
77
|
+
await Promise.all([...keys.map((key) => recordAttempt(key, now)), prune(now)]);
|
|
71
78
|
const { adminEmail, adminPassword } = useRuntimeConfig(event).cms;
|
|
72
79
|
const superAdmin = !!adminEmail && !!adminPassword && safeEqual(email, adminEmail.toLowerCase()) && safeEqual(body.password, adminPassword);
|
|
73
80
|
if (superAdmin) {
|
|
74
|
-
await
|
|
81
|
+
await clearAttempts(keys);
|
|
75
82
|
await replaceUserSession(event, { user: { email, role: "superadmin" } });
|
|
76
83
|
return { loggedIn: true };
|
|
77
84
|
}
|
|
78
85
|
const row = await findUserByEmail(email);
|
|
79
86
|
const passwordOk = row ? await verifyPassword(row.passwordHash, body.password) : await verifyNothing(body.password);
|
|
80
87
|
if (!row || !passwordOk) {
|
|
81
|
-
await Promise.all([recordFailure(ip, now), recordFailure(GLOBAL_KEY, now), prune(now)]);
|
|
82
88
|
throw createError({ statusCode: 401, statusMessage: "Invalid credentials" });
|
|
83
89
|
}
|
|
84
|
-
await
|
|
90
|
+
await clearAttempts(keys);
|
|
85
91
|
await useDb().update(cms_users).set({ lastLoginAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(cms_users.id, row.id));
|
|
86
92
|
await replaceUserSession(event, { user: sessionUserFor(row) });
|
|
87
93
|
return { loggedIn: true };
|
|
@@ -8,6 +8,5 @@ export declare function useDb(): import("drizzle-orm/d1").DrizzleD1Database<Reco
|
|
|
8
8
|
type Db = ReturnType<typeof useDb>;
|
|
9
9
|
export type CmsDb = Db;
|
|
10
10
|
export declare const cmsDialect: 'sqlite' | 'postgres';
|
|
11
|
-
export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
|
|
12
11
|
export declare function runBatch(build: (db: CmsDb) => PromiseLike<unknown>[]): Promise<unknown[]>;
|
|
13
12
|
export {};
|
|
@@ -22,10 +22,9 @@ export function useDb() {
|
|
|
22
22
|
return _db;
|
|
23
23
|
}
|
|
24
24
|
export const cmsDialect = "sqlite";
|
|
25
|
-
export function withTransaction(fn) {
|
|
26
|
-
return fn(useDb());
|
|
27
|
-
}
|
|
28
25
|
export function runBatch(build) {
|
|
29
26
|
const db = useDb();
|
|
30
|
-
|
|
27
|
+
const statements = build(db);
|
|
28
|
+
if (!statements.length) return Promise.resolve([]);
|
|
29
|
+
return db.batch(statements);
|
|
31
30
|
}
|
|
@@ -4,6 +4,5 @@ export declare function useDb(): import("drizzle-orm/libsql").LibSQLDatabase<Rec
|
|
|
4
4
|
type Db = ReturnType<typeof useDb>;
|
|
5
5
|
export type CmsDb = Db | Parameters<Parameters<Db['transaction']>[0]>[0];
|
|
6
6
|
export declare const cmsDialect: 'sqlite' | 'postgres';
|
|
7
|
-
export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
|
|
8
7
|
export declare function runBatch(build: (db: CmsDb) => PromiseLike<unknown>[]): Promise<unknown[]>;
|
|
9
8
|
export {};
|
|
@@ -21,10 +21,9 @@ export function useDb() {
|
|
|
21
21
|
return _db;
|
|
22
22
|
}
|
|
23
23
|
export const cmsDialect = "sqlite";
|
|
24
|
-
export function withTransaction(fn) {
|
|
25
|
-
return useDb().transaction((tx) => fn(tx));
|
|
26
|
-
}
|
|
27
24
|
export function runBatch(build) {
|
|
28
25
|
const db = useDb();
|
|
29
|
-
|
|
26
|
+
const statements = build(db);
|
|
27
|
+
if (!statements.length) return Promise.resolve([]);
|
|
28
|
+
return db.batch(statements);
|
|
30
29
|
}
|
|
@@ -4,6 +4,5 @@ export declare function useDb(): import("drizzle-orm/node-postgres").NodePgDatab
|
|
|
4
4
|
type Db = ReturnType<typeof useDb>;
|
|
5
5
|
export type CmsDb = Db | Parameters<Parameters<Db['transaction']>[0]>[0];
|
|
6
6
|
export declare const cmsDialect: 'sqlite' | 'postgres';
|
|
7
|
-
export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
|
|
8
7
|
export declare function runBatch(build: (db: CmsDb) => PromiseLike<unknown>[]): Promise<unknown[]>;
|
|
9
8
|
export {};
|
|
@@ -17,11 +17,8 @@ export function useDb() {
|
|
|
17
17
|
return _db;
|
|
18
18
|
}
|
|
19
19
|
export const cmsDialect = "postgres";
|
|
20
|
-
export function withTransaction(fn) {
|
|
21
|
-
return useDb().transaction((tx) => fn(tx));
|
|
22
|
-
}
|
|
23
20
|
export function runBatch(build) {
|
|
24
|
-
return
|
|
21
|
+
return useDb().transaction(async (db) => {
|
|
25
22
|
const results = [];
|
|
26
23
|
for (const statement of build(db)) results.push(await statement);
|
|
27
24
|
return results;
|
|
@@ -4,5 +4,4 @@ export declare function useDb(): import("drizzle-orm/better-sqlite3").BetterSQLi
|
|
|
4
4
|
};
|
|
5
5
|
export type CmsDb = ReturnType<typeof useDb>;
|
|
6
6
|
export declare const cmsDialect: 'sqlite' | 'postgres';
|
|
7
|
-
export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
|
|
8
7
|
export declare function runBatch(build: (db: CmsDb) => PromiseLike<unknown>[]): Promise<unknown[]>;
|
|
@@ -18,14 +18,15 @@ export function useDb() {
|
|
|
18
18
|
}
|
|
19
19
|
export const cmsDialect = "sqlite";
|
|
20
20
|
let txQueue = Promise.resolve();
|
|
21
|
-
export function
|
|
21
|
+
export function runBatch(build) {
|
|
22
22
|
const db = useDb();
|
|
23
23
|
const run = txQueue.then(async () => {
|
|
24
24
|
await db.run(sql`begin immediate`);
|
|
25
25
|
try {
|
|
26
|
-
const
|
|
26
|
+
const results = [];
|
|
27
|
+
for (const statement of build(db)) results.push(await statement);
|
|
27
28
|
await db.run(sql`commit`);
|
|
28
|
-
return
|
|
29
|
+
return results;
|
|
29
30
|
} catch (error) {
|
|
30
31
|
await db.run(sql`rollback`);
|
|
31
32
|
throw error;
|
|
@@ -35,10 +36,3 @@ export function withTransaction(fn) {
|
|
|
35
36
|
});
|
|
36
37
|
return run;
|
|
37
38
|
}
|
|
38
|
-
export function runBatch(build) {
|
|
39
|
-
return withTransaction(async (db) => {
|
|
40
|
-
const results = [];
|
|
41
|
-
for (const statement of build(db)) results.push(await statement);
|
|
42
|
-
return results;
|
|
43
|
-
});
|
|
44
|
-
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
renderGraphqlSdl,
|
|
38
38
|
typeName
|
|
39
39
|
} from "../../shared/graphql-sdl.js";
|
|
40
|
+
import { normalizeTimestampFields } from "../../shared/timestamps.js";
|
|
40
41
|
import { useMediaIndex } from "./media-index.js";
|
|
41
42
|
import { readPages } from "./page-storage.js";
|
|
42
43
|
import { getContentI18n, resolveTable, tableColumns } from "./registry.js";
|
|
@@ -103,7 +104,7 @@ function resolveLocaleArg(locale) {
|
|
|
103
104
|
}
|
|
104
105
|
function localizeRow(entry, row, locale) {
|
|
105
106
|
const { defaultLocale } = getContentI18n();
|
|
106
|
-
const result = { ...row, [LOCALE]: locale };
|
|
107
|
+
const result = normalizeTimestampFields({ ...row, [LOCALE]: locale });
|
|
107
108
|
const fields = entryFieldsFor(entry);
|
|
108
109
|
for (const key of translatableFieldKeys(entry)) {
|
|
109
110
|
if (isTranslatableMediaField(fields[key])) {
|
|
@@ -12,5 +12,6 @@ export interface LibsqlMigrationClient {
|
|
|
12
12
|
rows: ArrayLike<unknown>[];
|
|
13
13
|
}>;
|
|
14
14
|
batch: (statements: LibsqlStatement[], mode: 'write') => Promise<unknown>;
|
|
15
|
+
migrate: (statements: LibsqlStatement[]) => Promise<unknown>;
|
|
15
16
|
}
|
|
16
17
|
export declare function applyLibsqlMigrations(client: LibsqlMigrationClient, migrations: LibsqlMigration[]): Promise<number>;
|
|
@@ -24,7 +24,7 @@ export async function applyLibsqlMigrations(client, migrations) {
|
|
|
24
24
|
}
|
|
25
25
|
]);
|
|
26
26
|
try {
|
|
27
|
-
await client.
|
|
27
|
+
await client.migrate(statements);
|
|
28
28
|
} catch (error) {
|
|
29
29
|
const latest = Math.max(...migrations.map((migration) => migration.folderMillis));
|
|
30
30
|
const appliedNow = await lastAppliedMillis(client).catch(() => null);
|
|
@@ -1,24 +1,21 @@
|
|
|
1
|
-
import { eq,
|
|
2
|
-
import { useDb } from "#cms-db";
|
|
1
|
+
import { eq, or, sql } from "drizzle-orm";
|
|
2
|
+
import { runBatch, useDb } from "#cms-db";
|
|
3
3
|
import { cms_media } from "#cms-tables";
|
|
4
4
|
import { mediaFolderMarkerKey } from "../../shared/index.js";
|
|
5
|
-
|
|
5
|
+
import { nowTimestamp } from "../../shared/timestamps.js";
|
|
6
|
+
import {
|
|
7
|
+
chunks,
|
|
8
|
+
KEY_CHUNK,
|
|
9
|
+
mediaDeleteStatements,
|
|
10
|
+
mediaFolderMarkerValues
|
|
11
|
+
} from "./write-statements.js";
|
|
6
12
|
function escapeLike(value) {
|
|
7
13
|
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
8
14
|
}
|
|
9
15
|
export async function writeMediaFolderMarker(store, folder) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
key,
|
|
14
|
-
mime: "application/x-empty",
|
|
15
|
-
size: 0,
|
|
16
|
-
width: null,
|
|
17
|
-
height: null,
|
|
18
|
-
alt: null,
|
|
19
|
-
folder
|
|
20
|
-
};
|
|
21
|
-
await useDb().insert(cms_media).values(values).onConflictDoUpdate({ target: cms_media.key, set: values });
|
|
16
|
+
await store.write(mediaFolderMarkerKey(folder), new Uint8Array(), "application/x-empty");
|
|
17
|
+
const values = mediaFolderMarkerValues(folder);
|
|
18
|
+
await useDb().insert(cms_media).values({ ...values, createdAt: nowTimestamp() }).onConflictDoUpdate({ target: cms_media.key, set: values });
|
|
22
19
|
}
|
|
23
20
|
export function mediaRowsInFolder(folder) {
|
|
24
21
|
return useDb().select({ key: cms_media.key, folder: cms_media.folder }).from(cms_media).where(
|
|
@@ -29,17 +26,11 @@ export function mediaRowsInFolder(folder) {
|
|
|
29
26
|
);
|
|
30
27
|
}
|
|
31
28
|
export async function deleteMediaRows(store, keys) {
|
|
29
|
+
await runBatch((db) => mediaDeleteStatements(db, cms_media, keys));
|
|
32
30
|
for (const key of keys) {
|
|
33
31
|
await store.remove(key);
|
|
34
32
|
}
|
|
35
|
-
for (const chunk of keyChunks(keys)) {
|
|
36
|
-
await useDb().delete(cms_media).where(inArray(cms_media.key, chunk));
|
|
37
|
-
}
|
|
38
33
|
}
|
|
39
34
|
export function keyChunks(keys) {
|
|
40
|
-
|
|
41
|
-
for (let index = 0; index < keys.length; index += KEY_CHUNK) {
|
|
42
|
-
chunks.push(keys.slice(index, index + KEY_CHUNK));
|
|
43
|
-
}
|
|
44
|
-
return chunks;
|
|
35
|
+
return chunks(keys, KEY_CHUNK);
|
|
45
36
|
}
|
|
@@ -2,6 +2,7 @@ import { AwsClient } from "aws4fetch";
|
|
|
2
2
|
import { createError } from "h3";
|
|
3
3
|
import { useRuntimeConfig } from "#imports";
|
|
4
4
|
import { formatFileSize, mediaPublicUrl, mediaTypeFor } from "../../shared/index.js";
|
|
5
|
+
import { normalizeTimestamp } from "../../shared/timestamps.js";
|
|
5
6
|
import { removeMediaFile, writeMediaFile } from "./media-fs.js";
|
|
6
7
|
const UPLOAD_TYPE_PREFIXES = ["image/", "video/", "audio/", "font/"];
|
|
7
8
|
const UPLOAD_TYPE_BLOCKLIST = /* @__PURE__ */ new Set(["image/svg+xml"]);
|
|
@@ -147,6 +148,7 @@ export function useMediaStorage(event) {
|
|
|
147
148
|
export function toMediaItem(row, publicUrl) {
|
|
148
149
|
return {
|
|
149
150
|
...row,
|
|
151
|
+
createdAt: normalizeTimestamp(row.createdAt),
|
|
150
152
|
type: mediaTypeFor(row.mime, row.key),
|
|
151
153
|
url: publicUrl(row.key)
|
|
152
154
|
};
|
|
@@ -5,5 +5,6 @@ export interface CmsMigration {
|
|
|
5
5
|
hash: string;
|
|
6
6
|
}
|
|
7
7
|
export declare function runCmsMigrations(db: unknown): Promise<void>;
|
|
8
|
+
export declare function runSqliteMigrations(db: unknown): Promise<void>;
|
|
8
9
|
export declare function runLibsqlMigrations(db: unknown): Promise<void>;
|
|
9
10
|
export declare function runD1Migrations(binding: unknown): Promise<void>;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
1
2
|
import cmsConfig from "#cms-config";
|
|
2
3
|
import { migrations as bundledMigrations } from "#cms-migrations";
|
|
4
|
+
import * as cmsTables from "#cms-tables";
|
|
3
5
|
import { useRuntimeConfig } from "#imports";
|
|
4
6
|
import { applyLibsqlMigrations } from "./libsql-migrations.js";
|
|
7
|
+
import { backfillTimestamps } from "./timestamps.js";
|
|
5
8
|
const MIGRATIONS_TABLE = "__drizzle_migrations";
|
|
6
9
|
async function readMigrations(migrationsDir) {
|
|
7
10
|
if (import.meta.dev) {
|
|
@@ -29,11 +32,36 @@ export async function runCmsMigrations(db) {
|
|
|
29
32
|
const { dialect, session } = db;
|
|
30
33
|
await dialect.migrate(migrations, session, {});
|
|
31
34
|
}
|
|
35
|
+
export async function runSqliteMigrations(db) {
|
|
36
|
+
const migrations = await pendingMigrations();
|
|
37
|
+
if (!migrations.length) return;
|
|
38
|
+
const { dialect, session, $client } = db;
|
|
39
|
+
$client.pragma("foreign_keys = OFF");
|
|
40
|
+
try {
|
|
41
|
+
await dialect.migrate(migrations, session, {});
|
|
42
|
+
} finally {
|
|
43
|
+
$client.pragma("foreign_keys = ON");
|
|
44
|
+
}
|
|
45
|
+
const sqlite = db;
|
|
46
|
+
await backfillTimestamps(cmsTables, {
|
|
47
|
+
select: async (query) => sqlite.all(sql.raw(query)),
|
|
48
|
+
write: async (statements) => {
|
|
49
|
+
for (const statement of statements) sqlite.run(sql.raw(statement));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
32
53
|
export async function runLibsqlMigrations(db) {
|
|
33
54
|
const migrations = await pendingMigrations();
|
|
34
55
|
if (!migrations.length) return;
|
|
35
56
|
const { $client } = db;
|
|
36
57
|
await applyLibsqlMigrations($client, migrations);
|
|
58
|
+
await backfillTimestamps(cmsTables, {
|
|
59
|
+
select: async (query) => Array.from((await $client.execute(query)).rows),
|
|
60
|
+
write: (statements) => $client.batch(
|
|
61
|
+
statements.map((statement) => ({ sql: statement, args: [] })),
|
|
62
|
+
"write"
|
|
63
|
+
)
|
|
64
|
+
});
|
|
37
65
|
}
|
|
38
66
|
export async function runD1Migrations(binding) {
|
|
39
67
|
const migrations = await pendingMigrations();
|
|
@@ -55,4 +83,8 @@ export async function runD1Migrations(binding) {
|
|
|
55
83
|
);
|
|
56
84
|
}
|
|
57
85
|
if (batch.length) await d1.batch(batch);
|
|
86
|
+
await backfillTimestamps(cmsTables, {
|
|
87
|
+
select: async (query) => (await d1.prepare(query).all()).results,
|
|
88
|
+
write: (statements) => d1.batch(statements.map((statement) => d1.prepare(statement)))
|
|
89
|
+
});
|
|
58
90
|
}
|
|
@@ -9,6 +9,9 @@ export declare function idColumn(table: SQLiteTable): AnySQLiteColumn;
|
|
|
9
9
|
export declare function withUpdatedAt(table: SQLiteTable, values: Record<string, unknown>): {
|
|
10
10
|
[x: string]: unknown;
|
|
11
11
|
};
|
|
12
|
+
export declare function withTimestamps(table: SQLiteTable, values: Record<string, unknown>): {
|
|
13
|
+
[x: string]: unknown;
|
|
14
|
+
};
|
|
12
15
|
export declare function getRegistryEntry(event: H3Event): {
|
|
13
16
|
name: string;
|
|
14
17
|
entry: CmsEntry;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
pageFields,
|
|
10
10
|
pageRouteOf
|
|
11
11
|
} from "../../shared/index.js";
|
|
12
|
+
import { normalizeTimestampFields, nowTimestamp } from "../../shared/timestamps.js";
|
|
12
13
|
import { buildEntrySchema } from "../../shared/validation.js";
|
|
13
14
|
let contentI18n;
|
|
14
15
|
export function getContentI18n() {
|
|
@@ -30,9 +31,16 @@ export function idColumn(table) {
|
|
|
30
31
|
}
|
|
31
32
|
export function withUpdatedAt(table, values) {
|
|
32
33
|
const set = { ...values };
|
|
33
|
-
if ("updatedAt" in table) set.updatedAt = (
|
|
34
|
+
if ("updatedAt" in table) set.updatedAt = nowTimestamp();
|
|
34
35
|
return set;
|
|
35
36
|
}
|
|
37
|
+
export function withTimestamps(table, values) {
|
|
38
|
+
const now = nowTimestamp();
|
|
39
|
+
const stamped = { ...values };
|
|
40
|
+
if ("createdAt" in table) stamped.createdAt ??= now;
|
|
41
|
+
if ("updatedAt" in table) stamped.updatedAt ??= now;
|
|
42
|
+
return stamped;
|
|
43
|
+
}
|
|
36
44
|
export function getRegistryEntry(event) {
|
|
37
45
|
const name = getRouterParam(event, "collection");
|
|
38
46
|
const config = cmsConfig;
|
|
@@ -58,6 +66,7 @@ export function encodeColumnValues(entry, values) {
|
|
|
58
66
|
return encodeEntryTranslatableMedia(entry, values);
|
|
59
67
|
}
|
|
60
68
|
export function decodeRows(entry, rows) {
|
|
69
|
+
for (const row of rows) normalizeTimestampFields(row);
|
|
61
70
|
return decodeEntryTranslatableMedia(entry, rows, getContentI18n().defaultLocale);
|
|
62
71
|
}
|
|
63
72
|
export function parseId(event) {
|
|
@@ -8,7 +8,7 @@ export declare function splitRelationValues(entry: CmsEntry, body: Row): {
|
|
|
8
8
|
lists: Record<string, string[]>;
|
|
9
9
|
};
|
|
10
10
|
export declare function assertRelationTargets(entry: CmsEntry, lists: Record<string, string[]>): Promise<void>;
|
|
11
|
-
export declare function
|
|
11
|
+
export declare function manyToManyStatements(db: CmsDb, name: string, sourceId: string, lists: Record<string, string[]>): PromiseLike<unknown>[];
|
|
12
12
|
export declare function relationTitles(db: CmsDb, entry: CmsEntry, rows: Row[]): Promise<Record<string, Record<string, unknown>>>;
|
|
13
13
|
export declare function attachManyToMany<T extends Row>(db: CmsDb, name: string, entry: CmsEntry, rows: T[]): Promise<T[]>;
|
|
14
14
|
export {};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { asc,
|
|
1
|
+
import { asc, inArray } from "drizzle-orm";
|
|
2
2
|
import { createError } from "h3";
|
|
3
3
|
import cmsConfig from "#cms-config";
|
|
4
4
|
import { useDb } from "#cms-db";
|
|
5
5
|
import * as cmsTables from "#cms-tables";
|
|
6
6
|
import { entryFieldsFor } from "../../shared/index.js";
|
|
7
7
|
import { resolveTable, tableColumns } from "./registry.js";
|
|
8
|
+
import { joinWriteStatements } from "./write-statements.js";
|
|
8
9
|
function manyToManyKeys(entry) {
|
|
9
10
|
return Object.entries(entryFieldsFor(entry)).filter(([, field]) => field.type === "relation" && field.cardinality === "many-to-many").map(([key]) => key);
|
|
10
11
|
}
|
|
@@ -50,14 +51,10 @@ export async function assertRelationTargets(entry, lists) {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (ids.length) {
|
|
58
|
-
await db.insert(join.table).values(ids.map((targetId, position) => ({ sourceId, targetId, position })));
|
|
59
|
-
}
|
|
60
|
-
}
|
|
54
|
+
export function manyToManyStatements(db, name, sourceId, lists) {
|
|
55
|
+
return Object.entries(lists).flatMap(
|
|
56
|
+
([key, ids]) => joinWriteStatements(db, joinTable(name, key), sourceId, ids)
|
|
57
|
+
);
|
|
61
58
|
}
|
|
62
59
|
export async function relationTitles(db, entry, rows) {
|
|
63
60
|
const config = cmsConfig;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface TimestampColumn {
|
|
2
|
+
table: string;
|
|
3
|
+
column: string;
|
|
4
|
+
}
|
|
5
|
+
export interface TimestampBackfillDriver {
|
|
6
|
+
select: (query: string) => Promise<unknown[]>;
|
|
7
|
+
write: (statements: string[]) => Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
export declare function timestampColumns(tables: Record<string, unknown>): TimestampColumn[];
|
|
10
|
+
export declare function timestampProbeQuery(columns: TimestampColumn[]): string;
|
|
11
|
+
export declare function timestampBackfillStatement({ table, column }: TimestampColumn): string;
|
|
12
|
+
export declare function backfillTimestamps(tables: Record<string, unknown>, driver: TimestampBackfillDriver): Promise<number>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { is } from "drizzle-orm";
|
|
2
|
+
import { SQLiteTable, getTableConfig } from "drizzle-orm/sqlite-core";
|
|
3
|
+
import { SQLITE_ISO_FORMAT } from "../../shared/timestamps.js";
|
|
4
|
+
const TIMESTAMP_COLUMNS = /* @__PURE__ */ new Set(["created_at", "updated_at"]);
|
|
5
|
+
function quoteIdentifier(name) {
|
|
6
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
7
|
+
}
|
|
8
|
+
function legacyCondition(column) {
|
|
9
|
+
const target = quoteIdentifier(column);
|
|
10
|
+
return `${target} NOT LIKE '%T%' AND strftime('${SQLITE_ISO_FORMAT}', ${target}) IS NOT NULL`;
|
|
11
|
+
}
|
|
12
|
+
export function timestampColumns(tables) {
|
|
13
|
+
const found = [];
|
|
14
|
+
for (const table of Object.values(tables)) {
|
|
15
|
+
if (!is(table, SQLiteTable)) continue;
|
|
16
|
+
const { name, columns } = getTableConfig(table);
|
|
17
|
+
for (const column of columns) {
|
|
18
|
+
if (TIMESTAMP_COLUMNS.has(column.name)) found.push({ table: name, column: column.name });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
export function timestampProbeQuery(columns) {
|
|
24
|
+
return columns.map(
|
|
25
|
+
({ table, column }, index) => `SELECT ${index} AS target WHERE EXISTS (SELECT 1 FROM ${quoteIdentifier(
|
|
26
|
+
table
|
|
27
|
+
)} WHERE ${legacyCondition(column)})`
|
|
28
|
+
).join(" UNION ALL ");
|
|
29
|
+
}
|
|
30
|
+
export function timestampBackfillStatement({ table, column }) {
|
|
31
|
+
const target = quoteIdentifier(column);
|
|
32
|
+
return `UPDATE ${quoteIdentifier(
|
|
33
|
+
table
|
|
34
|
+
)} SET ${target} = strftime('${SQLITE_ISO_FORMAT}', ${target}) WHERE ${legacyCondition(column)}`;
|
|
35
|
+
}
|
|
36
|
+
function targetIndex(row) {
|
|
37
|
+
if (Array.isArray(row)) return Number(row[0]);
|
|
38
|
+
return Number(row.target);
|
|
39
|
+
}
|
|
40
|
+
export async function backfillTimestamps(tables, driver) {
|
|
41
|
+
const columns = timestampColumns(tables);
|
|
42
|
+
if (!columns.length) return 0;
|
|
43
|
+
const rows = await driver.select(timestampProbeQuery(columns));
|
|
44
|
+
const stale = rows.map((row) => columns[targetIndex(row)]).filter((column) => !!column);
|
|
45
|
+
if (!stale.length) return 0;
|
|
46
|
+
await driver.write(stale.map(timestampBackfillStatement));
|
|
47
|
+
return stale.length;
|
|
48
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { eq } from "drizzle-orm";
|
|
2
2
|
import { useDb } from "#cms-db";
|
|
3
3
|
import { cms_users } from "#cms-tables";
|
|
4
|
+
import { normalizeTimestamp } from "../../shared/timestamps.js";
|
|
4
5
|
export async function findUserByEmail(email) {
|
|
5
6
|
const [row] = await useDb().select().from(cms_users).where(eq(cms_users.email, email.toLowerCase()));
|
|
6
7
|
return row;
|
|
@@ -28,7 +29,7 @@ export function toAccount(row) {
|
|
|
28
29
|
email: row.email,
|
|
29
30
|
name: row.name ?? null,
|
|
30
31
|
role: row.role,
|
|
31
|
-
lastLoginAt: row.lastLoginAt ?? null,
|
|
32
|
-
createdAt: String(row.createdAt)
|
|
32
|
+
lastLoginAt: normalizeTimestamp(row.lastLoginAt ?? null),
|
|
33
|
+
createdAt: String(normalizeTimestamp(row.createdAt))
|
|
33
34
|
};
|
|
34
35
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AnySQLiteColumn, BaseSQLiteDatabase, SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
export type WriteDb = Pick<BaseSQLiteDatabase<'sync' | 'async', unknown>, 'insert' | 'update' | 'delete'>;
|
|
3
|
+
export interface JoinTable {
|
|
4
|
+
table: SQLiteTable;
|
|
5
|
+
sourceId: AnySQLiteColumn;
|
|
6
|
+
targetId: AnySQLiteColumn;
|
|
7
|
+
position: AnySQLiteColumn;
|
|
8
|
+
}
|
|
9
|
+
export interface RebasedMediaRow {
|
|
10
|
+
key: string;
|
|
11
|
+
folder: string | null;
|
|
12
|
+
target: string | null;
|
|
13
|
+
}
|
|
14
|
+
export interface MediaFolderRename {
|
|
15
|
+
folders: [string, string | null][];
|
|
16
|
+
markers: string[];
|
|
17
|
+
staleMarkers: string[];
|
|
18
|
+
}
|
|
19
|
+
export declare const KEY_CHUNK = 90;
|
|
20
|
+
export declare function chunks<T>(items: T[], size: number): T[][];
|
|
21
|
+
export declare function joinWriteStatements(db: WriteDb, join: JoinTable, sourceId: string, ids: string[]): PromiseLike<unknown>[];
|
|
22
|
+
export declare function mediaFolderMarkerValues(folder: string): {
|
|
23
|
+
key: string;
|
|
24
|
+
mime: string;
|
|
25
|
+
size: number;
|
|
26
|
+
width: null;
|
|
27
|
+
height: null;
|
|
28
|
+
alt: null;
|
|
29
|
+
folder: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function planMediaFolderRename(rows: RebasedMediaRow[]): MediaFolderRename;
|
|
32
|
+
export declare function mediaFolderRenameStatements(db: WriteDb, table: SQLiteTable, plan: MediaFolderRename): PromiseLike<unknown>[];
|
|
33
|
+
export declare function mediaDeleteStatements(db: WriteDb, table: SQLiteTable, keys: string[]): PromiseLike<unknown>[];
|