Initial commit

This commit is contained in:
Blake Ridgway 2025-07-05 15:29:33 -05:00
commit 315e731234
27 changed files with 3403 additions and 0 deletions

34
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 MiB

461
strava_cache.json Normal file
View 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
View 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
View 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 %}

View 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 %}

View 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>&lt;strong&gt;bold&lt;/strong&gt;</code><br>
<code>&lt;em&gt;italic&lt;/em&gt;</code><br>
<code>&lt;code&gt;code&lt;/code&gt;</code><br>
<code>&lt;pre&gt;code block&lt;/pre&gt;</code>
</div>
</div>
</div>
</div>
{% endblock %}

View 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 %}

View 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
View 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">&copy; 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

83
traffic_tracker.py Normal file
View 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
View 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