Files
pantry/api.php
Eric Wagoner 7e523392c0 Add backend PIN verification and security hardening
- Add requirePin() check on add/update/delete endpoints (closes PIN bypass vulnerability)
- Restrict CORS to specific allowed origins only
- Add input length limits to sanitize() function
- Frontend now sends currentPin with all write requests
- Deploy script copies data/index.php to block directory listing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 22:30:39 -05:00

320 lines
10 KiB
PHP

<?php
/**
* Pantry Inventory API
*
* Simple JSON file-based backend for the pantry inventory app.
* No database required - stores everything in inventory.json
*/
header('Content-Type: application/json');
// CORS - restrict to same origin (remove for local development if needed)
$allowedOrigins = [
'https://pantry.kestrelsnest.social',
'http://localhost:8000'
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins)) {
header("Access-Control-Allow-Origin: $origin");
}
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// File paths
define('DATA_FILE', __DIR__ . '/data/inventory.json');
define('PIN_FILE', __DIR__ . '/data/pin.txt');
define('DATA_DIR', __DIR__ . '/data');
// Ensure data directory exists
if (!is_dir(DATA_DIR)) {
mkdir(DATA_DIR, 0755, true);
}
// Default inventory (sample data using new container types)
$defaultInventory = [
['id' => 1, 'name' => 'Bulgur', 'container' => 'glass-flip-med-tall', 'outOfStock' => false],
['id' => 2, 'name' => 'Wild Rice Flour', 'container' => 'glass-flip-med-squat', 'outOfStock' => false],
['id' => 3, 'name' => 'Self Rising Flour', 'container' => 'plastic-oxo-large', 'outOfStock' => false],
['id' => 4, 'name' => 'Whole Wheat Flour', 'container' => 'plastic-oxo-large', 'outOfStock' => false],
['id' => 5, 'name' => 'Cake Flour', 'container' => 'plastic-clamp-large', 'outOfStock' => false],
['id' => 6, 'name' => 'GF Pancake Mix', 'container' => 'original-package', 'outOfStock' => false],
['id' => 7, 'name' => 'Coconut Flour', 'container' => 'glass-flip-small', 'outOfStock' => false],
['id' => 8, 'name' => 'All Purpose Flour', 'container' => 'plastic-oxo-large', 'outOfStock' => false],
['id' => 9, 'name' => 'Bread Flour', 'container' => 'plastic-clamp-tall', 'outOfStock' => false],
['id' => 10, 'name' => 'Powdered Sugar', 'container' => 'plastic-flip', 'outOfStock' => false],
['id' => 11, 'name' => 'Linguine', 'container' => 'original-package', 'outOfStock' => false],
['id' => 12, 'name' => 'Fettuccine', 'container' => 'original-package', 'outOfStock' => false],
['id' => 13, 'name' => 'Penne Rigate', 'container' => 'original-package', 'outOfStock' => false],
['id' => 14, 'name' => 'Ancho Chile', 'container' => 'glass-flip-large', 'outOfStock' => false],
['id' => 15, 'name' => 'Chocolate Chips', 'container' => 'glass-canning-quart', 'outOfStock' => false],
['id' => 16, 'name' => 'Dried Mushrooms', 'container' => 'glass-canning-pint', 'outOfStock' => false],
['id' => 17, 'name' => 'Bay Leaves', 'container' => 'spice-hex-large', 'outOfStock' => false],
['id' => 18, 'name' => 'Dates', 'container' => 'glass-canning-quart', 'outOfStock' => false],
['id' => 19, 'name' => 'Xanthan Gum', 'container' => 'spice-flip-tiny', 'outOfStock' => false],
['id' => 20, 'name' => 'M.S.G.', 'container' => 'spice-hex-small', 'outOfStock' => false],
['id' => 21, 'name' => 'Potato Starch', 'container' => 'glass-flip-small', 'outOfStock' => false],
['id' => 22, 'name' => 'Potato Flour', 'container' => 'glass-flip-small', 'outOfStock' => false],
['id' => 23, 'name' => 'Garbanzo Flour', 'container' => 'glass-flip-med-squat', 'outOfStock' => false],
['id' => 24, 'name' => 'Mochi Rice Flour', 'container' => 'glass-flip-small', 'outOfStock' => false],
['id' => 25, 'name' => 'Couscous', 'container' => 'original-package', 'outOfStock' => false],
['id' => 26, 'name' => 'Granola', 'container' => 'original-package', 'outOfStock' => false],
['id' => 27, 'name' => 'Cheerios', 'container' => 'original-package', 'outOfStock' => false],
['id' => 28, 'name' => 'Baking Powder', 'container' => 'spice-hex-large', 'outOfStock' => false],
['id' => 29, 'name' => 'Corn Starch', 'container' => 'glass-canning-small', 'outOfStock' => false],
['id' => 30, 'name' => 'Cocoa Powder', 'container' => 'spice-hex-large', 'outOfStock' => false],
];
/**
* Load inventory from file
*/
function loadInventory() {
global $defaultInventory;
if (file_exists(DATA_FILE)) {
$content = file_get_contents(DATA_FILE);
$data = json_decode($content, true);
if ($data !== null) {
return $data;
}
}
// Initialize with default inventory
saveInventory($defaultInventory);
return $defaultInventory;
}
/**
* Save inventory to file
*/
function saveInventory($inventory) {
file_put_contents(DATA_FILE, json_encode($inventory, JSON_PRETTY_PRINT));
}
/**
* Check if PIN is set
*/
function hasPinSet() {
return file_exists(PIN_FILE) && strlen(trim(file_get_contents(PIN_FILE))) === 4;
}
/**
* Get stored PIN
*/
function getPin() {
if (file_exists(PIN_FILE)) {
return trim(file_get_contents(PIN_FILE));
}
return null;
}
/**
* Set PIN
*/
function setPin($pin) {
file_put_contents(PIN_FILE, $pin);
// Secure the file
chmod(PIN_FILE, 0600);
}
/**
* Verify PIN
*/
function verifyPin($pin) {
$storedPin = getPin();
return $storedPin !== null && $storedPin === $pin;
}
/**
* Get next available ID
*/
function getNextId($inventory) {
if (empty($inventory)) {
return 1;
}
$maxId = max(array_column($inventory, 'id'));
return $maxId + 1;
}
/**
* Sanitize string input
*/
function sanitize($str, $maxLength = 200) {
$str = trim($str);
if (strlen($str) > $maxLength) {
$str = substr($str, 0, $maxLength);
}
return $str;
}
/**
* Check if PIN is required and valid for write operations
*/
function requirePin($input) {
// If no PIN is set yet, allow writes (first-time setup)
if (!hasPinSet()) {
return true;
}
// PIN is set, so require it for write operations
$pin = $input['pin'] ?? '';
return verifyPin($pin);
}
// Main request handling
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['action'])) {
// Allow GET requests to view inventory
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$inventory = loadInventory();
echo json_encode([
'success' => true,
'inventory' => $inventory,
'hasPin' => hasPinSet()
]);
exit();
}
echo json_encode(['error' => 'Invalid request']);
exit();
}
$action = $input['action'];
switch ($action) {
case 'get':
$inventory = loadInventory();
echo json_encode([
'success' => true,
'inventory' => $inventory,
'hasPin' => hasPinSet()
]);
break;
case 'setPin':
$pin = $input['pin'] ?? '';
if (strlen($pin) !== 4 || !ctype_digit($pin)) {
echo json_encode(['success' => false, 'error' => 'PIN must be 4 digits']);
break;
}
// Only allow setting PIN if not already set
if (hasPinSet()) {
echo json_encode(['success' => false, 'error' => 'PIN already set']);
break;
}
setPin($pin);
echo json_encode(['success' => true]);
break;
case 'verifyPin':
$pin = $input['pin'] ?? '';
$valid = verifyPin($pin);
echo json_encode(['success' => $valid]);
break;
case 'add':
if (!requirePin($input)) {
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
break;
}
$name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? 'glass-bail-medium', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false);
if (empty($name)) {
echo json_encode(['success' => false, 'error' => 'Name required']);
break;
}
$inventory = loadInventory();
$newItem = [
'id' => getNextId($inventory),
'name' => $name,
'container' => $container,
'outOfStock' => $outOfStock
];
$inventory[] = $newItem;
saveInventory($inventory);
echo json_encode(['success' => true, 'item' => $newItem]);
break;
case 'update':
if (!requirePin($input)) {
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
break;
}
$id = (int)($input['id'] ?? 0);
$name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? '', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false);
if ($id <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid ID']);
break;
}
$inventory = loadInventory();
$found = false;
foreach ($inventory as &$item) {
if ($item['id'] === $id) {
if (!empty($name)) $item['name'] = $name;
if (!empty($container)) $item['container'] = $container;
$item['outOfStock'] = $outOfStock;
$found = true;
break;
}
}
if (!$found) {
echo json_encode(['success' => false, 'error' => 'Item not found']);
break;
}
saveInventory($inventory);
echo json_encode(['success' => true]);
break;
case 'delete':
if (!requirePin($input)) {
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
break;
}
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid ID']);
break;
}
$inventory = loadInventory();
$originalCount = count($inventory);
$inventory = array_values(array_filter($inventory, fn($item) => $item['id'] !== $id));
if (count($inventory) === $originalCount) {
echo json_encode(['success' => false, 'error' => 'Item not found']);
break;
}
saveInventory($inventory);
echo json_encode(['success' => true]);
break;
default:
echo json_encode(['error' => 'Unknown action']);
}