This is an update to the code that I posted earlier.
The changes to the serveFile function deal with the relative path stuff I was seeing in Quatro and allow multiple directories and other more complex setups beyond a single index file.
<?php
session_start();
//example setup for the *teaching* directory, make sure you change lines 8 and 11 to match your directory and then adjust the .htaccess file lines 11 and 12
const CLIENT_ID = 'PLACEHOLDER_INFORMATION_HERE.apps.googleusercontent.com';//get from Google Cloud Console OAuth 2.0 credentials
const CLIENT_SECRET = 'PLACEHOLDER_INFORMATION_HERE';//get from Google Cloud Console OAuth 2.0 credentials
const REDIRECT_URI = 'https://PLACEHOLDER_INFORMATION_HERE/teaching/protected/auth.php?callback=1';//matches cloud console authorized redirect URI to your directory
const ALLOWED_DOMAIN = 'middlebury.edu';//community email domain restriction - leave me alone
const CONTENT_DIR = __DIR__ . '/_private/';//where the protected files are stored
const PROTECTED_PATH = '/teaching/protected/';//the path to the protected directory
// --- Logout ---
if (isset($_GET['logout'])) {
session_destroy();
header('Location: /');
exit;
}
// --- OAuth Callback ---
if (isset($_GET['callback']) && isset($_GET['code'])) {
$token = fetchToken($_GET['code']);
$userInfo = fetchUserInfo($token);
if (!$userInfo || !str_ends_with($userInfo['email'] ?? '', '@' . ALLOWED_DOMAIN)) {
http_response_code(403);
die('Access restricted to @' . ALLOWED_DOMAIN . ' accounts. <a href="?logout=1">Try again</a>');
}
$_SESSION['authed_email'] = $userInfo['email'];
$return = $_SESSION['return_to'] ?? 'index.html';
unset($_SESSION['return_to']);
header('Location: ' . PROTECTED_PATH . $return);
exit;
}
// --- Auth Check ---
if (empty($_SESSION['authed_email'])) {
$_SESSION['return_to'] = $_GET['file'] ?? 'index.html';
header('Location: ' . buildAuthUrl());
exit;
}
// --- Serve File ---
$requestedFile = $_GET['file'] ?? 'index.html';
serveFile($requestedFile);
// --- Functions ---
function buildAuthUrl(): string {
$params = http_build_query([
'client_id' => CLIENT_ID,
'redirect_uri' => REDIRECT_URI,
'response_type' => 'code',
'scope' => 'openid email',
'hd' => ALLOWED_DOMAIN,
'state' => session_id(),
]);
return 'https://accounts.google.com/o/oauth2/v2/auth?' . $params;
}
function fetchToken(string $code): ?string {
$response = httpPost('https://oauth2.googleapis.com/token', [
'code' => $code,
'client_id' => CLIENT_ID,
'client_secret' => CLIENT_SECRET,
'redirect_uri' => REDIRECT_URI,
'grant_type' => 'authorization_code',
]);
return json_decode($response, true)['access_token'] ?? null;
}
function fetchUserInfo(?string $token): ?array {
if (!$token) return null;
$ctx = stream_context_create(['http' => ['header' => "Authorization: Bearer $token"]]);
$result = @file_get_contents('https://www.googleapis.com/oauth2/v3/userinfo', false, $ctx);
return $result ? json_decode($result, true) : null;
}
function httpPost(string $url, array $data): string {
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => http_build_query($data),
]]);
return @file_get_contents($url, false, $ctx) ?: '';
}
function serveFile(string $file): void {
$realBase = realpath(CONTENT_DIR);
// Check if the path resolves to a directory . . . deals with relative URLs, multiple directories, etc.
$realPath = realpath(CONTENT_DIR . $file);
if ($realPath && is_dir($realPath)) {
if (!str_ends_with($file, '/')) {
// Redirect to add trailing slash so relative URLs resolve correctly
header('Location: ' . PROTECTED_PATH . $file . '/');
exit;
}
// Serve index.html from the directory
$file = rtrim($file, '/') . '/index.html';
}
$realFile = realpath(CONTENT_DIR . $file);
if (!$realFile || !str_starts_with($realFile, $realBase) || !is_file($realFile)) {
http_response_code(404);
die('File not found.');
}
//this is a basic mapping of common file extensions to MIME types. Expand this list if we end up with weird stuff.
$mimeTypes = [
'html' => 'text/html',
'js' => 'application/javascript',
'css' => 'text/css',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'pdf' => 'application/pdf',
'woff2' => 'font/woff2',
'woff' => 'font/woff',
'json' => 'application/json',
// Video formats
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'mov' => 'video/quicktime',
'mkv' => 'video/x-matroska',
'avi' => 'video/x-msvideo',
'flv' => 'video/x-flv',
// Audio formats
'mp3' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg' => 'audio/ogg',
'm4a' => 'audio/mp4',
'flac' => 'audio/flac',
'aac' => 'audio/aac',
];
$ext = strtolower(pathinfo($realFile, PATHINFO_EXTENSION));
$mime = $mimeTypes[$ext] ?? 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($realFile));
readfile($realFile);
exit;
}