Skip to content

Commit

Permalink
feat: access control and authentication (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
james-elicx authored Aug 31, 2023
1 parent 381b453 commit 258a207
Show file tree
Hide file tree
Showing 62 changed files with 2,195 additions and 144 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
AUTH_SECRET=
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
3 changes: 3 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"no-console": "error",
"no-await-in-loop": "off",
"no-restricted-syntax": "off",
"no-continue": "off",
"no-underscore-dangle": "off",
"max-classes-per-file": "off",

"react/no-unstable-nested-components": "off"
}
Expand Down
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,16 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# secrets
.env*
!.env*.example

# migrations
migrations/*
!migrations/*.ts
!migrations/*.sql
migrations/*.d.ts

# wrangler
.wrangler/state/v3/d1
Binary file not shown.
Binary file modified .wrangler/state/v3/r2/cloudy-demo/db.sqlite-shm
Binary file not shown.
Binary file modified .wrangler/state/v3/r2/cloudy-demo/db.sqlite-wal
Binary file not shown.
36 changes: 32 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ Cloudy is a file explorer that allows you to easily manage your Cloudflare R2 bu

It is designed to be deployed to your own Cloudflare account with bindings added to the project.

A live demo (read-only) is available at [cloudy.pages.dev](https://cloudy.pages.dev).
A live demo (read-only) is available at [cloudy.pages.dev](https://cloudy.pages.dev/bucket/cloudy-demo).

## Features

- **File Explorer** - Browse your R2 buckets and files.
- **Access Control** - Comprehensive access control rules for buckets and files.
- **Preview Files** - Preview images and videos in the browser.
- **Upload Files** - Upload files to your buckets.

Expand Down Expand Up @@ -51,16 +52,43 @@ This project uses [`@cloudflare/next-on-pages`](https://github.com/cloudflare/ne

After that, you just need to add your R2 Bindings to your project 🙂.

### Read-Only Mode
## Access Control

### Global Read-Only Mode

To enable read-only mode for your deployment, set the `CLOUDY_READ_ONLY` environment variable to `true`.

When advanced access control is enabled, this setting is ignored.

### Advanced Access Control

Note: This is an entirely optional feature and your Cloudy instance will function perfectly fine without enabling it.

Cloudy comes with support for configurable rules about who should be able to access your buckets and files. For visibility, it is possible to declare a bucket as public or private, as well as read-only or read-write when publically accessible.

Future updates will include support on a more granular level, where you can use globs to provide different rules for different paths within a bucket, or for the entire bucket. Additionally, people will be able to authenticate with your instance of Cloudy and be granted user-specific access to buckets and files, each with their own rules.

#### Setup Steps

1. Create a new D1 database.
2. Add a D1 binding to your Pages project named `CLOUDY_D1`. (_Settings > Functions > D1 Database Bindings_).
3. Run the migrations against your database.
- Create a wrangler.toml file and [add your D1 binding to it](https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases).
- Apply the migrations with `pnpm run migrations:apply CLOUDY_D1` (this uses Wrangler).
4. Set environment variables for authentication to use.
- Set `AUTH_SECRET` to a random string.
- Set `AUTH_GITHUB_ID` to your GitHub OAuth Client ID.
- Set `AUTH_GITHUB_SECRET` to your GitHub OAuth Client Secret.
5. Deploy your Cloudy instance.

**Note: The first account to be created on your Cloudy instance will be granted admin permissions.**

## Contributing

Contributions are welcome! For length changes, please open an issue first to discuss what you would like to add.
Contributions are welcome! For large changes, please open an issue first to discuss what you would like to add.

During local development, this project uses [`cf-bindings-proxy`](https://github.com/james-elicx/cf-bindings-proxy) to allow you to use `next dev`. You must run the proxy in a separate terminal window to `next dev`, using `pnpm run proxy`.

## Extra Words

The design for Cloudy was inspired by [Spacedrive](https://github.com/spacedriveapp/spacedrive), an incredible open-source file explorer. I would strongly recommend checking it out!
The visual design for Cloudy was inspired by [Spacedrive](https://github.com/spacedriveapp/spacedrive), an incredible open-source file explorer. I would strongly recommend checking it out!
3 changes: 3 additions & 0 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { GET, POST } from '@/utils/auth';

export const runtime = 'edge';
10 changes: 5 additions & 5 deletions app/api/bucket/[bucket]/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getBucketFromEnv } from '@/utils/cf';
import { getBucket } from '@/utils/cf';

export const runtime = 'edge';

Expand All @@ -8,9 +8,9 @@ export const GET = async (
_req: Request,
{ params: { bucket: bucketName, path } }: { params: Params },
) => {
const bucket = getBucketFromEnv(bucketName);
const bucket = await getBucket(bucketName);
if (!bucket) {
return new Response('Failed to find bucket', { status: 400 });
return new Response('Unable to read bucket', { status: 400 });
}

const object = await bucket.get(path.join('/'));
Expand All @@ -31,9 +31,9 @@ export const POST = async (
_req: Request,
{ params: { bucket: bucketName, path } }: { params: Params },
) => {
const bucket = getBucketFromEnv(bucketName);
const bucket = await getBucket(bucketName);
if (!bucket) {
return new Response('Failed to find bucket', { status: 400 });
return new Response('Unable to read bucket', { status: 400 });
}

const object = await bucket.head(path.join('/'));
Expand Down
21 changes: 14 additions & 7 deletions app/api/bucket/[bucket]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getBucketFromEnv } from '@/utils/cf';
import { getUserSession, isGlobalReadOnly } from '@/utils/auth';
import { getBucket } from '@/utils/cf';

export const runtime = 'edge';

Expand All @@ -12,17 +13,16 @@ export const PUT = async (
req: Request,
{ params: { bucket: bucketName } }: { params: { bucket: string } },
) => {
if (process.env.CLOUDY_READ_ONLY) {
if (isGlobalReadOnly()) {
return new Response('Read only mode enabled', { status: 400 });
}

const bucket = getBucketFromEnv(bucketName);
const bucket = await getBucket(bucketName, { needsWriteAccess: true });
if (!bucket) {
return new Response('Failed to find bucket', { status: 400 });
return new Response('Unable to modify bucket', { status: 400 });
}

const formData = await req.formData();

for (const [rawFileInfo, file] of formData) {
let fileInfo: FileInfo;
try {
Expand All @@ -41,15 +41,22 @@ export const PUT = async (
return new Response(msg, { status: 400 });
}

const asFile = file as unknown as File;
const session = await getUserSession();

const customMetadata: Record<string, string> = {};

if (fileInfo.lastMod) customMetadata['mtime'] = fileInfo.lastMod.toString();
customMetadata['uploadedByUid'] = (session?.id ?? 0).toString(); // 0 = guest

try {
const asFile = file as unknown as File;
const fileContents = await asFile.arrayBuffer();

await bucket.put(fileInfo.key, fileContents, {
httpMetadata: {
contentType: asFile.type,
},
customMetadata: fileInfo.lastMod ? { mtime: fileInfo.lastMod?.toString() } : {},
customMetadata,
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to upload file to bucket';
Expand Down
29 changes: 0 additions & 29 deletions app/bucket/[...path]/layout.tsx

This file was deleted.

File renamed without changes.
30 changes: 30 additions & 0 deletions app/bucket/[bucket]/[[...path]]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { validateBucketName } from '@/utils/cf';
import { formatBucketName, formatFullPath } from '@/utils';
import { ObjectExplorerProvider, FilePreviewProvider } from '@/components';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Ctx } from './ctx';

export type RouteParams = { bucket: string; path?: string[] };
type Props = { params: RouteParams; children: React.ReactNode };

export const generateMetadata = ({ params }: { params: RouteParams }): Metadata => ({
title: formatBucketName(params.bucket),
});

const Layout = async ({ params: { bucket, path }, children }: Props): Promise<JSX.Element> => {
const fullPath = formatFullPath(path);
if (!(await validateBucketName(bucket))) return notFound();

return (
<>
<Ctx bucketName={bucket} path={fullPath} />

<ObjectExplorerProvider>
<FilePreviewProvider bucketName={bucket}>{children}</FilePreviewProvider>
</ObjectExplorerProvider>
</>
);
};

export default Layout;
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { getBucketItems } from '@/utils/cf';
import { formatFullPath } from '@/utils';
import { FilesTables } from '@/components';
import { ObjectExplorer } from '@/components';
import type { RouteParams } from './layout';

type Props = { params: RouteParams };

const Page = async ({ params: { path: fullPath } }: Props) => {
const [bucketName, ...path] = formatFullPath(fullPath);
const items = await getBucketItems(bucketName, { directory: path.join('/') });
const Page = async ({ params: { bucket, path } }: Props) => {
const fullPath = formatFullPath(path);
const items = await getBucketItems(bucket, { directory: fullPath.join('/') });

const objects = [...items.delimitedPrefixes, ...items.objects];

return (
<main className="mx-4 flex flex-grow flex-col justify-between">
{items.delimitedPrefixes.length === 0 && items.objects.length === 0 ? (
<span className="flex flex-grow items-center justify-center">No items found...</span>
) : (
<FilesTables directories={items.delimitedPrefixes} files={items.objects} />
<ObjectExplorer objects={objects} />
)}
</main>
);
Expand Down
2 changes: 1 addition & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

@layer base {
body {
@apply flex min-h-screen flex-col bg-background font-sans text-base font-normal text-secondary dark:bg-background-dark dark:text-secondary-dark;
@apply flex min-h-screen flex-col !bg-background font-sans text-base font-normal !text-secondary dark:!bg-background-dark dark:!text-secondary-dark;
}
}

Expand Down
47 changes: 27 additions & 20 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import type { Metadata } from 'next';
import localFont from 'next/font/local';
import { LocationProvider, ThemeProvider, SideNav, TopNav } from '@/components';
import './globals.css';
import { getBucketsFromEnv } from '@/utils/cf';
import { getBuckets } from '@/utils/cf';
import { AuthProvider } from '@/components/providers/auth-provider';
import { getUser } from '@/utils/auth';

export const runtime = 'edge';

Expand All @@ -18,7 +20,7 @@ const TASAOrbiterText = localFont({

export const metadata: Metadata = {
title: {
default: 'Home',
default: 'Cloudy',
template: '%s | Cloudy',
},
description: 'File explorer for Cloudflare R2 Storage.',
Expand All @@ -40,26 +42,31 @@ export const metadata: Metadata = {

type Props = { children: React.ReactNode };

const buckets = getBucketsFromEnv();
const Layout = async ({ children }: Props) => {
const buckets = await getBuckets();
const user = await getUser();

const Layout = async ({ children }: Props) => (
<html lang="en">
<body className={TASAOrbiterText.variable}>
<ThemeProvider attribute="data-theme" defaultTheme="light">
<LocationProvider buckets={Object.keys(buckets)}>
<div className="flex flex-grow flex-row">
<SideNav />
return (
<html lang="en">
<body className={TASAOrbiterText.variable}>
<AuthProvider user={user}>
<ThemeProvider attribute="data-theme" defaultTheme="light">
<LocationProvider buckets={buckets}>
<div className="flex flex-grow flex-row bg-background dark:bg-background-dark">
<SideNav />

<div className="flex h-screen flex-grow flex-col overflow-y-auto">
<TopNav />
<div className="flex h-screen flex-grow flex-col overflow-y-auto">
<TopNav />

{children}
</div>
</div>
</LocationProvider>
</ThemeProvider>
</body>
</html>
);
{children}
</div>
</div>
</LocationProvider>
</ThemeProvider>
</AuthProvider>
</body>
</html>
);
};

export default Layout;
Loading

0 comments on commit 258a207

Please sign in to comment.