Initial commit of EVE Industry Tracker

This commit is contained in:
clay 2025-05-22 18:02:27 -04:00
commit 43988423e0
30 changed files with 3627 additions and 0 deletions

60
.gitignore vendored Normal file
View file

@ -0,0 +1,60 @@
# Cache and config files
cache/*
config.php
.env
.env.*
.DS_Store
*.cache
# Logs and databases
*.log
*.sql
*.sqlite
logs/
# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
*.swn
*.bak
*~
# Composer dependencies
vendor/
composer.lock
# Node modules and logs
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Operating System Files
Thumbs.db
.DS_Store
._*
.Spotlight-V100
.Trashes
# Backup files
*.bak
*.backup
*.old
*.tmp
# Session files
sess_*
sessions/
# Local development files
*.local
*.dev
*.development
# Build files
*.min.css
*.min.js
!assets/*.min.css
!assets/*.min.js

72
.htaccess Normal file
View file

@ -0,0 +1,72 @@
# Enable URL rewriting
RewriteEngine On
# Force HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Remove trailing slashes
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)/$ /$1 [L,R=301]
# Remove .php extension
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.*)$ $1.php [L]
# Browser caching for static resources
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
</IfModule>
# Enable compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/plain
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType DEFLATE text/xml
AddOutputFilterByType DEFLATE text/css
AddOutputFilterByType DEFLATE application/xml
AddOutputFilterByType DEFLATE application/xhtml+xml
AddOutputFilterByType DEFLATE application/rss+xml
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE application/x-javascript
</IfModule>
# Security headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-XSS-Protection "1; mode=block"
Header set X-Frame-Options "SAMEORIGIN"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# Prevent directory listing
Options -Indexes
# Protect sensitive files
<FilesMatch "^\.">
Order allow,deny
Deny from all
</FilesMatch>
<FilesMatch "\.(json|txt|log|cache)$">
Order allow,deny
Deny from all
</FilesMatch>
# Allow access to visits.json specifically
<Files "visits.json">
Order allow,deny
Allow from all
</Files>
# Custom error pages
ErrorDocument 404 /404.php
ErrorDocument 403 /403.php
ErrorDocument 500 /500.php

0
.zed/settings.json Executable file
View file

65
404.php Normal file
View file

@ -0,0 +1,65 @@
<?php
require_once __DIR__ . "/session_bootstrap.php";
http_response_code(404);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found - EVE Industry Tracker</title>
<meta name="description" content="Page not found - EVE Industry Tracker">
<meta name="robots" content="noindex,follow">
<link rel="canonical" href="/404">
<link rel="stylesheet" href="assets/styles.min.css">
<link rel="icon" href="assets/EIJT-FAVICON.png" type="image/png">
</head>
<body>
<div class="container" style="text-align: center;">
<img src="assets/logo.png"
width="30%"
style="max-width: 300px; margin-bottom: 2em;"
alt="EVE Jobs Tracker Logo"
loading="eager">
<div class="section">
<h1 style="color: #00ffcc;">404 - Page Not Found</h1>
<p style="margin-bottom: 2em;">Sorry, the page you're looking for doesn't exist.</p>
<div class="no-jobs-message" style="text-align: left; max-width: 400px; margin: 2em auto;">
<p style="color: #00ffcc; margin-bottom: 1em;">Looking for something specific? Here are some tips:</p>
<ul style="list-style-type: disc; padding-left: 2em; margin-bottom: 2em;">
<li>Check that the URL is spelled correctly</li>
<li>Try navigating from our homepage</li>
<li>If you're logged in, try refreshing your session</li>
</ul>
</div>
<div style="display: flex; justify-content: center; margin: 2em 0;">
<a href="login.php">
<button>Log In</button>
</a>
</div>
<div class="divider"><span>or</span></div>
<div class="import-form">
<h3 style="color: #00ffcc; margin-bottom: 1em;">📂 Import a Saved Session</h3>
<form action="import.php" method="POST" enctype="multipart/form-data">
<input type="file"
name="session_file"
accept=".json"
required>
<button type="submit">Import Session</button>
</form>
</div>
</div>
</div>
<footer>
<p>Need help? Send ISK to <a href="https://evewho.com/character/95770276" target="_blank" rel="noopener">Clay's Accountant</a></p>
</footer>
<?php require_once "track_visits.php"; ?>
</body>
</html>

149
README.md Normal file
View file

@ -0,0 +1,149 @@
# EVE Industry Tracker
A lightweight PHP application for tracking EVE Online industry jobs across multiple characters in a clean, simple dashboard. No database required, built on ESI (EVE Swagger Interface).
## Features
### Core Functionality
- Monitor all industry jobs in real-time
- Multi-character support
- Blueprint inventory tracking
- Auto-refreshing countdown timers
- Session export/import system
- Mobile-responsive design
### Technical Features
- EVE SSO Authentication
- ESI token management & auto-refresh
- Local caching system
- Session-based storage
- Zero database requirements
## Requirements
- PHP 7.4+
- SSL certificate (for EVE SSO)
- Web server (Apache/Nginx)
- Required PHP extensions:
- curl
- json
- session
## Installation
1. Register at EVE Developers Portal
- Create application at https://developers.eveonline.com
- Set callback URL to `https://your-domain/callback.php`
- Required scopes:
- `esi-industry.read_character_jobs.v1`
- `esi-characters.read_blueprints.v1`
2. Server Setup
```bash
# Create cache directory
mkdir cache
chmod 755 cache
chown www-data:www-data cache
# Create config file
cp config.example.php config.php
```
3. Configure Application
```php
// config.php
define('CLIENT_ID', 'your-eve-client-id');
define('CLIENT_SECRET', 'your-eve-client-secret');
define('CALLBACK_URL', 'https://your-domain/callback.php');
```
4. Web Server Configuration
Example Nginx configuration:
```nginx
server {
listen 443 ssl;
server_name your-domain;
root /path/to/eveindy;
index index.php;
# SSL Configuration
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# PHP Processing
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
}
# Static files
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 1y;
add_header Cache-Control "public, no-transform";
}
}
```
## Project Structure
```
eveindy/
├── assets/ # Static files (CSS, images)
├── cache/ # Cache directory
├── config.php # Configuration
├── functions.php # Core functions
├── callback.php # EVE SSO handling
├── dashboard.php # Main dashboard
├── index.php # Entry point
└── session_bootstrap.php
```
## Core Functions
### Authentication
- `refresh_token()`: Token refresh handling
- `is_token_expired()`: Token validation
### Data Retrieval
- `fetch_character_jobs()`: Get industry jobs
- `fetch_character_blueprints()`: Get blueprints
- `fetch_type_names()`: Get item information
- `fetch_system_names()`: Get location data
### Caching
- `populate_blueprint_cache()`: Update blueprint cache
- `clean_cache()`: Maintain cache
- `get_cache_data()`: Read cache
- `set_cache_data()`: Write cache
## Security Features
- EVE SSO authentication
- Session-based data storage
- No permanent data storage
- Secure cookie handling
- SSL/TLS required
## Session Management
- 14-day session lifetime
- Exportable session data
- Cross-device compatibility
- Zero server-side storage
## Contributing
1. Fork the repository
2. Create feature branch
3. Commit changes
4. Push to branch
5. Create Pull Request
## License
This project is licensed under the MIT License.
## Credits
EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights reserved. Used with permission.

BIN
assets/EIJT-FAVICON.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 KiB

BIN
assets/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

378
assets/styles.css Executable file
View file

@ -0,0 +1,378 @@
/* Base styles */
body {
font-family: Arial, sans-serif;
background: #1b1b1b;
color: #d4d4d4;
margin: 0;
padding: 0;
}
/* Loader spinner for dashboard and tables */
.loader {
border: 4px solid #333 !important;
border-top: 4px solid #00ffcc !important;
border-radius: 50% !important;
width: 20px !important;
height: 20px !important;
animation: spin 1s linear infinite !important;
display: inline-block !important;
margin-left: 10px !important;
vertical-align: middle !important;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
footer {
text-align: center;
margin-top: 20px;
padding: 10px 0;
font-size: 0.9em;
color: #888;
}
footer .fa,
footer .fab,
footer .fas {
margin: 0 10px;
color: #00aaff;
transition:
color 0.3s ease,
transform 0.3s ease;
}
footer .fa:hover,
footer .fab:hover,
footer .fas:hover {
color: #00ccff;
transform: scale(1.2);
}
a {
color: #00aaff;
text-decoration: none;
}
a:hover {
color: #00ccff;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
button {
background-color: #333;
color: #fff;
padding: 10px 20px;
border: none;
cursor: pointer;
margin: 5px;
border-radius: 5px;
transition:
background-color 0.2s ease,
box-shadow 0.2s ease;
}
.dashboard-toggle button {
background-color: #333;
color: #fff;
padding: 10px 20px;
border: none;
cursor: pointer;
margin: 5px;
border-radius: 5px;
transition:
background-color 0.2s ease,
box-shadow 0.2s ease;
}
.dashboard-toggle button.active {
background-color: #00ffcc;
color: #000;
}
button:hover {
background-color: #444;
box-shadow: 0 0 8px rgba(0, 255, 255, 0.1);
}
/* Job cards */
.job {
background: #222;
margin: 10px;
padding: 10px;
border-radius: 8px;
}
.job-header {
font-weight: bold;
}
.job-details {
font-size: 0.9em;
color: #aaa;
}
/* Dashboard Table */
#jobsTable {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
table-layout: auto; /* Let columns adjust based on content */
}
#jobsTable th,
#jobsTable td,
.jobsTable th,
.jobsTable td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #333;
white-space: nowrap; /* Prevent text wrapping */
overflow: hidden; /* Ensure text stays within the cell */
text-overflow: ellipsis; /* Shorten overflow text with "..." */
min-width: 100px; /* Ensure minimum width for columns */
}
#jobsTable th,
.jobsTable th {
background-color: #2a2a2a;
color: #00ffcc;
cursor: pointer;
position: sticky;
top: 0;
z-index: 1;
}
#jobsTable tr:nth-child(even),
.jobsTable tr:nth-child(even) {
background-color: #1e1e1e;
}
#jobsTable tr:hover,
.jobsTable tr:hover {
background-color: #2c2c2c;
}
/* Top Button Row */
.buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
position: relative;
}
/* Import Form Styling */
.import-form {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 10px;
}
.import-form input[type="file"] {
margin-bottom: 10px;
padding: 8px;
background-color: #333;
color: #ddd;
border: 1px solid #444;
border-radius: 5px;
}
.import-form button {
background-color: #00ff9d;
color: #000;
padding: 10px 20px;
border: none;
border-radius: 5px;
font-weight: bold;
transition: background-color 0.2s ease;
}
.import-form button:hover {
background-color: #00b379;
}
/* Section Block Styles (Login/Import panels) */
.section {
max-width: 500px;
margin: 2em auto;
padding: 2em;
border: 1px solid #2a2a2a;
border-radius: 10px;
background: linear-gradient(145deg, #1e1e1e, #2a2a2a);
box-shadow:
0 0 20px rgba(0, 255, 200, 0.08),
0 0 40px rgba(0, 150, 255, 0.05);
transition:
background 0.3s ease,
box-shadow 0.3s ease;
}
.section:hover {
background: linear-gradient(145deg, #222, #333);
box-shadow: 0 0 25px rgba(0, 200, 255, 0.15);
}
/* Divider Styling */
.divider {
margin: 3em auto;
text-align: center;
position: relative;
}
.divider::before,
.divider::after {
content: "";
display: inline-block;
width: 30%;
height: 1px;
background: #444;
vertical-align: middle;
margin: 0 1em;
}
.divider span {
color: #888;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Button flash animation */
@keyframes flashButton {
0%,
100% {
background-color: #333;
}
50% {
background-color: #00ffcc;
color: #000;
}
}
.export-flash {
animation: flashButton 0.8s ease-in-out 3;
}
/* Responsive tweaks */
.no-jobs-message {
color: #d4d4d4;
background-color: #222;
border-left: 3px solid #00ffcc;
padding: 10px;
margin: 20px 0;
font-style: italic;
text-align: center;
}
@media (max-width: 768px) {
.buttons {
flex-direction: column;
align-items: flex-start;
}
#jobsTable th,
#jobsTable td {
font-size: 0.9em;
}
}
th.sorted-asc::after {
content: " ▲";
}
th.sorted-desc::after {
content: " ▼";
}
.soon {
background-color: #1e2b38 !important; /* Darker blue-gray for dark theme */
color: #00ffcc !important; /* Make text pop */
font-weight: bold !important;
border-left: 2px solid #00ffcc !important; /* Accent highlight */
}
.character-list {
margin-top: 20px;
}
.character-list ul {
list-style-type: none;
padding: 0;
}
.character-list li {
font-size: 1.1em;
margin: 5px 0;
}
/* Stats Table Styles */
.jobsTable {
width: 100%;
margin-top: 20px;
border-collapse: collapse;
}
.jobsTable th,
.jobsTable td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #444;
}
.jobsTable th {
background-color: #2a2a2a;
color: #00ffcc;
cursor: pointer;
position: relative;
}
.jobsTable th.sorted-asc::after {
content: " ▲";
}
.jobsTable th.sorted-desc::after {
content: " ▼";
}
.jobsTable tr:nth-child(even) {
background-color: #1e1e1e;
}
.jobsTable tr:hover {
background-color: #2c2c2c;
}
/* Responsive tables */
@media (max-width: 768px) {
.jobsTable,
.jobsTable thead,
.jobsTable tbody,
.jobsTable th,
.jobsTable td,
.jobsTable tr {
display: block;
}
.jobsTable thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
.jobsTable tr {
border: 1px solid #444;
margin-bottom: 1em;
}
.jobsTable td {
border: none;
position: relative;
padding-left: 50%;
}
.jobsTable td:before {
position: absolute;
left: 6px;
width: 45%;
white-space: nowrap;
font-weight: bold;
}
}

1
assets/styles.min.css vendored Normal file
View file

@ -0,0 +1 @@
body{font-family:Arial,sans-serif;background:#1b1b1b;color:#d4d4d4;margin:0;padding:0}@keyframes flashButton{0%,100%{background-color:#333}50%{background-color:#00ffcc;color:#000}}.export-flash{animation:flashButton .8s ease-in-out 3}.no-jobs-message{color:#00ffcc;background-color:#444;padding:10px;margin:20px 0;text-align:center;border-radius:5px;font-style:italic}footer{text-align:center;margin-top:20px;padding:10px 0;font-size:.9em;color:#888}footer .fa,footer .fab,footer .fas{margin:0 10px;color:#00aaff;transition:color .3s ease,transform .3s ease}footer .fa:hover,footer .fab:hover,footer .fas:hover{color:#00ccff;transform:scale(1.2)}a{color:#00aaff;text-decoration:none}a:hover{color:#00ccff}.container{max-width:1200px;margin:0 auto;padding:20px}button{background-color:#333;color:#fff;padding:10px 20px;border:none;cursor:pointer;margin:5px;border-radius:5px;transition:background-color .2s ease,box-shadow .2s ease}.dashboard-toggle button{background-color:#333;color:#fff;padding:10px 20px;border:none;cursor:pointer;margin:5px;border-radius:5px;transition:background-color .2s ease,box-shadow .2s ease}.dashboard-toggle button.active{background-color:#00ffcc;color:#000}button:hover{background-color:#444;box-shadow:0 0 8px rgba(0,255,255,.1)}.job{background:#222;margin:10px;padding:10px;border-radius:8px}.job-header{font-weight:700}.job-details{font-size:.9em;color:#aaa}#jobsTable,.jobsTable{width:100%;border-collapse:collapse;margin-top:20px;table-layout:auto}#jobsTable td,#jobsTable th,.jobsTable td,.jobsTable th{padding:10px;text-align:left;border-bottom:1px solid #333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:100px}#jobsTable th,.jobsTable th{background-color:#2a2a2a;color:#00ffcc;cursor:pointer;position:sticky;top:0;z-index:1}#jobsTable tr:nth-child(even),.jobsTable tr:nth-child(even){background-color:#1e1e1e}#jobsTable tr:hover,.jobsTable tr:hover{background-color:#2c2c2c}.buttons{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px;position:relative}.import-form{display:flex;flex-direction:column;align-items:center;margin-top:10px}.import-form input[type=file]{margin-bottom:10px;padding:8px;background-color:#333;color:#ddd;border:1px solid #444;border-radius:5px}.import-form button{background-color:#00ff9d;color:#000;padding:10px 20px;border:none;border-radius:5px;font-weight:700;transition:background-color .2s ease}.import-form button:hover{background-color:#00b379}.section{max-width:500px;margin:2em auto;padding:2em;border:1px solid #2a2a2a;border-radius:10px;background:linear-gradient(145deg,#1e1e1e,#2a2a2a);box-shadow:0 0 20px rgba(0,255,200,.08),0 0 40px rgba(0,150,255,.05);transition:background .3s ease,box-shadow .3s ease}.section:hover{background:linear-gradient(145deg,#222,#333);box-shadow:0 0 25px rgba(0,200,255,.15)}.divider{margin:3em auto;text-align:center;position:relative}.divider::after,.divider::before{content:"";display:inline-block;width:30%;height:1px;background:#444;vertical-align:middle;margin:0 1em}.divider span{color:#888;font-size:.8em;text-transform:uppercase;letter-spacing:1px}@media (max-width:768px){.buttons{flex-direction:column;align-items:flex-start}#jobsTable td,#jobsTable th{font-size:.9em}.jobsTable,.jobsTable tbody,.jobsTable td,.jobsTable th,.jobsTable thead,.jobsTable tr{display:block}.jobsTable thead tr{position:absolute;top:-9999px;left:-9999px}.jobsTable tr{border:1px solid #444;margin-bottom:1em}.jobsTable td{border:none;position:relative;padding-left:50%}.jobsTable td:before{position:absolute;left:6px;width:45%;white-space:nowrap;font-weight:700}}th.sorted-asc::after{content:" ▲"}th.sorted-desc::after{content:" ▼"}.soon{background-color:#1e2b38!important;color:#00ffcc!important;font-weight:700!important;border-left:2px solid #00ffcc!important}.character-list{margin-top:20px}.character-list ul{list-style-type:none;padding:0}.character-list li{font-size:1.1em;margin:5px 0}.jobsTable th.sorted-asc::after{content:" ▲"}.jobsTable th.sorted-desc::after{content:" ▼"}

56
assets/table-sort.js Normal file
View file

@ -0,0 +1,56 @@
let sortDirection = {};
function sortTable(tableId, col) {
const table = document.getElementById(tableId);
if (!table) return;
// Initialize this table's sort direction if not set
if (!sortDirection[tableId]) {
sortDirection[tableId] = {};
}
const dir = sortDirection[tableId][col] = !sortDirection[tableId][col];
const rows = Array.from(table.tBodies[0].rows);
rows.sort((a, b) => {
const aCell = a.cells[col];
const bCell = b.cells[col];
if (col === 1) { // If sorting by visit count (numeric column)
const aValue = parseInt(aCell.innerText.trim()) || 0;
const bValue = parseInt(bCell.innerText.trim()) || 0;
return dir ? bValue - aValue : aValue - bValue;
} else { // Sorting by text (browser/os/other columns)
const aText = aCell.innerText.trim();
const bText = bCell.innerText.trim();
return dir ? bText.localeCompare(aText) : aText.localeCompare(bText);
}
});
// Re-attach the sorted rows
rows.forEach(row => table.tBodies[0].appendChild(row));
// Update sorting indicators
Array.from(table.querySelectorAll('th')).forEach((th, idx) => {
th.classList.remove('sorted-asc', 'sorted-desc');
if (idx === col) {
th.classList.add(dir ? 'sorted-desc' : 'sorted-asc');
}
});
}
// Initialize table sorting when the document is loaded
document.addEventListener('DOMContentLoaded', function() {
// Apply sorting to tables with sortable class
const tables = document.querySelectorAll('.jobsTable');
tables.forEach(table => {
const tableId = table.id;
const headers = table.querySelectorAll('th');
headers.forEach((header, idx) => {
header.addEventListener('click', function() {
sortTable(tableId, idx);
});
});
});
});

286
blueprints_dashboard.php Executable file
View file

@ -0,0 +1,286 @@
<?php
require_once __DIR__ . "/session_bootstrap.php";
require_once "functions.php";
// Set a timestamp for when the page was loaded
$page_load_time = time();
// Refresh tokens only when necessary
if (isset($_SESSION["characters"]) && !empty($_SESSION["characters"])) {
// Only refresh tokens once every 5 minutes per user session
$last_token_refresh = $_SESSION['last_token_refresh'] ?? 0;
if ($page_load_time - $last_token_refresh > 300) {
foreach ($_SESSION["characters"] as $character_id => &$charData) {
if (isset($charData["refresh_token"])) {
if (
!isset($charData["access_token"]) ||
is_token_expired($charData["access_token"])
) {
$new_tokens = refresh_token($charData["refresh_token"]);
if (!empty($new_tokens["access_token"])) {
$charData["access_token"] = $new_tokens["access_token"];
} else {
error_log(
"Failed to refresh token for character ID $character_id"
);
}
}
}
}
unset($charData);
$_SESSION['last_token_refresh'] = $page_load_time;
}
}
if (!isset($_SESSION["characters"]) || empty($_SESSION["characters"])) {
echo "No characters logged in. <a href='index.php'>Go back</a>";
exit();
}
// Check if we have a cached blueprint list
$cache_key = "all_character_blueprints";
$cached_data = get_cache_data($cache_key);
if ($cached_data !== null) {
$blueprints = $cached_data;
} else {
// Fetch blueprints for all characters
$blueprints = [];
$blueprint_ids = [];
foreach ($_SESSION["characters"] as $character_id => $charData) {
$access_token = $charData["access_token"] ?? null;
if (!$access_token) {
continue;
}
$character_blueprints = fetch_character_blueprints(
$character_id,
$access_token
);
foreach ($character_blueprints as $bp) {
$key =
$bp["blueprint_type_id"] .
"-" .
$bp["material_efficiency"] .
"-" .
$bp["time_efficiency"] .
"-" .
$bp["runs"];
$blueprints[$key] = $bp;
$blueprint_ids[] = $bp["blueprint_type_id"];
}
}
$blueprint_names = fetch_type_names(array_unique($blueprint_ids));
foreach ($blueprints as &$bp) {
$bp["blueprint_name"] =
$blueprint_names[$bp["blueprint_type_id"]] ?? "Unknown Blueprint";
}
unset($bp);
// Cache the results for 10 minutes
set_cache_data($cache_key, $blueprints, 600);
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Blueprints</title>
<style>
th.sorted-asc::after { content: ""; }
th.sorted-desc::after { content: ""; }
#blueprintsTable {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
table-layout: auto;
}
#blueprintsTable th,
#blueprintsTable td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 100px;
}
#blueprintsTable th {
background-color: #2a2a2a;
color: #00ffcc;
cursor: pointer;
position: sticky;
top: 0;
z-index: 1;
}
#blueprintsTable tr:nth-child(even) {
background-color: #1e1e1e;
}
#blueprintsTable tr:hover {
background-color: #2c2c2c;
}
</style>
</head>
<body>
<h1>Blueprints</h1>
<table id="blueprintsTable" class="dashboard-table">
<thead>
<tr>
<th>Blueprint Name</th>
<th>Material Efficiency</th>
<th>Time Efficiency</th>
<th>Number of Runs</th>
<th>Runs Remaining</th>
</tr>
</thead>
<tbody>
<?php foreach ($blueprints as $bp): ?>
<tr>
<td><?php echo htmlspecialchars(
$bp["blueprint_name"]
); ?></td>
<td><?php echo htmlspecialchars(
$bp["material_efficiency"]
); ?>%</td>
<td><?php echo htmlspecialchars(
$bp["time_efficiency"]
); ?>%</td>
<td><?php echo htmlspecialchars(
$bp["runs"] == -1 ? "Original" : $bp["runs"]
); ?></td>
<td><?php echo htmlspecialchars(
$bp["runs"] == -1 ? "" : $bp["runs"]
); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<script>
document.addEventListener("DOMContentLoaded", () => {
let sortDirection = [];
document.getElementById('blueprintsTable').querySelector('thead').addEventListener('click', (event) => {
const th = event.target;
if (th.tagName === 'TH') {
const col = Array.from(th.parentNode.children).indexOf(th);
sortTable(col);
}
});
function sortTable(col) {
const table = document.getElementById("blueprintsTable");
const rows = Array.from(table.tBodies[0].rows);
const dir = (sortDirection[col] = !sortDirection[col]);
// Create a document fragment for better performance
const fragment = document.createDocumentFragment();
rows.sort((a, b) => {
const aCell = a.cells[col];
const bCell = b.cells[col];
const aText = aCell.textContent.trim();
const bText = bCell.textContent.trim();
if (!isNaN(aText) && !isNaN(bText)) {
return dir ? bText - aText : aText - bText;
}
return dir ? bText.localeCompare(aText) : aText.localeCompare(bText);
});
// Add rows to fragment
rows.forEach((row) => fragment.appendChild(row));
// Clear and append in a single operation
const tbody = table.tBodies[0];
tbody.innerHTML = '';
tbody.appendChild(fragment);
Array.from(table.querySelectorAll("th")).forEach((th, idx) => {
th.classList.remove("sorted-asc", "sorted-desc");
if (idx === col) {
th.classList.add(dir ? "sorted-desc" : "sorted-asc");
}
});
}
});
</script>
<script>
document.addEventListener("DOMContentLoaded", () => {
let sortDirection = [];
document.getElementById('blueprintsTable').querySelector('thead').addEventListener('click', (event) => {
const th = event.target;
if (th.tagName === 'TH') {
const col = Array.from(th.parentNode.children).indexOf(th);
sortTable(col);
}
});
function sortTable(col) {
const table = document.getElementById("blueprintsTable");
const rows = Array.from(table.tBodies[0].rows);
const dir = (sortDirection[col] = !sortDirection[col]);
rows.sort((a, b) => {
const aCell = a.cells[col];
const bCell = b.cells[col];
const aText = aCell.textContent.trim();
const bText = bCell.textContent.trim();
if (!isNaN(aText) && !isNaN(bText)) {
return dir ? bText - aText : aText - bText;
}
return dir ? bText.localeCompare(aText) : aText.localeCompare(bText);
});
rows.forEach((row) => table.tBodies[0].appendChild(row));
Array.from(table.querySelectorAll("th")).forEach((th, idx) => {
th.classList.remove("sorted-asc", "sorted-desc");
if (idx === col) {
th.classList.add(dir ? "sorted-desc" : "sorted-asc");
}
});
}
// Session keep-alive mechanism
function keepSessionAlive() {
// Add cache-busting parameter to prevent browser caching
fetch("keep_alive.php?t=" + Date.now())
.then(response => response.json())
.then(data => {
if (data.status !== "success") {
console.error("Failed to refresh session:", data.message);
}
})
.catch(error => console.error("Error in keep-alive request:", error));
}
// Call keepSessionAlive only if the user is active
let sessionTimer;
const resetSessionTimer = () => {
clearTimeout(sessionTimer);
sessionTimer = setTimeout(keepSessionAlive, 300000); // 5 minutes
};
// Reset timer on user activity
['mousemove', 'keydown', 'click', 'scroll'].forEach(event => {
document.addEventListener(event, resetSessionTimer, false);
});
// Initial setup
resetSessionTimer();
});
</script>
</body>
</html>

