add all files

This commit is contained in:
Rucus
2026-02-17 09:29:34 -06:00
parent b8c8d67c67
commit 782d203799
21925 changed files with 2433086 additions and 0 deletions

23
lasuca/api/.htaccess Normal file
View File

@@ -0,0 +1,23 @@
# Route all API requests to the router
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /api/
# Allow direct access to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Route everything to index.php
RewriteRule ^(.*)$ index.php [QSA,L]
</IfModule>
# Deny access to helper files directly
<FilesMatch "^(helpers|endpoints|migrations)">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>

View File

@@ -0,0 +1,51 @@
<?php
// phpcs:ignoreFile
/**
* POST /api/auth/login
*
* Authenticate user and return JWT tokens.
*/
declare(strict_types=1);
$body = api_get_json_body();
api_require_fields($body, ['username', 'password']);
$username = trim((string) $body['username']);
$password = (string) $body['password'];
// Attempt authentication
$member = auth_attempt_login($username, $password);
if ($member === null) {
api_error('Invalid username or password', 401);
}
// Generate tokens
$accessToken = jwt_create_access_token($member);
$refreshData = jwt_create_refresh_token($member);
// Store refresh token in database
$stored = jwt_store_refresh_token(
$member['username'],
$refreshData['token_id'],
$refreshData['expires_at']
);
if (!$stored) {
// Log but don't fail - access token still works
error_log('Failed to store refresh token for user: ' . $member['username']);
}
api_success([
'access_token' => $accessToken,
'refresh_token' => $refreshData['token'],
'token_type' => 'Bearer',
'expires_in' => 900, // 15 minutes in seconds
'user' => [
'username' => $member['username'],
'growerid' => $member['growerid'] ?? null,
'growername' => $member['growername'] ?? null,
'email' => $member['email'] ?? null,
],
]);

View File

@@ -0,0 +1,37 @@
<?php
// phpcs:ignoreFile
/**
* POST /api/auth/logout
*
* Revoke the current refresh token.
*/
declare(strict_types=1);
$body = api_get_json_body();
// If refresh token provided, revoke it specifically
if (isset($body['refresh_token']) && $body['refresh_token'] !== '') {
$refreshToken = trim((string) $body['refresh_token']);
$payload = jwt_decode_token($refreshToken);
if ($payload !== null && isset($payload['jti'])) {
jwt_revoke_refresh_token($payload['jti']);
}
}
// Optionally, if access token is valid, revoke all user tokens
$accessToken = jwt_get_bearer_token();
if ($accessToken !== null) {
$payload = jwt_decode_token($accessToken);
if ($payload !== null && isset($payload['sub'])) {
// If 'revoke_all' flag is set, revoke all tokens for this user
if (isset($body['revoke_all']) && $body['revoke_all'] === true) {
jwt_revoke_all_user_tokens($payload['sub']);
}
}
}
api_success(['message' => 'Logged out successfully']);

View File

@@ -0,0 +1,30 @@
<?php
// phpcs:ignoreFile
/**
* GET /api/auth/me
*
* Return current authenticated user information.
*/
declare(strict_types=1);
// Require valid access token
$tokenUser = jwt_require_auth();
// Fetch fresh user data from database
$member = auth_find_member($tokenUser['username']);
if ($member === null) {
api_error('User not found', 404);
}
api_success([
'user' => [
'username' => $member['username'],
'growerid' => $member['growerid'] ?? null,
'growername' => $member['growername'] ?? null,
'email' => $member['email'] ?? null,
'phone' => $member['phone'] ?? null,
'last_login_at' => $member['last_login_at'] ?? null,
],
]);

View File

@@ -0,0 +1,65 @@
<?php
// phpcs:ignoreFile
/**
* POST /api/auth/refresh
*
* Exchange a valid refresh token for a new access token.
*/
declare(strict_types=1);
$body = api_get_json_body();
api_require_fields($body, ['refresh_token']);
$refreshToken = trim((string) $body['refresh_token']);
// Decode the refresh token
$payload = jwt_decode_token($refreshToken);
if ($payload === null) {
api_error('Invalid or expired refresh token', 401);
}
if (!isset($payload['type']) || $payload['type'] !== 'refresh') {
api_error('Invalid token type', 401);
}
$username = $payload['sub'] ?? null;
$tokenId = $payload['jti'] ?? null;
if ($username === null || $tokenId === null) {
api_error('Invalid refresh token payload', 401);
}
// Verify token exists in database and is not revoked
if (!jwt_validate_refresh_token($username, $tokenId)) {
api_error('Refresh token has been revoked or does not exist', 401);
}
// Look up current user data
$member = auth_find_member($username);
if ($member === null) {
api_error('User no longer exists', 401);
}
// Revoke the old refresh token (rotation for security)
jwt_revoke_refresh_token($tokenId);
// Generate new tokens
$accessToken = jwt_create_access_token($member);
$newRefreshData = jwt_create_refresh_token($member);
// Store new refresh token
jwt_store_refresh_token(
$member['username'],
$newRefreshData['token_id'],
$newRefreshData['expires_at']
);
api_success([
'access_token' => $accessToken,
'refresh_token' => $newRefreshData['token'],
'token_type' => 'Bearer',
'expires_in' => 900,
]);

