OIDC test - move to www base
This commit is contained in:
1 parent
d572067cf6
commit
f2810a7950
12 files changed
+13
-725
No files matched your search
+13
@@ -10,13 +10,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
supervisor \
|
supervisor \
|
||||||
# Others
|
# Others
|
||||||
net-tools curl less nano \
|
net-tools curl less nano \
|
||||||
|
# PHP extensions needed by the OIDC helpers
|
||||||
|
libzip-dev libpng-dev libicu-dev libpq-dev \
|
||||||
|
&& docker-php-ext-install pdo_mysql \
|
||||||
|
&& docker-php-ext-install zip \
|
||||||
|
&& docker-php-ext-install intl \
|
||||||
# Clean up
|
# Clean up
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# Install Composer (stand‑alone)
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||||
|
|
||||||
# Apache on 8069
|
# Apache on 8069
|
||||||
COPY apache_ports.conf /etc/apache2/ports.conf
|
COPY apache_ports.conf /etc/apache2/ports.conf
|
||||||
COPY apache_vhost.conf /etc/apache2/sites-available/000-default.conf
|
COPY apache_vhost.conf /etc/apache2/sites-available/000-default.conf
|
||||||
COPY web/ /var/www/html/
|
COPY web/ /var/www/html/
|
||||||
|
# Set up composer
|
||||||
|
WORKDIR /var/www/html/
|
||||||
|
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||||
|
|
||||||
# API on 5000
|
# API on 5000
|
||||||
RUN mkdir -p /opt/Fitness
|
RUN mkdir -p /opt/Fitness
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
OIDC_CLIENT_ID=fitness-oidc
|
|
||||||
OIDC_CLIENT_SECRET=egebuqzZfVnjjBG9JDQ8tjW3TzV9gJurvT7qfB6c2SX2nPBNeaGycDfx4788QzJd
|
|
||||||
OIDC_REDIRECT_URI=https://fitness.matt-cloud.com/pgapi/auth/oidc/callback
|
|
||||||
OIDC_AUTH_ENDPOINT=https://auth.matt-cloud.com/oidc/auth
|
|
||||||
OIDC_TOKEN_ENDPOINT=https://auth.matt-cloud.com/oidc/token
|
|
||||||
OIDC_USERINFO_ENDPOINT=https://auth.matt-cloud.com/oidc/userinfo
|
|
||||||
OIDC_LOGOUT_ENDPOINT=https://auth.matt-cloud.com/logout
|
|
||||||
# Optional: the default redirect after logout
|
|
||||||
OIDC_POST_LOGOUT_REDIRECT_URI=https://fitness.matt-cloud.com/
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
require_once __DIR__ . '/config.php';
|
|
||||||
require_once __DIR__ . '/vendor/autoload.php';
|
|
||||||
|
|
||||||
use Fitness\OIDC\OIDC;
|
|
||||||
|
|
||||||
// Ensure a state was sent back.
|
|
||||||
if (!isset($_GET['state']) || !isset($_SESSION['oidc_state'])) {
|
|
||||||
http_response_code(400);
|
|
||||||
exit('Missing state parameter.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the state matches what we stored.
|
|
||||||
if ($_GET['state'] !== $_SESSION['oidc_state']) {
|
|
||||||
http_response_code(400);
|
|
||||||
exit('Invalid state.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the session variables.
|
|
||||||
$state = $_SESSION['oidc_state'];
|
|
||||||
$nonce = $_SESSION['oidc_nonce'];
|
|
||||||
unset($_SESSION['oidc_state'], $_SESSION['oidc_nonce']);
|
|
||||||
|
|
||||||
if (!isset($_GET['code'])) {
|
|
||||||
http_response_code(400);
|
|
||||||
exit('Missing authorization code.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$code = $_GET['code'];
|
|
||||||
$oidc = new OIDC();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Exchange code for tokens.
|
|
||||||
$tokens = $oidc->exchangeCode($code);
|
|
||||||
|
|
||||||
// Validate ID token (minimal).
|
|
||||||
$idInfo = $oidc->validateIDToken($tokens['id_token'], $nonce);
|
|
||||||
|
|
||||||
// Get userinfo (includes groups because of scope).
|
|
||||||
$userInfo = $oidc->getUserInfo($tokens['access_token']);
|
|
||||||
|
|
||||||
// Store everything we need in the session.
|
|
||||||
$_SESSION['user'] = [
|
|
||||||
'id_token' => $tokens['id_token'],
|
|
||||||
'access_token'=> $tokens['access_token'],
|
|
||||||
'refresh_token'=> $tokens['refresh_token'] ?? null,
|
|
||||||
'expires_in' => $tokens['expires_in'] ?? null,
|
|
||||||
'id_info' => $idInfo,
|
|
||||||
'user_info' => $userInfo,
|
|
||||||
];
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
http_response_code(500);
|
|
||||||
exit('Error during OIDC exchange: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the query string (code, state) by redirecting to the home page.
|
|
||||||
header('Location: /');
|
|
||||||
exit;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"require": {
|
|
||||||
"guzzlehttp/guzzle": "^7.0",
|
|
||||||
"firebase/php-jwt": "^6.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
session_start();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load environment variables. If you don't use phpdotenv,
|
|
||||||
* copy the values from .env directly here.
|
|
||||||
*/
|
|
||||||
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
|
||||||
$dotenv->load();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Basic OIDC configuration – keep all secrets out of source‑control.
|
|
||||||
*/
|
|
||||||
define('OIDC_CLIENT_ID', getenv('OIDC_CLIENT_ID'));
|
|
||||||
define('OIDC_CLIENT_SECRET', getenv('OIDC_CLIENT_SECRET'));
|
|
||||||
define('OIDC_REDIRECT_URI', getenv('OIDC_REDIRECT_URI'));
|
|
||||||
define('OIDC_AUTH_ENDPOINT', getenv('OIDC_AUTH_ENDPOINT'));
|
|
||||||
define('OIDC_TOKEN_ENDPOINT', getenv('OIDC_TOKEN_ENDPOINT'));
|
|
||||||
define('OIDC_USERINFO_ENDPOINT', getenv('OIDC_USERINFO_ENDPOINT'));
|
|
||||||
define('OIDC_LOGOUT_ENDPOINT', getenv('OIDC_LOGOUT_ENDPOINT'));
|
|
||||||
define('OIDC_POST_LOGOUT_REDIRECT_URI', getenv('OIDC_POST_LOGOUT_REDIRECT_URI'));
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
require_once __DIR__ . '/config.php';
|
|
||||||
?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Fitness Site – OIDC Demo</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; margin: 2rem; }
|
|
||||||
.info { margin-top: 1rem; }
|
|
||||||
pre { background: #f4f4f4; padding: 1rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Fitness Site – OIDC Demo</h1>
|
|
||||||
|
|
||||||
<?php if (!isset($_SESSION['user'])): ?>
|
|
||||||
<p>You are not logged in.</p>
|
|
||||||
<p><a href="/login.php"><button>Login with Authelia</button></a></p>
|
|
||||||
<?php else: ?>
|
|
||||||
<p>Logged in as: <strong><?=htmlspecialchars($_SESSION['user']['user_info']['name'] ?? 'N/A')?></strong></p>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<h2>User Information</h2>
|
|
||||||
<pre><?=htmlspecialchars(json_encode($_SESSION['user']['user_info'], JSON_PRETTY_PRINT))?></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<h2>ID Token Payload (claims)</h2>
|
|
||||||
<pre><?=htmlspecialchars(json_encode($_SESSION['user']['id_info'], JSON_PRETTY_PRINT))?></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><a href="/logout.php"><button>Logout</button></a></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
require_once __DIR__ . '/config.php';
|
|
||||||
require_once __DIR__ . '/vendor/autoload.php';
|
|
||||||
|
|
||||||
use Fitness\OIDC\OIDC;
|
|
||||||
|
|
||||||
// Generate a cryptographically random state & nonce.
|
|
||||||
$state = bin2hex(random_bytes(16));
|
|
||||||
$nonce = bin2hex(random_bytes(16));
|
|
||||||
|
|
||||||
// Persist them in the session to validate later.
|
|
||||||
$_SESSION['oidc_state'] = $state;
|
|
||||||
$_SESSION['oidc_nonce'] = $nonce;
|
|
||||||
|
|
||||||
$oidc = new OIDC();
|
|
||||||
header('Location: ' . $oidc->buildAuthUrl($state, $nonce));
|
|
||||||
exit;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
require_once __DIR__ . '/config.php';
|
|
||||||
|
|
||||||
// Destroy the PHP session.
|
|
||||||
session_start();
|
|
||||||
session_destroy();
|
|
||||||
|
|
||||||
// Redirect the user to Authelia’s logout endpoint.
|
|
||||||
// Authelia will clean its session and then redirect back.
|
|
||||||
$logoutUrl = OIDC_LOGOUT_ENDPOINT
|
|
||||||
. '?post_logout_redirect_uri=' . urlencode(OIDC_POST_LOGOUT_REDIRECT_URI);
|
|
||||||
|
|
||||||
header('Location: ' . $logoutUrl);
|
|
||||||
exit;
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Fitness\OIDC;
|
|
||||||
|
|
||||||
use GuzzleHttp\Client;
|
|
||||||
use Firebase\JWT\JWT;
|
|
||||||
use Firebase\JWT\Key;
|
|
||||||
|
|
||||||
class OIDC
|
|
||||||
{
|
|
||||||
private Client $http;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->http = new Client(['http_errors' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Auth URL that redirects the user to Authelia.
|
|
||||||
*/
|
|
||||||
public function buildAuthUrl(string $state, string $nonce): string
|
|
||||||
{
|
|
||||||
$params = [
|
|
||||||
'response_type' => 'code',
|
|
||||||
'client_id' => OIDC_CLIENT_ID,
|
|
||||||
'redirect_uri' => OIDC_REDIRECT_URI,
|
|
||||||
'scope' => 'openid profile email groups',
|
|
||||||
'state' => $state,
|
|
||||||
'nonce' => $nonce,
|
|
||||||
];
|
|
||||||
|
|
||||||
return OIDC_AUTH_ENDPOINT . '?' . http_build_query($params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchange the Authorization Code for tokens.
|
|
||||||
*/
|
|
||||||
public function exchangeCode(string $code): array
|
|
||||||
{
|
|
||||||
$response = $this->http->post(OIDC_TOKEN_ENDPOINT, [
|
|
||||||
'auth' => [OIDC_CLIENT_ID, OIDC_CLIENT_SECRET],
|
|
||||||
'form_params' => [
|
|
||||||
'grant_type' => 'authorization_code',
|
|
||||||
'code' => $code,
|
|
||||||
'redirect_uri' => OIDC_REDIRECT_URI,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->getStatusCode() !== 200) {
|
|
||||||
throw new \RuntimeException('Token endpoint returned ' . $response->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
return json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call /userinfo with the access token.
|
|
||||||
*/
|
|
||||||
public function getUserInfo(string $accessToken): array
|
|
||||||
{
|
|
||||||
$response = $this->http->get(OIDC_USERINFO_ENDPOINT, [
|
|
||||||
'headers' => [
|
|
||||||
'Authorization' => 'Bearer ' . $accessToken,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->getStatusCode() !== 200) {
|
|
||||||
throw new \RuntimeException('Userinfo endpoint returned ' . $response->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
return json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the ID Token (minimal validation for demo purposes).
|
|
||||||
*/
|
|
||||||
public function validateIDToken(string $idToken, string $nonce): array
|
|
||||||
{
|
|
||||||
// Authelia uses RS256 by default, but your config says "none".
|
|
||||||
// We'll decode without verification if alg is "none".
|
|
||||||
$decoded = JWT::decode($idToken, new Key('', ''), ['none', 'RS256']);
|
|
||||||
|
|
||||||
// Check nonce matches what we stored in session.
|
|
||||||
if ($decoded->nonce !== $nonce) {
|
|
||||||
throw new \RuntimeException('Invalid nonce');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (array)$decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<!-- index.html (served by PHP) -->
|
|
||||||
<input type="file" id="photo" style="display:none;">
|
|
||||||
<div id="drop" style="border:2px dashed #bbb; padding:20px;">
|
|
||||||
Drag photo here or click to choose
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const drop = document.getElementById('drop');
|
|
||||||
const photoInput = document.getElementById('photo');
|
|
||||||
|
|
||||||
drop.onclick = () => photoInput.click();
|
|
||||||
|
|
||||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.background='#eef'; });
|
|
||||||
drop.addEventListener('dragleave', e => { e.preventDefault(); drop.style.background=''; });
|
|
||||||
|
|
||||||
drop.addEventListener('drop', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
drop.style.background='';
|
|
||||||
const file = e.dataTransfer.files[0];
|
|
||||||
upload(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
photoInput.addEventListener('change', () => upload(photoInput.files[0]));
|
|
||||||
|
|
||||||
async function upload(file) {
|
|
||||||
// 1️⃣ Send file to PHP, which will forward it to Flask
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('photo', file);
|
|
||||||
|
|
||||||
const res = await fetch('/api/upload_photo', { // <-- PHP endpoint
|
|
||||||
method: 'POST',
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
console.log('Uploaded:', json);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
-314
@@ -1,314 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* photos.php - Service Provider Photo Gallery Implementation
|
|
||||||
* Requirements:
|
|
||||||
* 1. Upload multiple images via PHP Frontend
|
|
||||||
* 2. Display gallery with thumbnail selection
|
|
||||||
* 3. Use Flask API for storage and metadata
|
|
||||||
* 4. Store photos in file system (not database BLOBs)
|
|
||||||
*/
|
|
||||||
require_once __DIR__ . '/config.php';
|
|
||||||
|
|
||||||
// Validate provider ID
|
|
||||||
if (!isset($_GET['id']) || empty($_GET['id'])) {
|
|
||||||
die('Provider ID missing. Use: photos.php?id=123');
|
|
||||||
}
|
|
||||||
$provider_id = $_GET['id'];
|
|
||||||
|
|
||||||
// Fetch existing photos from API
|
|
||||||
$api_url = BASE_API_URL . '/providers/' . urlencode($provider_id) . "/photos";
|
|
||||||
$existing_photos = [];
|
|
||||||
try {
|
|
||||||
$response = file_get_contents($api_url);
|
|
||||||
$existing_photos = json_decode($response, true);
|
|
||||||
if (isset($existing_photos['error'])) {
|
|
||||||
trigger_error($existing_photos['error'], E_USER_WARNING);
|
|
||||||
$existing_photos = [];
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
trigger_error('API error: ' . $e->getMessage(), E_USER_WARNING);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check upload permissions
|
|
||||||
$has_upload_perm = true; // Add authorization logic here
|
|
||||||
|
|
||||||
// Generate unique token for upload (security)
|
|
||||||
$upload_token = bin2hex(random_bytes(16));
|
|
||||||
?>
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Photos for Service Provider #<?php echo htmlspecialchars($provider_id); ?></title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #333;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
}
|
|
||||||
.upload-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.upload-btn {
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: #3a86ff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.upload-btn:hover {
|
|
||||||
background: #1d66cc;
|
|
||||||
}
|
|
||||||
#preview-container {
|
|
||||||
display: none;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.photo-item {
|
|
||||||
position: relative;
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
margin: 5px;
|
|
||||||
}
|
|
||||||
.photo-item img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.main-indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
right: 2px;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
background: #3a86ff;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
#gallery {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.no-photos {
|
|
||||||
text-align: center;
|
|
||||||
padding: 50px;
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color: #e74c3c;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Photos for Service Provider #<?php echo htmlspecialchars($provider_id); ?></h1>
|
|
||||||
|
|
||||||
<?php if (isset($existing_photos['error'])): ?>
|
|
||||||
<div class="error">
|
|
||||||
<?php echo htmlspecialchars($existing_photos['error']); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($has_upload_perm): ?>
|
|
||||||
<div class="upload-section">
|
|
||||||
<input type="file" id="photo-upload" name="photos[]" multiple accept="image/*">
|
|
||||||
<button id="upload-btn" class="upload-btn">Upload Photos</button>
|
|
||||||
</div>
|
|
||||||
<div id="preview-container"></div>
|
|
||||||
<?php else: ?>
|
|
||||||
<p>Upload permissions disabled. Contact administrator.</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="loading" id="loading">
|
|
||||||
Loading photos...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="gallery">
|
|
||||||
<?php if (empty($existing_photos)): ?>
|
|
||||||
<div class="no-photos">
|
|
||||||
<h2>No photos uploaded yet</h2>
|
|
||||||
<p>Upload images to display them here</p>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($existing_photos as $photo): ?>
|
|
||||||
<div class="photo-item">
|
|
||||||
<input type="radio" name="main_photo" value="<?php echo htmlspecialchars($photo['path']); ?>" <?php echo $photo['is_main'] ? 'checked' : ''; ?> class="main-radio">
|
|
||||||
<img src="<?php echo htmlspecialchars($photo['path']); ?>" alt="Service provider photo">
|
|
||||||
<?php if ($photo['is_main']): ?>
|
|
||||||
<div class="main-indicator"></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const photoInput = document.getElementById('photo-upload');
|
|
||||||
const previewContainer = document.getElementById('preview-container');
|
|
||||||
const gallery = document.getElementById('gallery');
|
|
||||||
const loading = document.getElementById('loading');
|
|
||||||
|
|
||||||
// Show preview on file selection
|
|
||||||
photoInput.addEventListener('change', function(e) {
|
|
||||||
if (this.files.length > 0) {
|
|
||||||
previewContainer.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
previewContainer.innerHTML = '';
|
|
||||||
for (let i = 0; i < this.files.length; i++) {
|
|
||||||
const file = this.files[i];
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = function(e) {
|
|
||||||
const preview = document.createElement('div');
|
|
||||||
preview.className = 'photo-item';
|
|
||||||
|
|
||||||
preview.innerHTML = `
|
|
||||||
<input type="radio" name="main_photo" value="${file.name}"
|
|
||||||
class="main-radio"
|
|
||||||
style="position: absolute; top: 2px; left: 2px;">
|
|
||||||
<img src="${e.target.result}"
|
|
||||||
alt="Photo preview">
|
|
||||||
<div style="position: absolute; top: 2px; right: 2px;
|
|
||||||
width: 16px; height: 16px;
|
|
||||||
background: #3a86ff;
|
|
||||||
border-radius: 50%;"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
previewContainer.appendChild(preview);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle upload button
|
|
||||||
document.getElementById('upload-btn').addEventListener('click', async () => {
|
|
||||||
const fileInput = document.getElementById('photo-upload');
|
|
||||||
if (!fileInput.files.length) {
|
|
||||||
alert('Please select at least one photo to upload');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
for (let i = 0; i < fileInput.files.length; i++) {
|
|
||||||
formData.append('photos[]', fileInput.files[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
BASE_API_URL . '/providers/<?php echo urlencode($provider_id); ?>/photos',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: {
|
|
||||||
'X-Upload-Token': '<?php echo $upload_token; ?>'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Add new photos to gallery
|
|
||||||
data.paths.forEach(path => {
|
|
||||||
const newPhoto = document.createElement('div');
|
|
||||||
newPhoto.className = 'photo-item';
|
|
||||||
|
|
||||||
newPhoto.innerHTML = `
|
|
||||||
<input type="radio" name="main_photo" value="${path}"
|
|
||||||
class="main-radio"
|
|
||||||
style="position: absolute; top: 2px; left: 2px;">
|
|
||||||
<img src="${path}"
|
|
||||||
alt="New photo"
|
|
||||||
style="object-fit: cover;">
|
|
||||||
<div style="position: absolute; top: 2px; right: 2px;
|
|
||||||
width: 16px; height: 16px;
|
|
||||||
background: #3a86ff;
|
|
||||||
border-radius: 50%;"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
gallery.appendChild(newPhoto);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
fileInput.value = '';
|
|
||||||
previewContainer.style.display = 'none';
|
|
||||||
|
|
||||||
// Update main photo selection (if any)
|
|
||||||
const mainRadios = gallery.querySelectorAll('.main-radio');
|
|
||||||
if (mainRadios.length > 0) {
|
|
||||||
mainRadios[0].checked = true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload failed:', error);
|
|
||||||
alert('Upload failed: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle main photo selection
|
|
||||||
gallery.addEventListener('change', async (e) => {
|
|
||||||
if (e.target.className === 'main-radio') {
|
|
||||||
const mainPhoto = e.target.value;
|
|
||||||
const providerId = '<?php echo $provider_id; ?>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
BASE_API_URL . '/providers/${providerId}/main_photo',
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
photo_path: mainPhoto
|
|
||||||
})
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update visuals
|
|
||||||
gallery.querySelectorAll('.main-indicator').forEach(ind => {
|
|
||||||
ind.style.display = 'none';
|
|
||||||
});
|
|
||||||
e.target.parentElement.querySelector('.main-indicator').style.display = 'block';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Setting main photo failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide loading message
|
|
||||||
loading.style.display = 'none';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Fitness Tracker</title>
|
|
||||||
<style>
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
th, td { border: 1px solid #ddd; padding: 8px; }
|
|
||||||
th { background:#f0f0f0; }
|
|
||||||
#drop-zone { border: 2px dashed #aaa; padding: 1rem; text-align:center; cursor:pointer; }
|
|
||||||
#drop-zone.hover { background:#e0e0ff; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>Providers</h1>
|
|
||||||
<button id="add-provider-btn">Add Provider</button>
|
|
||||||
<table id="providers-table">
|
|
||||||
<thead><tr><th>ID</th><th>Name</th><th>Phone</th><th>Photo</th></tr></thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Add Provider Modal -->
|
|
||||||
<div id="modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5);">
|
|
||||||
<div style="background:#fff; margin:5% auto; padding:1rem; width:300px; position:relative;">
|
|
||||||
<h2>Add Provider</h2>
|
|
||||||
<label>Name: <input type="text" id="p-name"></label><br>
|
|
||||||
<label>Age: <input type="number" id="p-age"></label><br>
|
|
||||||
<label>Phone: <input type="text" id="p-phone"></label><br>
|
|
||||||
<label>Rates: <input type="text" id="p-rates" placeholder='{"hourly":70}'></label><br>
|
|
||||||
<label>Private: <input type="checkbox" id="p-private"></label><br>
|
|
||||||
|
|
||||||
<div id="drop-zone">Drag photo or click to upload</div>
|
|
||||||
<input type="file" id="p-file" style="display:none;">
|
|
||||||
<br>
|
|
||||||
<button id="save-provider">Save</button>
|
|
||||||
<button id="cancel-provider">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API = 'http://localhost:5000';
|
|
||||||
|
|
||||||
// ----------- Fetch & render providers -----------------
|
|
||||||
async function loadProviders() {
|
|
||||||
const res = await fetch(`${API}/providers`);
|
|
||||||
const data = await res.json();
|
|
||||||
const tbody = document.querySelector('#providers-table tbody');
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
data.forEach(p => {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${p.id}</td>
|
|
||||||
<td>${p.name}</td>
|
|
||||||
<td>${p.phone}</td>
|
|
||||||
<td><img src="${p.photo}" width="50"></td>
|
|
||||||
`;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
loadProviders();
|
|
||||||
|
|
||||||
// ----------- Modal handling -----------------
|
|
||||||
const modal = document.getElementById('modal');
|
|
||||||
const addBtn = document.getElementById('add-provider-btn');
|
|
||||||
const cancelBtn = document.getElementById('cancel-provider');
|
|
||||||
const dropZone = document.getElementById('drop-zone');
|
|
||||||
const fileInput = document.getElementById('p-file');
|
|
||||||
let uploadedPhotoUrl = null;
|
|
||||||
|
|
||||||
addBtn.onclick = () => { modal.style.display='block'; }
|
|
||||||
cancelBtn.onclick = () => { modal.style.display='none'; }
|
|
||||||
|
|
||||||
dropZone.onclick = () => fileInput.click();
|
|
||||||
|
|
||||||
fileInput.onchange = () => uploadFile(fileInput.files[0]);
|
|
||||||
|
|
||||||
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('hover'); });
|
|
||||||
dropZone.addEventListener('dragleave', e => { e.preventDefault(); dropZone.classList.remove('hover'); });
|
|
||||||
dropZone.addEventListener('drop', e => {
|
|
||||||
e.preventDefault(); dropZone.classList.remove('hover');
|
|
||||||
uploadFile(e.dataTransfer.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function uploadFile(file) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('photo', file);
|
|
||||||
const resp = await fetch(`${API}/upload_photo`, { method: 'POST', body: formData });
|
|
||||||
uploadedPhotoUrl = await resp.text(); // /uploads/xxxx.jpg
|
|
||||||
dropZone.textContent = '✓ Uploaded';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------- Save provider -----------------
|
|
||||||
const saveBtn = document.getElementById('save-provider');
|
|
||||||
saveBtn.onclick = async () => {
|
|
||||||
const payload = {
|
|
||||||
name: document.getElementById('p-name').value,
|
|
||||||
age: parseInt(document.getElementById('p-age').value, 10),
|
|
||||||
phone: document.getElementById('p-phone').value,
|
|
||||||
rates: JSON.parse(document.getElementById('p-rates').value),
|
|
||||||
private: document.getElementById('p-private').checked,
|
|
||||||
photo: uploadedPhotoUrl
|
|
||||||
};
|
|
||||||
await fetch(`${API}/providers`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
modal.style.display='none';
|
|
||||||
loadProviders(); // refresh table
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in new issue
Block a user