Skip to content

Commit

Permalink
feat: settings option for cache-control header (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
james-elicx committed Oct 4, 2023
1 parent a9a6e4b commit 8ec3260
Show file tree
Hide file tree
Showing 14 changed files with 293 additions and 50 deletions.
8 changes: 8 additions & 0 deletions app/api/bucket/[bucket]/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getBucket } from '@/utils/cf';
import { getSettingsRecord } from '@/utils/db/queries';

export const runtime = 'edge';

Expand All @@ -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);

Expand Down
25 changes: 25 additions & 0 deletions app/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -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<JSX.Element> => {
const user = await getUser();
if (!user?.admin) return notFound();

return (
<div className="mx-4 my-4 flex flex-col gap-2">
<TabGroup
tabs={[
{ label: 'General', href: '/settings', exactMatch: true },
{ label: 'Visibility', href: '/settings/visibility' },
]}
/>

{children}
</div>
);
};

export default Layout;
54 changes: 11 additions & 43 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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<JSX.Element> => {
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 (
<div className="m-4 flex flex-col gap-2">
<div className="flex flex-col">
<span className="text-lg font-bold">Visibility Settings</span>
<p className="text-sm">
Control the visibility of your bindings, whether they are publically accessible, and the
permissions allowed when public.
</p>
</div>
<div className="flex flex-col gap-2">
<Header
title="General Settings"
desc="Specify general configuation options for your Cloudy instance."
/>

<VisibilityTable records={allRecords} updateVisibilityAction={updateVisibility} />
{allRecords.length === 0 && <span>No entries found</span>}
<SettingsGrid settings={settings} updateCacheHeaderAction={updateCacheHeader} />
</div>
);
};
Expand Down
56 changes: 56 additions & 0 deletions app/settings/settings-grid.tsx
Original file line number Diff line number Diff line change
@@ -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<string, SettingsRecord> | 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 (
<div className="grid grid-cols-2">
<div className="flex flex-col">
<span className="font-semibold">Object Cache-Control Header</span>
<span className="text-sm">The cache header applied to object GET response.</span>
<div className="flex flex-row">
<input
type="text"
className="w-full max-w-sm rounded-md rounded-r-none border border-secondary px-2 py-1 focus:border-accent/60 focus:outline-none dark:border-secondary-dark dark:focus:border-accent-dark/60"
value={cacheHeader}
onChange={(e) => setCacheHeader(e.target.value)}
/>
<button
type="button"
className="rounded-md rounded-l-none border border-secondary px-2 py-1 focus:border-accent/60 disabled:pointer-events-none disabled:opacity-50 dark:border-secondary-dark dark:focus:border-accent-dark/60"
onClick={() => execute({ cacheHeader })}
disabled={isExecuting}
>
Save
</button>
</div>
</div>
</div>
);
};
47 changes: 47 additions & 0 deletions app/settings/visibility/page.tsx
Original file line number Diff line number Diff line change
@@ -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<JSX.Element> => {
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 (
<div className="flex flex-col gap-2">
<Header
title="Visibility Settings"
desc="Control the visibility of your bindings, whether they are publically accessible, and the
permissions allowed when public."
/>

<VisibilityTable records={allRecords} updateVisibilityAction={updateVisibility} />
{allRecords.length === 0 && <span>No entries found</span>}
</div>
);
};

export default Page;
File renamed without changes.
4 changes: 4 additions & 0 deletions migrations/0003_settings.sql
Original file line number Diff line number Diff line change
@@ -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"));

23 changes: 23 additions & 0 deletions migrations/0003_settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Kysely } from 'kysely';
import { sql } from 'kysely';

type MigrationFunction = (
db: Kysely<unknown>,
addSql: (rawSql: string) => void,
) => Promise<void> | 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);
};
4 changes: 4 additions & 0 deletions migrations/0004_settings-type-col.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Migration number: 0004 2023-10-04T21:01:19.012Z

alter table "Settings" add column "type" text not null;

20 changes: 20 additions & 0 deletions migrations/0004_settings-type-col.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Kysely } from 'kysely';
// import { sql } from 'kysely';

type MigrationFunction = (
db: Kysely<unknown>,
addSql: (rawSql: string) => void,
) => Promise<void> | 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);
};
22 changes: 22 additions & 0 deletions utils/actions/settings.ts
Original file line number Diff line number Diff line change
@@ -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;
},
);
49 changes: 49 additions & 0 deletions utils/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, NonNullable<typeof records>[number]>,
);
};

export type SettingsRecord = NonNullable<Awaited<ReturnType<typeof getSettingsRecord>>>;

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();
};
7 changes: 7 additions & 0 deletions utils/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ export type Database = {
public: number;
readOnly: number;
};
Settings: {
type: string;
key: string;
value: string;
updatedAt: Generated<Date>;
updatedBy: UserId; // fkey
};
};

const getDatabaseFromEnv = () => {
Expand Down
24 changes: 17 additions & 7 deletions utils/hooks/use-local-storage.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
'use client';

import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';

export const useLocalStorage = <T extends Record<string, unknown>>(
key: string,
defaultValue: T,
) => {
const [value, setValue] = useState<T>(() => ({
...defaultValue,
...(JSON.parse(localStorage.getItem(key) || '{}') as Partial<T>),
}));
const [value, setValue] = useState<T>(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<T>),
}));
}, []);

useEffect(() => {
if (value === null) return;

localStorage.setItem(keyRef.current, JSON.stringify(value));
}, [value]);

return [value, setValue] as const;
};

0 comments on commit 8ec3260

Please sign in to comment.