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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,5 @@ next-env.d.ts
.VSCodeCounter

# ──────────────── Contributor generated files ────────────────
scripts/contributors/*.html
scripts/contributors/*.html.env.local
.env.*.local
102 changes: 102 additions & 0 deletions app/(core)/components/AuthStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use client";
import React, { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons";

type AuthState = {
authenticated: boolean;
username?: string;
avatarUrl?: string;
};

const AUTH_ERROR_MESSAGES: Record<string, string> = {
state_mismatch: "Sign-in failed a security check. Please try again.",
missing_code: "GitHub didn't return an authorization code. Please try again.",
server_not_configured: "GitHub sign-in isn't configured on this server yet.",
token_exchange_failed:
"Couldn't complete sign-in with GitHub. Please try again.",
unexpected: "Something went wrong during sign-in. Please try again.",
};

export default function AuthStatus({
onAuthChange,
}: {
onAuthChange?: (state: AuthState) => void;
}) {
const [auth, setAuth] = useState<AuthState>({ authenticated: false });
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState<string | null>(null);

useEffect(() => {
const params = new URLSearchParams(window.location.search);
const errorCode = params.get("auth_error");
if (errorCode) {
setAuthError(
AUTH_ERROR_MESSAGES[errorCode] || "Sign-in failed. Please try again."
);
// Clean the error out of the URL so a refresh doesn't re-show it.
params.delete("auth_error");
const newSearch = params.toString();
window.history.replaceState(
{},
"",
window.location.pathname + (newSearch ? `?${newSearch}` : "")
);
}

fetch("/api/auth/me")
.then((res) => res.json())
.then((data: AuthState) => {
setAuth(data);
onAuthChange?.(data);
})
.catch(() => setAuth({ authenticated: false }))
.finally(() => setLoading(false));
}, []);

const handleSignOut = async () => {
await fetch("/api/auth/github/logout", { method: "POST" });
setAuth({ authenticated: false });
onAuthChange?.({ authenticated: false });
};

if (loading) return null;

if (!auth.authenticated) {
return (
<div className="auth-status-banner">
{authError && <p className="auth-status-error">{authError}</p>}
<a
href="/api/auth/github"
className="ph-btn ph-btn--primary auth-status-signin"
>
<FontAwesomeIcon icon={faGithub} /> Sign in with GitHub to publish
</a>
</div>
);
}

return (
<div className="auth-status-banner auth-status-signed-in">
{auth.avatarUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={auth.avatarUrl}
alt={auth.username}
className="auth-status-avatar"
/>
)}
<span>
Publishing as <strong>@{auth.username}</strong>
</span>
<button
type="button"
onClick={handleSignOut}
className="ph-btn ph-btn--ghost auth-status-signout"
>
<FontAwesomeIcon icon={faSignOutAlt} /> Sign out
</button>
</div>
);
}
47 changes: 47 additions & 0 deletions app/(core)/lib/githubSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { cookies } from "next/headers";

const SESSION_COOKIE = "gh_session";

// The cookie value is just the GitHub access token. It's set as an
// httpOnly, secure, sameSite=lax cookie so it's never readable from
// client-side JS, but is still sent on the top-level OAuth redirect.
export async function getSessionToken(): Promise<string | null> {
const store = await cookies();
return store.get(SESSION_COOKIE)?.value ?? null;
}

export async function setSessionToken(token: string) {
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 8, // 8 hours
});
}

export async function clearSessionToken() {
const store = await cookies();
store.delete(SESSION_COOKIE);
}

const OAUTH_STATE_COOKIE = "gh_oauth_state";

export async function setOAuthState(state: string) {
const store = await cookies();
store.set(OAUTH_STATE_COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 10, // 10 minutes — just long enough for the OAuth round trip
});
}

export async function consumeOAuthState(): Promise<string | null> {
const store = await cookies();
const value = store.get(OAUTH_STATE_COOKIE)?.value ?? null;
store.delete(OAUTH_STATE_COOKIE);
return value;
}
36 changes: 36 additions & 0 deletions app/(core)/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -3275,6 +3275,42 @@ footer {
font-weight: 600;
}

.auth-status-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: color-mix(in oklab, var(--accent-color), transparent 94%);
font-size: 0.9rem;
}

.auth-status-signed-in {
justify-content: flex-start;
}

.auth-status-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}

.auth-status-signout {
margin-left: auto;
}

.auth-status-error {
color: var(--danger-color, #e53e3e);
font-size: 0.85rem;
margin: 0;
}

.auth-status-signin {
text-decoration: none;
}

.blog-editor-area {
padding: 0;
}
Expand Down
13 changes: 13 additions & 0 deletions app/(pages)/blog/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { BlogContent } from "../../../(core)/components/theory/types";
import AuthStatus from "../../../(core)/components/AuthStatus";

// --- INTERFACCE TYPESCRIPT ---

Expand Down Expand Up @@ -391,6 +392,7 @@ export default function CreateBlogPage() {
const [viewMode, setViewMode] = useState<"Editor" | "JS" | "Preview">(
"Editor"
);
const [isAuthenticated, setIsAuthenticated] = useState(false);

const dataContentString = useMemo(
() => objectToJSString(dataContent),
Expand Down Expand Up @@ -487,6 +489,9 @@ export default function CreateBlogPage() {
t("Request sent successfully! The blog will be online after review.")
);
router.push("/blog"); // Torna alla lista
} else if (result.requiresAuth) {
alert("Please sign in with GitHub first, then try saving again.");
window.location.href = "/api/auth/github";
} else {
throw new Error(result.error);
}
Expand Down Expand Up @@ -564,11 +569,19 @@ export default function CreateBlogPage() {
onClick={handleSave}
className="ph-btn ph-btn--primary cursor-pointer save-button-top"
type="button"
disabled={!isAuthenticated}
title={
!isAuthenticated ? "Sign in with GitHub to publish" : undefined
}
>
<FontAwesomeIcon icon={faSave} /> {t("Save")}
</button>
</div>

<AuthStatus
onAuthChange={(state) => setIsAuthenticated(state.authenticated)}
/>

<main className="blog-editor-area">
<form
onSubmit={(e) => {
Expand Down
68 changes: 68 additions & 0 deletions app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import {
setSessionToken,
consumeOAuthState,
} from "../../../../(core)/lib/githubSession";

export async function GET(req: Request) {
const url = new URL(req.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const returnTo = "/blog/create";

const expectedState = await consumeOAuthState();
if (!state || !expectedState || state !== expectedState) {
return NextResponse.redirect(
new URL(`${returnTo}?auth_error=state_mismatch`, req.url)
);
}

if (!code) {
return NextResponse.redirect(
new URL(`${returnTo}?auth_error=missing_code`, req.url)
);
}

const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return NextResponse.redirect(
new URL(`${returnTo}?auth_error=server_not_configured`, req.url)
);
}

try {
const tokenRes = await fetch(
"https://github.com/login/oauth/access_token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
}),
}
);

const tokenData = await tokenRes.json();

if (!tokenData.access_token) {
console.error("GitHub OAuth token exchange failed:", tokenData);
return NextResponse.redirect(
new URL(`${returnTo}?auth_error=token_exchange_failed`, req.url)
);
}

await setSessionToken(tokenData.access_token);
return NextResponse.redirect(new URL(returnTo, req.url));
} catch (error) {
console.error("GitHub OAuth callback error:", error);
return NextResponse.redirect(
new URL(`${returnTo}?auth_error=unexpected`, req.url)
);
}
}
7 changes: 7 additions & 0 deletions app/api/auth/github/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { clearSessionToken } from "../../../../(core)/lib/githubSession";

export async function POST() {
await clearSessionToken();
return NextResponse.json({ success: true });
}
31 changes: 31 additions & 0 deletions app/api/auth/github/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { setOAuthState } from "../../../(core)/lib/githubSession";

export async function GET(req: Request) {
const clientId = process.env.GITHUB_CLIENT_ID;
if (!clientId) {
return NextResponse.json(
{
error:
"GitHub OAuth is not configured on this server (missing GITHUB_CLIENT_ID).",
},
{ status: 500 }
);
}

// CSRF protection: generate a random state, store it server-side,
// and verify it matches on the callback before exchanging the code.
const state = randomBytes(16).toString("hex");
await setOAuthState(state);

const redirectUri = new URL("/api/auth/github/callback", req.url).toString();

const authorizeUrl = new URL("https://github.com/login/oauth/authorize");
authorizeUrl.searchParams.set("client_id", clientId);
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
authorizeUrl.searchParams.set("scope", "public_repo");
authorizeUrl.searchParams.set("state", state);

return NextResponse.redirect(authorizeUrl.toString());
}
24 changes: 24 additions & 0 deletions app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { Octokit } from "@octokit/rest";
import { getSessionToken } from "../../../(core)/lib/githubSession";

export async function GET() {
const token = await getSessionToken();
if (!token) {
return NextResponse.json({ authenticated: false });
}

try {
const octokit = new Octokit({ auth: token });
const { data: user } = await octokit.users.getAuthenticated();
return NextResponse.json({
authenticated: true,
username: user.login,
avatarUrl: user.avatar_url,
});
} catch (error) {
// Token is invalid or expired — treat as signed out rather than erroring.
console.error("Failed to fetch authenticated GitHub user:", error);
return NextResponse.json({ authenticated: false });
}
}
Loading
Loading