Compare commits

...

10 Commits

13 changed files with 240 additions and 366 deletions

View File

@ -1,29 +1,90 @@
# Create T3 App # File Hosting App
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. This is a [T3 Stack](https://create.t3.gg/) project. It provides a file hosting service where users can upload, download, and manage files.
## What's next? How do I make an app with this? ## Features
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. - User authentication with NextAuth.js
- File upload and download using MinIO
- File metadata management with Prisma
- Real-time updates using Server-Sent Events (SSE)
- Responsive UI built with Tailwind CSS
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. ## Technologies Used
- [Next.js](https://nextjs.org) - [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org) - [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io) - [Prisma](https://prisma.io)
- [Drizzle](https://orm.drizzle.team) - [MinIO](https://min.io)
- [Tailwind CSS](https://tailwindcss.com) - [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io) - [tRPC](https://trpc.io)
## Learn More ## API Documentation
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: ### **Download File**
- [Documentation](https://create.t3.gg/) **Endpoint**: `/api/files/download`
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome! **Method**: `GET`
## How do I deploy this? **Query Parameters**:
- `fileId` (required): The ID of the file to download.
- `fileName` (required): The name of the file to download.
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. **Response**:
- **200 OK**: Returns the file as a binary stream.
- **400 Bad Request**: If `fileId` or `fileName` is missing.
- **404 Not Found**: If the file does not exist.
- **500 Internal Server Error**: If there is an error fetching the file.
---
### **Serve File**
**Endpoint**: `/api/files/serv`
**Method**: `GET`
**Query Parameters**:
- `id` (required): The ID of the file to serve.
**Response**:
- **200 OK**: Returns the file as a binary stream with the appropriate MIME type.
- **400 Bad Request**: If `id` is missing.
- **404 Not Found**: If the file does not exist.
- **500 Internal Server Error**: If there is an error fetching the file.
---
### **List Files**
**Endpoint**: `/api/files`
**Method**: `GET`
**Response**:
- **200 OK**: Returns a list of files with metadata.
- **500 Internal Server Error**: If there is an error fetching the files.
---
### **Real-Time Updates**
**Endpoint**: `/api/files/stream`
**Method**: `GET`
**Response**:
- **200 OK**: Streams real-time updates for file additions and removals.
---
## How to Run Locally
1. Clone the repository:
```bash
git clone https://github.com/your-username/file-hosting.git
cd file-hosting
npm install
npm run build
npm run start

View File

@ -15,6 +15,17 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model Post {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User @relation(fields: [createdById], references: [id])
createdById String
@@index([name])
}
// Necessary for Next auth // Necessary for Next auth
model Account { model Account {
@ -52,6 +63,7 @@ model User {
image String? image String?
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
posts Post[]
files File[] // Relation to the File model files File[] // Relation to the File model
} }
@ -73,5 +85,4 @@ model File {
description String @default("") description String @default("")
uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull) uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
uploadedById String? uploadedById String?
public Boolean @default(false) // Indicates if the file is public or private
} }

View File

@ -1,4 +1,4 @@
"use client"; 'use client';
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useFileActions } from "~/app/_components/FileActions"; import { useFileActions } from "~/app/_components/FileActions";
@ -7,13 +7,11 @@ export function FileActionsContainer({
fileName, fileName,
fileUrl, fileUrl,
isOwner, isOwner,
isPublic,
}: { }: {
fileId: string; fileId: string;
fileName: string; fileName: string;
fileUrl: string; fileUrl: string;
isOwner: boolean; isOwner: boolean;
isPublic: boolean;
}) { }) {
const { handleDownload, handleCopyUrl, handleRemove} = useFileActions(() => fileId, (description: string) => { const { handleDownload, handleCopyUrl, handleRemove} = useFileActions(() => fileId, (description: string) => {
if (isOwner) { if (isOwner) {
@ -38,55 +36,14 @@ export function FileActionsContainer({
> >
<img src="/icons/copy.svg" alt="Copy URL" className="w-6 h-6" /> <img src="/icons/copy.svg" alt="Copy URL" className="w-6 h-6" />
</button> </button>
{/* Remove Button */} {/* Remove Button */}
{isOwner && (
<button <button
onClick={() => handleRemove(fileId)} onClick={() => handleRemove(fileId)}
className="flex items-center justify-center rounded-full bg-red-500 p-2 hover:bg-red-600" className="flex items-center justify-center rounded-full bg-red-500 p-2 hover:bg-red-600"
> >
<img src="/icons/delete.svg" alt="Remove" className="w-6 h-6" /> <img src="/icons/delete.svg" alt="Remove" className="w-6 h-6" />
</button> </button>
)}
{isOwner && (
<div className="mt-4 flex items-center gap-2">
<label className="text-sm">
Public:
</label>
<label
htmlFor={`public-toggle-${fileId}`}
className="relative inline-flex items-center cursor-pointer"
>
<input
id={`public-toggle-${fileId}`}
type="checkbox"
checked={isPublic} // Ensure this reflects the prop
onChange={async (e) => {
const newIsPublic = e.target.checked;
try {
const response = await fetch(`/api/files/update-public`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ fileId: fileId, isPublic: newIsPublic }),
});
if (!response.ok) {
throw new Error("Failed to update public status");
}
console.log("Public status updated successfully");
} catch (err) {
console.error("Error updating public status:", err);
}
}}
className="sr-only peer"
/>
{/* Toggle Background */}
<div className="w-10 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 peer-focus:ring-2 peer-focus:ring-green-300 transition-colors"></div>
{/* Toggle Handle */}
<div className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow-md peer-checked:translate-x-5 transition-transform"></div>
</label>
</div>
)}
</div> </div>
); );
} }

View File

@ -15,7 +15,6 @@ interface FileDetails {
description: string; description: string;
extension: string; extension: string;
isOwner: boolean; // Indicates if the user owns the file isOwner: boolean; // Indicates if the user owns the file
isPublic: boolean; // Indicates if the file is public
} }
interface FileGridProps { interface FileGridProps {
@ -117,8 +116,7 @@ export default function FileGrid({ session }: FileGridProps) {
fileId={file.id} fileId={file.id}
fileName={file.name} fileName={file.name}
fileUrl={file.url} fileUrl={file.url}
isOwner={true} isOwner={file.isOwner}
isPublic={file.isPublic}
/> />
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import path from "path"; import { db } from "~/server/db";
import { promises as fs } from "fs"; import { minioClient } from "~/utils/minioClient";
export async function GET(req: Request) { export async function GET(req: Request) {
const url = new URL(req.url); const url = new URL(req.url);
@ -15,17 +15,38 @@ export async function GET(req: Request) {
} }
try { try {
const filePath = path.join(process.cwd(), "uploads", fileId); // Fetch file metadata from the database
const fileBuffer = await fs.readFile(filePath); const file = await db.file.findFirst({
where: { id: fileId },
});
return new Response(fileBuffer, { if (!file) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const bucketName = process.env.MINIO_BUCKET || "file-hosting";
const objectName = `${file.id}-${file.name}`; // Construct the object name in MinIO
// Fetch the file from MinIO
const stream = await minioClient.getObject(bucketName, objectName);
// Return the file as a binary response
const readableStream = new ReadableStream({
start(controller) {
stream.on("data", (chunk) => controller.enqueue(chunk));
stream.on("end", () => controller.close());
stream.on("error", (err) => controller.error(err));
},
});
return new Response(readableStream, {
headers: { headers: {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`, "Content-Disposition": `attachment; filename="${fileName}"`,
}, },
}); });
} catch (error) { } catch (error) {
console.error("Error reading file:", error); console.error("Error fetching file from MinIO:", error);
return NextResponse.json({ error: "File not found" }, { status: 404 }); return NextResponse.json({ error: "Failed to fetch file" }, { status: 500 });
} }
} }

