Initial commit
This commit is contained in:
commit
315e731234
27 changed files with 3403 additions and 0 deletions
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Environment variables
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Data files
|
||||
data/blog_posts.json
|
||||
|
||||
# Uploads
|
||||
static/uploads/*
|
||||
!static/uploads/.gitkeep
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
FROM python:3.11-slim-buster
|
||||
|
||||
# Install build dependencies (build-essential provides gcc and other tools)
|
||||
RUN apt-get update && apt-get install -y build-essential
|
||||
|
||||
WORKDIR /personalsite
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV FLASK_APP=app.py
|
||||
|
||||
EXPOSE 5002
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5002", "app:app"]
|
||||
382
app.py
Normal file
382
app.py
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
from flask import Flask, render_template, jsonify, request, redirect, url_for, flash
|
||||
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from blog_manager import BlogManager
|
||||
from forms import LoginForm, BlogPostForm
|
||||
from user import User
|
||||
|
||||
# Import traffic tracking components
|
||||
from models import db
|
||||
from traffic_tracker import TrafficTracker
|
||||
from sqlalchemy import func, desc
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-key-change-in-production')
|
||||
|
||||
# SQLite Configuration for traffic tracking
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(basedir, "traffic_analytics.db")}'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
tracker = TrafficTracker(app)
|
||||
|
||||
# Initialize Flask-Login
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'admin_login'
|
||||
login_manager.login_message = 'Please log in to access the admin panel.'
|
||||
|
||||
# Initialize Blog Manager
|
||||
blog_manager = BlogManager()
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.get(user_id)
|
||||
|
||||
|
||||
# Initialize Strava service (with error handling)
|
||||
try:
|
||||
from strava_service import StravaService
|
||||
|
||||
strava = StravaService()
|
||||
STRAVA_ENABLED = True
|
||||
except Exception as e:
|
||||
print(f"Strava service not available: {e}")
|
||||
strava = None
|
||||
STRAVA_ENABLED = False
|
||||
|
||||
|
||||
# Load blog posts from BlogManager
|
||||
def load_blog_posts():
|
||||
return blog_manager.load_posts()
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
posts = load_blog_posts()
|
||||
recent_posts = sorted(posts, key=lambda x: x['date'], reverse=True)[:3]
|
||||
return render_template('index.html', recent_posts=recent_posts)
|
||||
|
||||
|
||||
@app.route('/about')
|
||||
def about():
|
||||
return render_template('about.html')
|
||||
|
||||
|
||||
@app.route('/hardware')
|
||||
def hardware():
|
||||
return render_template('hardware.html')
|
||||
|
||||
|
||||
@app.route('/biking')
|
||||
def biking():
|
||||
ytd_stats = None
|
||||
recent_activities = []
|
||||
|
||||
if STRAVA_ENABLED and strava:
|
||||
try:
|
||||
ytd_stats = strava.get_ytd_stats()
|
||||
recent_activities = strava.format_recent_activities()
|
||||
except Exception as e:
|
||||
print(f"Error fetching Strava data: {e}")
|
||||
ytd_stats = {
|
||||
'distance': 0,
|
||||
'count': 0,
|
||||
'elevation': 0,
|
||||
'time': 0
|
||||
}
|
||||
else:
|
||||
ytd_stats = {
|
||||
'distance': 2450,
|
||||
'count': 127,
|
||||
'elevation': 45600,
|
||||
'time': 156
|
||||
}
|
||||
recent_activities = [
|
||||
{
|
||||
'name': 'Morning Training Ride',
|
||||
'distance': 25.3,
|
||||
'elevation': 1200,
|
||||
'time': '1h 45m',
|
||||
'date': 'January 5, 2025'
|
||||
}
|
||||
]
|
||||
|
||||
current_time = datetime.now().strftime('%B %d, %Y at %I:%M %p')
|
||||
|
||||
return render_template('biking.html',
|
||||
ytd_stats=ytd_stats,
|
||||
recent_activities=recent_activities,
|
||||
strava_enabled=STRAVA_ENABLED,
|
||||
last_updated=current_time)
|
||||
|
||||
|
||||
@app.route('/blog')
|
||||
def blog():
|
||||
posts = load_blog_posts()
|
||||
posts = sorted(posts, key=lambda x: x['date'], reverse=True)
|
||||
return render_template('blog.html', posts=posts)
|
||||
|
||||
|
||||
@app.route('/blog/<int:post_id>')
|
||||
def blog_post(post_id):
|
||||
post = blog_manager.get_post(post_id)
|
||||
if not post:
|
||||
return "Post not found", 404
|
||||
return render_template('blog_post.html', post=post)
|
||||
|
||||
|
||||
# Admin Routes
|
||||
@app.route('/admin/login', methods=['GET', 'POST'])
|
||||
def admin_login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.authenticate(form.username.data, form.password.data)
|
||||
if user:
|
||||
login_user(user)
|
||||
flash('Logged in successfully!', 'success')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
else:
|
||||
flash('Invalid username or password.', 'error')
|
||||
|
||||
return render_template('admin/login.html', form=form)
|
||||
|
||||
|
||||
@app.route('/admin/logout')
|
||||
@login_required
|
||||
def admin_logout():
|
||||
logout_user()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@app.route('/admin')
|
||||
@login_required
|
||||
def admin_dashboard():
|
||||
posts = load_blog_posts()
|
||||
posts = sorted(posts, key=lambda x: x['date'], reverse=True)
|
||||
return render_template('admin/dashboard.html', posts=posts)
|
||||
|
||||
|
||||
@app.route('/admin/post/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def admin_new_post():
|
||||
form = BlogPostForm()
|
||||
if form.validate_on_submit():
|
||||
blog_manager.create_post(
|
||||
title=form.title.data,
|
||||
content=form.content.data,
|
||||
category=form.category.data,
|
||||
excerpt=form.excerpt.data or None
|
||||
)
|
||||
flash('Post created successfully!', 'success')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
return render_template('admin/edit_post.html', form=form, title='New Post')
|
||||
|
||||
|
||||
@app.route('/admin/post/<int:post_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def admin_edit_post(post_id):
|
||||
post = blog_manager.get_post(post_id)
|
||||
if not post:
|
||||
flash('Post not found.', 'error')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
form = BlogPostForm(obj=post)
|
||||
if form.validate_on_submit():
|
||||
blog_manager.update_post(
|
||||
post_id=post_id,
|
||||
title=form.title.data,
|
||||
content=form.content.data,
|
||||
category=form.category.data,
|
||||
excerpt=form.excerpt.data or None
|
||||
)
|
||||
flash('Post updated successfully!', 'success')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
return render_template('admin/edit_post.html', form=form, post=post, title='Edit Post')
|
||||
|
||||
|
||||
@app.route('/admin/post/<int:post_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def admin_delete_post(post_id):
|
||||
blog_manager.delete_post(post_id)
|
||||
flash('Post deleted successfully!', 'success')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
|
||||
# Traffic Analytics Admin Route
|
||||
@app.route('/admin/traffic')
|
||||
@login_required
|
||||
def admin_traffic():
|
||||
from models import PageView, UniqueVisitor
|
||||
|
||||
# Get date range from query params (default to last 30 days)
|
||||
days = int(request.args.get('days', 30))
|
||||
end_date = datetime.utcnow().date()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Basic stats
|
||||
total_views = PageView.query.filter(
|
||||
func.date(PageView.timestamp).between(start_date, end_date)
|
||||
).count()
|
||||
|
||||
unique_visitors = db.session.query(func.count(func.distinct(PageView.ip_address))).filter(
|
||||
func.date(PageView.timestamp).between(start_date, end_date)
|
||||
).scalar()
|
||||
|
||||
avg_response_time = db.session.query(func.avg(PageView.response_time)).filter(
|
||||
func.date(PageView.timestamp).between(start_date, end_date)
|
||||
).scalar() or 0
|
||||
|
||||
# Calculate bounce rate (sessions with only one page view)
|
||||
single_page_sessions = db.session.query(
|
||||
PageView.session_id
|
||||
).filter(
|
||||
func.date(PageView.timestamp).between(start_date, end_date)
|
||||
).group_by(PageView.session_id).having(func.count(PageView.id) == 1).count()
|
||||
|
||||
total_sessions = db.session.query(
|
||||
func.count(func.distinct(PageView.session_id))
|
||||
).filter(
|
||||
func.date(PageView.timestamp).between(start_date, end_date)
|
||||
).scalar()
|
||||
|
||||
bounce_rate = (single_page_sessions / total_sessions * 100) if total_sessions > 0 else 0
|
||||
|
||||
# Top pages
|
||||
top_pages = db.session.query(
|
||||
PageView.path,
|
||||
func.count(PageView.id).label('views')
|
||||
).filter(
|
||||
func.date(PageView.timestamp).between(start_date, end_date)
|
||||
).group_by(PageView.path).order_by(desc('views')).limit(10).all()
|
||||
|
||||
# Top referrers
|
||||
top_referrers = db.session.query(
|
||||
PageView.referrer,
|
||||
func.count(PageView.id).label('views')
|
||||
).filter(
|
||||
func.date(PageView.timestamp).between(start_date, end_date),
|
||||
PageView.referrer.isnot(None),
|
||||
PageView.referrer != ''
|
||||
).group_by(PageView.referrer).order_by(desc('views')).limit(10).all()
|
||||
|
||||
# Daily views for chart - Fixed to handle SQLite date strings
|
||||
daily_views_raw = db.session.query(
|
||||
func.date(PageView.timestamp).label('date'),
|
||||
func.count(PageView.id).label('views'),
|
||||
func.count(func.distinct(PageView.ip_address)).label('unique_visitors')
|
||||
).filter(
|
||||
func.date(PageView.timestamp).between(start_date, end_date)
|
||||
).group_by(func.date(PageView.timestamp)).order_by('date').all()
|
||||
|
||||
# Recent activity (last 50 page views)
|
||||
recent_activity = PageView.query.filter(
|
||||
PageView.timestamp >= datetime.utcnow() - timedelta(hours=24)
|
||||
).order_by(desc(PageView.timestamp)).limit(50).all()
|
||||
|
||||
stats = {
|
||||
'total_views': total_views,
|
||||
'unique_visitors': unique_visitors,
|
||||
'avg_response_time': round(avg_response_time, 2),
|
||||
'bounce_rate': round(bounce_rate, 2)
|
||||
}
|
||||
|
||||
# Format daily views for JSON - Handle both string and date objects
|
||||
daily_views_json = []
|
||||
for day in daily_views_raw:
|
||||
# Handle case where SQLite returns date as string
|
||||
if isinstance(day.date, str):
|
||||
date_str = day.date
|
||||
else:
|
||||
date_str = day.date.isoformat()
|
||||
|
||||
daily_views_json.append({
|
||||
'date': date_str,
|
||||
'views': day.views,
|
||||
'unique_visitors': day.unique_visitors
|
||||
})
|
||||
|
||||
return render_template('admin/traffic.html',
|
||||
stats=stats,
|
||||
top_pages=top_pages,
|
||||
top_referrers=top_referrers,
|
||||
daily_views=daily_views_json,
|
||||
recent_activity=recent_activity,
|
||||
days=days)
|
||||
|
||||
# Real-time traffic API for admin dashboard
|
||||
@app.route('/admin/traffic/api/realtime')
|
||||
@login_required
|
||||
def realtime_traffic():
|
||||
from models import PageView
|
||||
|
||||
# Last 5 minutes of traffic
|
||||
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
|
||||
|
||||
recent_views = PageView.query.filter(
|
||||
PageView.timestamp >= five_min_ago
|
||||
).order_by(desc(PageView.timestamp)).limit(50).all()
|
||||
|
||||
active_users = db.session.query(func.count(func.distinct(PageView.session_id))).filter(
|
||||
PageView.timestamp >= five_min_ago
|
||||
).scalar()
|
||||
|
||||
return jsonify({
|
||||
'active_users': active_users,
|
||||
'recent_views': [{
|
||||
'path': view.path,
|
||||
'timestamp': view.timestamp.isoformat(),
|
||||
'ip_address': view.ip_address[:8] + '...', # Partial IP for privacy
|
||||
'country': view.country,
|
||||
'city': view.city
|
||||
} for view in recent_views]
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/strava-stats')
|
||||
def strava_stats_api():
|
||||
if not STRAVA_ENABLED or not strava:
|
||||
return jsonify({
|
||||
'error': 'Strava service not available',
|
||||
'ytd_stats': None,
|
||||
'recent_activities': []
|
||||
}), 503
|
||||
|
||||
try:
|
||||
ytd_stats = strava.get_ytd_stats()
|
||||
recent_activities = strava.format_recent_activities()
|
||||
|
||||
return jsonify({
|
||||
'ytd_stats': ytd_stats,
|
||||
'recent_activities': recent_activities
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': str(e),
|
||||
'ytd_stats': None,
|
||||
'recent_activities': []
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()}
|
||||
|
||||
|
||||
# Create database tables on startup
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
92
blog_manager.py
Normal file
92
blog_manager.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BlogManager:
|
||||
def __init__(self, data_file='data/blog_posts.json'):
|
||||
self.data_file = data_file
|
||||
self.ensure_data_file()
|
||||
|
||||
def ensure_data_file(self):
|
||||
"""Ensure the data file and directory exist"""
|
||||
os.makedirs(os.path.dirname(self.data_file), exist_ok=True)
|
||||
if not os.path.exists(self.data_file):
|
||||
with open(self.data_file, 'w') as f:
|
||||
json.dump([], f)
|
||||
|
||||
def load_posts(self):
|
||||
"""Load all blog posts"""
|
||||
try:
|
||||
with open(self.data_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
def save_posts(self, posts):
|
||||
"""Save all blog posts"""
|
||||
with open(self.data_file, 'w') as f:
|
||||
json.dump(posts, f, indent=2)
|
||||
|
||||
def get_next_id(self):
|
||||
"""Get the next available ID"""
|
||||
posts = self.load_posts()
|
||||
if not posts:
|
||||
return 1
|
||||
return max(post['id'] for post in posts) + 1
|
||||
|
||||
def create_post(self, title, content, category, excerpt=None):
|
||||
"""Create a new blog post"""
|
||||
posts = self.load_posts()
|
||||
|
||||
# Auto-generate excerpt if not provided
|
||||
if not excerpt:
|
||||
excerpt = content[:150] + "..." if len(content) > 150 else content
|
||||
|
||||
new_post = {
|
||||
'id': self.get_next_id(),
|
||||
'title': title,
|
||||
'content': content,
|
||||
'excerpt': excerpt,
|
||||
'category': category,
|
||||
'date': datetime.now().strftime('%Y-%m-%d'),
|
||||
'created_at': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
posts.append(new_post)
|
||||
self.save_posts(posts)
|
||||
return new_post
|
||||
|
||||
def get_post(self, post_id):
|
||||
"""Get a specific post by ID"""
|
||||
posts = self.load_posts()
|
||||
return next((p for p in posts if p['id'] == post_id), None)
|
||||
|
||||
def update_post(self, post_id, title, content, category, excerpt=None):
|
||||
"""Update an existing post"""
|
||||
posts = self.load_posts()
|
||||
post = next((p for p in posts if p['id'] == post_id), None)
|
||||
|
||||
if not post:
|
||||
return None
|
||||
|
||||
if not excerpt:
|
||||
excerpt = content[:150] + "..." if len(content) > 150 else content
|
||||
|
||||
post.update({
|
||||
'title': title,
|
||||
'content': content,
|
||||
'excerpt': excerpt,
|
||||
'category': category,
|
||||
'updated_at': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
self.save_posts(posts)
|
||||
return post
|
||||
|
||||
def delete_post(self, post_id):
|
||||
"""Delete a post"""
|
||||
posts = self.load_posts()
|
||||
posts = [p for p in posts if p['id'] != post_id]
|
||||
self.save_posts(posts)
|
||||
return True
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
- FLASK_DEBUG=1
|
||||
volumes:
|
||||
- .:/app
|
||||
command: flask run --host=0.0.0.0 --port=8000
|
||||
|
||||
# Optional: Add database service
|
||||
# db:
|
||||
# image: postgres:15
|
||||
# environment:
|
||||
# POSTGRES_DB: myapp
|
||||
# POSTGRES_USER: user
|
||||
# POSTGRES_PASSWORD: password
|
||||
# volumes:
|
||||
# - postgres_data:/var/lib/postgresql/data
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
|
||||
# volumes:
|
||||
# postgres_data:
|
||||
23
forms.py
Normal file
23
forms.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, SelectField, PasswordField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired()])
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
submit = SubmitField('Login')
|
||||
|
||||
class BlogPostForm(FlaskForm):
|
||||
title = StringField('Title', validators=[DataRequired(), Length(max=200)])
|
||||
category = SelectField('Category', choices=[
|
||||
('Technology', 'Technology'),
|
||||
('Hardware', 'Hardware'),
|
||||
('Biking', 'Biking'),
|
||||
('Cybersecurity', 'Cybersecurity'),
|
||||
('Personal', 'Personal'),
|
||||
('Tutorial', 'Tutorial')
|
||||
], validators=[DataRequired()])
|
||||
excerpt = TextAreaField('Excerpt (optional)', validators=[Length(max=300)])
|
||||
content = TextAreaField('Content', validators=[DataRequired()],
|
||||
render_kw={"rows": 15})
|
||||
submit = SubmitField('Save Post')
|
||||
68
get_strava_token.py
Normal file
68
get_strava_token.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import requests
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Replace with your actual values from Strava API settings
|
||||
CLIENT_ID = '76528'
|
||||
CLIENT_SECRET = 'de46e1ec96ad277ae0be94f949301483a4cd1a4d'
|
||||
REDIRECT_URI = 'http://localhost'
|
||||
|
||||
# Step 1: Get authorization URL
|
||||
auth_url = f"https://www.strava.com/oauth/authorize?{urlencode({
|
||||
'client_id': CLIENT_ID,
|
||||
'response_type': 'code',
|
||||
'redirect_uri': REDIRECT_URI,
|
||||
'approval_prompt': 'force',
|
||||
'scope': 'read,activity:read_all'
|
||||
})}"
|
||||
|
||||
print("=" * 60)
|
||||
print("STRAVA API SETUP")
|
||||
print("=" * 60)
|
||||
print(f"1. Visit this URL in your browser:")
|
||||
print(f"{auth_url}")
|
||||
print()
|
||||
print("2. Click 'Authorize' to allow access to your Strava data")
|
||||
print("3. You'll be redirected to a localhost URL that won't load")
|
||||
print("4. Copy the ENTIRE URL from your browser's address bar")
|
||||
print("5. Look for the 'code=' parameter in that URL")
|
||||
print()
|
||||
print("Example: http://localhost/?state=&code=ABC123XYZ&scope=read,activity:read_all")
|
||||
print("In this example, your code would be: ABC123XYZ")
|
||||
print("=" * 60)
|
||||
|
||||
code = input("Enter the authorization code from the URL: ").strip()
|
||||
|
||||
if not code:
|
||||
print("No code entered. Exiting.")
|
||||
exit()
|
||||
|
||||
# Step 2: Exchange code for tokens
|
||||
print("\nExchanging code for tokens...")
|
||||
token_url = 'https://www.strava.com/oauth/token'
|
||||
data = {
|
||||
'client_id': CLIENT_ID,
|
||||
'client_secret': CLIENT_SECRET,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code'
|
||||
}
|
||||
|
||||
response = requests.post(token_url, data=data)
|
||||
|
||||
if response.status_code == 200:
|
||||
tokens = response.json()
|
||||
print("\n" + "=" * 60)
|
||||
print("SUCCESS! Here are your tokens:")
|
||||
print("=" * 60)
|
||||
print(f"ACCESS_TOKEN: {tokens['access_token']}")
|
||||
print(f"REFRESH_TOKEN: {tokens['refresh_token']}")
|
||||
print(f"EXPIRES_AT: {tokens['expires_at']}")
|
||||
print()
|
||||
print("Add these to your .env file:")
|
||||
print("=" * 60)
|
||||
print(f"STRAVA_CLIENT_ID={CLIENT_ID}")
|
||||
print(f"STRAVA_CLIENT_SECRET={CLIENT_SECRET}")
|
||||
print(f"STRAVA_REFRESH_TOKEN={tokens['refresh_token']}")
|
||||
print("=" * 60)
|
||||
else:
|
||||
print(f"Error: {response.status_code}")
|
||||
print(response.text)
|
||||
39
models.py
Normal file
39
models.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class PageView(db.Model):
|
||||
__tablename__ = 'page_views'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
ip_address = db.Column(db.String(45), index=True)
|
||||
user_agent = db.Column(db.Text)
|
||||
path = db.Column(db.String(255), index=True)
|
||||
method = db.Column(db.String(10))
|
||||
referrer = db.Column(db.String(500))
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
response_time = db.Column(db.Float)
|
||||
status_code = db.Column(db.Integer, index=True)
|
||||
country = db.Column(db.String(2), index=True)
|
||||
city = db.Column(db.String(100))
|
||||
session_id = db.Column(db.String(255), index=True)
|
||||
|
||||
|
||||
class UniqueVisitor(db.Model):
|
||||
__tablename__ = 'unique_visitors'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
ip_address = db.Column(db.String(45), index=True)
|
||||
user_agent_hash = db.Column(db.String(64), index=True)
|
||||
first_visit = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_visit = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
visit_count = db.Column(db.Integer, default=1)
|
||||
country = db.Column(db.String(2))
|
||||
city = db.Column(db.String(100))
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_visitor_lookup', 'ip_address', 'user_agent_hash'),
|
||||
)
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
Flask==2.3.3
|
||||
Werkzeug==2.3.7
|
||||
requests
|
||||
python-dotenv
|
||||
Flask-Login==0.6.3
|
||||
Flask-WTF==1.1.1
|
||||
WTForms==3.0.1
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
gunicorn==21.2.0
|
||||
156
static/css/style.css
Normal file
156
static/css/style.css
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/* Wave animation for emoji */
|
||||
.wave-animation {
|
||||
animation: wave 2s infinite;
|
||||
transform-origin: 70% 70%;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% { transform: rotate(0deg); }
|
||||
10% { transform: rotate(14deg); }
|
||||
20% { transform: rotate(-8deg); }
|
||||
30% { transform: rotate(14deg); }
|
||||
40% { transform: rotate(-4deg); }
|
||||
50% { transform: rotate(10deg); }
|
||||
60% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* Hero section styles - MUCH SMALLER */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 2rem 0; /* Much smaller padding */
|
||||
margin-bottom: 2rem; /* Add some space below */
|
||||
}
|
||||
|
||||
.hero-section .lead {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 300;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 2.5rem; /* Control the heading size */
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.hero-section .lead {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 2rem; /* Smaller on mobile */
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
padding: 1.5rem 0; /* Even less padding on mobile */
|
||||
}
|
||||
}
|
||||
|
||||
/* Rest of your styles... */
|
||||
.card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.skill-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.skill-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.tech-icons img {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.tech-icons img:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
||||
}
|
||||
|
||||
.rounded-circle {
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Social Links Styling */
|
||||
.social-links {
|
||||
font-size: 0; /* Remove whitespace between inline elements */
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: inline-block;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
margin-right: 1rem;
|
||||
font-size: 1.25rem; /* Reset font size for icons */
|
||||
transition: color 0.3s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.social-link:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
color: #007bff;
|
||||
transform: translateY(-2px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.social-link i {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Alternative cleaner approach */
|
||||
.social-links-flex {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.social-links-flex {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
BIN
static/images/profile.jpg
Normal file
BIN
static/images/profile.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 MiB |
461
strava_cache.json
Normal file
461
strava_cache.json
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
{
|
||||
"access_token": [
|
||||
{
|
||||
"token_type": "Bearer",
|
||||
"access_token": "387110714399904dfb8430fe9b309410fad713c2",
|
||||
"expires_at": 1751768062,
|
||||
"expires_in": 21600,
|
||||
"refresh_token": "c5e2d4b68ec8ff9acd2dfbd2451f724dc3e2009f"
|
||||
},
|
||||
1751746462.565112
|
||||
],
|
||||
"athlete_stats": [
|
||||
{
|
||||
"biggest_ride_distance": 115397.0,
|
||||
"biggest_climb_elevation_gain": 62.0,
|
||||
"recent_ride_totals": {
|
||||
"count": 9,
|
||||
"distance": 264123.8994140625,
|
||||
"moving_time": 34386,
|
||||
"elapsed_time": 35962,
|
||||
"elevation_gain": 832.5999984741211,
|
||||
"achievement_count": 0
|
||||
},
|
||||
"all_ride_totals": {
|
||||
"count": 756,
|
||||
"distance": 18027711.82313776,
|
||||
"moving_time": 2598611,
|
||||
"elapsed_time": 2969272,
|
||||
"elevation_gain": 49674.28379535675
|
||||
},
|
||||
"recent_run_totals": {
|
||||
"count": 0,
|
||||
"distance": 0,
|
||||
"moving_time": 0,
|
||||
"elapsed_time": 0,
|
||||
"elevation_gain": 0,
|
||||
"achievement_count": 0
|
||||
},
|
||||
"all_run_totals": {
|
||||
"count": 8,
|
||||
"distance": 5448.39013671875,
|
||||
"moving_time": 7367,
|
||||
"elapsed_time": 7397,
|
||||
"elevation_gain": 6.239949941635132
|
||||
},
|
||||
"recent_swim_totals": {
|
||||
"count": 0,
|
||||
"distance": 0,
|
||||
"moving_time": 0,
|
||||
"elapsed_time": 0,
|
||||
"elevation_gain": 0,
|
||||
"achievement_count": 0
|
||||
},
|
||||
"all_swim_totals": {
|
||||
"count": 0,
|
||||
"distance": 0,
|
||||
"moving_time": 0,
|
||||
"elapsed_time": 0,
|
||||
"elevation_gain": 0
|
||||
},
|
||||
"ytd_ride_totals": {
|
||||
"count": 51,
|
||||
"distance": 1586506,
|
||||
"moving_time": 215634.0,
|
||||
"elapsed_time": 239584.0,
|
||||
"elevation_gain": 4510.799995422363
|
||||
},
|
||||
"ytd_run_totals": {
|
||||
"count": 0,
|
||||
"distance": 0,
|
||||
"moving_time": 0,
|
||||
"elapsed_time": 0,
|
||||
"elevation_gain": 0
|
||||
},
|
||||
"ytd_swim_totals": {
|
||||
"count": 0,
|
||||
"distance": 0,
|
||||
"moving_time": 0,
|
||||
"elapsed_time": 0,
|
||||
"elevation_gain": 0
|
||||
}
|
||||
},
|
||||
1751746463.1249552
|
||||
],
|
||||
"ytd_stats": [
|
||||
{
|
||||
"distance": 985.8,
|
||||
"elevation": 14799,
|
||||
"time": 59.9,
|
||||
"count": 51,
|
||||
"avg_speed": 16.5
|
||||
},
|
||||
1751746463.1257896
|
||||
],
|
||||
"recent_activities_5": [
|
||||
[
|
||||
{
|
||||
"resource_state": 2,
|
||||
"athlete": {
|
||||
"id": 57387663,
|
||||
"resource_state": 1
|
||||
},
|
||||
"name": "Morning Ride",
|
||||
"distance": 65308.1,
|
||||
"moving_time": 8555,
|
||||
"elapsed_time": 8998,
|
||||
"total_elevation_gain": 187.0,
|
||||
"type": "Ride",
|
||||
"sport_type": "Ride",
|
||||
"workout_type": null,
|
||||
"id": 15004705624,
|
||||
"start_date": "2025-07-04T11:19:15Z",
|
||||
"start_date_local": "2025-07-04T06:19:15Z",
|
||||
"timezone": "(GMT-06:00) America/Chicago",
|
||||
"utc_offset": -18000.0,
|
||||
"location_city": null,
|
||||
"location_state": null,
|
||||
"location_country": null,
|
||||
"achievement_count": 1,
|
||||
"kudos_count": 11,
|
||||
"comment_count": 0,
|
||||
"athlete_count": 1,
|
||||
"photo_count": 0,
|
||||
"map": {
|
||||
"id": "a15004705624",
|
||||
"summary_polyline": "mbc}ExirtQFpDEvIDbEAxGCn@CFQDaAEaE@iBA{@?]DKDe@^QZa@~@UZc@d@wAjAWVq@bAKFYH{@CIDGNCn@@tB?jUEbJAPK^O\\IJKFMDkAAyAJi@Nc@XKFK@{@E_AA}VCcCJq@?O@KDIHIb@HhDCv\\?jEBtAEPGDSBc@Cy@@m@Eo@?u@DMGEGAKBsAAaDIk@OYWSYO{IgEWKWCc@@WDi@Xm@f@KNAVH\\p@hBFDPDXEp@SLAX@VLhAp@nCpATNFLBLElA?|@DRLLb@BnBApE@`ABHD@R@`SCh_@GfJENOD{@A}@Dog@Rgh@XaBDK@IDEHGn@BxLE|h@@du@GVCBS@yAEwt@KeKGgOCuq@B}\\CsMBmJC{CBoDCiJ@}ZD_JDuBAsC@iH?E@IHERCn@YpsETtoBCd@ENINIJMH]DqAEuHC_TAcJCgW?eAAwVAmQIsJ?mLCi~@EkxA?sjAEoa@GiSByCAgQ@_MA{D@iFA_XFyEA}a@B_D@kN?uLBiVCac@@QAMGIOEQAUFqA?qDA{\\EqHBkFEeq@@uICmID{LBMDELCpBDrAAPBNA\\F`BKvDBxB?bACh@BnL?bDOJKP_@HWB[@_MBq@H_@HKTUXGpC@bH?dECf@@dCCbBBzJA|AFJEDSBiOXsv@|@{aBIcyBC}HAqi@KioAAglAk@k}BKsnAC_C@eAAgDFSFERCxCBzrACnrDYbPDv^Pfi@PpSBlGAtDCtJSnFSrIMvj@?hDCnMB`f@CpFDjHNvH`@dELxDFhWB~OElTCdDDnRvBvDFbAEnBUt@OpD_ApBe@`AQdBOlAE~KAtHB|PALJDVEtG@zDC~CChRBxIRvBFLHHZHZ@dACbAIfB?xGDdU@tBBnG@fLLvQClB@r@BvBMzAApABhB?tTGtEGhDB~BExG?zIG~B@hGEdD@rAAvBMhP@rBChPD|CCXFbAE|CCNBDDFRCz@E~DGzWGjOEvABv@@nECfCDbAEfQDlG@pTDXFHNFZ?~BG\\@HBBF@VAxDBJNJXBnAC|CBjC?HBBD?~ICdL?pK",
|
||||
"resource_state": 2
|
||||
},
|
||||
"trainer": false,
|
||||
"commute": false,
|
||||
"manual": false,
|
||||
"private": false,
|
||||
"visibility": "everyone",
|
||||
"flagged": false,
|
||||
"gear_id": "b13730672",
|
||||
"start_latlng": [
|
||||
36.392646,
|
||||
-97.91032
|
||||
],
|
||||
"end_latlng": [
|
||||
36.392489,
|
||||
-97.909817
|
||||
],
|
||||
"average_speed": 7.634,
|
||||
"max_speed": 15.0,
|
||||
"average_cadence": 80.9,
|
||||
"average_temp": 23,
|
||||
"average_watts": 145.0,
|
||||
"max_watts": 574,
|
||||
"weighted_average_watts": 153,
|
||||
"device_watts": true,
|
||||
"kilojoules": 1238.0,
|
||||
"has_heartrate": true,
|
||||
"average_heartrate": 162.0,
|
||||
"max_heartrate": 183.0,
|
||||
"heartrate_opt_out": false,
|
||||
"display_hide_heartrate_option": true,
|
||||
"elev_high": 422.6,
|
||||
"elev_low": 348.2,
|
||||
"upload_id": 16017980571,
|
||||
"upload_id_str": "16017980571",
|
||||
"external_id": "470201470455676929.fit",
|
||||
"from_accepted_tag": false,
|
||||
"pr_count": 0,
|
||||
"total_photo_count": 0,
|
||||
"has_kudoed": false
|
||||
},
|
||||
{
|
||||
"resource_state": 2,
|
||||
"athlete": {
|
||||
"id": 57387663,
|
||||
"resource_state": 1
|
||||
},
|
||||
"name": "TrainingPeaks Virtual - Workout: 1x45 @ 60%",
|
||||
"distance": 28366.5,
|
||||
"moving_time": 3631,
|
||||
"elapsed_time": 3631,
|
||||
"total_elevation_gain": 146.0,
|
||||
"type": "VirtualRide",
|
||||
"sport_type": "VirtualRide",
|
||||
"id": 14999603655,
|
||||
"start_date": "2025-07-03T22:38:49Z",
|
||||
"start_date_local": "2025-07-03T17:38:49Z",
|
||||
"timezone": "(GMT-05:00) America/Atikokan",
|
||||
"utc_offset": -18000.0,
|
||||
"location_city": null,
|
||||
"location_state": null,
|
||||
"location_country": null,
|
||||
"achievement_count": 0,
|
||||
"kudos_count": 6,
|
||||
"comment_count": 0,
|
||||
"athlete_count": 1,
|
||||
"photo_count": 0,
|
||||
"map": {
|
||||
"id": "a14999603655",
|
||||
"summary_polyline": "bpxGsedp[BsCf@OVpAGxHPt@r@N\\cAL_H`@UZZ`@fBRvC?fEQtA_@TwBIu@NyCtCeDbBmBlBc@fAUrDkCfDoB`EYtAArBe@~AeHfMQjFa@`Bk@r@{DpB}B`@yAkAy@U}Ju@}HyFaCcA}Ck@wDFmG|AuXUeAm@wDsGqAeDw@k@w@OuAZwAtBKbANtCc@hAgBl@yCc@gAqBBaBWu@uHiDwGsFYq@Cw@l@}CrA{CAwBk@o@qEQm@m@Ak@n@qAJcA_BmGnB{CF{C}AgGaCqDhAkDh@}Dr@_Cl@s@|AG`BXz@z@\\pA@dAkAbExB`H~AhDVzHXdBdAbCxAhBdBpAxCrAtBFjJaBbBgAtBkD|GmDj@gAZsBYuEaAkCoAmAs@Wu@Ds@`@_@r@]pBoAl@mJuD}Bo@cCAcClA{Es@oCmAgCqBjA}DI}Ai@mAaB}@uC@o@h@w@|Bc@pDqA`E?TrCvEjAdFKlC}ArBSp@`B|FG~@q@tAAn@l@v@nENt@x@?~AuAfDm@|C@p@`@bA`HvFdH|CTd@B~Br@vAx@d@nCNxAo@h@sAOwCL_AtAqBrASxAt@nAdDtDlGlAv@vXVvG}AnDIxF|A`JlGdKt@z@ZdA`ArBQxE_Ct@aA\\uATwF`H_Mb@yAD_CZuAbBqDlCeDT_AFgCTm@zAgBzDsB|CwClDEb@]TqDQuFq@mCe@S[LQbAGzFKb@YHa@{@DiI[kA_@EWPIbD{@r@}AOg@g@c@qAsAUg@RYp@?hE\\x@`ANx@g@J}ESw@i@Wy@Di@j@AdF\\l@f@Nt@Kb@k@@yEQk@g@Wk@?s@`@Kh@BnEn@r@v@Bn@a@|@eBxCy@Z}@AaCHOb@BRlAE`IJf@b@V`@QLg@LcHX]TDf@lAVdCFzDKnCm@j@oBI{@R{CtCaExBuA~A[`ACpBS`AmCfDeBpD[tAC`C[hAmHxMQ~E]bBi@v@}EbCcBRyAkA_AWmJo@eJoGyBy@iCe@sDHmG|AyXUeAq@sDkGqAeDi@g@eAWyAZmAfBSdAPzCa@jA{An@{BKaAi@q@uA?{BWc@qHgDaGqEc@q@Qw@Dy@j@oCfA_CNuAOcAe@a@kEQc@QSe@Dk@n@qADgAaBsFtBoDFqBQcBaAkDiAuBcAoAaD_Am@y@WcACaCn@aEwAeI\\sD|BoC|CKpCuA~AsAjAeB",
|
||||
"resource_state": 2
|
||||
},
|
||||
"trainer": false,
|
||||
"commute": false,
|
||||
"manual": false,
|
||||
"private": false,
|
||||
"visibility": "everyone",
|
||||
"flagged": false,
|
||||
"gear_id": "b13730672",
|
||||
"start_latlng": [
|
||||
-1.439482,
|
||||
149.612514
|
||||
],
|
||||
"end_latlng": [
|
||||
-1.41586,
|
||||
149.622902
|
||||
],
|
||||
"average_speed": 7.812,
|
||||
"max_speed": 17.48,
|
||||
"average_cadence": 86.7,
|
||||
"average_watts": 125.3,
|
||||
"max_watts": 162,
|
||||
"weighted_average_watts": 128,
|
||||
"device_watts": true,
|
||||
"kilojoules": 455.0,
|
||||
"has_heartrate": true,
|
||||
"average_heartrate": 140.0,
|
||||
"max_heartrate": 157.0,
|
||||
"heartrate_opt_out": false,
|
||||
"display_hide_heartrate_option": true,
|
||||
"elev_high": 70.0,
|
||||
"elev_low": 39.2,
|
||||
"upload_id": 16012254216,
|
||||
"upload_id_str": "16012254216",
|
||||
"external_id": "file.dat.fit",
|
||||
"from_accepted_tag": false,
|
||||
"pr_count": 0,
|
||||
"total_photo_count": 0,
|
||||
"has_kudoed": false
|
||||
},
|
||||
{
|
||||
"resource_state": 2,
|
||||
"athlete": {
|
||||
"id": 57387663,
|
||||
"resource_state": 1
|
||||
},
|
||||
"name": "Lunch Walk",
|
||||
"distance": 880.0,
|
||||
"moving_time": 690,
|
||||
"elapsed_time": 722,
|
||||
"total_elevation_gain": 3.0,
|
||||
"type": "Walk",
|
||||
"sport_type": "Walk",
|
||||
"id": 14996947783,
|
||||
"start_date": "2025-07-03T17:51:25Z",
|
||||
"start_date_local": "2025-07-03T12:51:25Z",
|
||||
"timezone": "(GMT-06:00) America/Chicago",
|
||||
"utc_offset": -18000.0,
|
||||
"location_city": null,
|
||||
"location_state": null,
|
||||
"location_country": null,
|
||||
"achievement_count": 0,
|
||||
"kudos_count": 4,
|
||||
"comment_count": 0,
|
||||
"athlete_count": 1,
|
||||
"photo_count": 0,
|
||||
"map": {
|
||||
"id": "a14996947783",
|
||||
"summary_polyline": "ied}E|_ltQ?qB@yEAk@?eAD[v@A^@`@C\\B`AADtBEnB",
|
||||
"resource_state": 2
|
||||
},
|
||||
"trainer": false,
|
||||
"commute": false,
|
||||
"manual": false,
|
||||
"private": false,
|
||||
"visibility": "everyone",
|
||||
"flagged": false,
|
||||
"gear_id": null,
|
||||
"start_latlng": [
|
||||
36.399217,
|
||||
-97.880887
|
||||
],
|
||||
"end_latlng": [
|
||||
36.398729,
|
||||
-97.880043
|
||||
],
|
||||
"average_speed": 1.275,
|
||||
"max_speed": 2.5,
|
||||
"average_cadence": 51.3,
|
||||
"has_heartrate": true,
|
||||
"average_heartrate": 99.9,
|
||||
"max_heartrate": 108.0,
|
||||
"heartrate_opt_out": false,
|
||||
"display_hide_heartrate_option": true,
|
||||
"elev_high": 387.0,
|
||||
"elev_low": 384.0,
|
||||
"upload_id": 16009366898,
|
||||
"upload_id_str": "16009366898",
|
||||
"external_id": "470182370203435011.fit",
|
||||
"from_accepted_tag": false,
|
||||
"pr_count": 0,
|
||||
"total_photo_count": 0,
|
||||
"has_kudoed": false
|
||||
},
|
||||
{
|
||||
"resource_state": 2,
|
||||
"athlete": {
|
||||
"id": 57387663,
|
||||
"resource_state": 1
|
||||
},
|
||||
"name": "Get the movement in",
|
||||
"distance": 1752.1,
|
||||
"moving_time": 1447,
|
||||
"elapsed_time": 1447,
|
||||
"total_elevation_gain": 0.0,
|
||||
"type": "Walk",
|
||||
"sport_type": "Walk",
|
||||
"id": 14992813188,
|
||||
"start_date": "2025-07-03T10:22:50Z",
|
||||
"start_date_local": "2025-07-03T05:22:50Z",
|
||||
"timezone": "(GMT-06:00) America/Chicago",
|
||||
"utc_offset": -18000.0,
|
||||
"location_city": null,
|
||||
"location_state": null,
|
||||
"location_country": null,
|
||||
"achievement_count": 0,
|
||||
"kudos_count": 5,
|
||||
"comment_count": 0,
|
||||
"athlete_count": 1,
|
||||
"photo_count": 0,
|
||||
"map": {
|
||||
"id": "a14992813188",
|
||||
"summary_polyline": "a~b}E|grtQ_@@_@Co@@EBELBj@CnG@`CC~BFbFA`CEdBBn@CDKg@?cGB_AGoA?aFB}@Ay@@eBCq@@u@CqA@GDCZILILA",
|
||||
"resource_state": 2
|
||||
},
|
||||
"trainer": false,
|
||||
"commute": false,
|
||||
"manual": false,
|
||||
"private": false,
|
||||
"visibility": "everyone",
|
||||
"flagged": false,
|
||||
"gear_id": null,
|
||||
"start_latlng": [
|
||||
36.39246,
|
||||
-97.909735
|
||||
],
|
||||
"end_latlng": [
|
||||
36.392535,
|
||||
-97.909878
|
||||
],
|
||||
"average_speed": 1.211,
|
||||
"max_speed": 3.06,
|
||||
"average_cadence": 50.3,
|
||||
"has_heartrate": true,
|
||||
"average_heartrate": 84.1,
|
||||
"max_heartrate": 93.0,
|
||||
"heartrate_opt_out": false,
|
||||
"display_hide_heartrate_option": true,
|
||||
"elev_high": 384.0,
|
||||
"elev_low": 383.0,
|
||||
"upload_id": 16004847991,
|
||||
"upload_id_str": "16004847991",
|
||||
"external_id": "470175341223116808.fit",
|
||||
"from_accepted_tag": false,
|
||||
"pr_count": 0,
|
||||
"total_photo_count": 0,
|
||||
"has_kudoed": false
|
||||
},
|
||||
{
|
||||
"resource_state": 2,
|
||||
"athlete": {
|
||||
"id": 57387663,
|
||||
"resource_state": 1
|
||||
},
|
||||
"name": "Afternoon Ride",
|
||||
"distance": 8484.4,
|
||||
"moving_time": 1478,
|
||||
"elapsed_time": 1478,
|
||||
"total_elevation_gain": 0,
|
||||
"type": "Ride",
|
||||
"sport_type": "Ride",
|
||||
"workout_type": 10,
|
||||
"id": 14946240425,
|
||||
"start_date": "2025-06-28T21:05:49Z",
|
||||
"start_date_local": "2025-06-28T16:05:49Z",
|
||||
"timezone": "(GMT-05:00) America/Atikokan",
|
||||
"utc_offset": -18000.0,
|
||||
"location_city": null,
|
||||
"location_state": null,
|
||||
"location_country": null,
|
||||
"achievement_count": 0,
|
||||
"kudos_count": 1,
|
||||
"comment_count": 0,
|
||||
"athlete_count": 1,
|
||||
"photo_count": 0,
|
||||
"map": {
|
||||
"id": "a14946240425",
|
||||
"summary_polyline": "",
|
||||
"resource_state": 2
|
||||
},
|
||||
"trainer": true,
|
||||
"commute": false,
|
||||
"manual": false,
|
||||
"private": false,
|
||||
"visibility": "everyone",
|
||||
"flagged": false,
|
||||
"gear_id": "b13730672",
|
||||
"start_latlng": [],
|
||||
"end_latlng": [],
|
||||
"average_speed": 5.74,
|
||||
"max_speed": 7.5,
|
||||
"average_cadence": 91.6,
|
||||
"average_watts": 117.2,
|
||||
"max_watts": 198,
|
||||
"weighted_average_watts": 123,
|
||||
"device_watts": true,
|
||||
"kilojoules": 178.0,
|
||||
"has_heartrate": true,
|
||||
"average_heartrate": 139.8,
|
||||
"max_heartrate": 155.0,
|
||||
"heartrate_opt_out": false,
|
||||
"display_hide_heartrate_option": true,
|
||||
"elev_high": 0.0,
|
||||
"elev_low": 0.0,
|
||||
"upload_id": 15953591302,
|
||||
"upload_id_str": "15953591302",
|
||||
"external_id": "470069740855918614.fit",
|
||||
"from_accepted_tag": false,
|
||||
"pr_count": 0,
|
||||
"total_photo_count": 0,
|
||||
"has_kudoed": false
|
||||
}
|
||||
],
|
||||
1751746463.3906252
|
||||
],
|
||||
"formatted_activities": [
|
||||
[
|
||||
{
|
||||
"name": "Morning Ride",
|
||||
"distance": 40.6,
|
||||
"elevation": 614,
|
||||
"time": "2h 22m",
|
||||
"date": "July 04, 2025",
|
||||
"avg_speed": 17.1
|
||||
},
|
||||
{
|
||||
"name": "TrainingPeaks Virtual - Workout: 1x45 @ 60%",
|
||||
"distance": 17.6,
|
||||
"elevation": 479,
|
||||
"time": "1h 0m",
|
||||
"date": "July 03, 2025",
|
||||
"avg_speed": 17.5
|
||||
},
|
||||
{
|
||||
"name": "Afternoon Ride",
|
||||
"distance": 5.3,
|
||||
"elevation": 0,
|
||||
"time": "0h 24m",
|
||||
"date": "June 28, 2025",
|
||||
"avg_speed": 12.8
|
||||
}
|
||||
],
|
||||
1751746463.397715
|
||||
]
|
||||
}
|
||||
352
strava_service.py
Normal file
352
strava_service.py
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import requests
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class StravaService:
|
||||
def __init__(self, cache_file='strava_cache.json'):
|
||||
self.client_id = os.getenv('STRAVA_CLIENT_ID')
|
||||
self.client_secret = os.getenv('STRAVA_CLIENT_SECRET')
|
||||
self.refresh_token = os.getenv('STRAVA_REFRESH_TOKEN')
|
||||
self.access_token = None
|
||||
self.base_url = 'https://www.strava.com/api/v3'
|
||||
|
||||
# 12-hour cache duration (43200 seconds)
|
||||
self.cache_duration = 43200
|
||||
self.cache_file = Path(cache_file)
|
||||
self.cache = self._load_cache()
|
||||
|
||||
# Check if credentials are available
|
||||
if not all([self.client_id, self.client_secret, self.refresh_token]):
|
||||
print("Warning: Strava credentials not found in .env file")
|
||||
self.mock_mode = True
|
||||
else:
|
||||
self.mock_mode = False
|
||||
print("Strava service initialized with API credentials")
|
||||
|
||||
def _load_cache(self):
|
||||
"""Load cache from file"""
|
||||
if self.cache_file.exists():
|
||||
try:
|
||||
with open(self.cache_file, 'r') as f:
|
||||
cache_data = json.load(f)
|
||||
# Clean expired entries on load
|
||||
return self._clean_expired_cache(cache_data)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f"Error loading cache: {e}")
|
||||
return {}
|
||||
|
||||
def _save_cache(self):
|
||||
"""Save cache to file"""
|
||||
try:
|
||||
with open(self.cache_file, 'w') as f:
|
||||
json.dump(self.cache, f, indent=2)
|
||||
except IOError as e:
|
||||
print(f"Error saving cache: {e}")
|
||||
|
||||
def _clean_expired_cache(self, cache_data):
|
||||
"""Remove expired entries from cache"""
|
||||
now = time.time()
|
||||
cleaned_cache = {}
|
||||
|
||||
for key, (data, timestamp) in cache_data.items():
|
||||
if now - timestamp < self.cache_duration:
|
||||
cleaned_cache[key] = (data, timestamp)
|
||||
|
||||
return cleaned_cache
|
||||
|
||||
def _is_cache_valid(self, key):
|
||||
"""Check if cache entry is still valid"""
|
||||
if key not in self.cache:
|
||||
return False
|
||||
|
||||
_, timestamp = self.cache[key]
|
||||
return time.time() - timestamp < self.cache_duration
|
||||
|
||||
def _get_cache_age(self, key):
|
||||
"""Get age of cache entry in hours"""
|
||||
if key not in self.cache:
|
||||
return None
|
||||
|
||||
_, timestamp = self.cache[key]
|
||||
age_seconds = time.time() - timestamp
|
||||
return round(age_seconds / 3600, 1)
|
||||
|
||||
def get_cached_or_fetch(self, key, fetch_function):
|
||||
"""Get data from cache or fetch fresh data"""
|
||||
if self._is_cache_valid(key):
|
||||
data, _ = self.cache[key]
|
||||
cache_age = self._get_cache_age(key)
|
||||
print(f"Using cached {key} (age: {cache_age}h)")
|
||||
return data
|
||||
|
||||
print(f"Cache miss or expired for {key}, fetching fresh data...")
|
||||
data = fetch_function()
|
||||
|
||||
if data:
|
||||
self.cache[key] = (data, time.time())
|
||||
self._save_cache()
|
||||
print(f"Cached fresh {key} data")
|
||||
|
||||
return data
|
||||
|
||||
def get_access_token(self):
|
||||
"""Get a fresh access token using refresh token"""
|
||||
if self.mock_mode:
|
||||
return None
|
||||
|
||||
# Check if we have a cached valid token
|
||||
if self._is_cache_valid('access_token'):
|
||||
token_data, _ = self.cache['access_token']
|
||||
self.access_token = token_data['access_token']
|
||||
return self.access_token
|
||||
|
||||
url = 'https://www.strava.com/oauth/token'
|
||||
data = {
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'refresh_token': self.refresh_token,
|
||||
'grant_type': 'refresh_token'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=data, timeout=10)
|
||||
if response.status_code == 200:
|
||||
token_data = response.json()
|
||||
self.access_token = token_data['access_token']
|
||||
|
||||
# Cache the token with shorter duration (1 hour)
|
||||
self.cache['access_token'] = (token_data, time.time())
|
||||
self._save_cache()
|
||||
|
||||
print("Successfully refreshed Strava access token")
|
||||
return self.access_token
|
||||
else:
|
||||
print(f"Failed to refresh token: {response.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error refreshing token: {e}")
|
||||
return None
|
||||
|
||||
def make_request(self, endpoint):
|
||||
"""Make authenticated request to Strava API"""
|
||||
if self.mock_mode:
|
||||
return None
|
||||
|
||||
if not self.access_token:
|
||||
if not self.get_access_token():
|
||||
return None
|
||||
|
||||
headers = {'Authorization': f'Bearer {self.access_token}'}
|
||||
|
||||
try:
|
||||
response = requests.get(f'{self.base_url}{endpoint}',
|
||||
headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 401:
|
||||
# Token expired, try to refresh
|
||||
print("Token expired, refreshing...")
|
||||
# Clear cached token
|
||||
if 'access_token' in self.cache:
|
||||
del self.cache['access_token']
|
||||
self._save_cache()
|
||||
|
||||
if self.get_access_token():
|
||||
headers = {'Authorization': f'Bearer {self.access_token}'}
|
||||
response = requests.get(f'{self.base_url}{endpoint}',
|
||||
headers=headers, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
print(f"API request failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error making API request: {e}")
|
||||
return None
|
||||
|
||||
def calculate_avg_speed(self, distance_meters, moving_time_seconds):
|
||||
"""Calculate average speed in mph"""
|
||||
if moving_time_seconds == 0:
|
||||
return 0
|
||||
|
||||
# Convert distance from meters to miles
|
||||
distance_miles = distance_meters * 0.000621371
|
||||
# Convert time from seconds to hours
|
||||
time_hours = moving_time_seconds / 3600
|
||||
# Calculate speed in mph
|
||||
avg_speed = distance_miles / time_hours
|
||||
return round(avg_speed, 1)
|
||||
|
||||
def get_athlete_stats(self):
|
||||
"""Get athlete statistics with caching"""
|
||||
if self.mock_mode:
|
||||
return self._mock_athlete_stats()
|
||||
|
||||
def fetch_stats():
|
||||
athlete = self.make_request('/athlete')
|
||||
if athlete:
|
||||
athlete_id = athlete['id']
|
||||
return self.make_request(f'/athletes/{athlete_id}/stats')
|
||||
return None
|
||||
|
||||
return self.get_cached_or_fetch('athlete_stats', fetch_stats)
|
||||
|
||||
def get_recent_activities(self, limit=10):
|
||||
"""Get recent activities with caching"""
|
||||
if self.mock_mode:
|
||||
return self._mock_recent_activities()
|
||||
|
||||
def fetch_activities():
|
||||
return self.make_request(f'/athlete/activities?per_page={limit}')
|
||||
|
||||
return self.get_cached_or_fetch(f'recent_activities_{limit}', fetch_activities)
|
||||
|
||||
def get_ytd_stats(self):
|
||||
"""Get year-to-date statistics with caching"""
|
||||
if self.mock_mode:
|
||||
return self._mock_ytd_stats()
|
||||
|
||||
def fetch_ytd():
|
||||
stats = self.get_athlete_stats()
|
||||
if stats:
|
||||
ytd_ride = stats.get('ytd_ride_totals', {})
|
||||
|
||||
# Calculate average speed for YTD
|
||||
total_distance = ytd_ride.get('distance', 0)
|
||||
total_time = ytd_ride.get('moving_time', 0)
|
||||
avg_speed = self.calculate_avg_speed(total_distance, total_time)
|
||||
|
||||
return {
|
||||
'distance': round(ytd_ride.get('distance', 0) * 0.000621371, 1),
|
||||
'elevation': round(ytd_ride.get('elevation_gain', 0) * 3.28084),
|
||||
'time': round(ytd_ride.get('moving_time', 0) / 3600, 1),
|
||||
'count': ytd_ride.get('count', 0),
|
||||
'avg_speed': avg_speed
|
||||
}
|
||||
return None
|
||||
|
||||
result = self.get_cached_or_fetch('ytd_stats', fetch_ytd)
|
||||
return result if result else self._mock_ytd_stats()
|
||||
|
||||
def format_recent_activities(self):
|
||||
"""Format recent activities for display with caching"""
|
||||
if self.mock_mode:
|
||||
return self._mock_formatted_activities()
|
||||
|
||||
def fetch_formatted():
|
||||
activities = self.get_recent_activities(5)
|
||||
if not activities:
|
||||
return None
|
||||
|
||||
formatted = []
|
||||
for activity in activities:
|
||||
if activity['type'] in ['Ride', 'VirtualRide']:
|
||||
# Calculate average speed for this activity
|
||||
avg_speed = self.calculate_avg_speed(
|
||||
activity.get('distance', 0),
|
||||
activity.get('moving_time', 0)
|
||||
)
|
||||
|
||||
formatted.append({
|
||||
'name': activity['name'],
|
||||
'distance': round(activity['distance'] * 0.000621371, 1),
|
||||
'elevation': round(activity['total_elevation_gain'] * 3.28084),
|
||||
'time': self.format_time(activity['moving_time']),
|
||||
'date': datetime.strptime(
|
||||
activity['start_date'],
|
||||
'%Y-%m-%dT%H:%M:%SZ'
|
||||
).strftime('%B %d, %Y'),
|
||||
'avg_speed': avg_speed
|
||||
})
|
||||
|
||||
return formatted[:3] if formatted else None
|
||||
|
||||
result = self.get_cached_or_fetch('formatted_activities', fetch_formatted)
|
||||
return result if result else self._mock_formatted_activities()
|
||||
|
||||
def get_cache_status(self):
|
||||
"""Get status of all cached items"""
|
||||
status = {}
|
||||
for key in self.cache:
|
||||
if key != 'access_token': # Don't expose token info
|
||||
age = self._get_cache_age(key)
|
||||
valid = self._is_cache_valid(key)
|
||||
status[key] = {
|
||||
'age_hours': age,
|
||||
'valid': valid,
|
||||
'expires_in_hours': round((self.cache_duration - (time.time() - self.cache[key][1])) / 3600, 1)
|
||||
}
|
||||
return status
|
||||
|
||||
def clear_cache(self, key=None):
|
||||
"""Clear cache (specific key or all)"""
|
||||
if key:
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
print(f"Cleared cache for {key}")
|
||||
else:
|
||||
print(f"No cache found for {key}")
|
||||
else:
|
||||
self.cache.clear()
|
||||
print("Cleared all cache")
|
||||
|
||||
self._save_cache()
|
||||
|
||||
def format_time(self, seconds):
|
||||
"""Convert seconds to readable time format"""
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
return f"{hours}h {minutes}m"
|
||||
|
||||
# Mock data methods for fallback (unchanged)
|
||||
def _mock_ytd_stats(self):
|
||||
return {
|
||||
'distance': 2450.5,
|
||||
'count': 127,
|
||||
'elevation': 45600,
|
||||
'time': 156.2,
|
||||
'avg_speed': 15.7
|
||||
}
|
||||
|
||||
def _mock_formatted_activities(self):
|
||||
return [
|
||||
{
|
||||
'name': 'Morning Training Ride',
|
||||
'distance': 25.3,
|
||||
'elevation': 1200,
|
||||
'time': '1h 45m',
|
||||
'date': 'January 5, 2025',
|
||||
'avg_speed': 14.5
|
||||
},
|
||||
{
|
||||
'name': 'Weekend Century',
|
||||
'distance': 102.1,
|
||||
'elevation': 3500,
|
||||
'time': '5h 30m',
|
||||
'date': 'January 3, 2025',
|
||||
'avg_speed': 18.6
|
||||
},
|
||||
{
|
||||
'name': 'Recovery Spin',
|
||||
'distance': 15.2,
|
||||
'elevation': 400,
|
||||
'time': '45m',
|
||||
'date': 'January 1, 2025',
|
||||
'avg_speed': 20.3
|
||||
}
|
||||
]
|
||||
|
||||
def _mock_recent_activities(self):
|
||||
return []
|
||||
|
||||
def _mock_athlete_stats(self):
|
||||
return None
|
||||
148
templates/about.html
Normal file
148
templates/about.html
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}About - Blake Ridgway{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h1>About Me</h1>
|
||||
<p class="lead">Welcome! I'm Blake Ridgway, a Systems Administrator passionate about cybersecurity, technology, and ultra endurance cycling.</p>
|
||||
|
||||
<h3>Background</h3>
|
||||
<p>
|
||||
As a Systems Administrator, I focus on ensuring the stability, integrity, and performance of critical IT infrastructure.
|
||||
Through hands-on experience managing Linux, AWS, and Azure environments, I've learned that true system stability
|
||||
is impossible without robust security. This realization has driven me to actively develop my skills in both
|
||||
offensive and defensive security, with a particular focus on application security and secure infrastructure.
|
||||
</p>
|
||||
<p>
|
||||
I believe that understanding how to break systems is the best way to learn how to build unbreakable ones.
|
||||
I'm currently documenting my entire cybersecurity learning journey, including all notes and projects, in my
|
||||
<a href="https://gitlab.com/blakeridgway/cybersec-odyssey" target="_blank" rel="noopener noreferrer">
|
||||
CyberSec Odyssey Repository <i class="fas fa-external-link-alt ms-1"></i>
|
||||
</a>.
|
||||
</p>
|
||||
|
||||
<h3>Core Competencies</h3>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="skill-card p-3 bg-light rounded-3 shadow-sm h-100">
|
||||
<h5><i class="fas fa-server text-primary me-2"></i>Systems Administration</h5>
|
||||
<p class="small mb-0">Expertise in deploying, managing, and troubleshooting Linux and Windows server environments.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="skill-card p-3 bg-light rounded-3 shadow-sm h-100">
|
||||
<h5><i class="fas fa-cloud text-success me-2"></i>Cloud Technologies</h5>
|
||||
<p class="small mb-0">Hands-on experience with AWS and Azure, including EC2, S3, Azure VMs, and virtual networking.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="skill-card p-3 bg-light rounded-3 shadow-sm h-100">
|
||||
<h5><i class="fas fa-shield-alt text-danger me-2"></i>Security & Monitoring</h5>
|
||||
<p class="small mb-0">Implementing monitoring solutions and security practices to proactively identify and resolve issues.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="skill-card p-3 bg-light rounded-3 shadow-sm h-100">
|
||||
<h5><i class="fas fa-network-wired text-info me-2"></i>Network Management</h5>
|
||||
<p class="small mb-0">Practical knowledge of Cisco, Fortigate, and Netgate solutions for network optimization.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="skill-card p-3 bg-light rounded-3 shadow-sm h-100">
|
||||
<h5><i class="fas fa-code text-warning me-2"></i>Automation & Scripting</h5>
|
||||
<p class="small mb-0">Using Bash, PowerShell, and Python to automate tasks and improve efficiency.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="skill-card p-3 bg-light rounded-3 shadow-sm h-100">
|
||||
<h5><i class="fas fa-bicycle text-success me-2"></i>Ultra Endurance Cycling</h5>
|
||||
<p class="small mb-0">Combining passion for cycling with data-driven insights and secure development practices.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Technologies & Tools</h3>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="tech-category p-3 bg-white rounded-3 shadow-sm h-100">
|
||||
<h6 class="text-center mb-3">OS & Cloud</h6>
|
||||
<div class="tech-icons d-flex justify-content-center flex-wrap gap-2">
|
||||
<img alt="Linux" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/linux/linux-original.svg" title="Linux" />
|
||||
<img alt="Fedora" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/fedora/fedora-plain.svg" title="Fedora" />
|
||||
<img alt="Debian" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/debian/debian-plain.svg" title="Debian" />
|
||||
<img alt="AWS" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/amazonwebservices/amazonwebservices-original-wordmark.svg" title="AWS" />
|
||||
<img alt="Azure" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/azure/azure-original.svg" title="Azure" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="tech-category p-3 bg-white rounded-3 shadow-sm h-100">
|
||||
<h6 class="text-center mb-3">DevOps & Automation</h6>
|
||||
<div class="tech-icons d-flex justify-content-center flex-wrap gap-2">
|
||||
<img alt="Docker" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/docker/docker-plain.svg" title="Docker" />
|
||||
<img alt="Kubernetes" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/kubernetes/kubernetes-plain.svg" title="Kubernetes" />
|
||||
<img alt="Git" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/git/git-plain.svg" title="Git" />
|
||||
<img alt="Bash" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bash/bash-original.svg" title="Bash" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="tech-category p-3 bg-white rounded-3 shadow-sm h-100">
|
||||
<h6 class="text-center mb-3">Languages & Frameworks</h6>
|
||||
<div class="tech-icons d-flex justify-content-center flex-wrap gap-2">
|
||||
<img alt="C#" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/csharp/csharp-original.svg" title="C#" />
|
||||
<img alt=".NET" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/dotnetcore/dotnetcore-original.svg" title=".NET" />
|
||||
<img alt="Python" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-original.svg" title="Python" />
|
||||
<img alt="Ruby" width="30" src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-plain.svg" title="Ruby" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="tech-category p-3 bg-white rounded-3 shadow-sm h-100">
|
||||
<h6 class="text-center mb-3">Security & Monitoring</h6>
|
||||
<div class="tech-icons d-flex justify-content-center flex-wrap gap-2">
|
||||
<i class="fab fa-linux fa-2x text-dark" title="Linux Security"></i>
|
||||
<i class="fas fa-shield-alt fa-2x text-primary" title="Security"></i>
|
||||
<i class="fas fa-chart-line fa-2x text-success" title="Monitoring"></i>
|
||||
<i class="fas fa-lock fa-2x text-danger" title="Encryption"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center">
|
||||
<img src="{{ url_for('static', filename='images/profile.jpg') }}"
|
||||
alt="Blake Ridgway Profile Picture" class="img-fluid rounded-circle mb-3"
|
||||
style="max-width: 200px;">
|
||||
<h5>Blake Ridgway</h5>
|
||||
<p class="text-muted">Systems Administrator & Cybersecurity Enthusiast</p>
|
||||
<div class="d-flex justify-content-center gap-2 flex-wrap">
|
||||
<span class="badge bg-primary">Systems Admin</span>
|
||||
<span class="badge bg-success">Cybersecurity Enthusiast</span>
|
||||
<span class="badge bg-info">Linux & Open Source Advocate</span>
|
||||
<span class="badge bg-warning">Ultra Endurance Cyclist</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Quick Links</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled">
|
||||
<li><a href="{{ url_for('hardware') }}" class="text-decoration-none"><i class="fas fa-desktop me-2"></i>Hardware Setup</a></li>
|
||||
<li><a href="{{ url_for('biking') }}" class="text-decoration-none"><i class="fas fa-bicycle me-2"></i>Cycling Adventures</a></li>
|
||||
<li><a href="{{ url_for('blog') }}" class="text-decoration-none"><i class="fas fa-blog me-2"></i>Blog Posts</a></li>
|
||||
<li><a href="https://gitlab.com/blakeridgway/cybersec-odyssey" target="_blank" rel="noopener noreferrer" class="text-decoration-none"><i class="fab fa-gitlab me-2"></i>CyberSec Repository</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
76
templates/admin/dashboard.html
Normal file
76
templates/admin/dashboard.html
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - Your Name{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('admin_new_post') }}" class="btn btn-primary">New Post</a>
|
||||
<a href="{{ url_for('admin_logout') }}" class="btn btn-outline-secondary">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Blog Posts ({{ posts|length }})</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if posts %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Category</th>
|
||||
<th>Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ post.title }}</strong><br>
|
||||
<small class="text-muted">{{ post.excerpt[:100] }}...</small>
|
||||
</td>
|
||||
<td><span class="badge bg-secondary">{{ post.category }}</span></td>
|
||||
<td>{{ post.date }}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('blog_post', post_id=post.id) }}"
|
||||
class="btn btn-outline-primary" target="_blank">View</a>
|
||||
<a href="{{ url_for('admin_edit_post', post_id=post.id) }}"
|
||||
class="btn btn-outline-warning">Edit</a>
|
||||
<form method="POST" action="{{ url_for('admin_delete_post', post_id=post.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this post?')">
|
||||
<button type="submit" class="btn btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted">No blog posts yet.</p>
|
||||
<a href="{{ url_for('admin_new_post') }}" class="btn btn-primary">Create Your First Post</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
98
templates/admin/edit_post.html
Normal file
98
templates/admin/edit_post.html
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ title }} - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>{{ title }}</h1>
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.title.label(class="form-label") }}
|
||||
{{ form.title(class="form-control") }}
|
||||
{% if form.title.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.title.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
{{ form.category.label(class="form-label") }}
|
||||
{{ form.category(class="form-select") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.excerpt.label(class="form-label") }}
|
||||
{{ form.excerpt(class="form-control", rows="3", placeholder="Auto-generated if left blank") }}
|
||||
<div class="form-text">Brief description shown in blog listings</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.content.label(class="form-label") }}
|
||||
{{ form.content(class="form-control") }}
|
||||
{% if form.content.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.content.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
{% if post %}
|
||||
<a href="{{ url_for('blog_post', post_id=post.id) }}"
|
||||
class="btn btn-outline-info" target="_blank">Preview</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Writing Tips</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small">
|
||||
<li>Use clear, descriptive titles</li>
|
||||
<li>Break up long paragraphs</li>
|
||||
<li>Include code examples when relevant</li>
|
||||
<li>Add personal insights and experiences</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Markdown Support</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small">You can use basic HTML in your posts:</p>
|
||||
<code><strong>bold</strong></code><br>
|
||||
<code><em>italic</em></code><br>
|
||||
<code><code>code</code></code><br>
|
||||
<code><pre>code block</pre></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
45
templates/admin/login.html
Normal file
45
templates/admin/login.html
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Login - Your Name{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">Admin Login</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
216
templates/admin/traffic.html
Normal file
216
templates/admin/traffic.html
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Traffic Analytics - Admin</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
||||
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.stat-number { font-size: 2em; font-weight: bold; color: #2563eb; }
|
||||
.stat-label { color: #666; margin-top: 5px; }
|
||||
.chart-container { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 30px; }
|
||||
.table-container { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
|
||||
th { background: #f8f9fa; font-weight: 600; }
|
||||
h1 { color: #333; margin-bottom: 30px; }
|
||||
h2 { color: #333; margin-bottom: 15px; }
|
||||
.controls { margin-bottom: 20px; }
|
||||
.controls select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.real-time-btn { background: #10b981; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.real-time-btn:hover { background: #059669; }
|
||||
.activity-item { padding: 8px; margin: 4px 0; background: #f8f9fa; border-radius: 4px; font-size: 0.9em; }
|
||||
.activity-time { color: #666; font-size: 0.8em; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
@media (max-width: 768px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
|
||||
<h1>Traffic Analytics Dashboard</h1>
|
||||
<div class="controls">
|
||||
<select id="dateRange">
|
||||
<option value="7" {% if days == 7 %}selected{% endif %}>Last 7 days</option>
|
||||
<option value="30" {% if days == 30 %}selected{% endif %}>Last 30 days</option>
|
||||
<option value="90" {% if days == 90 %}selected{% endif %}>Last 90 days</option>
|
||||
</select>
|
||||
<button id="realTimeBtn" class="real-time-btn">
|
||||
Real-time: <span id="activeUsers">0</span> active
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ "{:,}".format(stats.total_views) }}</div>
|
||||
<div class="stat-label">Total Page Views</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ "{:,}".format(stats.unique_visitors) }}</div>
|
||||
<div class="stat-label">Unique Visitors</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ stats.avg_response_time }}ms</div>
|
||||
<div class="stat-label">Avg Response Time</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ stats.bounce_rate }}%</div>
|
||||
<div class="stat-label">Bounce Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Traffic Chart -->
|
||||
<div class="chart-container">
|
||||
<h2>Daily Traffic (Last {{ days }} Days)</h2>
|
||||
<canvas id="dailyTrafficChart" width="400" height="100"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Tables -->
|
||||
<div class="grid-2">
|
||||
<div class="table-container">
|
||||
<h2>Top Pages</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page</th>
|
||||
<th>Views</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for page, views in top_pages %}
|
||||
<tr>
|
||||
<td>{{ page }}</td>
|
||||
<td>{{ views }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<h2>Top Referrers</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Referrer</th>
|
||||
<th>Views</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for referrer, views in top_referrers %}
|
||||
<tr>
|
||||
<td>{{ referrer[:50] }}{% if referrer|length > 50 %}...{% endif %}</td>
|
||||
<td>{{ views }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="table-container">
|
||||
<h2>Recent Activity (Last 24 Hours)</h2>
|
||||
<div id="recentActivity" style="max-height: 300px; overflow-y: auto;">
|
||||
{% for activity in recent_activity %}
|
||||
<div class="activity-item">
|
||||
<strong>{{ activity.path }}</strong>
|
||||
{% if activity.referrer %}
|
||||
from {{ activity.referrer[:30] }}{% if activity.referrer|length > 30 %}...{% endif %}
|
||||
{% endif %}
|
||||
<div class="activity-time">{{ activity.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} - {{ activity.ip_address[:8] }}...</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Chart.js configurations - Fixed tojsonfilter to tojson
|
||||
const dailyData = {{ daily_views | tojson | safe }};
|
||||
|
||||
// Daily Traffic Chart
|
||||
const ctx = document.getElementById('dailyTrafficChart').getContext('2d');
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dailyData.map(d => d.date),
|
||||
datasets: [{
|
||||
label: 'Page Views',
|
||||
data: dailyData.map(d => d.views),
|
||||
borderColor: 'rgb(37, 99, 235)',
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
}, {
|
||||
label: 'Unique Visitors',
|
||||
data: dailyData.map(d => d.unique_visitors),
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.1,
|
||||
fill: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time updates
|
||||
function updateRealTime() {
|
||||
fetch('/admin/traffic/api/realtime')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('activeUsers').textContent = data.active_users;
|
||||
|
||||
const activityDiv = document.getElementById('recentActivity');
|
||||
if (data.recent_views && data.recent_views.length > 0) {
|
||||
activityDiv.innerHTML = data.recent_views.map(view => `
|
||||
<div class="activity-item">
|
||||
<strong>${view.path}</strong>
|
||||
<div class="activity-time">
|
||||
${new Date(view.timestamp).toLocaleString()} - ${view.ip_address}
|
||||
${view.country ? ' (' + view.country + ')' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Real-time update failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Update every 30 seconds
|
||||
setInterval(updateRealTime, 30000);
|
||||
updateRealTime(); // Initial load
|
||||
|
||||
// Date range selector
|
||||
document.getElementById('dateRange').addEventListener('change', function() {
|
||||
window.location.href = `?days=${this.value}`;
|
||||
});
|
||||
|
||||
// Real-time button click
|
||||
document.getElementById('realTimeBtn').addEventListener('click', function() {
|
||||
updateRealTime();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
70
templates/base.html
Normal file
70
templates/base.html
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Blake Ridgway - Personal Website{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('index') }}">Blake Ridgway</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('index') }}">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('about') }}">About</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('hardware') }}">Hardware</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('biking') }}">Biking Adventures</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('blog') }}">Blog</a>
|
||||
</li>
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_dashboard') }}">Admin</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container mt-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="bg-dark text-light mt-5 py-4">
|
||||
<div class="container">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6 text-center text-md-start">
|
||||
<p class="mb-0">© 2025 Blake Ridgway. All rights reserved.</p>
|
||||
</div>
|
||||
<div class="col-md-6 text-center text-md-end">
|
||||
<div class="social-links">
|
||||
<a href="mailto:blake@blakeridway.com" class="social-link" title="Email"><i class="fas fa-envelope"></i></a>
|
||||
<a href="https://gitlab.com/blakeridgway" class="social-link" title="GitLab" target="_blank" rel="noopener"><i class="fab fa-gitlab"></i></a>
|
||||
<a href="https://floss.social/@blake" class="social-link" title="Mastodon" target="_blank" rel="noopener"><i class="fab fa-mastodon"></i></a>
|
||||
<a href="https://youtube.com/@BlakeRidgway" class="social-link" title="YouTube" target="_blank" rel="noopener"><i class="fab fa-youtube"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
354
templates/biking.html
Normal file
354
templates/biking.html
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Biking Adventures - Blake Ridgway{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h1>Biking Adventures</h1>
|
||||
<p class="lead">Exploring the world on two wheels as an ultra endurance cyclist - powered by real-time Strava data!</p>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3><i class="fas fa-route text-primary me-2"></i>Recent Adventures</h3>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="refreshStravaData()">
|
||||
<span id="refresh-icon">🔄</span> Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="recent-activities">
|
||||
{% if recent_activities %}
|
||||
{% for activity in recent_activities %}
|
||||
<div class="card mb-3 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-bicycle text-success me-2"></i>{{ activity.name }}
|
||||
</h5>
|
||||
<p class="card-text text-muted">Latest ride from Strava - automatically synced!</p>
|
||||
</div>
|
||||
<span class="badge bg-primary">{{ activity.date }}</span>
|
||||
</div>
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-box p-2 bg-light rounded text-center">
|
||||
<i class="fas fa-road text-primary"></i>
|
||||
<div><strong>{{ activity.distance }}</strong></div>
|
||||
<small class="text-muted">Miles</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-box p-2 bg-light rounded text-center">
|
||||
<i class="fas fa-mountain text-warning"></i>
|
||||
<div><strong>{{ activity.elevation }}</strong></div>
|
||||
<small class="text-muted">Feet</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-box p-2 bg-light rounded text-center">
|
||||
<i class="fas fa-clock text-info"></i>
|
||||
<div><strong>{{ activity.time }}</strong></div>
|
||||
<small class="text-muted">Duration</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-box p-2 bg-light rounded text-center">
|
||||
<i class="fas fa-tachometer-alt text-success"></i>
|
||||
<div><strong>{{ activity.avg_speed or 'N/A' }}</strong></div>
|
||||
<small class="text-muted">Avg MPH</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="alert alert-info border-start border-primary border-4">
|
||||
<i class="fas fa-info-circle me-2"></i>Loading Strava data...
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h3 class="mt-5 mb-4"><i class="fas fa-chart-line text-success me-2"></i>2025 Cycling Performance</h3>
|
||||
<div class="row g-4" id="cycling-stats">
|
||||
{% if ytd_stats %}
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="skill-card text-center p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="stat-icon mb-3">
|
||||
<i class="fas fa-road fa-2x text-primary"></i>
|
||||
</div>
|
||||
<h4 class="text-primary mb-2">{{ ytd_stats.distance }}</h4>
|
||||
<p class="mb-0">Miles This Year</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="skill-card text-center p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="stat-icon mb-3">
|
||||
<i class="fas fa-bicycle fa-2x text-success"></i>
|
||||
</div>
|
||||
<h4 class="text-success mb-2">{{ ytd_stats.count }}</h4>
|
||||
<p class="mb-0">Rides Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="skill-card text-center p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="stat-icon mb-3">
|
||||
<i class="fas fa-mountain fa-2x text-warning"></i>
|
||||
</div>
|
||||
<h4 class="text-warning mb-2">{{ ytd_stats.elevation }}</h4>
|
||||
<p class="mb-0">Feet Climbed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="skill-card text-center p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="stat-icon mb-3">
|
||||
<i class="fas fa-clock fa-2x text-info"></i>
|
||||
</div>
|
||||
<h4 class="text-info mb-2">{{ ytd_stats.time }}</h4>
|
||||
<p class="mb-0">Hours Riding</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning border-start border-warning border-4">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>Unable to load Strava stats. Please check your API connection.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-bicycle me-2"></i>My Bikes</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="bike-item mb-4">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="fas fa-road fa-lg text-primary me-3"></i>
|
||||
<div>
|
||||
<h6 class="mb-1">Road Bike</h6>
|
||||
<p class="mb-0 text-muted">Giant TCR Advanced 2</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bike-specs">
|
||||
<span class="badge bg-light text-dark me-1">Carbon Frame</span>
|
||||
<span class="badge bg-light text-dark me-1">Aero</span>
|
||||
<span class="badge bg-light text-dark">Racing</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bike-item">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="fas fa-mountain fa-lg text-warning me-3"></i>
|
||||
<div>
|
||||
<h6 class="mb-1">Gravel Bike</h6>
|
||||
<p class="mb-0 text-muted">Canyon Endurace CF SL 8</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bike-specs">
|
||||
<span class="badge bg-light text-dark me-1">Endurance</span>
|
||||
<span class="badge bg-light text-dark me-1">All-Road</span>
|
||||
<span class="badge bg-light text-dark">Adventure</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-trophy me-2"></i>Cycling Goals 2025</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="goal-item mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-road text-primary me-2"></i>Annual Distance</span>
|
||||
<span class="badge bg-primary">5,000 mi</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="goal-item mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-mountain text-warning me-2"></i>Elevation Gain</span>
|
||||
<span class="badge bg-warning">250,000 ft</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="goal-item mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-bicycle text-success me-2"></i>Total Rides</span>
|
||||
<span class="badge bg-success">200 rides</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="goal-item">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-medal text-danger me-2"></i>Ultra Events</span>
|
||||
<span class="badge bg-danger">3 events</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-info-circle me-2"></i>Data Source</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
<i class="fab fa-strava text-danger me-2"></i>
|
||||
<strong>Powered by Strava API</strong>
|
||||
</p>
|
||||
<p class="small text-muted mb-3">
|
||||
Real-time cycling data automatically synced from my Strava account.
|
||||
</p>
|
||||
<p class="small text-muted mb-0">
|
||||
<strong>Last updated:</strong> <span id="last-updated">Just now</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function refreshStravaData() {
|
||||
const refreshIcon = document.getElementById('refresh-icon');
|
||||
refreshIcon.style.animation = 'spin 1s linear infinite';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/strava-stats');
|
||||
const data = await response.json();
|
||||
|
||||
// Update stats
|
||||
if (data.ytd_stats) {
|
||||
updateStats(data.ytd_stats);
|
||||
}
|
||||
|
||||
// Update recent activities
|
||||
if (data.recent_activities) {
|
||||
updateRecentActivities(data.recent_activities);
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
document.getElementById('last-updated').textContent = new Date().toLocaleString();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing Strava data:', error);
|
||||
} finally {
|
||||
refreshIcon.style.animation = '';
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(stats) {
|
||||
const statsContainer = document.getElementById('cycling-stats');
|
||||
statsContainer.innerHTML = `
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="skill-card text-center p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="stat-icon mb-3">
|
||||
<i class="fas fa-road fa-2x text-primary"></i>
|
||||
</div>
|
||||
<h4 class="text-primary mb-2">${stats.distance}</h4>
|
||||
<p class="mb-0">Miles This Year</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="skill-card text-center p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="stat-icon mb-3">
|
||||
<i class="fas fa-bicycle fa-2x text-success"></i>
|
||||
</div>
|
||||
<h4 class="text-success mb-2">${stats.count}</h4>
|
||||
<p class="mb-0">Rides Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="skill-card text-center p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="stat-icon mb-3">
|
||||
<i class="fas fa-mountain fa-2x text-warning"></i>
|
||||
</div>
|
||||
<h4 class="text-warning mb-2">${stats.elevation}</h4>
|
||||
<p class="mb-0">Feet Climbed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="skill-card text-center p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="stat-icon mb-3">
|
||||
<i class="fas fa-clock fa-2x text-info"></i>
|
||||
</div>
|
||||
<h4 class="text-info mb-2">${stats.time}</h4>
|
||||
<p class="mb-0">Hours Riding</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateRecentActivities(activities) {
|
||||
const container = document.getElementById('recent-activities');
|
||||
if (activities.length === 0) {
|
||||
container.innerHTML = '<div class="alert alert-info border-start border-primary border-4"><i class="fas fa-info-circle me-2"></i>No recent activities found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = activities.map(activity => `
|
||||
<div class="card mb-3 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-bicycle text-success me-2"></i>${activity.name}
|
||||
</h5>
|
||||
<p class="card-text text-muted">Latest ride from Strava - automatically synced!</p>
|
||||
</div>
|
||||
<span class="badge bg-primary">${activity.date}</span>
|
||||
</div>
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-box p-2 bg-light rounded text-center">
|
||||
<i class="fas fa-road text-primary"></i>
|
||||
<div><strong>${activity.distance}</strong></div>
|
||||
<small class="text-muted">Miles</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-box p-2 bg-light rounded text-center">
|
||||
<i class="fas fa-mountain text-warning"></i>
|
||||
<div><strong>${activity.elevation}</strong></div>
|
||||
<small class="text-muted">Feet</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-box p-2 bg-light rounded text-center">
|
||||
<i class="fas fa-clock text-info"></i>
|
||||
<div><strong>${activity.time}</strong></div>
|
||||
<small class="text-muted">Duration</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-box p-2 bg-light rounded text-center">
|
||||
<i class="fas fa-tachometer-alt text-success"></i>
|
||||
<div><strong>${activity.avg_speed || 'N/A'}</strong></div>
|
||||
<small class="text-muted">Avg MPH</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Auto-refresh every 5 minutes
|
||||
setInterval(refreshStravaData, 300000);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-box:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
78
templates/blog.html
Normal file
78
templates/blog.html
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Blog - Blake Ridgway{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h1><i class="fas fa-blog text-primary me-2"></i>Blog</h1>
|
||||
<p class="lead">Thoughts on cybersecurity, systems administration, technology, cycling, and the journey of continuous learning.</p>
|
||||
|
||||
{% if posts %}
|
||||
{% for post in posts %}
|
||||
<article class="card mb-4 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<h2 class="card-title h4 mb-2">
|
||||
<a href="{{ url_for('blog_post', post_id=post.id) }}" class="text-decoration-none">
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h2>
|
||||
<div class="post-meta mb-2">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-calendar-alt me-1"></i>{{ post.date }}
|
||||
<i class="fas fa-user ms-3 me-1"></i>Blake Ridgway
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge bg-primary ms-3">{{ post.category }}</span>
|
||||
</div>
|
||||
|
||||
<p class="card-text">{{ post.excerpt }}</p>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<a href="{{ url_for('blog_post', post_id=post.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-arrow-right me-1"></i>Read More
|
||||
</a>
|
||||
<div class="post-tags">
|
||||
{% if post.tags %}
|
||||
{% for tag in post.tags %}
|
||||
<span class="badge bg-light text-dark me-1">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Pagination implement it later)
|
||||
<nav aria-label="Blog pagination" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Previous</span>
|
||||
</li>
|
||||
<li class="page-item active">
|
||||
<span class="page-link">1</span>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Next</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
-->
|
||||
{% else %}
|
||||
<div class="alert alert-info border-start border-primary border-4 shadow-sm">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-info-circle fa-2x text-primary me-3"></i>
|
||||
<div>
|
||||
<h4 class="alert-heading mb-2">No posts yet!</h4>
|
||||
<p class="mb-0">I'm currently working on some exciting content about cybersecurity, systems administration, and my cycling adventures. Check back soon for new posts!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
287
templates/blog_post.html
Normal file
287
templates/blog_post.html
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - Blake Ridgway{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<article>
|
||||
<header class="mb-4">
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ url_for('index') }}" class="text-decoration-none">
|
||||
<i class="fas fa-home me-1"></i>Home
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ url_for('blog') }}" class="text-decoration-none">
|
||||
<i class="fas fa-blog me-1"></i>Blog
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ post.title }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="display-5 mb-3">{{ post.title }}</h1>
|
||||
|
||||
<div class="post-meta mb-4 p-3 bg-light rounded-3">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex align-items-center mb-2 mb-md-0">
|
||||
<img src="{{ url_for('static', filename='images/profile.jpg') }}"
|
||||
alt="Blake Ridgway"
|
||||
class="rounded-circle me-3"
|
||||
width="40" height="40">
|
||||
<div>
|
||||
<div class="fw-bold">Blake Ridgway</div>
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-calendar-alt me-1"></i>{{ post.date }}
|
||||
<i class="fas fa-clock ms-3 me-1"></i>~5 min read
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end">
|
||||
<span class="badge bg-primary fs-6 px-3 py-2">
|
||||
<i class="fas fa-tag me-1"></i>{{ post.category }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="blog-content">
|
||||
<div class="content-wrapper p-4 bg-white rounded-3 shadow-sm">
|
||||
{{ post.content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Tags (if available) -->
|
||||
{% if post.tags %}
|
||||
<div class="post-tags mt-4">
|
||||
<h6 class="mb-2">Tags:</h6>
|
||||
{% for tag in post.tags %}
|
||||
<span class="badge bg-light text-dark me-2 mb-2">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<hr class="my-5">
|
||||
|
||||
<!-- Navigation and Actions -->
|
||||
<div class="post-actions">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<a href="{{ url_for('blog') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Back to Blog
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<button class="btn btn-outline-success me-2" onclick="sharePost()">
|
||||
<i class="fas fa-share-alt me-2"></i>Share Post
|
||||
</button>
|
||||
<button class="btn btn-outline-info" onclick="printPost()">
|
||||
<i class="fas fa-print me-2"></i>Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<!-- Table of Contents -->
|
||||
|
||||
|
||||
<!-- Post Information -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-info-circle me-2"></i>Post Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="detail-item mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><i class="fas fa-tag text-primary me-2"></i>Category:</span>
|
||||
<span class="badge bg-primary">{{ post.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><i class="fas fa-calendar-alt text-success me-2"></i>Published:</span>
|
||||
<span>{{ post.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><i class="fas fa-clock text-warning me-2"></i>Reading Time:</span>
|
||||
<span>~5 min</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><i class="fas fa-user text-info me-2"></i>Author:</span>
|
||||
<span>Blake Ridgway</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share This Post -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-share-alt me-2"></i>Share This Post</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="sharePost()">
|
||||
<i class="fas fa-link me-2"></i>Copy Link
|
||||
</button>
|
||||
<button class="btn btn-outline-info btn-sm" onclick="shareTwitter()">
|
||||
<i class="fab fa-twitter me-2"></i>Share on Twitter
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="shareLinkedIn()">
|
||||
<i class="fab fa-linkedin me-2"></i>Share on LinkedIn
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Author Bio -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-user-circle me-2"></i>About the Author</h5>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<img src="{{ url_for('static', filename='images/profile.jpg') }}"
|
||||
alt="Blake Ridgway"
|
||||
class="rounded-circle mb-3"
|
||||
width="80" height="80">
|
||||
<h6>Blake Ridgway</h6>
|
||||
<p class="small text-muted mb-3">
|
||||
Systems Administrator & Cybersecurity Enthusiast passionate about secure infrastructure and continuous learning.
|
||||
</p>
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<a href="{{ url_for('about') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-user me-1"></i>About
|
||||
</a>
|
||||
<a href="https://gitlab.com/blakeridgway/" target="_blank" rel="noopener noreferrer" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fab fa-gitlab me-1"></i>GitLab
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function sharePost() {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: '{{ post.title }}',
|
||||
text: 'Check out this blog post by Blake Ridgway',
|
||||
url: window.location.href
|
||||
});
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
showToast('Link copied to clipboard!');
|
||||
}).catch(() => {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = window.location.href;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
showToast('Link copied to clipboard!');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function shareTwitter() {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
const text = encodeURIComponent('{{ post.title }} by @BlakeRidgway');
|
||||
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank');
|
||||
}
|
||||
|
||||
function shareLinkedIn() {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank');
|
||||
}
|
||||
|
||||
function printPost() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
// Simple toast notification
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'alert alert-success position-fixed';
|
||||
toast.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 250px;';
|
||||
toast.innerHTML = `<i class="fas fa-check-circle me-2"></i>${message}`;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Smooth scrolling for table of contents
|
||||
document.querySelectorAll('.toc a').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.blog-content {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.blog-content h2, .blog-content h3, .blog-content h4 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.blog-content p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.blog-content code {
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.blog-content pre {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.blog-content blockquote {
|
||||
border-left: 4px solid #007bff;
|
||||
padding-left: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.col-md-4 {
|
||||
display: none;
|
||||
}
|
||||
.col-md-8 {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
201
templates/hardware.html
Normal file
201
templates/hardware.html
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Hardware - Blake Ridgway{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h1>My Hardware Setup</h1>
|
||||
<p class="lead">A showcase of my current tech setup and hardware projects focused on systems administration, cybersecurity, and development.</p>
|
||||
|
||||
<h3>Current PC Build</h3>
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0"><i class="fas fa-desktop me-2"></i>Main Workstation</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fab fa-fedora text-primary me-2"></i>OS:</strong> Fedora 42 Workstation
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-microchip text-info me-2"></i>CPU:</strong> Intel Core i9-12900K
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-tv text-success me-2"></i>GPU:</strong> Intel Arc A770
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-memory text-warning me-2"></i>RAM:</strong> Corsair LPX 64GB (3200 MHz)
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-hdd text-danger me-2"></i>NVMe:</strong> Samsung 980 PRO 1TB
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-hdd text-secondary me-2"></i>SSD:</strong> Samsung 860 EVO 500GB
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-hdd text-secondary me-2"></i>HDD:</strong> WD Green 1TB
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-hdd text-secondary me-2"></i>Storage:</strong> Seagate Constellation 4TB
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-microchip text-info me-2"></i>Mobo:</strong> ASRock B660M PRO
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-plug text-warning me-2"></i>PSU:</strong> Corsair CX750M
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-cube text-dark me-2"></i>Case:</strong> CORSAIR 4000D
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Peripherals & Accessories</h3>
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h4 class="mb-0"><i class="fas fa-keyboard me-2"></i>Input & Output Devices</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-desktop text-primary me-2"></i>Primary:</strong> LG 34WQ500-B - 34"
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-desktop text-secondary me-2"></i>Secondary:</strong> ASUS VS248H - 24"
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-keyboard text-info me-2"></i>Keyboard:</strong> System76 Launch
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-mouse text-warning me-2"></i>Mouse:</strong> Logitech G305
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-headphones text-success me-2"></i>Headset:</strong> CCZ DC01 PRO
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-sliders-h text-danger me-2"></i>Mixer:</strong> Pupgsis T12
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="spec-item p-2 bg-light rounded">
|
||||
<strong><i class="fas fa-video text-primary me-2"></i>Webcam:</strong> Logitech C920x
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Current Hardware Projects</h3>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="skill-card p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="project-icon mb-3">
|
||||
<i class="fas fa-server fa-2x text-primary"></i>
|
||||
</div>
|
||||
<h5>Home Server Re-setup</h5>
|
||||
<p class="mb-0">
|
||||
Reinstalling Proxmox on my PowerEdge R720 to host containers and VMs for expanding
|
||||
knowledge in cybersecurity, systems administration, and development projects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="skill-card p-4 bg-light rounded-3 shadow-sm h-100">
|
||||
<div class="project-icon mb-3">
|
||||
<i class="fas fa-shield-alt fa-2x text-danger"></i>
|
||||
</div>
|
||||
<h5>Open Source SOC</h5>
|
||||
<p class="mb-0">
|
||||
Building a Security Operations Center using open source tools like ELK Stack,
|
||||
Wazuh, and MISP for threat detection and incident response.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-chart-pie me-2"></i>System Stats</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="stat-item mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><strong>Total Storage:</strong></span>
|
||||
<span class="badge bg-primary">6.5TB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><strong>RAM:</strong></span>
|
||||
<span class="badge bg-success">64GB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><strong>CPU Cores:</strong></span>
|
||||
<span class="badge bg-warning">16C/24T</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><strong>Display Area:</strong></span>
|
||||
<span class="badge bg-info">58" Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-tools me-2"></i>Hardware Focus</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2"><i class="fas fa-server text-primary me-2"></i>Enterprise Server Hardware</li>
|
||||
<li class="mb-2"><i class="fas fa-network-wired text-success me-2"></i>Network Infrastructure</li>
|
||||
<li class="mb-2"><i class="fas fa-shield-alt text-danger me-2"></i>Security Appliances</li>
|
||||
<li class="mb-2"><i class="fas fa-cloud text-info me-2"></i>Virtualization Platforms</li>
|
||||
<li class="mb-2"><i class="fab fa-linux text-warning me-2"></i>Linux Workstations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
61
templates/index.html
Normal file
61
templates/index.html
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero-section text-center">
|
||||
<div class="container">
|
||||
<!-- Animated Greeting -->
|
||||
<div class="hero-greeting mb-3">
|
||||
<h1 class="mb-3">
|
||||
Greetings, I'm Blake Ridgway
|
||||
<img src="https://media.giphy.com/media/hvRJCLFzcasrR4ia7z/giphy.gif"
|
||||
width="35" height="35"
|
||||
alt="Waving hand"
|
||||
class="wave-animation">
|
||||
</h1>
|
||||
<div class="hero-tagline">
|
||||
<span class="badge bg-primary me-2">Systems Administrator</span>
|
||||
<span class="badge bg-success me-2">Cybersecurity Enthusiast</span>
|
||||
<span class="badge bg-info me-2">Linux & Open Source Advocate</span>
|
||||
<span class="badge bg-warning">Ultra Endurance Cyclist</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mission Statement -->
|
||||
<div class="hero-mission my-4">
|
||||
<p class="lead mb-3">
|
||||
As a Systems Administrator, my focus is on ensuring the stability, integrity, and performance of critical IT infrastructure.
|
||||
Through hands-on experience managing Linux, AWS, and Azure environments, I've learned that
|
||||
<strong class="text-light">true system stability is impossible without robust security</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personal Projects -->
|
||||
<div class="text-center mb-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-lg bg-gradient">
|
||||
<div class="card-body text-white p-4">
|
||||
<h3 class="card-title mb-3">🚴 Personal Projects</h3>
|
||||
<p class="card-text mb-4">
|
||||
Beyond my professional endeavors, I'm an avid cyclist and open-source enthusiast.
|
||||
I'm currently developing a platform to help cyclists train smarter, combining
|
||||
data-driven insights with a strong emphasis on
|
||||
<strong>end-to-end data encryption, user privacy, and secure development practices.</strong>
|
||||
</p>
|
||||
<div class="d-flex justify-content-center gap-3">
|
||||
<a href="/biking" class="btn btn-light">
|
||||
<i class="fas fa-bicycle me-2"></i>View My Cycling Stats
|
||||
</a>
|
||||
<a href="/blog" class="btn btn-outline-light">
|
||||
<i class="fas fa-blog me-2"></i>Read My Blog
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
BIN
traffic_analytics.db
Normal file
BIN
traffic_analytics.db
Normal file
Binary file not shown.
83
traffic_tracker.py
Normal file
83
traffic_tracker.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
from flask import request, g, session
|
||||
from datetime import datetime
|
||||
import time
|
||||
import hashlib
|
||||
from models import db, PageView, UniqueVisitor
|
||||
|
||||
|
||||
class TrafficTracker:
|
||||
def __init__(self, app=None):
|
||||
self.app = app
|
||||
if app is not None:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
app.before_request(self.before_request)
|
||||
app.after_request(self.after_request)
|
||||
|
||||
def before_request(self):
|
||||
g.start_time = time.time()
|
||||
|
||||
if 'session_id' not in session:
|
||||
session['session_id'] = hashlib.md5(
|
||||
f"{request.remote_addr}{time.time()}".encode()
|
||||
).hexdigest()
|
||||
|
||||
def after_request(self, response):
|
||||
# Skip tracking for static files
|
||||
if (request.endpoint and 'static' in request.endpoint):
|
||||
return response
|
||||
|
||||
try:
|
||||
self.track_page_view(response)
|
||||
self.update_unique_visitor()
|
||||
except Exception as e:
|
||||
print(f"Traffic tracking error: {e}")
|
||||
|
||||
return response
|
||||
|
||||
def track_page_view(self, response):
|
||||
response_time = (time.time() - g.start_time) * 1000
|
||||
|
||||
page_view = PageView(
|
||||
ip_address=self.get_real_ip(),
|
||||
user_agent=request.headers.get('User-Agent', ''),
|
||||
path=request.path,
|
||||
method=request.method,
|
||||
referrer=request.headers.get('Referer', ''),
|
||||
response_time=response_time,
|
||||
status_code=response.status_code,
|
||||
session_id=session.get('session_id')
|
||||
)
|
||||
|
||||
db.session.add(page_view)
|
||||
db.session.commit()
|
||||
|
||||
def update_unique_visitor(self):
|
||||
ip = self.get_real_ip()
|
||||
user_agent = request.headers.get('User-Agent', '')
|
||||
user_agent_hash = hashlib.sha256(user_agent.encode()).hexdigest()
|
||||
|
||||
visitor = UniqueVisitor.query.filter_by(
|
||||
ip_address=ip,
|
||||
user_agent_hash=user_agent_hash
|
||||
).first()
|
||||
|
||||
if visitor:
|
||||
visitor.last_visit = datetime.utcnow()
|
||||
visitor.visit_count += 1
|
||||
else:
|
||||
visitor = UniqueVisitor(
|
||||
ip_address=ip,
|
||||
user_agent_hash=user_agent_hash
|
||||
)
|
||||
db.session.add(visitor)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def get_real_ip(self):
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
elif request.headers.get('X-Real-IP'):
|
||||
return request.headers.get('X-Real-IP')
|
||||
return request.remote_addr
|
||||
24
user.py
Normal file
24
user.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import os
|
||||
from flask_login import UserMixin
|
||||
|
||||
|
||||
class User(UserMixin):
|
||||
def __init__(self, username):
|
||||
self.id = username
|
||||
self.username = username
|
||||
|
||||
@staticmethod
|
||||
def get(username):
|
||||
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
||||
if username == admin_username:
|
||||
return User(username)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def authenticate(username, password):
|
||||
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
||||
admin_password = os.getenv('ADMIN_PASSWORD', 'password')
|
||||
|
||||
if username == admin_username and password == admin_password:
|
||||
return User(username)
|
||||
return None
|
||||
Loading…
Add table
Add a link
Reference in a new issue