diff --git a/app/api/bucket/[bucket]/[...path]/route.ts b/app/api/bucket/[bucket]/[...path]/route.ts index dd6a7ad..466c5eb 100644 --- a/app/api/bucket/[bucket]/[...path]/route.ts +++ b/app/api/bucket/[bucket]/[...path]/route.ts @@ -1,4 +1,5 @@ import { getBucket } from '@/utils/cf'; +import { getSettingsRecord } from '@/utils/db/queries'; export const runtime = 'edge'; @@ -19,7 +20,14 @@ export const GET = async ( return new Response('Not found', { status: 404 }); } + const cacheControlSetting = await getSettingsRecord('cache-header'); + const headers = new Headers(); + + if (cacheControlSetting) { + headers.set('cache-control', cacheControlSetting.value); + } + object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); diff --git a/app/settings/layout.tsx b/app/settings/layout.tsx new file mode 100644 index 0000000..b75c2fb --- /dev/null +++ b/app/settings/layout.tsx @@ -0,0 +1,25 @@ +import { getUser } from '@/utils/auth'; +import { notFound } from 'next/navigation'; +import { TabGroup } from '@/components'; + +type Props = { children: React.ReactNode }; + +const Layout = async ({ children }: Props): Promise => { + const user = await getUser(); + if (!user?.admin) return notFound(); + + return ( +
+ + + {children} +
+ ); +}; + +export default Layout; diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 1fa4ea9..37cb013 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,51 +1,19 @@ -import { getUser } from '@/utils/auth'; -import { q } from '@/utils/db'; -import { notFound } from 'next/navigation'; -import { getBucketsFromEnv } from '@/utils/cf'; -import { updateVisibility } from '@/utils/actions/access-control'; -import type { Metadata } from 'next'; -import type { VisibilityTableRecord } from './visibility-table'; -import { VisibilityTable } from './visibility-table'; - -export const metadata: Metadata = { - title: 'Settings', -}; +import { Header } from '@/components'; +import { getSettingsRecords } from '@/utils/db/queries'; +import { updateCacheHeader } from '@/utils/actions/settings'; +import { SettingsGrid } from './settings-grid'; const Page = async (): Promise => { - const user = await getUser(); - if (!user?.admin) return notFound(); - - const records = await q.getVisibilityRecords(); - const buckets = getBucketsFromEnv(); - - const allRecords = [ - ...(records ?? []), - ...Object.keys(buckets) - .filter((b) => !records?.find((r) => r.kind === 'r2' && r.key === b)) - .map( - (b) => - ({ - kind: 'r2', - key: b, - glob: '*', - public: false, - readOnly: true, - }) satisfies VisibilityTableRecord, - ), - ]; + const settings = await getSettingsRecords('general'); return ( -
-
- Visibility Settings -

- Control the visibility of your bindings, whether they are publically accessible, and the - permissions allowed when public. -

-
+
+
- - {allRecords.length === 0 && No entries found} +
); }; diff --git a/app/settings/settings-grid.tsx b/app/settings/settings-grid.tsx new file mode 100644 index 0000000..07f0471 --- /dev/null +++ b/app/settings/settings-grid.tsx @@ -0,0 +1,56 @@ +'use client'; + +import type { updateCacheHeader } from '@/utils/actions/settings'; +import type { SettingsRecord } from '@/utils/db/queries'; +import { useAction } from 'next-safe-action/hook'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +type Props = { + settings: Record | undefined; + updateCacheHeaderAction: typeof updateCacheHeader; +}; + +export const SettingsGrid = ({ settings, updateCacheHeaderAction }: Props) => { + const router = useRouter(); + + const [cacheHeader, setCacheHeader] = useState(settings?.['cache-header']?.value ?? ''); + + const { execute, isExecuting } = useAction(updateCacheHeaderAction, { + onSuccess: () => router.refresh(), + onError: ({ fetchError, serverError, validationError }, reset) => { + // eslint-disable-next-line no-console + console.error('Error updating cache header', fetchError, serverError, validationError); + + // eslint-disable-next-line no-alert + alert('Error updating cache header'); + + reset(); + }, + }); + + return ( +
+
+ Object Cache-Control Header + The cache header applied to object GET response. +
+ setCacheHeader(e.target.value)} + /> + +
+
+
+ ); +}; diff --git a/app/settings/visibility/page.tsx b/app/settings/visibility/page.tsx new file mode 100644 index 0000000..7c3f1c6 --- /dev/null +++ b/app/settings/visibility/page.tsx @@ -0,0 +1,47 @@ +import { q } from '@/utils/db'; +import { getBucketsFromEnv } from '@/utils/cf'; +import { updateVisibility } from '@/utils/actions/access-control'; +import type { Metadata } from 'next'; +import { Header } from '@/components'; +import type { VisibilityTableRecord } from './visibility-table'; +import { VisibilityTable } from './visibility-table'; + +export const metadata: Metadata = { + title: 'Settings', +}; + +const Page = async (): Promise => { + const records = await q.getVisibilityRecords(); + const buckets = getBucketsFromEnv(); + + const allRecords = [ + ...(records ?? []), + ...Object.keys(buckets) + .filter((b) => !records?.find((r) => r.kind === 'r2' && r.key === b)) + .map( + (b) => + ({ + kind: 'r2', + key: b, + glob: '*', + public: false, + readOnly: true, + }) satisfies VisibilityTableRecord, + ), + ]; + + return ( +
+
+ + + {allRecords.length === 0 && No entries found} +
+ ); +}; + +export default Page; diff --git a/app/settings/visibility-table.tsx b/app/settings/visibility/visibility-table.tsx similarity index 100% rename from app/settings/visibility-table.tsx rename to app/settings/visibility/visibility-table.tsx diff --git a/migrations/0003_settings.sql b/migrations/0003_settings.sql new file mode 100644 index 0000000..3033479 --- /dev/null +++ b/migrations/0003_settings.sql @@ -0,0 +1,4 @@ +-- Migration number: 0003 2023-10-04T20:54:53.882Z + +create table "Settings" ("key" text not null primary key, "value" text not null, "updatedAt" timestamp default CURRENT_TIMESTAMP not null, "updatedBy" text not null references "User" ("id")); + diff --git a/migrations/0003_settings.ts b/migrations/0003_settings.ts new file mode 100644 index 0000000..7c47eb1 --- /dev/null +++ b/migrations/0003_settings.ts @@ -0,0 +1,23 @@ +import type { Kysely } from 'kysely'; +import { sql } from 'kysely'; + +type MigrationFunction = ( + db: Kysely, + addSql: (rawSql: string) => void, +) => Promise | void; + +export const up: MigrationFunction = async (db, addSql) => { + addSql( + db.schema + .createTable('Settings') + .addColumn('key', 'text', (c) => c.primaryKey().notNull()) + .addColumn('value', 'text', (c) => c.notNull()) + .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()) + .addColumn('updatedBy', 'text', (col) => col.references('User.id').notNull()) + .compile().sql, + ); +}; + +export const down: MigrationFunction = async (db, addSql) => { + addSql(db.schema.dropTable('Settings').compile().sql); +}; diff --git a/migrations/0004_settings-type-col.sql b/migrations/0004_settings-type-col.sql new file mode 100644 index 0000000..05a9757 --- /dev/null +++ b/migrations/0004_settings-type-col.sql @@ -0,0 +1,4 @@ +-- Migration number: 0004 2023-10-04T21:01:19.012Z + +alter table "Settings" add column "type" text not null; + diff --git a/migrations/0004_settings-type-col.ts b/migrations/0004_settings-type-col.ts new file mode 100644 index 0000000..2e0a043 --- /dev/null +++ b/migrations/0004_settings-type-col.ts @@ -0,0 +1,20 @@ +import type { Kysely } from 'kysely'; +// import { sql } from 'kysely'; + +type MigrationFunction = ( + db: Kysely, + addSql: (rawSql: string) => void, +) => Promise | void; + +export const up: MigrationFunction = async (db, addSql) => { + addSql( + db.schema + .alterTable('Settings') + .addColumn('type', 'text', (c) => c.notNull()) + .compile().sql, + ); +}; + +export const down: MigrationFunction = async (db, addSql) => { + addSql(db.schema.alterTable('Settings').dropColumn('type').compile().sql); +}; diff --git a/utils/actions/settings.ts b/utils/actions/settings.ts new file mode 100644 index 0000000..15a9b70 --- /dev/null +++ b/utils/actions/settings.ts @@ -0,0 +1,22 @@ +'use server'; + +import 'server-only'; + +import { z } from 'zod'; +import { actionWithSession } from './_action'; +import { q } from '../db'; + +export const updateCacheHeader = actionWithSession( + z.object({ + cacheHeader: z.string(), + }), + async ({ cacheHeader }, ctx) => { + if (!ctx.user?.admin) throw new Error('Unauthorized'); + + const resp = await q.updateSettingsRecord('general', 'cache-header', cacheHeader, ctx.user.id); + + if (!resp) throw new Error('Failed to update record'); + + return resp; + }, +); diff --git a/utils/db/queries.ts b/utils/db/queries.ts index 5082891..83b3efd 100644 --- a/utils/db/queries.ts +++ b/utils/db/queries.ts @@ -186,3 +186,52 @@ export const updateVisibilityRecord = async ( .returning(['id']) .executeTakeFirst(); }; + +export const getSettingsRecord = async (key: string) => { + if (!db) return undefined; + + return db + .selectFrom('Settings') + .select(['key', 'value', 'updatedAt', 'updatedBy']) + .where('key', '=', key) + .executeTakeFirst(); +}; + +type SettingsType = 'general'; + +export const getSettingsRecords = async (type: SettingsType) => { + if (!db) return undefined; + + const records = await db + .selectFrom('Settings') + .select(['key', 'value', 'updatedAt', 'updatedBy']) + .where('type', '=', type) + .execute(); + + return records.reduce( + (acc, record) => ({ ...acc, [record.key]: record }), + {} as Record[number]>, + ); +}; + +export type SettingsRecord = NonNullable>>; + +export const updateSettingsRecord = async ( + type: SettingsType, + key: string, + value: string, + updatedBy: number, +) => { + if (!db) return undefined; + + return db + .insertInto('Settings') + .values({ type, key, value, updatedBy }) + .onConflict((c) => + c + .doUpdateSet({ value, updatedAt: new Date().toUTCString() as unknown as Date, updatedBy }) + .where('key', '=', key), + ) + .returning(['key', 'value', 'updatedAt', 'updatedBy']) + .executeTakeFirst(); +}; diff --git a/utils/db/schema.ts b/utils/db/schema.ts index 953ccfe..608387b 100644 --- a/utils/db/schema.ts +++ b/utils/db/schema.ts @@ -62,6 +62,13 @@ export type Database = { public: number; readOnly: number; }; + Settings: { + type: string; + key: string; + value: string; + updatedAt: Generated; + updatedBy: UserId; // fkey + }; }; const getDatabaseFromEnv = () => { diff --git a/utils/hooks/use-local-storage.ts b/utils/hooks/use-local-storage.ts index 499c326..0183c2d 100644 --- a/utils/hooks/use-local-storage.ts +++ b/utils/hooks/use-local-storage.ts @@ -1,19 +1,29 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; export const useLocalStorage = >( key: string, defaultValue: T, ) => { - const [value, setValue] = useState(() => ({ - ...defaultValue, - ...(JSON.parse(localStorage.getItem(key) || '{}') as Partial), - })); + const [value, setValue] = useState(defaultValue); + + const keyRef = useRef(key); useEffect(() => { - if (value != null) localStorage.setItem(key, JSON.stringify(value)); - }, [key, value]); + if (typeof localStorage === 'undefined') return; + + setValue((prevVal) => ({ + ...prevVal, + ...(JSON.parse(localStorage?.getItem(keyRef.current) || '{}') as Partial), + })); + }, []); + + useEffect(() => { + if (value === null) return; + + localStorage.setItem(keyRef.current, JSON.stringify(value)); + }, [value]); return [value, setValue] as const; };