View File

@ -1,25 +0,0 @@
import { NextResponse } from "next/server";
import { db } from "~/server/db";
export async function GET(req: Request) {
const url = new URL(req.url);
const query = url.searchParams.get("query") || "";
try {
// if query is empty, return no files
const files = await db.file.findMany({
where: {
OR: [
{ name: { contains: query } },
{ description: { contains: query } },
],
public: true,
},
});
return NextResponse.json({ files });
} catch (error) {
console.error("Error fetching files:", error);
return NextResponse.json({ error: "Failed to fetch files" }, { status: 500 });
}
}

View File

@ -32,7 +32,6 @@ export async function GET(req: Request) {
type: file.extension, type: file.extension,
url: file.url, url: file.url,
description: file.description, description: file.description,
isPublic: file.public, // Ensure this is included
}); });
} catch (error) { } catch (error) {
console.error("Error fetching file details:", error); console.error("Error fetching file details:", error);

View File

@ -1,22 +0,0 @@
import { NextResponse } from "next/server";
import { db } from "~/server/db";
export async function POST(req: Request) {
try {
const { fileId, isPublic } = await req.json();
if (!fileId) {
return NextResponse.json({ error: "File ID is required" }, { status: 400 });
}
await db.file.update({
where: { id: fileId },
data: { public: isPublic },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating public status:", error);
return NextResponse.json({ error: "Failed to update public status" }, { status: 500 });
}
}

View File

@ -8,35 +8,14 @@ import { Toaster } from "react-hot-toast";
export default async function Home() { export default async function Home() {
const session = await auth(); const session = await auth();
return ( return (
<HydrateClient> <HydrateClient>
<Toaster position="top-right" reverseOrder={false} /> <Toaster position="top-right" reverseOrder={false} />
<main className="relative flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white"> <main className="relative flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
{/* Top-right corner sign-out button */} {/* Top-right corner sign-out button */}
{session?.user && ( {session?.user && (
<div className="absolute top-4 right-4 flex items-center gap-4"> <div className="absolute top-4 right-4">
{/* Search Button */}
<Link
href="/search"
className="rounded-full bg-white/10 p-2 transition hover:bg-white/20"
aria-label="Search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-4.35-4.35M16.65 10.65a6 6 0 11-12 0 6 6 0 0112 0z"
/>
</svg>
</Link>
<Link <Link
href="/api/auth/signout" href="/api/auth/signout"
className="rounded-full bg-white/10 px-4 py-2 font-semibold no-underline transition hover:bg-white/20" className="rounded-full bg-white/10 px-4 py-2 font-semibold no-underline transition hover:bg-white/20"

View File

@ -1,121 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { env } from "~/env.js";
import { FilePreview } from "~/app/_components/FilePreview";
import { FileActionsContainer } from "~/app/_components/ActionButtons";
import { HomeButton } from "~/app/_components/HomeButton";
interface FileDetails {
name: string;
size: number;
owner: string;
ownerAvatar: string | null;
uploadDate: string;
id: string;
isOwner: boolean;
extension: string;
url: string;
description: string;
isPublic: boolean;
}
export default function SearchFile() {
const [searchQuery, setSearchQuery] = useState<string>("");
const [files, setFiles] = useState<FileDetails[]>([]);
const [error, setError] = useState<string | null>(null);
const pageUrl = env.NEXT_PUBLIC_PAGE_URL;
const router = useRouter();
useEffect(() => {
const fetchFiles = async () => {
try {
const response = await fetch(
`/api/files/search?query=${encodeURIComponent(searchQuery)}`
);
if (!response.ok) {
throw new Error("Failed to fetch files");
}
const data = await response.json();
setFiles(data.files);
} catch (err) {
console.error(err);
setError("Failed to load files.");
}
};
fetchFiles();
}, [searchQuery]);
return (
<main className="flex min-h-screen flex-col items-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<div className="absolute top-4 left-4 z-20">
<HomeButton />
</div>
{/* Search Bar */}
<div className="sticky top-0 z-10 w-full bg-[#2e026d] px-4 py-4 shadow-md">
<div className="relative w-full max-w-md mx-auto">
<input
type="text"
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full p-3 pl-12 text-white bg-[#3b0764] rounded-full shadow-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent placeholder-gray-400"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
className="absolute left-4 top-1/2 transform -translate-y-1/2 h-6 w-6 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-4.35-4.35M16.65 10.65a6 6 0 11-12 0 6 6 0 0112 0z"
/>
</svg>
</div>
</div>
{/* File Grid */}
<div className="flex-grow flex flex-col items-center justify-center w-full px-4">
{error && <div className="text-red-500">{error}</div>}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 w-full max-w-7xl">
{files.map((file) => {
return (
<div
key={file.id}
className="flex place-content-end w-xxs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
>
<div className="self-center max-w-100 sm:max-w-50">
<FilePreview fileId={file.id} fileType={file.extension} />
</div>
<button onClick={() => router.push(pageUrl + file.url)}>
<h3 className="text-2xl font-bold">{file.name}</h3>
</button>
{file.description && (
<p className="text-sm text-gray-400">
Description: {file.description}
</p>
)}
<div className="flex self-center gap-2">
<FileActionsContainer
fileId={file.id}
fileName={file.name}
fileUrl={file.url}
isOwner={false} // Check if the user is the owner
isPublic={file.isPublic} // Check if the file is public
/>
</div>
</div>
);
})}
</div>
</div>
</main>
);
}

View File

@ -2,8 +2,11 @@ import { notFound } from "next/navigation";
import { FilePreview } from "~/app/_components/FilePreview"; import { FilePreview } from "~/app/_components/FilePreview";
import { HomeButton } from "~/app/_components/HomeButton"; // Import the client component import { HomeButton } from "~/app/_components/HomeButton"; // Import the client component
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { FileActionsContainer, FileDescriptionContainer } from "~/app/_components/ActionButtons"; // Import the client component import {
import Head from "next/head"; FileActionsContainer,
FileDescriptionContainer,
} from "~/app/_components/ActionButtons"; // Import the client component
import type { Metadata } from "next";
interface FileDetails { interface FileDetails {
name: string; name: string;
@ -16,7 +19,47 @@ interface FileDetails {
type: string; type: string;
url: string; url: string;
description: string; description: string;
isPublic: boolean; }
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ id?: string }>;
}): Promise<Metadata> {
const resolvedSearchParams = await searchParams; // Resolve the promise
const fileId = resolvedSearchParams.id;
if (!fileId) {
return {
title: "File Not Found",
description: "The file you are looking for does not exist.",
};
}
const fileDetails = await fetchFileDetails(fileId);
if (!fileDetails) {
return {
title: "File Not Found",
description: "The file you are looking for does not exist.",
};
}
return {
title: fileDetails.name,
description: fileDetails.description || fileDetails.name,
openGraph: {
title: fileDetails.name,
description: fileDetails.description || fileDetails.name,
url: `${process.env.NEXT_PUBLIC_PAGE_URL}/share?id=${fileDetails.id}`,
images: [
{
url: `${process.env.NEXT_PUBLIC_PAGE_URL}/api/files/serv?id=${fileDetails.id}`,
alt: `${fileDetails.name} preview`,
},
],
},
};
} }
async function fetchFileDetails(fileId: string): Promise<FileDetails | null> { async function fetchFileDetails(fileId: string): Promise<FileDetails | null> {
@ -53,7 +96,6 @@ export default async function FilePreviewContainer({
const fileDetails = await fetchFileDetails(fileId); const fileDetails = await fetchFileDetails(fileId);
if (!fileDetails) { if (!fileDetails) {
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white"> <main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
@ -71,55 +113,21 @@ export default async function FilePreviewContainer({
} }
return ( return (
<>
<Head>
<title>{fileDetails.name} - File Details</title>
<meta
property="og:title"
content={`${fileDetails.name} - File Details`}
/>
<meta
property="og:description"
content={`Size: ${
fileDetails.size > 1024 * 1024 * 1024
? (fileDetails.size / (1024 * 1024 * 1024)).toFixed(2) + " GB"
: fileDetails.size > 1024 * 1024
? (fileDetails.size / (1024 * 1024)).toFixed(2) + " MB"
: fileDetails.size > 1024
? (fileDetails.size / 1024).toFixed(2) + " KB"
: fileDetails.size + " Bytes"
}, Owner: ${fileDetails.owner}, Uploaded on: ${new Date(
fileDetails.uploadDate
).toLocaleString()}`}
/>
<meta property="og:type" content="website" />
<meta
property="og:image"
content={`${process.env.NEXT_PUBLIC_PAGE_URL}/api/files/serv?id=${fileId}`}
/>
<meta property="og:image:alt" content={`${fileDetails.name} preview`} />
<meta
property="og:url"
content={`${process.env.NEXT_PUBLIC_PAGE_URL}/share?id=${fileId}`}
/>
</Head>
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white"> <main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<div className="absolute top-4 left-4"> <div className="absolute top-4 left-4">
<HomeButton /> {/* Use the client component */} <HomeButton />
</div> </div>
<Toaster position="top-right" reverseOrder={false} />
<div className="container flex flex-col items-center gap-12 px-4 py-16"> <div className="container flex flex-col items-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]"> <h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
<span className="text-[hsl(280,100%,70%)]">File</span> Details <span className="text-[hsl(280,100%,70%)]">File</span> Details
</h1> </h1>
<div className="mt-6"> <div className="mt-6">
{fileDetails.type !== "unknown" && ( {fileDetails.type !== "unknown" && (
<FilePreview <FilePreview fileId={fileDetails.id} fileType={fileDetails.type} />
fileId={fileDetails.id}
fileType={fileDetails.type}
/>
)} )}
</div> </div>
<div className="bg-white/10 shadow-md rounded-lg p-6 w-full max-w-md text-white"> <div className="w-full max-w-md rounded-lg bg-white/10 p-6 text-white shadow-md">
<p> <p>
<strong>Name:</strong> {fileDetails.name} <strong>Name:</strong> {fileDetails.name}
</p> </p>
@ -136,7 +144,7 @@ export default async function FilePreviewContainer({
<p> <p>
<strong>Owner:</strong>{" "} <strong>Owner:</strong>{" "}
<img <img
className="rounded-md inline size-5" className="inline size-5 rounded-md"
src={fileDetails.ownerAvatar || ""} src={fileDetails.ownerAvatar || ""}
alt="Owner avatar" alt="Owner avatar"
/>{" "} />{" "}
@ -148,7 +156,10 @@ export default async function FilePreviewContainer({
</p> </p>
<div> <div>
<strong>Description:</strong>{" "} <strong>Description:</strong>{" "}
<FileDescriptionContainer fileId={fileDetails.id} fileDescription={fileDetails.description}/> <FileDescriptionContainer
fileId={fileDetails.id}
fileDescription={fileDetails.description}
/>
</div> </div>
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<FileActionsContainer <FileActionsContainer
@ -156,13 +167,10 @@ export default async function FilePreviewContainer({
fileName={fileDetails.name} fileName={fileDetails.name}
fileUrl={fileDetails.url} fileUrl={fileDetails.url}
isOwner={fileDetails.isOwner} isOwner={fileDetails.isOwner}
isPublic={fileDetails.isPublic}
/> />
</div> </div>
</div> </div>
</div> </div>
</main> </main>
</>
); );
} }

View File

@ -18,18 +18,20 @@ export const postRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(z.object({ name: z.string().min(1) })) .input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return ctx.db.post.create({ return ctx.db.file.create({
data: { data: {
name: input.name, name: input.name,
createdBy: { connect: { id: ctx.session.user.id } }, url: "default-url", // Replace with actual URL logic
size: 0, // Replace with actual size logic
extension: "txt", // Replace with actual extension logic
}, },
}); });
}), }),
getLatest: protectedProcedure.query(async ({ ctx }) => { getLatest: protectedProcedure.query(async ({ ctx }) => {
const post = await ctx.db.post.findFirst({ const post = await ctx.db.file.findFirst({
orderBy: { createdAt: "desc" }, orderBy: { uploadDate: "desc" }, // Replace 'createdAt' with the correct field name from your schema
where: { createdBy: { id: ctx.session.user.id } }, where: { uploadedById: ctx.session.user.id },
}); });
return post ?? null; return post ?? null;

6
tailwind.config.ts Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx}", // Ensure your paths are correct
],
plugins: [require("@tailwindcss/typography")],
};