Initial commit of EVE Industry Tracker
This commit is contained in:
commit
43988423e0
30 changed files with 3627 additions and 0 deletions
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal 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
72
.htaccess
Normal 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
0
.zed/settings.json
Executable file
65
404.php
Normal file
65
404.php
Normal 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
149
README.md
Normal 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
BIN
assets/EIJT-FAVICON.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 713 KiB |
BIN
assets/logo.png
Executable file
BIN
assets/logo.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 101 KiB |
378
assets/styles.css
Executable file
378
assets/styles.css
Executable 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
1
assets/styles.min.css
vendored
Normal 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
56
assets/table-sort.js
Normal 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
286
blueprints_dashboard.php
Executable 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
82
callback.php
Executable 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
625
dashboard.php
Executable 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
97
eveindy.claytonia.net
Normal 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
39
export.php
Executable 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
795
functions.php
Executable 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
88
import.php
Executable 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
194
index.php
Executable 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
89
keep_alive.php
Normal 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
31
login.php
Executable 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
1
logout.php
Executable file
|
@ -0,0 +1 @@
|
|||
<?php session_start(); session_destroy(); header('Location: index.php'); exit; ?>
|
103
populate_cache.php
Normal file
103
populate_cache.php
Normal 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
29
project_description.md
Normal 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
24
robots.txt
Normal 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
31
session_bootstrap.php
Executable 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
27
sitemap.xml
Normal 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
119
track_visits.php
Normal 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
184
views.php
Executable 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 isn’t the most sophisticated way to track visitors. No fancy servers or big data here, just some humble tracking behind the scenes. But hey, it’s privacy-friendly! We’re not tracking your every move or creating a digital dossier, just counting visits in a way that doesn’t 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
1
visits.json
Executable 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
1
visits.txt
Executable file
|
@ -0,0 +1 @@
|
|||
185
|
Loading…
Add table
Reference in a new issue