diff --git a/.gitignore b/.gitignore index a76d5ea..dcb7ada 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ Desktop.ini /.deploy.lock /current /releases/ +/data/ +/backups/ diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..56779ce --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'drizzle-kit'; +export default defineConfig({ dialect: 'sqlite', schema: './src/server/schema.ts', out: './migrations' }); diff --git a/migrations/0000_clever_vision.sql b/migrations/0000_clever_vision.sql new file mode 100644 index 0000000..3c87942 --- /dev/null +++ b/migrations/0000_clever_vision.sql @@ -0,0 +1,88 @@ +CREATE TABLE `account` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `entries` ( + `id` text PRIMARY KEY NOT NULL, + `kind` text NOT NULL, + `position` integer NOT NULL, + `visible` integer NOT NULL, + `shared` text NOT NULL, + `translations` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `languages` ( + `code` text PRIMARY KEY NOT NULL, + `data` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `media` ( + `key` text PRIMARY KEY NOT NULL, + `original_name` text NOT NULL, + `mime` text NOT NULL, + `width` integer, + `height` integer, + `size` integer NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `sections` ( + `key` text PRIMARY KEY NOT NULL, + `data` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `session` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint +CREATE TABLE `site_settings` ( + `id` integer PRIMARY KEY NOT NULL, + `data` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `app_state` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `user` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `email_verified` integer NOT NULL, + `image` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint +CREATE TABLE `verification` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..9c602f4 --- /dev/null +++ b/migrations/meta/0000_snapshot.json @@ -0,0 +1,547 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b9356621-932c-4541-a1ca-4ef8af30f9d3", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entries": { + "name": "entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visible": { + "name": "visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translations": { + "name": "translations", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "languages": { + "name": "languages", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sections": { + "name": "sections", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_settings": { + "name": "site_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_state": { + "name": "app_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json new file mode 100644 index 0000000..1dfa9ce --- /dev/null +++ b/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1789474520735, + "tag": "0000_clever_vision", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..27f9cf4 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.NEXT_PHASE !== 'phase-production-build') { + const { initialize } = await import('./server/initialize'); + initialize(); + } +} diff --git a/src/server/db.ts b/src/server/db.ts new file mode 100644 index 0000000..eab06e0 --- /dev/null +++ b/src/server/db.ts @@ -0,0 +1,34 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import { readMigrationFiles } from 'drizzle-orm/migrator'; +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; +import * as schema from './schema'; + +// Runtime storage must never be traced into the deployable application image. +export const dataDirectory = () => path.resolve(/* turbopackIgnore: true */ process.env.DATA_DIR || './data'); +export function openDatabase(directory = dataDirectory()) { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const sqlite = new Database(/* turbopackIgnore: true */ path.join(directory, 'portfolio.sqlite')); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + sqlite.pragma('busy_timeout = 5000'); + const db = drizzle(sqlite, { schema }); + return { db, sqlite }; +} +export type DatabaseConnection = ReturnType; +let connection: DatabaseConnection | undefined; +export function getDatabase() { return connection ??= openDatabase(); } +export function migrateDatabase(connection: DatabaseConnection) { + // Only maintenance/startup invokes migrations. Importing this module has no disk side effects. + const config = { migrationsFolder: path.join(process.cwd(), 'migrations') }; + const available = readMigrationFiles(config); + if (connection.sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'").get()) { + const applied = connection.sqlite.prepare('SELECT hash, created_at FROM __drizzle_migrations').all() as { hash: string; created_at: number }[]; + if (applied.some(row => !available.some(migration => migration.hash === row.hash && migration.folderMillis === row.created_at))) { + throw new Error('Database migrations do not match this application version. Use the matching image or restore its pre-upgrade backup.'); + } + } + migrate(connection.db, config); +} diff --git a/src/server/initialize.ts b/src/server/initialize.ts new file mode 100644 index 0000000..854d3b6 --- /dev/null +++ b/src/server/initialize.ts @@ -0,0 +1,16 @@ +import { existsSync, writeFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import path from 'node:path'; +import { dataDirectory, getDatabase, migrateDatabase } from './db'; +import { seedDatabase } from './seed'; + +export function initialize() { + const connection = getDatabase(); + migrateDatabase(connection); + seedDatabase(connection); + const secretPath = path.join(dataDirectory(), 'auth-secret'); + if (!process.env.BETTER_AUTH_SECRET && !existsSync(/* turbopackIgnore: true */ secretPath)) { + writeFileSync(secretPath, randomBytes(48).toString('base64url'), { flag: 'wx', mode: 0o600 }); + } + return connection; +} diff --git a/src/server/schema.ts b/src/server/schema.ts new file mode 100644 index 0000000..dbc9dfa --- /dev/null +++ b/src/server/schema.ts @@ -0,0 +1,39 @@ +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; +import type { Settings, Language, Section, Entry } from '../content/contracts'; + +export const siteSettings = sqliteTable('site_settings', { id: integer('id').primaryKey(), data: text('data', { mode: 'json' }).$type().notNull() }); +export const languages = sqliteTable('languages', { code: text('code').primaryKey(), data: text('data', { mode: 'json' }).$type().notNull() }); +export const sections = sqliteTable('sections', { key: text('key').primaryKey(), data: text('data', { mode: 'json' }).$type
().notNull() }); +export const entries = sqliteTable('entries', { + id: text('id').primaryKey(), kind: text('kind').$type().notNull(), position: integer('position').notNull(), + visible: integer('visible', { mode: 'boolean' }).notNull(), + shared: text('shared', { mode: 'json' }).$type().notNull(), + translations: text('translations', { mode: 'json' }).$type().notNull(), +}); +export const media = sqliteTable('media', { + key: text('key').primaryKey(), originalName: text('original_name').notNull(), mime: text('mime').notNull(), + width: integer('width'), height: integer('height'), size: integer('size').notNull(), createdAt: integer('created_at').notNull(), +}); +export const state = sqliteTable('app_state', { key: text('key').primaryKey(), value: text('value').notNull() }); + +export const user = sqliteTable('user', { + id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull(), image: text('image'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}); +export const session = sqliteTable('session', { + id: text('id').primaryKey(), expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), token: text('token').notNull().unique(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), + ipAddress: text('ip_address'), userAgent: text('user_agent'), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), +}); +export const account = sqliteTable('account', { + id: text('id').primaryKey(), accountId: text('account_id').notNull(), providerId: text('provider_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), refreshToken: text('refresh_token'), idToken: text('id_token'), + accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }), refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }), + scope: text('scope'), password: text('password'), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}); +export const verification = sqliteTable('verification', { + id: text('id').primaryKey(), identifier: text('identifier').notNull(), value: text('value').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}); diff --git a/src/server/seed.ts b/src/server/seed.ts new file mode 100644 index 0000000..a3b9046 --- /dev/null +++ b/src/server/seed.ts @@ -0,0 +1,18 @@ +import { eq } from 'drizzle-orm'; +import type { DatabaseConnection } from './db'; +import * as tables from './schema'; +import { sampleSettings, sampleEntries, sampleSections } from '../content/samples'; + +export function seedDatabase({ db }: DatabaseConnection) { + db.transaction(tx => { + if (tx.select().from(tables.siteSettings).get()) return; + tx.insert(tables.siteSettings).values({ id: 1, data: sampleSettings }).run(); + tx.insert(tables.languages).values({ code: 'en', data: { code: 'en', name: 'English', direction: 'ltr', enabled: true, messages: {} } }).run(); + for (const section of sampleSections()) tx.insert(tables.sections).values({ key: section.key, data: section }).run(); + for (const entry of sampleEntries()) tx.insert(tables.entries).values(entry).run(); + tx.insert(tables.state).values({ key: 'content-status', value: 'sample' }).run(); + }); +} +export function markContentEdited({ db }: DatabaseConnection) { + db.update(tables.state).set({ value: 'edited' }).where(eq(tables.state.key, 'content-status')).run(); +}