Skip to content
Open
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
1 change: 1 addition & 0 deletions my-portfolio/.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
NEXT_PUBLIC_STORAGE_API_URL=/api/storage
NEXT_PUBLIC_MEDIA_API_URL=/api/media
NEXT_PUBLIC_ADMIN_API_URL=/api/admin
NEXTAUTH_SECRET=super_secret_jwt_key_12345
5 changes: 3 additions & 2 deletions my-portfolio/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import Link from "next/link";
import { useState, useEffect } from "react";
import { useToast } from "@/components/ToastProvider";
import { authFetch } from "@/lib/authFetch";

interface DockerContainer {
id: string;
Expand Down Expand Up @@ -51,7 +52,7 @@ export default function AdminPage() {
try {
setLoading(true);
const baseUrl = process.env.NEXT_PUBLIC_ADMIN_API_URL || (typeof window !== "undefined" ? `http://${window.location.hostname}:4003` : "http://localhost:4003");
const res = await fetch(`${baseUrl}/containers`);
const res = await authFetch(`${baseUrl}/containers`);
const data = await res.json();
const mappedContainers = data.map((c: any) => ({
id: c.Id,
Expand Down Expand Up @@ -81,7 +82,7 @@ export default function AdminPage() {
try {
addToast(`Requested container ${action}...`, "info");
const baseUrl = process.env.NEXT_PUBLIC_ADMIN_API_URL || (typeof window !== "undefined" ? `http://${window.location.hostname}:4003` : "http://localhost:4003");
const res = await fetch(`${baseUrl}/containers/${id}/${action}`, { method: "POST" });
const res = await authFetch(`${baseUrl}/containers/${id}/${action}`, { method: "POST" });
if (res.ok) {
addToast(`Container ${action}ed successfully`, "success");
fetchContainers();
Expand Down
39 changes: 39 additions & 0 deletions my-portfolio/app/api/auth/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/authOptions';
import crypto from 'crypto';

export async function GET() {
const session = await getServerSession(authOptions);

if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const secret = process.env.NEXTAUTH_SECRET;
if (!secret) {
return NextResponse.json({ error: 'Server misconfiguration' }, { status: 500 });
}

const header = {
alg: 'HS256',
typ: 'JWT'
};

const payload = {
user: session.user?.email,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (5 * 60) // 5 minutes expiration
};

const b64Header = Buffer.from(JSON.stringify(header)).toString('base64url');
const b64Payload = Buffer.from(JSON.stringify(payload)).toString('base64url');

const signature = crypto.createHmac('sha256', secret)
.update(b64Header + '.' + b64Payload)
.digest('base64url');

const token = `${b64Header}.${b64Payload}.${signature}`;

return NextResponse.json({ token });
}
57 changes: 37 additions & 20 deletions my-portfolio/app/cloud/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import Link from "next/link";
import { useState, useEffect } from "react";
import { useToast } from "@/components/ToastProvider";
import SparkVideoPlayer from "@/components/SparkVideoPlayer";
import { authFetch, getAuthToken } from "@/lib/authFetch";

const STORAGE_API_BASE = process.env.NEXT_PUBLIC_STORAGE_API_URL || (typeof window !== "undefined" ? `http://${window.location.hostname}:4001` : "http://localhost:4001");

Expand Down Expand Up @@ -80,6 +81,29 @@ export default function CloudStoragePage() {
const [previewContent, setPreviewContent] = useState<{ text?: string; type: "text" | "image" | "video" | "audio" | "binary" } | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [sideNavMode, setSideNavMode] = useState<"files" | "favorites" | "recent" | "trash">("files");
const [authToken, setAuthToken] = useState("");

useEffect(() => {
getAuthToken().then(setAuthToken);
const interval = setInterval(() => getAuthToken().then(setAuthToken), 3 * 60 * 1000);
return () => clearInterval(interval);
}, []);

const getViewUrl = (filename: string, pathSegments: string[]) => {
const subPath = pathSegments.join("/");
let url = `${STORAGE_API_BASE}/view/${encodeURIComponent(filename)}`;
if (subPath) url += `?path=${encodeURIComponent(subPath)}&token=${authToken}`;
else url += `?token=${authToken}`;
return url;
};

const getDownloadUrl = (filename: string, pathSegments: string[]) => {
const subPath = pathSegments.join("/");
let url = `${STORAGE_API_BASE}/download/${encodeURIComponent(filename)}`;
if (subPath) url += `?path=${encodeURIComponent(subPath)}&token=${authToken}`;
else url += `?token=${authToken}`;
return url;
};

const fetchFiles = async (pathSegments: string[] = currentPath, forcedMode?: typeof sideNavMode) => {
try {
Expand All @@ -98,7 +122,7 @@ export default function CloudStoragePage() {
? `${STORAGE_API_BASE}/files?path=${encodeURIComponent(subPath)}`
: `${STORAGE_API_BASE}/files`;
}
const res = await fetch(url);
const res = await authFetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const mappedFiles = data.map((f: any) => ({
Expand Down Expand Up @@ -173,7 +197,7 @@ export default function CloudStoragePage() {
const url = subPath
? `${STORAGE_API_BASE}/content/${encodeURIComponent(file.name)}?path=${encodeURIComponent(subPath)}`
: `${STORAGE_API_BASE}/content/${encodeURIComponent(file.name)}`;
const res = await fetch(url);
const res = await authFetch(url);
if (!res.ok) { setPreviewContent({ type: "binary" }); return; }
const data = await res.json();
if (data.encoding === "text") {
Expand Down Expand Up @@ -205,7 +229,7 @@ export default function CloudStoragePage() {
setNewFolderOpen(false);

try {
const res = await fetch(`${STORAGE_API_BASE}/mkdir`, {
const res = await authFetch(`${STORAGE_API_BASE}/mkdir`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folderName, parentPath: currentPath.join("/") }),
Expand Down Expand Up @@ -240,7 +264,7 @@ export default function CloudStoragePage() {
? `${STORAGE_API_BASE}/upload?path=${encodeURIComponent(subPath)}`
: `${STORAGE_API_BASE}/upload`;

fetch(uploadUrl, { method: "POST", body: formData })
authFetch(uploadUrl, { method: "POST", body: formData })
.then(res => res.json())
.then(() => {
clearInterval(interval);
Expand All @@ -256,10 +280,7 @@ export default function CloudStoragePage() {
};

const handleDownload = (filename: string) => {
const subPath = currentPath.join("/");
const url = subPath
? `${STORAGE_API_BASE}/download/${filename}?path=${encodeURIComponent(subPath)}`
: `${STORAGE_API_BASE}/download/${filename}`;
const url = getDownloadUrl(filename, currentPath);
window.open(url, '_blank');
addToast(`Downloading ${filename}`, "success");
};
Expand All @@ -272,7 +293,7 @@ export default function CloudStoragePage() {
? `${STORAGE_API_BASE}/${endpoint}/${filename}?path=${encodeURIComponent(subPath)}`
: `${STORAGE_API_BASE}/${endpoint}/${filename}`;

await fetch(url, { method: "DELETE" });
await authFetch(url, { method: "DELETE" });
addToast(sideNavMode === "trash" ? "Permanently deleted" : "Moved to trash", "success");
fetchFiles();
} catch {
Expand All @@ -283,7 +304,7 @@ export default function CloudStoragePage() {
const handleToggleFavorite = async (file: FileItem) => {
const filePath = file.path || file.name;
try {
const res = await fetch(`${STORAGE_API_BASE}/favorites/toggle`, {
const res = await authFetch(`${STORAGE_API_BASE}/favorites/toggle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath })
Expand All @@ -299,18 +320,14 @@ export default function CloudStoragePage() {
};

const handleShare = (file: FileItem) => {
const subPath = currentPath.join("/");
const filename = encodeURIComponent(file.name);
const pathQuery = subPath ? `?path=${encodeURIComponent(subPath)}` : "";
const publicLink = `${STORAGE_API_BASE}/download/${filename}${pathQuery}`;

const publicLink = getDownloadUrl(file.name, currentPath);
navigator.clipboard.writeText(publicLink);
addToast("Share link copied to clipboard!", "success");
};

const handleRestore = async (filename: string) => {
try {
const res = await fetch(`${STORAGE_API_BASE}/trash/restore/${encodeURIComponent(filename)}`, {
const res = await authFetch(`${STORAGE_API_BASE}/trash/restore/${encodeURIComponent(filename)}`, {
method: "POST"
});
if (res.ok) {
Expand Down Expand Up @@ -450,7 +467,7 @@ export default function CloudStoragePage() {
</button>
<button onClick={async () => {
try {
const res = await fetch(`${STORAGE_API_BASE}/files`);
const res = await authFetch(`${STORAGE_API_BASE}/files`);
if (res.ok) addToast("Storage Service Reachable!", "success");
else addToast("Storage Service returned " + res.status, "warning");
} catch (e) {
Expand Down Expand Up @@ -650,14 +667,14 @@ export default function CloudStoragePage() {
) : previewContent?.type === "image" ? (
<div className="flex items-center justify-center">
<img
src={`${STORAGE_API_BASE}/view/${encodeURIComponent(previewFile.name)}${currentPath.length ? `?path=${encodeURIComponent(currentPath.join("/"))}` : ""}`}
src={getViewUrl(previewFile.name, currentPath)}
alt={previewFile.name}
className="max-w-full max-h-[60vh] rounded-xl object-contain"
/>
</div>
) : previewContent?.type === "video" ? (
<SparkVideoPlayer
src={`${STORAGE_API_BASE}/view/${encodeURIComponent(previewFile.name)}${currentPath.length ? `?path=${encodeURIComponent(currentPath.join("/"))}` : ""}`}
src={getViewUrl(previewFile.name, currentPath)}
title={previewFile.name}
/>
) : previewContent?.type === "audio" ? (
Expand All @@ -666,7 +683,7 @@ export default function CloudStoragePage() {
<Music size={36} className="text-purple-400" />
</div>
<audio controls className="w-full max-w-md"
src={`${STORAGE_API_BASE}/view/${encodeURIComponent(previewFile.name)}${currentPath.length ? `?path=${encodeURIComponent(currentPath.join("/"))}` : ""}`}
src={getViewUrl(previewFile.name, currentPath)}
/>
</div>
) : previewContent?.type === "binary" ? (
Expand Down
29 changes: 29 additions & 0 deletions my-portfolio/lib/authFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export let globalAuthToken = "";
let tokenExp = 0;

export async function getAuthToken() {
if (globalAuthToken && Date.now() < tokenExp) {
return globalAuthToken;
}
try {
const res = await fetch('/api/auth/token');
if (res.ok) {
const data = await res.json();
globalAuthToken = data.token;
tokenExp = Date.now() + 4 * 60 * 1000; // cache for 4 minutes
return globalAuthToken;
}
} catch (err) {
console.error("Failed to fetch auth token", err);
}
return "";
}

export async function authFetch(url: string | URL | Request, options: RequestInit = {}) {
const token = await getAuthToken();
const headers = new Headers(options.headers || {});
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(url, { ...options, headers });
}
49 changes: 49 additions & 0 deletions my-portfolio/server/admin-service/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const crypto = require('crypto');

function verifyToken(req, res, next) {
// If the frontend and backend are run together, they should share this secret via env
const secret = process.env.NEXTAUTH_SECRET || 'fallback-secret-for-testing';

let token = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (req.query.token) {
token = req.query.token;
}

if (!token) {
return res.status(401).json({ error: 'Unauthorized: Missing token' });
}

const parts = token.split('.');
if (parts.length !== 3) {
return res.status(401).json({ error: 'Unauthorized: Invalid token format' });
}

const [b64Header, b64Payload, b64Signature] = parts;

const signature = crypto.createHmac('sha256', secret)
.update(b64Header + '.' + b64Payload)
.digest('base64url');

if (signature !== b64Signature) {
return res.status(401).json({ error: 'Unauthorized: Invalid signature' });
}

let payload;
try {
payload = JSON.parse(Buffer.from(b64Payload, 'base64url').toString('utf8'));
} catch (e) {
return res.status(401).json({ error: 'Unauthorized: Invalid payload' });
}

if (payload.exp && Date.now() >= payload.exp * 1000) {
return res.status(401).json({ error: 'Unauthorized: Token expired' });
}

req.user = payload;
next();
}

module.exports = { verifyToken };
5 changes: 4 additions & 1 deletion my-portfolio/server/admin-service/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
const express = require('express');
const Docker = require('dockerode');
const cors = require('cors');
require('dotenv').config();
require('dotenv').config({ path: require('path').resolve(__dirname, '../../.env') });

const app = express();
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
const PORT = process.env.PORT || 4003;
const { verifyToken } = require('./auth');

app.use(cors());
app.use(express.json());
app.use(verifyToken);


// List containers
app.get('/containers', async (req, res) => {
Expand Down
48 changes: 48 additions & 0 deletions my-portfolio/server/storage-service/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const crypto = require('crypto');

function verifyToken(req, res, next) {
const secret = process.env.NEXTAUTH_SECRET || 'fallback-secret-for-testing';

let token = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (req.query.token) {
token = req.query.token;
}

if (!token) {
return res.status(401).json({ error: 'Unauthorized: Missing token' });
}

const parts = token.split('.');
if (parts.length !== 3) {
return res.status(401).json({ error: 'Unauthorized: Invalid token format' });
}

const [b64Header, b64Payload, b64Signature] = parts;

const signature = crypto.createHmac('sha256', secret)
.update(b64Header + '.' + b64Payload)
.digest('base64url');

if (signature !== b64Signature) {
return res.status(401).json({ error: 'Unauthorized: Invalid signature' });
}

let payload;
try {
payload = JSON.parse(Buffer.from(b64Payload, 'base64url').toString('utf8'));
} catch (e) {
return res.status(401).json({ error: 'Unauthorized: Invalid payload' });
}

if (payload.exp && Date.now() >= payload.exp * 1000) {
return res.status(401).json({ error: 'Unauthorized: Token expired' });
}

req.user = payload;
next();
}

module.exports = { verifyToken };
Loading