82
callback.php Executable file
View file

@ -0,0 +1,82 @@
<?php
require_once __DIR__ . "/session_bootstrap.php";
$client_id = "YOUR-EVE-CLIENT-ID";
// Replace with your ESI client secret from EVE Developer Portal
$client_secret = "YOUR-EVE-CLIENT-SECRET";
$redirect_uri = "YOUR-CALLBACK-URL"; // Example: https://your-domain.com/callback.php
// Validate the OAuth state and code
if (
!isset($_GET["code"]) ||
!isset($_GET["state"]) ||
$_GET["state"] !== $_SESSION["oauth2state"]
) {
exit("Invalid state or code");
}
$code = $_GET["code"];
$token_url = "https://login.eveonline.com/v2/oauth/token";
// Get the access and refresh tokens
$ch = curl_init($token_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt(
$ch,
CURLOPT_POSTFIELDS,
http_build_query([
"grant_type" => "authorization_code",
"code" => $code,
"redirect_uri" => $redirect_uri,
])
);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Basic " . base64_encode($client_id . ":" . $client_secret),
"Content-Type: application/x-www-form-urlencoded",
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$token_data = json_decode($response, true);
// Fail gracefully if token fetch fails
if (!is_array($token_data) || !isset($token_data["access_token"])) {
error_log("Token exchange failed: $response");
exit("Failed to retrieve access token");
}
// Use access token to get character info
$ch = curl_init("https://login.eveonline.com/oauth/verify");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $token_data["access_token"],
]);
$user_data = json_decode(curl_exec($ch), true);
curl_close($ch);
// Validate user info
if (!is_array($user_data) || !isset($user_data["CharacterID"])) {
error_log("Failed to verify character: " . json_encode($user_data));
exit("Character verification failed");
}
$character_id = $user_data["CharacterID"];
$character_name = $user_data["CharacterName"];
// Save access & refresh tokens in session
if (!isset($_SESSION["characters"][$character_id])) {
$_SESSION["characters"][$character_id] = [
"name" => $character_name,
"access_token" => $token_data["access_token"],
"refresh_token" => $token_data["refresh_token"] ?? null,
];
} else {
error_log("Character data already exists for ID: $character_id. Skipping overwrite.");
}
// Redirect back to main page
header("Location: index.php");
exit();

