Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: settings option for cache-control header #13

Merged
merged 1 commit into from
Oct 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
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;
},
);
47 changes: 47 additions & 0 deletions utils/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,50 @@ 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, updatedAt: new Date(), updatedBy })
.onConflict((c) =>
c.doUpdateSet({ value, updatedAt: new 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;
};