View File

@@ -0,0 +1,24 @@
<?php
// phpcs:ignoreFile
/**
* GET /api/health
*
* Health check endpoint for monitoring.
*/
declare(strict_types=1);
global $conn;
$dbStatus = 'ok';
if (!$conn || $conn->connect_errno) {
$dbStatus = 'error';
}
api_success([
'status' => 'ok',
'timestamp' => date('c'),
'database' => $dbStatus,
'version' => '1.0.0',
]);

263
lasuca/api/helpers/jwt.php Normal file
View File

@@ -0,0 +1,263 @@
<?php
// phpcs:ignoreFile
/**
* JWT Token Helpers
*/
declare(strict_types=1);
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
/**
* Get the JWT secret key from environment or fallback.
*/
function jwt_get_secret(): string
{
$secret = getenv('JWT_SECRET');
if ($secret === false || $secret === '') {
// Fallback for development - MUST be set in production
$secret = 'lasuca-dev-secret-change-in-production-' . md5(__DIR__);
}
return $secret;
}
/**
* Generate an access token for a user.
*/
function jwt_create_access_token(array $user): string
{
$issuedAt = time();
$expiresAt = $issuedAt + (15 * 60); // 15 minutes
$payload = [
'iss' => 'lasuca-api',
'iat' => $issuedAt,
'exp' => $expiresAt,
'sub' => $user['username'],
'type' => 'access',
'user' => [
'username' => $user['username'],
'growerid' => $user['growerid'] ?? null,
'growername' => $user['growername'] ?? null,
'email' => $user['email'] ?? null,
],
];
return JWT::encode($payload, jwt_get_secret(), 'HS256');
}
/**
* Generate a refresh token for a user.
*/
function jwt_create_refresh_token(array $user): array
{
$issuedAt = time();
$expiresAt = $issuedAt + (30 * 24 * 60 * 60); // 30 days
$tokenId = bin2hex(random_bytes(32));
$payload = [
'iss' => 'lasuca-api',
'iat' => $issuedAt,
'exp' => $expiresAt,
'sub' => $user['username'],
'type' => 'refresh',
'jti' => $tokenId,
];
$token = JWT::encode($payload, jwt_get_secret(), 'HS256');
return [
'token' => $token,
'token_id' => $tokenId,
'expires_at' => date('Y-m-d H:i:s', $expiresAt),
];
}
/**
* Validate and decode a JWT token.
*/
function jwt_decode_token(string $token): ?array
{
try {
$decoded = JWT::decode($token, new Key(jwt_get_secret(), 'HS256'));
return (array) $decoded;
} catch (ExpiredException $e) {
return null;
} catch (\Exception $e) {
return null;
}
}
/**
* Extract Bearer token from Authorization header.
*/
function jwt_get_bearer_token(): ?string
{
$headers = null;
if (isset($_SERVER['Authorization'])) {
$headers = trim($_SERVER['Authorization']);
} elseif (isset($_SERVER['HTTP_AUTHORIZATION'])) {
$headers = trim($_SERVER['HTTP_AUTHORIZATION']);
} elseif (function_exists('apache_request_headers')) {
$requestHeaders = apache_request_headers();
$requestHeaders = array_combine(
array_map('ucwords', array_keys($requestHeaders)),
array_values($requestHeaders)
);
if (isset($requestHeaders['Authorization'])) {
$headers = trim($requestHeaders['Authorization']);
}
}
if ($headers === null) {
return null;
}
if (preg_match('/Bearer\s+(\S+)/i', $headers, $matches)) {
return $matches[1];
}
return null;
}
/**
* Require valid access token and return user data.
*/
function jwt_require_auth(): array
{
$token = jwt_get_bearer_token();
if ($token === null) {
api_error('Authorization required', 401);
}
$payload = jwt_decode_token($token);
if ($payload === null) {
api_error('Invalid or expired token', 401);
}
if (!isset($payload['type']) || $payload['type'] !== 'access') {
api_error('Invalid token type', 401);
}
if (!isset($payload['user']) || !is_array((array) $payload['user'])) {
api_error('Invalid token payload', 401);
}
return (array) $payload['user'];
}
/**
* Store refresh token in database.
*/
function jwt_store_refresh_token(string $username, string $tokenId, string $expiresAt): bool
{
global $conn;
$sql = "INSERT INTO refresh_tokens (username, token_id, expires_at, created_at)
VALUES (?, ?, ?, NOW())";
$stmt = $conn->prepare($sql);
if ($stmt === false) {
return false;
}
$stmt->bind_param('sss', $username, $tokenId, $expiresAt);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Validate refresh token exists and is not revoked.
*/
function jwt_validate_refresh_token(string $username, string $tokenId): bool
{
global $conn;
$sql = "SELECT id FROM refresh_tokens
WHERE username = ?
AND token_id = ?
AND expires_at > NOW()
AND revoked_at IS NULL
LIMIT 1";
$stmt = $conn->prepare($sql);
if ($stmt === false) {
return false;
}
$stmt->bind_param('ss', $username, $tokenId);
$stmt->execute();
$result = $stmt->get_result();
$exists = $result->num_rows > 0;
$stmt->close();
return $exists;
}
/**
* Revoke a specific refresh token.
*/
function jwt_revoke_refresh_token(string $tokenId): bool
{
global $conn;
$sql = "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_id = ?";
$stmt = $conn->prepare($sql);
if ($stmt === false) {
return false;
}
$stmt->bind_param('s', $tokenId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Revoke all refresh tokens for a user.
*/
function jwt_revoke_all_user_tokens(string $username): bool
{
global $conn;
$sql = "UPDATE refresh_tokens SET revoked_at = NOW() WHERE username = ? AND revoked_at IS NULL";
$stmt = $conn->prepare($sql);
if ($stmt === false) {
return false;
}
$stmt->bind_param('s', $username);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Clean up expired tokens (call periodically).
*/
function jwt_cleanup_expired_tokens(): int
{
global $conn;
$sql = "DELETE FROM refresh_tokens WHERE expires_at < NOW()";
$result = $conn->query($sql);
return $result ? $conn->affected_rows : 0;
}

View File

@@ -0,0 +1,84 @@
<?php
// phpcs:ignoreFile
/**
* API Response Helpers
*/
declare(strict_types=1);
/**
* Send a JSON success response.
*/
function api_success(array $data = [], int $statusCode = 200): void
{
http_response_code($statusCode);
echo json_encode([
'success' => true,
'data' => $data,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit();
}
/**
* Send a JSON error response.
*/
function api_error(string $message, int $statusCode = 400, array $details = []): void
{
http_response_code($statusCode);
$response = [
'success' => false,
'error' => [
'message' => $message,
'code' => $statusCode,
],
];
if (!empty($details)) {
$response['error']['details'] = $details;
}
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit();
}
/**
* Get JSON body from request.
*/
function api_get_json_body(): array
{
$rawBody = file_get_contents('php://input');
if ($rawBody === '' || $rawBody === false) {
return [];
}
$decoded = json_decode($rawBody, true);
if (!is_array($decoded)) {
return [];
}
return $decoded;
}
/**
* Require specific fields in request body.
*/
function api_require_fields(array $body, array $fields): void
{
$missing = [];
foreach ($fields as $field) {
if (!isset($body[$field]) || (is_string($body[$field]) && trim($body[$field]) === '')) {
$missing[] = $field;
}
}
if (!empty($missing)) {
api_error('Missing required fields: ' . implode(', ', $missing), 422);
}
}

76
lasuca/api/index.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
// phpcs:ignoreFile
/**
* LASUCA API Router
*
* Simple router for REST API endpoints.
* All requests to /api/* should be routed here via .htaccess or server config.
*/
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
// CORS headers for mobile/cross-origin requests
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
header('Access-Control-Max-Age: 86400');
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit();
}
// Load dependencies
$rootDir = dirname(__DIR__);
if (file_exists($rootDir . '/vendor/autoload.php')) {
require_once $rootDir . '/vendor/autoload.php';
}
require_once $rootDir . '/inc/dbconfig.php';
require_once $rootDir . '/inc/opendb.php';
require_once $rootDir . '/api/helpers/response.php';
require_once $rootDir . '/api/helpers/jwt.php';
require_once $rootDir . '/inc/auth.php';
// Parse the request path
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '';
// Remove query string
$path = parse_url($requestUri, PHP_URL_PATH);
// Remove /api prefix and normalize
$basePath = dirname($scriptName);
if ($basePath !== '/') {
$path = substr($path, strlen($basePath));
}
$path = '/' . trim(str_replace('/api', '', $path), '/');
$method = $_SERVER['REQUEST_METHOD'];
// Simple route matching
$routes = [
'POST /auth/login' => 'auth/login.php',
'POST /auth/refresh' => 'auth/refresh.php',
'POST /auth/logout' => 'auth/logout.php',
'GET /auth/me' => 'auth/me.php',
'GET /health' => 'health.php',
];
$routeKey = $method . ' ' . $path;
if (isset($routes[$routeKey])) {
$handlerPath = $rootDir . '/api/endpoints/' . $routes[$routeKey];
if (file_exists($handlerPath)) {
require $handlerPath;
} else {
api_error('Endpoint not implemented', 501);
}
} else {
api_error('Not found', 404);
}

View File

@@ -0,0 +1,22 @@
-- LASUCA API: Refresh Tokens Table
-- Run this migration to add JWT refresh token storage
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL,
token_id VARCHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME NULL DEFAULT NULL,
INDEX idx_username (username),
INDEX idx_token_id (token_id),
INDEX idx_expires_at (expires_at),
FOREIGN KEY (username) REFERENCES members(username) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Optional: Add password reset columns to members table for Phase 2
-- ALTER TABLE members
-- ADD COLUMN password_reset_token VARCHAR(64) NULL DEFAULT NULL,
-- ADD COLUMN password_reset_expires DATETIME NULL DEFAULT NULL;