625
dashboard.php Executable file
View file

@ -0,0 +1,625 @@
<?php
require_once __DIR__ . "/session_bootstrap.php";
require_once "functions.php";
// Set a timestamp for when the page was loaded
$page_load_time = time();
// Check if this is an AJAX request for additional data
$is_ajax = isset($_GET["ajax"]) && $_GET["ajax"] === "1";
// Initialize job data array for progressive loading
$all_jobs = [];
$character_ids = [];
// Refresh tokens only when necessary
if (isset($_SESSION["characters"]) && !empty($_SESSION["characters"])) {
// Only refresh tokens once every 5 minutes per user session
$last_token_refresh = $_SESSION["last_token_refresh"] ?? 0;
if ($page_load_time - $last_token_refresh > 300) {
foreach ($_SESSION["characters"] as $character_id => &$charData) {
if (isset($charData["refresh_token"])) {
// Check if the access token is expired or missing
if (
!isset($charData["access_token"]) ||
is_token_expired($charData["access_token"])
) {
$new_tokens = refresh_token($charData["refresh_token"]);
if (!empty($new_tokens["access_token"])) {
// Update session with new access token
$charData["access_token"] = $new_tokens["access_token"];
} else {
error_log(
"Failed to refresh token for character ID $character_id"
);
}
}
}
}
unset($charData); // Break reference to avoid unexpected behavior
$_SESSION["last_token_refresh"] = $page_load_time;
}
// Get list of character IDs
$character_ids = array_keys($_SESSION["characters"]);
}
if (!isset($_SESSION["characters"]) || empty($_SESSION["characters"])) {
echo "No characters logged in. <a href='index.php'>Go back</a>";
exit();
}
// For AJAX requests, only fetch requested character data and return JSON
if ($is_ajax) {
$requested_char_id = isset($_GET["character_id"])
? $_GET["character_id"]
: null;
if (
$requested_char_id &&
isset($_SESSION["characters"][$requested_char_id])
) {
$charData = $_SESSION["characters"][$requested_char_id];
$access_token = $charData["access_token"] ?? null;
if ($access_token) {
$jobs = fetch_character_jobs($requested_char_id, $access_token);
// Format jobs for JSON response
$formatted_jobs = [];
foreach ($jobs as $job) {
$end_timestamp = is_numeric($job["end_time_unix"])
? $job["end_time_unix"]
: 0;
$formatted_jobs[] = [
"character" => htmlspecialchars($job["character"]),
"blueprint" => htmlspecialchars($job["blueprint"]),
"activity" => htmlspecialchars($job["activity"]),
"location" => htmlspecialchars($job["location"]),
"start_time" => htmlspecialchars($job["start_time"]),
"end_time" => htmlspecialchars($job["end_time"]),
"end_time_unix" => $end_timestamp,
"time_left" => htmlspecialchars($job["time_left"]),
];
}
header("Content-Type: application/json");
echo json_encode(["jobs" => $formatted_jobs]);
exit();
}
}
// If we get here, something went wrong
header("HTTP/1.1 400 Bad Request");
echo json_encode(["error" => "Invalid request"]);
exit();
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Industry Jobs Dashboard</title>
<style>
th.sorted-asc::after { content: ""; }
th.sorted-desc::after { content: ""; }
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-row td {
text-align: center;
padding: 20px;
background-color: #1e1e1e;
color: #d4d4d4;
border-bottom: 1px solid #333;
}
</style>
</head>
<body>
<h1>Industry Jobs</h1>
<table id="jobsTable">
<thead>
<tr>
<th>Character</th>
<th>Blueprint</th>
<th>Activity</th>
<th>Location</th>
<th>Start Time</th>
<th>End Time</th>
<th>Time Left</th>
</tr>
</thead>
<tbody>
<?php // Load data for the first character only (for fast initial load)
if (!empty($character_ids)) {
$first_char_id = $character_ids[0];
$charData = $_SESSION["characters"][$first_char_id];
$access_token = $charData["access_token"] ?? null;
if ($access_token) {
$jobs = fetch_character_jobs($first_char_id, $access_token);
if (empty($jobs)) {
echo "<table class='no-jobs-table'><tr><td>No jobs found for " . htmlspecialchars($charData["name"]) . "</td></tr></table>";
} else {
foreach ($jobs as $job) {
$end_timestamp = is_numeric($job["end_time_unix"])
? $job["end_time_unix"]
: 0;
$time_left_display = htmlspecialchars(
$job["time_left"]
);
echo "<tr>";
echo "<td>" .
htmlspecialchars($job["character"]) .
"</td>";
echo "<td>" .
htmlspecialchars($job["blueprint"]) .
"</td>";
echo "<td>" .
htmlspecialchars($job["activity"]) .
"</td>";
echo "<td>" .
htmlspecialchars($job["location"]) .
"</td>";
echo "<td>" .
htmlspecialchars($job["start_time"]) .
"</td>";
echo "<td>" .
htmlspecialchars($job["end_time"]) .
"</td>";
echo "<td class='time-left' data-endtime='$end_timestamp'>" .
$time_left_display .
"</td>";
echo "</tr>";
}
}
}
// Add loading placeholder rows for other characters
if (count($character_ids) > 1) {
foreach (array_slice($character_ids, 1) as $char_id) {
$char_name =
$_SESSION["characters"][$char_id]["name"] ??
"Character";
echo "<tr id='loading-$char_id' class='loading-row'>";
echo "<td colspan='7'>Loading jobs for " .
htmlspecialchars($char_name) .
" <div class='loader'></div></td>";
echo "</tr>";
}
}
} ?>
</tbody>
</table>
<!-- List of all logged-in toons -->
<div class="character-list">
<h2>Logged-in Characters</h2>
<table id="jobsTable">
<thead>
<tr>
<th>Name</th>
<th>Character ID</th>
</tr>
</thead>
<tbody>
<?php foreach (
$_SESSION["characters"]
as $character_id => $charData
): ?>
<tr>
<td><?php echo htmlspecialchars(
$charData["name"]
); ?></td>
<td><?php echo $character_id; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<script>
// Track page visibility to handle inactive tabs
let pageVisible = true;
document.addEventListener("visibilitychange", () => {
pageVisible = document.visibilityState === "visible";
if (pageVisible) {
// Force a data refresh if the page has been hidden for a while
const lastRefresh = localStorage.getItem('lastDashboardRefresh');
const now = Date.now();
if (lastRefresh && (now - parseInt(lastRefresh) > 1800000)) { // 30 minutes
window.location.reload();
}
}
});
// Store the last refresh time
localStorage.setItem('lastDashboardRefresh', Date.now().toString());
document.addEventListener("DOMContentLoaded", () => {
let sortDirection = [];
let animationFrameId = null;
let jobsLoading = true;
let lastSortedCol = null;
let lastSortedDir = null;
// Jobs Table Sorting (disabled while loading)
const jobsTableHead = document.getElementById('jobsTable').querySelector('thead');
jobsTableHead.addEventListener('click', (event) => {
if (jobsLoading) {
// Optionally show a tooltip or visual cue here
return;
}
const th = event.target;
if (th.tagName === 'TH') {
const col = Array.from(th.parentNode.children).indexOf(th);
sortTable('jobsTable', col);
lastSortedCol = col;
lastSortedDir = sortDirection[col];
}
});
// Visual cue: change cursor to not-allowed while loading
jobsTableHead.style.cursor = "not-allowed";
// Character Table Sorting
const characterTable = document.getElementById('characterTable');
if (characterTable) {
characterTable.querySelector('thead').addEventListener('click', (event) => {
const th = event.target;
if (th.tagName === 'TH') {
const col = Array.from(th.parentNode.children).indexOf(th);
sortTable('characterTable', col);
}
});
}
function sortTable(tableId, col) {
const table = document.getElementById(tableId);
const rows = Array.from(table.tBodies[0].rows).map(row => {
return {
element: row,
data: Array.from(row.cells).map(cell => {
// Try to convert numeric strings to numbers for sorting
const cellText = cell.textContent.trim();
const number = parseFloat(cellText.replace(/,/g, ""));
return isNaN(number) ? cellText : number;
})
};
});
const dir = sortDirection[col] = !sortDirection[col];
// Create a document fragment for better performance
const fragment = document.createDocumentFragment();
rows.sort((a, b) => {
const aText = a.data[col];
const bText = b.data[col];
// Special handling for Time Left column in jobsTable (index 6)
if (tableId === 'jobsTable' && col === 6) {
const aTime = parseInt(a.element.cells[col].dataset.endtime) || 0;
const bTime = parseInt(b.element.cells[col].dataset.endtime) || 0;
// Ensures consistent sorting for time regardless of data type
return dir
? (typeof bTime === "number" && typeof aTime === "number" ? bTime - aTime : aText.toString().localeCompare(bText.toString()))
: (typeof aTime === "number" && typeof bTime === "number" ? aTime - bTime : aText.toString().localeCompare(bText.toString()));
}
// Numeric sort if both are numbers
if (!isNaN(aText) && !isNaN(bText) && aText !== "" && bText !== "") {
return dir ? bText - aText : aText - bText;
}
// Return comparison by trying to respect data types
return dir
? (typeof bText === "number" && typeof aText === "number" ? bText - aText : bText.toString().localeCompare(aText.toString()))
: (typeof aText === "number" && typeof bText === "number" ? aText - bText : aText.toString().localeCompare(bText.toString()));
});
rows.map(row => row.element).forEach(element => fragment.appendChild(element));
const tbody = table.tBodies[0];
tbody.innerHTML = '';
tbody.appendChild(fragment);
Array.from(table.querySelectorAll("th")).forEach((th, idx) => {
th.classList.remove("sorted-asc", "sorted-desc");
if (idx === col) {
th.classList.add(dir ? "sorted-desc" : "sorted-asc");
}
});
}
function formatDuration(seconds) {
if (seconds <= 0) return "Completed";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${d > 0 ? d + "d " : ""}${h}h ${m}m ${s}s`;
}
function updateCountdowns() {
const now = Math.floor(Date.now() / 1000);
const cells = document.querySelectorAll(".time-left");
cells.forEach(cell => {
const end = parseInt(cell.dataset.endtime);
if (isNaN(end) || end === 0) return;
const secondsLeft = end - now;
// Update all timers every second
cell.textContent = formatDuration(secondsLeft);
if (secondsLeft <= 3600) {
cell.classList.add("soon");
} else {
cell.classList.remove("soon");
}
});
// Schedule next update using requestAnimationFrame for smooth animation
if (cells.length > 0) {
animationFrameId = requestAnimationFrame(() => {
setTimeout(updateCountdowns, 1000);
});
}
}
// Progressive loading of character data
let remainingCharacters = <?php echo json_encode(
array_slice($character_ids, 1)
); ?>;
let loadTimeout;
function loadNextCharacter() {
// Clear any existing timeout
if (loadTimeout) {
clearTimeout(loadTimeout);
}
// Remove any empty characters from the start
while (remainingCharacters.length > 0 && (!remainingCharacters[0] || remainingCharacters[0] === "")) {
remainingCharacters.shift();
}
if (remainingCharacters.length === 0) {
jobsLoading = false;
jobsTableHead.style.cursor = "pointer";
if (lastSortedCol !== null) {
sortTable('jobsTable', lastSortedCol);
}
return;
}
const charId = remainingCharacters.shift();
const loadingRow = document.getElementById(`loading-${charId}`);
if (!loadingRow) {
setTimeout(loadNextCharacter, 100);
return;
}
// Set a timeout for this character's loading
loadTimeout = setTimeout(() => {
console.log(`Loading timed out for character ${charId}`);
if (loadingRow) {
loadingRow.remove();
}
setTimeout(loadNextCharacter, 100);
}, 10000); // 10 second timeout
fetch(`dashboard.php?ajax=1&character_id=${charId}&t=${Date.now()}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
})
.then(data => {
clearTimeout(loadTimeout);
if (data.jobs && data.jobs.length > 0) {
const fragment = document.createDocumentFragment();
data.jobs.forEach(job => {
if (!job || !job.character) return; // Skip invalid jobs
const row = document.createElement('tr');
row.innerHTML = `
<td>${job.character || ''}</td>
<td>${job.blueprint || ''}</td>
<td>${job.activity || ''}</td>
<td>${job.location || ''}</td>
<td>${job.start_time || ''}</td>
<td>${job.end_time || ''}</td>
<td class="time-left" data-endtime="${job.end_time_unix || ''}">${job.time_left || ''}</td>
`;
fragment.appendChild(row);
});
if (loadingRow && loadingRow.parentNode) {
loadingRow.parentNode.replaceChild(fragment, loadingRow);
} else {
const tbody = document.querySelector('#jobsTable tbody');
if (tbody) tbody.appendChild(fragment);
}
} else if (loadingRow) {
loadingRow.innerHTML = '<td colspan="7">No jobs found</td>';
setTimeout(() => loadingRow.remove(), 2000);
}
setTimeout(loadNextCharacter, 100);
})
.catch(error => {
clearTimeout(loadTimeout);
console.error('Error loading character data:', error);
remainingCharacters.shift();
if (loadingRow) {
loadingRow.innerHTML = '<td colspan="7">Error loading data</td>';
setTimeout(() => loadingRow.remove(), 2000);
}
setTimeout(loadNextCharacter, 100);
});
}
// Start loading after a short delay
setTimeout(loadNextCharacter, 500);
// Retry loading a specific character
window.retryLoadCharacter = function(charId) {
const retryRow = document.querySelector(`#jobsTable tbody tr td[colspan="7"]:contains("Error loading data")`).parentNode;
retryRow.innerHTML = `<td colspan="7"><div class="loader"></div> Loading jobs...</td>`;
fetch(`dashboard.php?ajax=1&character_id=${charId}&t=${Date.now()}`)
.then(response => response.json())
.then(data => {
if (data.jobs && data.jobs.length > 0) {
const fragment = document.createDocumentFragment();
data.jobs.forEach(job => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${job.character}</td>
<td>${job.blueprint}</td>
<td>${job.activity}</td>
<td>${job.location}</td>
<td>${job.start_time}</td>
<td>${job.end_time}</td>
<td class="time-left" data-endtime="${job.end_time_unix}">${job.time_left}</td>
`;
fragment.appendChild(row);
});
retryRow.parentNode.replaceChild(fragment, retryRow);
} else {
}
})
.catch(error => {
console.error('Error retrying character data load:', error);
retryRow.innerHTML = `<td colspan="7">Error loading data. <a href="#" onclick="retryLoadCharacter('${charId}'); return false;" style="color: #00aaff;">Retry</a></td>`;
});
};
// Start the update process
updateCountdowns();
// Clean up animation frame when leaving the page
window.addEventListener('beforeunload', () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
});
// Add page reload every 60 minutes for guaranteed fresh data
setInterval(() => {
if (pageVisible) {
console.log("Scheduled page refresh for fresh data");
window.location.reload();
}
}, 60 * 60 * 1000); // 60 minutes
});
</script>
<?php // Increment visit count asynchronously to improve page load time
if (!function_exists("fastcgi_finish_request")) {
// If not using PHP-FPM, just do it inline
$fp = fopen("visits.txt", "c+");
if (flock($fp, LOCK_EX)) {
$count = (int) fread($fp, 100);
rewind($fp);
$count++;
fwrite($fp, $count);
fflush($fp);
flock($fp, LOCK_UN);
}
fclose($fp);
} else {
// Register shutdown function for faster page load
register_shutdown_function(function () {
$fp = fopen("visits.txt", "c+");
if (flock($fp, LOCK_EX)) {
$count = (int) fread($fp, 100);
rewind($fp);
$count++;
fwrite($fp, $count);
fflush($fp);
flock($fp, LOCK_UN);
}
fclose($fp);
});
} ?>
<script>
document.addEventListener("DOMContentLoaded", () => {
function keepSessionAlive() {
// Add cache-busting parameter to prevent browser caching
fetch("keep_alive.php?t=" + Date.now())
.then(response => response.json())
.then(data => {
if (data.status !== "success") {
console.error("Failed to refresh session:", data.message);
// If session keep-alive fails, reload the page
window.location.reload();
} else {
// If tokens were refreshed or cache was cleared, reload the page to get fresh data
if (data.tokens_refreshed || data.cache_cleared) {
console.log("Tokens refreshed or cache cleared, reloading for fresh data");
window.location.reload();
}
}
})
.catch(error => {
console.error("Error in keep-alive request:", error);
// On network errors, try to reload after a delay
setTimeout(() => window.location.reload(), 30000); // 30 seconds
});
}
// Call keepSessionAlive periodically
setInterval(keepSessionAlive, 300000); // Every 5 minutes
// Also call it on user activity after inactivity
let userInactive = false;
let inactivityTimer;
// Set up inactivity detection
const setInactive = () => {
userInactive = true;
inactivityTimer = setTimeout(() => {
// User has been inactive for 10 minutes
console.log("User inactive for 10 minutes");
}, 600000); // 10 minutes
};
const setActive = () => {
// If user was inactive and is now active again
if (userInactive) {
userInactive = false;
clearTimeout(inactivityTimer);
// Call keep-alive to get fresh data
keepSessionAlive();
}
};
// Set inactive after 2 minutes of no activity
setTimeout(setInactive, 120000);
// Reset activity state on user interaction
['mousemove', 'keydown', 'click', 'scroll'].forEach(event => {
document.addEventListener(event, setActive, false);
});
// Initial call to keepSessionAlive
keepSessionAlive();
});
</script>
</body>
</html>

97
eveindy.claytonia.net Normal file
View file

@ -0,0 +1,97 @@
server {
server_name eveindy.claytonia.net;
root /home/eveindy;
index index.php index.html index.htm;
# Compression
gzip on;
gzip_vary on;
gzip_min_length 1000;
gzip_comp_level 6;
gzip_types text/plain text/css application/javascript image/x-icon application/json
text/xml application/xml application/xml+rss text/javascript;
# Logs
access_log /var/log/nginx/eveindy_access.log;
error_log /var/log/nginx/eveindy_error.log;
# Error pages
error_page 404 /404.php;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Allow visits.json (must be before the json block)
location = /visits.json {
allow all;
}
# Static files with caching
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ {
expires 1y;
add_header Cache-Control "public, no-transform";
access_log off;
try_files $uri =404;
}
# PHP handling
location ~ \.php$ {
try_files $uri =404;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Preload critical assets
add_header Link "</assets/styles.min.css>; rel=preload; as=style" always;
add_header Link "</assets/logo.png>; rel=preload; as=image" always;
}
# Block sensitive files
location ~ \.(json|txt|log|cache)$ {
deny all;
access_log off;
}
# Protect dot files
location ~ /\. {
deny all;
access_log off;
}
# Default location
location / {
try_files $uri $uri/ $uri.php =404;
}
# Remove www
if ($host ~* ^www\.(.*)) {
return 301 https://$1$request_uri;
}
# Remove trailing slashes
rewrite ^/(.*)/$ /$1 permanent;
# SSL Configuration
listen 443 ssl;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# SSL optimizations
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_prefer_server_ciphers on;
}
# HTTP to HTTPS redirect
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}

39
export.php Executable file
View file

@ -0,0 +1,39 @@
<?php
session_start();
// Make sure the session contains data to export
if (empty($_SESSION)) {
http_response_code(400);
header("Content-Type: application/json");
echo json_encode(["error" => "No session data available to export"]);
exit();
}
// Clean up the session data before export
if (isset($_SESSION["characters"])) {
// Remove any entries with empty character IDs
if (isset($_SESSION["characters"][""])) {
unset($_SESSION["characters"][""]);
}
// Create a clean copy for export (don't modify the actual session)
$export_data = $_SESSION;
} else {
$export_data = $_SESSION;
}
// Generate descriptive filename with timestamp for user download
$date_time = date("Y-m-d");
$download_filename = "eve_indy_job_tracker_backup_{$date_time}.json";
// Set headers for download
header("Content-Type: application/json");
header('Content-Disposition: attachment; filename="' . $download_filename . '"');
header("Cache-Control: no-store, no-cache, must-revalidate");
header("Pragma: no-cache");
// Output session data as pretty-printed JSON
echo json_encode($export_data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
error_log("Export completed successfully for session download.");
exit();
?>

795
functions.php Executable file
View file

@ -0,0 +1,795 @@
<?php
require_once __DIR__ . "/session_bootstrap.php";
define("ESI_CLIENT_ID", "YOUR-EVE-CLIENT-ID");
// Replace with your ESI client secret from EVE Developer Portal
define("ESI_CLIENT_SECRET", "YOUR-EVE-CLIENT-SECRET");
function fetch_character_jobs($character_id, &$access_token)
{
// Check cache first
$cache_key = "character_jobs_{$character_id}";
$cache = get_cache_data($cache_key);
if ($cache !== null) {
return $cache;
}
$esi_url = "https://esi.evetech.net/latest/characters/{$character_id}/industry/jobs/?include_completed=false";
$headers = [
"Authorization: Bearer $access_token",
"Accept: application/json",
];
$response = esi_call($esi_url, $headers);
// Token expired? Try to refresh it.
if (
$response["http_code"] === 401 &&
isset($_SESSION["characters"][$character_id]["refresh_token"])
) {
$new_tokens = refresh_token(
$_SESSION["characters"][$character_id]["refresh_token"]
);
if (!empty($new_tokens["access_token"])) {
// Save refreshed token
$_SESSION["characters"][$character_id]["access_token"] =
$new_tokens["access_token"];
$access_token = $new_tokens["access_token"];
$headers[0] = "Authorization: Bearer $access_token";
// Retry the API call
$response = esi_call($esi_url, $headers);
} else {
error_log(
"ERROR: Token refresh failed for character ID $character_id."
);
return []; // Avoid crashing page
}
}
// Still bad? Bail out
if ($response["http_code"] !== 200) {
error_log(
"ERROR: ESI call failed after token refresh for character ID $character_id. HTTP {$response["http_code"]}"
);
return [];
}
$char_jobs = json_decode($response["body"], true);
if (!is_array($char_jobs)) {
$char_jobs = [];
}
// Skip corporation jobs if no character jobs (faster load)
if (empty($char_jobs)) {
// Cache empty results for 60 seconds (shorter time for empty results)
set_cache_data($cache_key, [], 60);
return [];
}
// Fetch corporation jobs if possible
$corp_jobs = [];
$character_info = fetch_character_info($character_id, $access_token);
if (isset($character_info["corporation_id"])) {
$corp_jobs = fetch_corporation_jobs(
$character_info["corporation_id"],
$access_token
);
}
// Filter out corp jobs where character_id is not in the $_SESSION["characters"]
$filtered_corp_jobs = [];
foreach ($corp_jobs as $job) {
if (
isset($job["installer_id"]) &&
$job["installer_id"] == $character_id
) {
$filtered_corp_jobs[] = $job;
}
}
$all_jobs = array_merge($char_jobs, $filtered_corp_jobs);
// If still no jobs after merging, return empty results
if (empty($all_jobs)) {
// Cache empty results for 60 seconds (shorter time for empty results)
set_cache_data($cache_key, [], 60);
return [];
}
// Extract IDs and fetch names in batch
$blueprint_ids = array_unique(array_column($all_jobs, "blueprint_type_id"));
$system_ids = array_unique(array_column($all_jobs, "system_id"));
// Fetch names in parallel using promises
$blueprint_names = [];
$system_names = [];
// Optimize by caching character name
$character_name = $_SESSION["characters"][$character_id]["name"] ?? "Unknown";
// Pre-define activity mapping
$activity_map = [
1 => "Manufacturing",
3 => "TE Research",
4 => "ME Research",
5 => "Copying",
7 => "Reverse Engineering",
8 => "Invention",
9 => "Reaction",
11 => "Reaction",
];
// Process blueprint and system IDs in smaller batches for better performance
if (!empty($blueprint_ids)) {
$blueprint_names = fetch_type_names($blueprint_ids);
}
if (!empty($system_ids)) {
$system_names = fetch_system_names($system_ids);
}
$results = [];
$current_time = time();
foreach ($all_jobs as $job) {
$start_time = format_time($job["start_date"]);
$end_time = isset($job["end_date"])
? format_time($job["end_date"])
: "";
$end_timestamp = isset($job["end_date"])
? strtotime($job["end_date"])
: null;
$time_left =
$end_timestamp && $job["status"] !== "delivered"
? max(0, $end_timestamp - $current_time)
: "Completed";
$system_name = $system_names[$job["system_id"]] ?? "Private Structure";
$blueprint_name =
$blueprint_names[$job["blueprint_type_id"]] ??
$job["blueprint_type_id"];
$results[] = [
"character" =>
$character_name .
(isset($job["installer_id"]) &&
$job["installer_id"] != $character_id
? " (Corp)"
: ""),
"blueprint" => $blueprint_name,
"activity" =>
$activity_map[$job["activity_id"]] ??
"Activity {$job["activity_id"]}",
"status" => $job["status"],
"location" => $system_name,
"start_time" => $start_time,
"end_time" => $end_time,
"end_time_unix" => $end_timestamp,
"time_left" => is_numeric($time_left)
? gmdate("H:i:s", $time_left)
: $time_left,
];
}
// Cache results with dynamic TTL based on the nearest job completion
$min_time_left = PHP_INT_MAX;
foreach ($results as $job) {
if (is_numeric($job["end_time_unix"]) && $job["end_time_unix"] > $current_time) {
$time_until_completion = $job["end_time_unix"] - $current_time;
if ($time_until_completion < $min_time_left) {
$min_time_left = $time_until_completion;
}
}
}
// Cache until the next job completes (min 60 seconds, max 300 seconds)
$cache_ttl = ($min_time_left < PHP_INT_MAX)
? min(max(60, $min_time_left), 300)
: 300;
set_cache_data($cache_key, $results, $cache_ttl);
return $results;
}
function fetch_corporation_jobs($corporation_id, $access_token)
{
// Check cache first
$cache_key = "corporation_jobs_{$corporation_id}";
$cache = get_cache_data($cache_key);
if ($cache !== null) {
return $cache;
}
$esi_url = "https://esi.evetech.net/latest/corporations/{$corporation_id}/industry/jobs/?include_completed=false";
$headers = [
"Authorization: Bearer $access_token",
"Accept: application/json",
];
$response = esi_call($esi_url, $headers);
if ($response["http_code"] !== 200) {
error_log("Corp job fetch failed: " . $response["body"]);
return [];
}
$jobs = json_decode($response["body"], true);
$results = is_array($jobs) ? $jobs : [];
// Cache the results for 5 minutes
set_cache_data($cache_key, $results, 300);
return $results;
}
function fetch_character_info($character_id, $access_token)
{
// Check cache first
$cache_key = "character_info_{$character_id}";
$cache = get_cache_data($cache_key);
if ($cache !== null) {
return $cache;
}
$url = "https://esi.evetech.net/latest/characters/{$character_id}/";
$headers = [
"Authorization: Bearer $access_token",
"Accept: application/json",
];
$response = esi_call($url, $headers);
$info = json_decode($response["body"], true);
if (is_array($info)) {
// Cache character info for 1 hour (rarely changes)
set_cache_data($cache_key, $info, 3600);
}
return $info;
}
function format_time($iso_string)
{
static $cache = [];
// Use cached result if available
if (isset($cache[$iso_string])) {
return $cache[$iso_string];
}
$dt = DateTime::createFromFormat(DateTime::ATOM, $iso_string);
$result = $dt ? $dt->format("M j, Y H:i") : $iso_string;
// Cache the result
$cache[$iso_string] = $result;
return $result;
}
function fetch_type_names($type_ids) {
if (empty($type_ids)) {
return [];
}
// Check if cleanup is needed
$last_cleanup = get_last_cleanup_time();
$thirty_days_ago = strtotime("-30 days");
if (!$last_cleanup || strtotime($last_cleanup) < $thirty_days_ago) {
// Call populate_cache.php to refresh the cache
$populate_cache_script = __DIR__ . "/populate_cache.php";
if (file_exists($populate_cache_script)) {
exec("php " . escapeshellarg($populate_cache_script));
}
update_last_cleanup_time(); // Update the last cleanup timestamp
}
// Load cache
$cache_file = __DIR__ . "/cache/blueprint_cache.json";
$cache = file_exists($cache_file) ? json_decode(file_get_contents($cache_file), true) : [];
// Check cache first
$cached_names = [];
$ids_to_fetch = [];
foreach ($type_ids as $id) {
if (isset($cache[$id])) {
$cached_names[$id] = $cache[$id];
} else {
$ids_to_fetch[] = $id;
}
}
// Fetch missing IDs from API
if (!empty($ids_to_fetch)) {
$url = "https://esi.evetech.net/latest/universe/names/";
$chunks = array_chunk($ids_to_fetch, 1000); // Split into chunks of 1000 IDs
foreach ($chunks as $chunk) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(array_values($chunk)));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code === 200) {
$data = json_decode($response, true);
foreach ($data as $entry) {
if (isset($entry["id"], $entry["name"])) {
$cached_names[$entry["id"]] = $entry["name"];
$cache[$entry["id"]] = $entry["name"]; // Update cache
}
}
} else {
error_log("Failed to fetch type names. HTTP Code: $http_code");
}
}
}
// Save updated cache
file_put_contents($cache_file, json_encode($cache, JSON_PRETTY_PRINT));
return $cached_names;
}
function populate_blueprint_cache() {
echo "Fetching all type IDs...\n";
$all_type_ids = get_all_type_ids();
if (empty($all_type_ids)) {
echo "No type IDs retrieved.\n";
return;
}
echo "Filtering blueprint IDs...\n";
$blueprint_ids = filter_blueprint_ids($all_type_ids);
if (empty($blueprint_ids)) {
echo "No blueprint IDs found.\n";
return;
}
echo "Fetching blueprint names...\n";
$cached_data = [];
$chunks = array_chunk($blueprint_ids, 1000);
foreach ($chunks as $chunk) {
$batch_names = fetch_type_names($chunk);
foreach ($batch_names as $id => $name) {
$cached_data[$id] = $name;
}
usleep(250000);
}
// Save to JSON cache file
$cache_dir = __DIR__ . "/cache";
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0775, true);
}
$cache_file = $cache_dir . "/blueprint_cache.json";
file_put_contents(
$cache_file,
json_encode($cached_data, JSON_PRETTY_PRINT)
);
echo "Cache populated with " . count($cached_data) . " blueprint names.\n";
}
function get_all_type_ids() {
$all_ids = [];
$page = 1;
do {
$url = "https://esi.evetech.net/latest/universe/types/?page=$page";
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code !== 200) {
echo "Failed to fetch type IDs on page $page. HTTP Code: $http_code\n";
break;
}
$ids = json_decode($response, true);
if (empty($ids)) {
break;
}
$all_ids = array_merge($all_ids, $ids);
$page++;
usleep(250000); // Avoid API rate limit
} while (true);
return $all_ids;
}
function filter_blueprint_ids($ids) {
$blueprint_ids = [];
$chunks = array_chunk($ids, 1000);
foreach ($chunks as $chunk) {
$names = fetch_type_names($chunk);
foreach ($names as $id => $name) {
if (str_ends_with($name, "Blueprint")) {
$blueprint_ids[] = $id;
}
}
usleep(250000);
}
return $blueprint_ids;
}
function fetch_system_names($system_ids)
{
if (empty($system_ids)) {
return [];
}
// Generate a cache key based on the sorted system IDs
sort($system_ids);
$cache_key = "system_names_" . md5(json_encode($system_ids));
$cache = get_cache_data($cache_key);
if ($cache !== null) {
return $cache;
}
// Check if we already have some of these system names cached individually
$names = [];
$ids_to_fetch = [];
foreach ($system_ids as $id) {
$individual_cache_key = "system_name_$id";
$cached_name = get_cache_data($individual_cache_key);
if ($cached_name !== null) {
$names[$id] = $cached_name;
} else {
$ids_to_fetch[] = $id;
}
}
// If we have all system names from individual caches, return them
if (empty($ids_to_fetch)) {
// Still cache the combined result
set_cache_data($cache_key, $names, 86400);
return $names;
}
// Only fetch the IDs we don't already have cached
$url = "https://esi.evetech.net/latest/universe/names/";
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt(
$ch,
CURLOPT_POSTFIELDS,
json_encode(array_values($ids_to_fetch))
);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
if (is_array($data)) {
foreach ($data as $entry) {
if (isset($entry["id"]) && isset($entry["name"])) {
$names[$entry["id"]] = $entry["name"];
// Cache individual system names
$individual_cache_key = "system_name_{$entry["id"]}";
set_cache_data($individual_cache_key, $entry["name"], 86400 * 7); // Cache for a week
}
}
}
// Cache system names for 24 hours (they don't change)
set_cache_data($cache_key, $names, 86400);
return $names;
}
function refresh_token($refresh_token)
{
// Initialize cURL
$ch = curl_init("https://login.eveonline.com/v2/oauth/token");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Basic " . base64_encode(ESI_CLIENT_ID . ":" . ESI_CLIENT_SECRET),
"Content-Type: application/x-www-form-urlencoded",
]);
curl_setopt(
$ch,
CURLOPT_POSTFIELDS,
http_build_query([
"grant_type" => "refresh_token",
"refresh_token" => $refresh_token,
])
);
// Execute the request
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); // Get HTTP status code
$curl_error = curl_error($ch); // Capture cURL error if any
curl_close($ch);
// Handle cURL errors
if ($response === false) {
error_log("cURL error: $curl_error");
return [];
}
// Handle HTTP errors
if ($http_code !== 200) {
error_log("ERROR: Token refresh failed. HTTP $http_code. Response: $response");
// Specific handling for common HTTP errors
if ($http_code === 400) {
error_log("Bad Request: Check the refresh token or request parameters.");
} elseif ($http_code === 401) {
error_log("Unauthorized: Check the client ID and secret.");
} elseif ($http_code === 429) {
error_log("Rate limit exceeded. Retry after delay.");
} elseif ($http_code >= 500) {
error_log("Server error. Retry after a delay.");
}
return [];
}
// Decode the response
$data = json_decode($response, true);
// Validate the response
if (!isset($data["access_token"])) {
error_log("ERROR: Token refresh failed. Invalid response: $response");
return [];
}
// Save the new refresh token if provided
if (isset($data["refresh_token"])) {
$_SESSION["characters"][$character_id]["refresh_token"] = $data["refresh_token"];
}
return $data;
}
function esi_call($url, $headers)
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$body = substr($response, $header_size);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
"http_code" => $http_code,
"body" => $body,
];
}
function is_token_expired($access_token, $buffer_time = 300)
{
// Split the token into its three parts (header, payload, signature)
$parts = explode(".", $access_token);
// Validate that the token has exactly three parts
if (count($parts) !== 3) {
error_log("Invalid JWT format: $access_token");
return true; // Assume expired if the format is invalid
}
// Decode the payload (second part of the JWT)
$payload = json_decode(base64_decode($parts[1]), true);
// Validate that the payload is a valid JSON object
if (!is_array($payload)) {
error_log("Malformed JWT payload: " . base64_decode($parts[1]));
return true; // Assume expired if the payload is malformed
}
// Check if the "exp" (expiration) claim exists
if (!isset($payload["exp"])) {
error_log("JWT missing 'exp' claim: " . json_encode($payload));
return true; // Assume expired if the expiration claim is missing
}
// Compare the expiration time with the current time, considering the buffer time
// This prevents tokens from expiring during a user session by refreshing them early
return $payload["exp"] < (time() + $buffer_time);
}
function fetch_character_blueprints($character_id, $access_token)
{
// Check cache first
$cache_key = "character_blueprints_{$character_id}";
$cache = get_cache_data($cache_key);
if ($cache !== null) {
return $cache;
}
$esi_url = "https://esi.evetech.net/latest/characters/{$character_id}/blueprints/";
$headers = [
"Authorization: Bearer $access_token",
"Accept: application/json",
];
$response = esi_call($esi_url, $headers);
if ($response["http_code"] !== 200) {
error_log(
"Blueprint fetch failed for character ID $character_id: " .
$response["body"]
);
return [];
}
$blueprints = json_decode($response["body"], true);
$results = [];
foreach ($blueprints as $bp) {
$results[] = [
"blueprint_type_id" => $bp["type_id"],
"blueprint_name" => $bp["type_id"], // Placeholder, replace with actual name lookup if needed
"material_efficiency" => $bp["material_efficiency"],
"time_efficiency" => $bp["time_efficiency"],
"runs" => $bp["runs"],
"quantity" => $bp["quantity"] ?? 1,
];
}
// Cache the results for 10 minutes (blueprints change less frequently)
set_cache_data($cache_key, $results, 600);
return $results;
}
function get_cached_name($id) {
$cache_file = __DIR__ . "/cache/blueprint_cache.json";
if (!file_exists($cache_file)) {
return null;
}
$cache = json_decode(file_get_contents($cache_file), true);
return $cache[$id]["name"] ?? null;
}
function cache_name($id, $name) {
$cache_file = __DIR__ . "/cache/blueprint_cache.json";
$cache = file_exists($cache_file) ? json_decode(file_get_contents($cache_file), true) : [];
$cache[$id] = [
"name" => $name,
"last_updated" => gmdate("Y-m-d\TH:i:s\Z")
];
file_put_contents($cache_file, json_encode($cache, JSON_PRETTY_PRINT));
}
function clean_cache($expiry_days = 30) {
$cache_file = __DIR__ . "/cache/blueprint_cache.json";
if (!file_exists($cache_file)) {
return;
}
$cache = json_decode(file_get_contents($cache_file), true);
$expiry_time = strtotime("-$expiry_days days");
foreach ($cache as $id => $entry) {
if (strtotime($entry["last_updated"]) < $expiry_time) {
unset($cache[$id]);
}
}
file_put_contents($cache_file, json_encode($cache, JSON_PRETTY_PRINT));
}
function get_last_cleanup_time() {
$file = __DIR__ . "/cache/last_cleanup.json";
if (!file_exists($file)) {
return null;
}
$data = json_decode(file_get_contents($file), true);
return $data["last_cleanup"] ?? null;
}
function update_last_cleanup_time() {
$file = __DIR__ . "/cache/last_cleanup.json";
$data = ["last_cleanup" => gmdate("Y-m-d\TH:i:s\Z")];
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
// Cache functions
function get_cache_data($key) {
static $cache_data = null;
static $loaded = false;
$cache_dir = __DIR__ . "/cache";
$cache_file = $cache_dir . "/api_cache.json";
// Load cache data only once per request
if (!$loaded) {
if (file_exists($cache_file)) {
$cache_data = json_decode(file_get_contents($cache_file), true) ?: [];
} else {
$cache_data = [];
}
$loaded = true;
}
if (!isset($cache_data[$key]) || $cache_data[$key]['expires'] < time()) {
return null;
}
return $cache_data[$key]['data'];
}
function set_cache_data($key, $data, $ttl = 300) {
static $cache_data = null;
static $cache_modified = false;
static $loaded = false;
$cache_dir = __DIR__ . "/cache";
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
$cache_file = $cache_dir . "/api_cache.json";
// Load cache data only once per request
if (!$loaded) {
if (file_exists($cache_file)) {
$cache_data = json_decode(file_get_contents($cache_file), true) ?: [];
} else {
$cache_data = [];
}
$loaded = true;
}
if (!is_array($cache_data)) {
$cache_data = [];
}
// Clean expired cache entries (only occasionally to improve performance)
if (rand(1, 10) === 1) {
foreach ($cache_data as $cache_key => $cache_entry) {
if ($cache_entry['expires'] < time()) {
unset($cache_data[$cache_key]);
$cache_modified = true;
}
}
}
// Update cache with new data
$cache_data[$key] = [
'data' => $data,
'expires' => time() + $ttl
];
$cache_modified = true;
// Use register_shutdown_function to write cache only once at the end of the request
if ($cache_modified && !isset($GLOBALS['cache_shutdown_registered'])) {
$GLOBALS['cache_shutdown_registered'] = true;
register_shutdown_function(function() use ($cache_file) {
global $cache_data;
if (is_array($cache_data)) {
file_put_contents($cache_file, json_encode($cache_data));
}
});
}
}
?>

88
import.php Executable file
View file

@ -0,0 +1,88 @@
<?php
require_once __DIR__ . "/session_bootstrap.php";
require_once "functions.php"; // Include your existing functions
// Handle POST with file upload
if ($_SERVER["REQUEST_METHOD"] === "POST" && isset($_FILES["session_file"])) {
$raw = file_get_contents($_FILES["session_file"]["tmp_name"]);
$imported = json_decode($raw, true);
if (!is_array($imported)) {
http_response_code(400);
echo json_encode(["error" => "Invalid JSON session file"]);
exit();
}
// Debug: Log the original import data structure
error_log("Import data structure: " . print_r(array_keys($imported), true));
if (isset($imported["characters"])) {
error_log("Characters in import: " . print_r(array_keys($imported["characters"]), true));
}
// Thorough cleaning of the imported data
if (isset($imported["characters"]) && is_array($imported["characters"])) {
foreach (array_keys($imported["characters"]) as $charID) {
// Remove any entries with empty or non-numeric character IDs
if (empty($charID) || $charID === "" || !is_numeric($charID)) {
error_log("Removing invalid character ID: '$charID'");
unset($imported["characters"][$charID]);
}
}
}
// Only assign cleaned data to session
$_SESSION = $imported;
// Refresh tokens for each character if needed
if (isset($_SESSION["characters"]) && is_array($_SESSION["characters"])) {
// Extra validation check
foreach (array_keys($_SESSION["characters"]) as $charID) {
if (empty($charID) || $charID === "") {
error_log("Removing empty character ID from session");
unset($_SESSION["characters"][$charID]);
}
}
foreach ($_SESSION["characters"] as $charID => &$char) {
if (
empty($char["access_token"]) ||
!empty($char["refresh_token"]) // Always refresh on import
) {
error_log("Refreshing token for character $charID");
$new_tokens = refresh_token($char["refresh_token"]);
if (!empty($new_tokens["access_token"])) {
$char["access_token"] = $new_tokens["access_token"];
if (!empty($new_tokens["refresh_token"])) {
$char["refresh_token"] = $new_tokens["refresh_token"];
}
} else {
error_log("Failed to refresh token for character $charID");
}
}
}
unset($char); // Break the reference to avoid unexpected behavior
// Final validation to ensure no empty entries
if (isset($_SESSION["characters"][""])) {
error_log("Final cleanup: Removing empty character ID");
unset($_SESSION["characters"][""]);
}
}
// Log the final session structure
error_log("Final character count: " . count($_SESSION["characters"]));
error_log("Character IDs after import: " . implode(", ", array_keys($_SESSION["characters"])));
header("Content-Type: application/json");
echo json_encode([
"status" => "success",
"characters_imported" => count($_SESSION["characters"]),
]);
// After successful import, redirect to index.php
header("Location: index.php");
exit();
} else {
http_response_code(400);
echo json_encode(["error" => "POST with session_file required"]);
exit();
}

194
index.php Executable file
View file

@ -0,0 +1,194 @@
<?php require_once __DIR__ . "/session_bootstrap.php"; ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EVE Industry Tracker</title>
<!-- Styles -->
<link rel="stylesheet" href="assets/styles.min.css">
<!-- Meta Description -->
<meta name="description" content="Track and manage your EVE Online industry jobs efficiently. Monitor manufacturing, research, and industry activities across multiple characters in one dashboard.">
<!-- Keywords -->
<meta name="keywords" content="EVE Online, industry tracker, manufacturing, research, industry jobs, EVE Online tools">
<!-- Favicon -->
<link rel="icon" href="/assets/EIJT-FAVICON.png" type="image/png">
<!-- Preload -->
<link rel="preload" href="assets/logo.png" as="image">
<!-- Open Graph (for sharing links) -->
<meta property="og:title" content="EVE Industry Tracker - Manage Your EVE Online Industry">
<meta property="og:description" content="Track and optimize your EVE Online industry jobs across multiple characters. Free tool for manufacturing, research, and blueprint management.">
<meta property="og:image" content="assets/EIJT-FAVICON.png">
<meta property="og:type" content="website">
<meta property="og:url" content="https://your-domain/">
<meta property="og:site_name" content="EVE Industry Tracker">
<meta property="og:locale" content="en_US">
<!-- Canonical URL -->
<link rel="canonical" href="/">
<!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="EVE Industry Tracker - EVE Online Industry Management">
<meta name="twitter:description" content="Free tool to track and optimize your EVE Online industry jobs. Manage manufacturing, research, and blueprints across multiple characters.">
<meta name="twitter:image" content="assets/logo.png">
<meta name="twitter:site" content="@your-twitter-handle">
<meta name="twitter:creator" content="@your-twitter-handle">
<link rel="preload" href="assets/styles.min.css" as="style">
<!-- Mobile & Theming -->
<meta name="theme-color" content="#1b1b1b">
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Basic Security -->
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="Referrer-Policy" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" media="print" onload="this.media='all'">
<style>
/* Styles moved to styles.css */
</style>
<!-- Schema.org markup for rich snippets -->
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "WebApplication",
"name": "EVE Industry Tracker",
"applicationCategory": "Game Tool",
"operatingSystem": "Any",
"url": "https://your-domain/",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"description": "Track and manage your EVE Online industry jobs efficiently. Monitor manufacturing, research, and industry activities across multiple characters in one dashboard.",
"browserRequirements": "Requires JavaScript. Any modern web browser.",
"softwareVersion": "1.0",
"applicationSubCategory": "EVE Online Tools",
"featureList": [
"Industry jobs tracking",
"Multi-character support",
"Blueprint management",
"Real-time updates",
"Session export/import"
]
}
</script>
</head>
<body>
<div class="container" style="text-align: center; padding: 2em;">
<img src="assets/logo.png" width="50%" style="max-width: 600px; margin-bottom: 2em;" alt="EVE Jobs Tracker Logo" loading="eager">
<?php if (
!isset($_SESSION["characters"]) ||
count($_SESSION["characters"]) === 0
): ?>
<div class="section">
<a href="login.php">
<button style="padding: 0.75em 1.5em; font-size: 1.1em;">Log In</button>
</a>
</div>
<div class="divider"><span>or</span></div>
<div class="section">
<h3>📂 Import a Saved Session</h3>
<form action="import.php" method="POST" enctype="multipart/form-data" style="margin-top: 1em;">
<input type="file" name="session_file" accept=".json" required style="margin-bottom: 1em;">
<br>
<button type="submit" style="padding: 0.5em 1.2em;">Import Session</button>
</form>
</div>
<?php else: ?>
<div class="buttons" style="margin-top: 0.1em;">
<a href="login.php"><button>Add Another Character</button></a>
<a href="logout.php"><button>Logout All</button></a>
<a href="export.php"><button id="exportButton">Export Save</button></a>
</div>
<div class="dashboard-toggle" style="margin-top: 1em;">
<button id="jobsDashboardButton" class="active">Industry Jobs</button>
<button id="blueprintsDashboardButton">Blueprints</button>
</div>
<div id="jobsDashboard" style="margin-top: 1em;">
<?php include "dashboard.php"; ?>
</div>
<div id="blueprintsDashboard" style="margin-top: 1em; display: none;">
<?php include "blueprints_dashboard.php"; ?>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
// Make export button flash only on first page load
const exportButton = document.getElementById("exportButton");
if (exportButton && !sessionStorage.getItem('exportButtonFlashed')) {
exportButton.classList.add('export-flash');
sessionStorage.setItem('exportButtonFlashed', 'true');
}
const jobsButton = document.getElementById("jobsDashboardButton");
const blueprintsButton = document.getElementById("blueprintsDashboardButton");
const jobsDashboard = document.getElementById("jobsDashboard");
const blueprintsDashboard = document.getElementById("blueprintsDashboard");
jobsButton.addEventListener("click", () => {
jobsDashboard.style.display = "block";
blueprintsDashboard.style.display = "none";
jobsButton.classList.add("active");
blueprintsButton.classList.remove("active");
});
blueprintsButton.addEventListener("click", () => {
jobsDashboard.style.display = "none";
blueprintsDashboard.style.display = "block";
blueprintsButton.classList.add("active");
jobsButton.classList.remove("active");
});
});
</script>
<?php endif; ?>
</div>
<!-- Footer -->
<footer style="text-align: center; padding: 2em 0; background-color: #222; color: #d4d4d4;">
<p>If you find this tool useful, please consider donating in-game!</p>
<p>Send ISK to <a href="https://evewho.com/character/95770276" target="_blank" style="color: #00aaff;">Clay's Accountant</a>.</p>
<p>Thank you for your support! <a href="views.php">🙏</a></p>
<p style="text-align: right; font-size: 0.7em; margin-top: 1em; color: #666;">EVE Online and all related materials are trademarks or intellectual property of CCP Games. </p>
</footer>
<!-- stats -->
<?php require_once "track_visits.php"; ?>
</body>
</html>

89
keep_alive.php Normal file
View file

@ -0,0 +1,89 @@
<?php
require_once __DIR__ . "/session_bootstrap.php";
require_once __DIR__ . "/functions.php";
// Refresh session and tokens
if (session_status() === PHP_SESSION_ACTIVE) {
$tokens_refreshed = false;
$cache_cleared = false;
// Check if we need to refresh tokens
if (isset($_SESSION["characters"]) && !empty($_SESSION["characters"])) {
$page_load_time = time();
$last_token_refresh = $_SESSION['last_token_refresh'] ?? 0;
// Only refresh tokens if it's been at least 5 minutes since the last refresh
if ($page_load_time - $last_token_refresh > 300) {
foreach ($_SESSION["characters"] as $character_id => &$charData) {
// Skip invalid character entries
if (empty($character_id)) continue;
if (isset($charData["refresh_token"])) {
// Check if the access token is expired or missing
if (!isset($charData["access_token"]) ||
is_token_expired($charData["access_token"])) {
$new_tokens = refresh_token($charData["refresh_token"]);
if (!empty($new_tokens["access_token"])) {
$charData["access_token"] = $new_tokens["access_token"];
$tokens_refreshed = true;
}
}
}
}
unset($charData); // Break reference to avoid unexpected behavior
$_SESSION['last_token_refresh'] = $page_load_time;
// Only clear cache if tokens were refreshed
if ($tokens_refreshed) {
// Clear specific cache entries
$cache_keys = [
'character_jobs_',
'corporation_jobs_',
'all_character_blueprints'
];
foreach ($cache_keys as $prefix) {
$cleared = clear_cache_by_prefix($prefix);
if ($cleared) $cache_cleared = true;
}
}
}
}
echo json_encode([
"status" => "success",
"message" => "Session refreshed",
"tokens_refreshed" => $tokens_refreshed,
"cache_cleared" => $cache_cleared,
"timestamp" => time()
]);
} else {
echo json_encode(["status" => "error", "message" => "Session not active"]);
}
// Function to clear cache entries by prefix
function clear_cache_by_prefix($prefix) {
$cache_dir = __DIR__ . "/cache";
$cache_file = $cache_dir . "/api_cache.json";
if (!file_exists($cache_file)) {
return false;
}
$cache = json_decode(file_get_contents($cache_file), true) ?: [];
$cleared = false;
foreach ($cache as $key => $value) {
if (strpos($key, $prefix) === 0) {
unset($cache[$key]);
$cleared = true;
}
}
if ($cleared) {
file_put_contents($cache_file, json_encode($cache));
}
return $cleared;
}
?>

31
login.php Executable file
View file

@ -0,0 +1,31 @@
<?php
require_once __DIR__ . "/session_bootstrap.php";
$client_id = "990297a28f9c49cabba56aa7ad2704a2";
$redirect_uri = "https://eveindy.claytonia.net/callback.php";
// Full recommended scope list for industry dashboard
$scope = implode(" ", [
"esi-industry.read_character_jobs.v1",
"esi-industry.read_corporation_jobs.v1",
"esi-characters.read_blueprints.v1",
"esi-corporations.read_blueprints.v1",
"esi-assets.read_corporation_assets.v1",
"esi-universe.read_structures.v1",
]);
$state = bin2hex(random_bytes(12));
$_SESSION["oauth2state"] = $state;
$url =
"https://login.eveonline.com/v2/oauth/authorize/?" .
http_build_query([
"response_type" => "code",
"redirect_uri" => $redirect_uri,
"client_id" => $client_id,
"scope" => $scope,
"state" => $state,
]);
header("Location: $url");
exit();

1
logout.php Executable file
View file

@ -0,0 +1 @@
<?php session_start(); session_destroy(); header('Location: index.php'); exit; ?>

103
populate_cache.php Normal file
View file

@ -0,0 +1,103 @@
<?php
require_once __DIR__ . "/functions.php"; // Ensure this has no conflicts
// Fetch all type IDs from ESI using pagination
function get_all_type_ids()
{
$all_ids = [];
$page = 1;
do {
$url = "https://esi.evetech.net/latest/universe/types/?page=$page";
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code !== 200) {
echo "Failed to fetch type IDs on page $page. HTTP Code: $http_code\n";
break;
}
$ids = json_decode($response, true);
if (empty($ids)) {
break;
}
$all_ids = array_merge($all_ids, $ids);
$page++;
usleep(250000); // Avoid API rate limit
} while (true);
return $all_ids;
}
// Filter IDs to include only blueprints by checking for names ending in "Blueprint"
function filter_blueprint_ids($ids)
{
$blueprint_ids = [];
$chunks = array_chunk($ids, 1000);
foreach ($chunks as $chunk) {
$names = fetch_type_names($chunk);
foreach ($names as $id => $name) {
if (str_ends_with($name, "Blueprint")) {
$blueprint_ids[] = $id;
}
}
usleep(250000);
}
return $blueprint_ids;
}
// Batch fetch type names using /universe/names/
// Populate the blueprint cache
function populate_blueprint_cache()
{
echo "Fetching all type IDs...\n";
$all_type_ids = get_all_type_ids();
if (empty($all_type_ids)) {
echo "No type IDs retrieved.\n";
return;
}
echo "Filtering blueprint IDs...\n";
$blueprint_ids = filter_blueprint_ids($all_type_ids);
if (empty($blueprint_ids)) {
echo "No blueprint IDs found.\n";
return;
}
echo "Fetching blueprint names...\n";
$cached_data = [];
$chunks = array_chunk($blueprint_ids, 1000);
foreach ($chunks as $chunk) {
$batch_names = fetch_type_names($chunk);
foreach ($batch_names as $id => $name) {
$cached_data[$id] = $name;
}
usleep(250000);
}
// Save to JSON cache file
$cache_dir = __DIR__ . "/cache";
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0775, true);
}
$cache_file = $cache_dir . "/blueprint_cache.json";
file_put_contents(
$cache_file,
json_encode($cached_data, JSON_PRETTY_PRINT)
);
echo "Cache populated with " . count($cached_data) . " blueprint names.\n";
}
// Run it
populate_blueprint_cache();

29
project_description.md Normal file
View file

@ -0,0 +1,29 @@
# 🏭 EVE Industry Tracker
A simple dashboard to track all your EVE Online industry jobs in one place.
## ✨ Features
- View all industry jobs across multiple characters
- Auto-refreshing status and countdown timers
- Sort and filter jobs easily
- Track blueprint inventory and research
- Export/Import session data for backups
- Works great on mobile devices
## 🚀 Getting Started
1. Visit https://eveindy.claytonia.net
2. Log in with EVE SSO
3. Add more characters if needed
4. Track your industry empire!
## 🔒 Privacy
- Uses official EVE SSO
- No data stored on servers
- Export data locally
- Secure token handling
## 💫 Support
If you find this tool useful, send ISK to `Clay's Accountant`
---
*EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights reserved. Used with permission.*

24
robots.txt Normal file
View file

@ -0,0 +1,24 @@
User-agent: *
Allow: /
Allow: /index.php
Allow: /dashboard.php
Allow: /blueprints_dashboard.php
Allow: /login.php
Allow: /assets/
Disallow: /cache/
Disallow: /exports/
Disallow: /api/
Disallow: /.local/
Disallow: /.zed/
Disallow: /views.php
Disallow: /track_visits.php
Disallow: /session_bootstrap.php
Disallow: /populate_cache.php
Disallow: /logout.php
Disallow: /keep_alive.php
Disallow: /import.php
Disallow: /functions.php
Disallow: /export.php
Disallow: /callback.php
Sitemap: https://eveindy.claytonia.net/sitemap.xml

31
session_bootstrap.php Executable file
View file

@ -0,0 +1,31 @@
<?php
$lifetime = 60 * 60 * 24 * 14; // 2 weeks
ini_set("session.gc_maxlifetime", $lifetime);
ini_set("session.cookie_lifetime", $lifetime);
session_set_cookie_params([
"lifetime" => $lifetime,
"path" => "/",
"secure" => isset($_SERVER["HTTPS"]),
"httponly" => true,
"samesite" => "Lax",
]);
session_start();
// Basic session cleanup - only remove empty character IDs
if (isset($_SESSION["characters"]) && is_array($_SESSION["characters"])) {
// Remove any empty character IDs
if (isset($_SESSION["characters"][""])) {
unset($_SESSION["characters"][""]);
}
// Remove any null or false entries
foreach ($_SESSION["characters"] as $charID => $char) {
if (empty($charID) || $charID === false || $charID === null) {
unset($_SESSION["characters"][$charID]);
}
}
}
?>

27
sitemap.xml Normal file
View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://eveindy.claytonia.net/</loc>
<lastmod>2024-01-09</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://eveindy.claytonia.net/login.php</loc>
<lastmod>2024-01-09</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://eveindy.claytonia.net/dashboard.php</loc>
<lastmod>2024-01-09</lastmod>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://eveindy.claytonia.net/blueprints_dashboard.php</loc>
<lastmod>2024-01-09</lastmod>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
</urlset>

119
track_visits.php Normal file
View file

@ -0,0 +1,119 @@
<?php
$visitsFile = "visits.json";
// Check if the cookie exists to track if the visitor has been here before
$cookieName = "EIJT";
$cookieLifetime = 60 * 60 * 24 * 30; // Cookie valid for 30 days
$firstVisit = !isset($_COOKIE[$cookieName]);
// Get user-agent for browser and OS info
$userAgent = $_SERVER["HTTP_USER_AGENT"];
$browser = "Unknown Browser";
$os = "Unknown OS";
// Detect browser using simple substr checks
if (strpos($userAgent, "Firefox") !== false) {
$browser = "Firefox";
} elseif (strpos($userAgent, "Chrome") !== false) {
$browser = "Chrome";
} elseif (strpos($userAgent, "Safari") !== false) {
$browser = "Safari";
} elseif (strpos($userAgent, "Edge") !== false) {
$browser = "Edge";
} elseif (
strpos($userAgent, "MSIE") !== false ||
strpos($userAgent, "Trident") !== false
) {
$browser = "Internet Explorer";
}
// Detect operating system
if (strpos($userAgent, "Windows NT") !== false) {
$os = "Windows";
} elseif (strpos($userAgent, "Macintosh") !== false) {
$os = "macOS";
} elseif (strpos($userAgent, "Linux") !== false) {
$os = "Linux";
} elseif (strpos($userAgent, "Android") !== false) {
$os = "Android";
} elseif (strpos($userAgent, "iPhone") !== false) {
$os = "iOS";
}
// Open the file in read/write mode, creating it if it doesn't exist
$fp = fopen($visitsFile, "c+");
if ($fp === false) {
die("Error opening the file.");
}
// Lock the file to avoid race conditions
if (flock($fp, LOCK_EX)) {
// Read the file content to get the stats
$data = fgets($fp); // Read the entire file content (assuming one line per file)
if ($data) {
$stats = json_decode($data, true);
} else {
$stats = [
"total_visits" => 0,
"unique_visits" => 0,
"last_visit" => null,
"monthly" => [],
"weekly" => [],
"browsers" => [],
"operating_systems" => [],
];
}
// Increment the total visit count
$stats["total_visits"]++;
// If it's a new visitor, increase the unique visit count
if ($firstVisit) {
$stats["unique_visits"]++;
setcookie($cookieName, "1", time() + $cookieLifetime, "/"); // Set cookie for this user
}
// Update the last visit timestamp
$timestamp = time();
$stats["last_visit"] = $timestamp;
// Track by week and month
$week = date("W", $timestamp); // Week number (ISO 8601 format)
$month = date("Y-m", $timestamp); // Month in YYYY-MM format
// Increment weekly stats
if (!isset($stats["weekly"][$week])) {
$stats["weekly"][$week] = 0;
}
$stats["weekly"][$week]++;
// Increment monthly stats
if (!isset($stats["monthly"][$month])) {
$stats["monthly"][$month] = 0;
}
$stats["monthly"][$month]++;
// Track browser type
if (!isset($stats["browsers"][$browser])) {
$stats["browsers"][$browser] = 0;
}
$stats["browsers"][$browser]++;
// Track operating system
if (!isset($stats["operating_systems"][$os])) {
$stats["operating_systems"][$os] = 0;
}
$stats["operating_systems"][$os]++;
// Write the updated stats back to the file
fseek($fp, 0);
fwrite($fp, json_encode($stats));
fflush($fp);
// Unlock the file
flock($fp, LOCK_UN);
}
// Close the file
fclose($fp);
?>

184
views.php Executable file
View file

@ -0,0 +1,184 @@
<?php
// Use a function to get stats safely
function getVisitStats() {
$visitsFile = "visits.json";
if (!file_exists($visitsFile)) {
return [
"total_visits" => 0,
"unique_visits" => 0,
"last_visit" => time(),
"monthly" => [],
"weekly" => [],
"browsers" => [],
"operating_systems" => [],
];
}
// Read the stats from the file
$fp = fopen($visitsFile, "r");
if ($fp === false) {
die("Error opening the file.");
}
$data = fgets($fp);
fclose($fp);
$stats = json_decode($data, true);
// Check if JSON decode failed
if ($stats === null) {
error_log("Failed to decode visits.json: " . json_last_error_msg());
return [
"total_visits" => 0,
"unique_visits" => 0,
"last_visit" => time(),
"monthly" => [],
"weekly" => [],
"browsers" => [],
"operating_systems" => [],
];
}
return $stats;
}
$stats = getVisitStats();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Website Stats</title>
<link rel="stylesheet" href="assets/styles.css"> <!-- Linking your provided CSS -->
<style>
/* Specific labels for each table in responsive mode */
@media (max-width: 768px) {
#monthlyTable td:nth-of-type(1):before { content: "Month"; }
#monthlyTable td:nth-of-type(2):before { content: "Visits"; }
#weeklyTable td:nth-of-type(1):before { content: "Week"; }
#weeklyTable td:nth-of-type(2):before { content: "Visits"; }
#browserTable td:nth-of-type(1):before { content: "Browser"; }
#browserTable td:nth-of-type(2):before { content: "Visits"; }
#osTable td:nth-of-type(1):before { content: "OS"; }
#osTable td:nth-of-type(2):before { content: "Visits"; }
}
</style>
<script src="assets/table-sort.js"></script>
</head>
<body>
<div class="container">
<h1>Website Stats</h1>
<section class="section">
<h2>General Stats</h2>
<p><strong>Total Visits:</strong> <?php echo $stats[
"total_visits"
]; ?></p>
<p><strong>Unique Visits:</strong> <?php echo $stats[
"unique_visits"
]; ?></p>
<p><strong>Last Visit Timestamp:</strong> <?php echo date(
"Y-m-d H:i:s",
$stats["last_visit"]
); ?></p>
</section>
<!-- Monthly Visits Table -->
<section class="section">
<h2>Monthly Visits</h2>
<table id="monthlyTable" class="jobsTable">
<thead>
<tr>
<th>Month</th>
<th>Visits</th>
</tr>
</thead>
<tbody>
<?php foreach ($stats["monthly"] as $month => $count): ?>
<tr>
<td><?php echo $month; ?></td>
<td><?php echo $count; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<!-- Weekly Visits Table -->
<section class="section">
<h2>Weekly Visits</h2>
<table id="weeklyTable" class="jobsTable">
<thead>
<tr>
<th>Week</th>
<th>Visits</th>
</tr>
</thead>
<tbody>
<?php foreach ($stats["weekly"] as $week => $count): ?>
<tr>
<td>Week <?php echo $week; ?></td>
<td><?php echo $count; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<!-- Browser Stats Table -->
<section class="section">
<h2>Browser Stats</h2>
<table id="browserTable" class="jobsTable">
<thead>
<tr>
<th>Browser</th>
<th>Visits</th>
</tr>
</thead>
<tbody>
<?php foreach ($stats["browsers"] as $browser => $count): ?>
<tr>
<td><?php echo $browser; ?></td>
<td><?php echo $count; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<!-- Operating System Stats Table -->
<section class="section">
<h2>Operating System Stats</h2>
<table id="osTable" class="jobsTable">
<thead>
<tr>
<th>Operating System</th>
<th>Visits</th>
</tr>
</thead>
<tbody>
<?php foreach (
$stats["operating_systems"]
as $os => $count
): ?>
<tr>
<td><?php echo $os; ?></td>
<td><?php echo $count; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
</div>
<section class="section">
<h2>Just a Quick Note</h2>
<p><em>We know, we know… this isnt the most sophisticated way to track visitors. No fancy servers or big data here, just some humble tracking behind the scenes. But hey, its privacy-friendly! Were not tracking your every move or creating a digital dossier, just counting visits in a way that doesnt require selling your personal info to the highest bidder. So enjoy the stats, and know that your privacy is safe with us!</em></p>
</section>
</body>
</html>

1
visits.json Executable file
View file

@ -0,0 +1 @@
{"total_visits":521,"unique_visits":43,"last_visit":1747923537,"monthly":{"2025-05":471},"weekly":{"20":81,"21":390},"browsers":{"Firefox":439,"Unknown Browser":19,"Chrome":11,"Safari":2},"operating_systems":{"Linux":322,"Unknown OS":20,"Windows":99,"iOS":2,"Android":26,"macOS":2}}

1
visits.txt Executable file
View file

@ -0,0 +1 @@
185