Compare commits

...

7 Commits

Author SHA1 Message Date
mi
6d36d43b9e api 2025-11-06 22:41:30 +10:00
mi
a3f0132c7e 🐳 docker 2025-11-06 22:30:56 +10:00
mi
08bc888df0 📝 update readme 2025-11-06 22:24:57 +10:00
mi
5ae0d2ab39 🔧 toggle debug 2025-11-06 22:24:49 +10:00
mi
5905e773b9 💄 social links 2025-11-06 22:18:00 +10:00
mi
881d63dbbb 🔒 manage secret and port via environment variables 2025-11-06 22:13:35 +10:00
mi
a74b9dc9e0 🔧 update default port 2025-11-06 22:11:45 +10:00
8 changed files with 418 additions and 22 deletions

42
.dockerignore Normal file
View File

@@ -0,0 +1,42 @@
# Git
.git
.gitignore
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Documentation
README.md
*.md
# Docker
Dockerfile
.dockerignore
docker-compose.yml
# Logs
*.log
# Testing
.pytest_cache/
.coverage
htmlcov/

47
Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# Production Dockerfile for Sunday Comics
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
# Copy requirements first for better caching
COPY --chown=appuser:appuser requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt gunicorn
# Copy application code
COPY --chown=appuser:appuser . .
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 3000
# Environment variables
ENV PYTHONUNBUFFERED=1 \
PORT=3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:3000').read()"
# Run with Gunicorn
CMD gunicorn app:app \
--bind 0.0.0.0:${PORT} \
--workers 4 \
--threads 2 \
--worker-class gthread \
--access-logfile - \
--error-logfile - \
--log-level info

190
README.md
View File

@@ -15,21 +15,27 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
``` ```
sunday/ sunday/
├── app.py # Main Flask application ├── app.py # Main Flask application
├── comics_data.py # Comic data (edit this to add comics)
├── requirements.txt # Python dependencies ├── requirements.txt # Python dependencies
├── Dockerfile # Production Docker image
├── docker-compose.yml # Docker Compose configuration
├── .dockerignore # Docker build exclusions
├── scripts/ # Utility scripts
│ ├── add_comic.py # Script to add new comic entries
│ └── generate_rss.py # Script to generate RSS feed
├── templates/ # Jinja2 templates ├── templates/ # Jinja2 templates
│ ├── base.html # Base template with navigation │ ├── base.html # Base template with navigation
│ ├── index.html # Latest comic page │ ├── index.html # Latest comic page
│ ├── comic.html # Individual comic viewer │ ├── comic.html # Individual comic viewer
│ ├── archive.html # Archive grid │ ├── archive.html # Archive grid
│ ├── about.html # About page │ ├── about.html # About page
│ ├── contact.html # Contact page
│ └── 404.html # Error page │ └── 404.html # Error page
└── static/ # Static files └── static/ # Static files
├── css/ ├── css/
│ └── style.css # Main stylesheet │ └── style.css # Main stylesheet
├── js/ ├── images/ # Comic images
└── images/ # Comic images │ └── thumbs/ # Thumbnail images for archive
└── thumbs/ # Thumbnail images for archive └── feed.rss # RSS feed (generated)
``` ```
## Setup ## Setup
@@ -44,32 +50,154 @@ pip install -r requirements.txt
python app.py python app.py
``` ```
3. Visit http://127.0.0.1:5000 in your browser 3. Visit http://127.0.0.1:3000 in your browser
## Environment Variables
The app can be configured via environment variables:
- `SECRET_KEY` - Flask secret key (defaults to 'your-secret-key')
- `PORT` - Port to run on (defaults to 3000)
- `DEBUG` - Enable debug mode (defaults to False)
**Development:**
```bash
export DEBUG=True
python app.py
```
**Production:**
```bash
export SECRET_KEY="your-secure-random-secret-key"
export PORT=3000
python app.py
```
## Adding Comics ## Adding Comics
Currently, comics are stored in the `COMICS` list in `app.py`. Each comic needs: Comics are stored in the `COMICS` list in `comics_data.py`. Each comic entry:
```python ```python
{ {
'number': 1, # Comic number (sequential) 'number': 1, # Comic number (required, sequential)
'title': 'Comic Title', # Title of the comic 'filename': 'comic-001.png', # Image filename (required)
'filename': 'comic-001.png', # Image filename 'date': '2025-01-01', # Publication date (required)
'date': '2025-01-01', # Publication date 'alt_text': 'Alt text for comic', # Accessibility text (required)
'alt_text': 'Alt text for comic', # Accessibility text 'title': 'Comic Title', # Title (optional, shows #X if absent)
'transcript': 'Optional transcript' # Optional transcript 'author_note': 'Optional note' # Author note (optional)
} }
``` ```
### Adding a New Comic ### Adding a New Comic
**Option 1: Use the script (recommended)**
```bash
python scripts/add_comic.py
```
This will automatically add a new entry with defaults. Then edit `comics_data.py` to customize.
**Option 2: Manual**
1. Save your comic image in `static/images/` (e.g., `comic-001.png`) 1. Save your comic image in `static/images/` (e.g., `comic-001.png`)
2. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename 2. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename
3. Add the comic entry to the `COMICS` list in `app.py` 3. Add the comic entry to the `COMICS` list in `comics_data.py`
### Generating RSS Feed
After adding comics, regenerate the RSS feed:
```bash
python scripts/generate_rss.py
```
This creates/updates `static/feed.rss`
## Production Deployment
For production, you should **NOT** use Flask's built-in development server. Choose one of the following deployment methods:
### Option 1: Docker (Recommended)
**1. Generate a secure secret key:**
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
**2. Create a `.env` file:**
```bash
SECRET_KEY=your-generated-secret-key-here
```
**3. Build and run with Docker Compose:**
```bash
docker-compose up -d
```
**Or build and run manually:**
```bash
# Build the image
docker build -t sunday-comics .
# Run the container
docker run -d \
-p 3000:3000 \
-e SECRET_KEY="your-secret-key" \
-v $(pwd)/comics_data.py:/app/comics_data.py:ro \
-v $(pwd)/static/images:/app/static/images:ro \
--name sunday-comics \
sunday-comics
```
**View logs:**
```bash
docker-compose logs -f
```
### Option 2: Manual Deployment with Gunicorn
**1. Generate a Secure Secret Key**
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
**2. Set Environment Variables**
```bash
export SECRET_KEY="generated-secret-key-from-above"
export DEBUG=False
export PORT=3000
```
**3. Use a Production WSGI Server**
**Install Gunicorn:**
```bash
pip install gunicorn
```
**Run with Gunicorn:**
```bash
gunicorn app:app --bind 0.0.0.0:3000 --workers 4
```
### Using a Reverse Proxy (Recommended)
Set up Nginx or another reverse proxy in front of your app for:
- HTTPS/SSL termination
- Static file serving
- Load balancing
- Better security
### Additional Production Considerations
- Use a process manager (systemd, supervisor) for non-Docker deployments
- Set appropriate file permissions
- Enable HTTPS with Let's Encrypt
- Consider using a CDN for static assets
- Monitor logs and performance
- Set up automated backups of `comics_data.py`
## Upgrading to a Database ## Upgrading to a Database
For production use, consider replacing the `COMICS` list with a database: For larger comic archives, consider replacing the `COMICS` list with a database:
- SQLite for simple setups - SQLite for simple setups
- PostgreSQL/MySQL for production - PostgreSQL/MySQL for production
@@ -94,7 +222,39 @@ For production use, consider replacing the `COMICS` list with a database:
- `/comic/<id>` - View a specific comic by number - `/comic/<id>` - View a specific comic by number
- `/archive` - Browse all comics in a grid - `/archive` - Browse all comics in a grid
- `/about` - About the comic and author - `/about` - About the comic and author
- `/contact` - Contact form - `/static/feed.rss` - RSS feed
## API Endpoints
The app exposes a JSON API for programmatic access:
- **GET `/api/comics`** - Returns all comics as a JSON array
```json
[
{
"number": 1,
"title": "First Comic",
"filename": "comic-001.png",
"date": "2025-01-01",
"alt_text": "The very first comic",
"author_note": "This is where your comic journey begins!"
}
]
```
- **GET `/api/comics/<id>`** - Returns a specific comic as JSON
```json
{
"number": 1,
"title": "First Comic",
"filename": "comic-001.png",
"date": "2025-01-01",
"alt_text": "The very first comic",
"author_note": "This is where your comic journey begins!"
}
```
Returns 404 with `{"error": "Comic not found"}` if the comic doesn't exist.
## License ## License

28
app.py
View File

@@ -1,10 +1,11 @@
from flask import Flask, render_template, abort import os
from flask import Flask, render_template, abort, jsonify, request
from comics_data import COMICS from comics_data import COMICS
app = Flask(__name__) app = Flask(__name__)
# Configuration # Configuration
app.config['SECRET_KEY'] = 'your-secret-key-here' # Change this in production app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key')
def get_comic_by_number(number): def get_comic_by_number(number):
@@ -58,11 +59,32 @@ def about():
return render_template('about.html', title='About') return render_template('about.html', title='About')
@app.route('/api/comics')
def api_comics():
"""API endpoint - returns all comics as JSON"""
return jsonify(COMICS)
@app.route('/api/comics/<int:comic_id>')
def api_comic(comic_id):
"""API endpoint - returns a specific comic as JSON"""
comic = get_comic_by_number(comic_id)
if not comic:
return jsonify({'error': 'Comic not found'}), 404
return jsonify(comic)
@app.errorhandler(404) @app.errorhandler(404)
def page_not_found(e): def page_not_found(e):
"""404 error handler""" """404 error handler"""
# Return JSON for API requests
if request.path.startswith('/api/'):
return jsonify({'error': 'Not found'}), 404
# Return HTML for regular pages
return render_template('404.html', title='Page Not Found'), 404 return render_template('404.html', title='Page Not Found'), 404
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True) port = int(os.environ.get('PORT', 3000))
debug = os.environ.get('DEBUG', 'False').lower() in ('true', '1', 't')
app.run(debug=debug, port=port)

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
environment:
- SECRET_KEY=${SECRET_KEY:-please-change-this-secret-key}
- PORT=3000
- DEBUG=False
volumes:
# Mount comics data for easy updates without rebuilding
- ./comics_data.py:/app/comics_data.py:ro
- ./static/images:/app/static/images:ro
- ./static/feed.rss:/app/static/feed.rss:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000').read()"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s

View File

@@ -13,7 +13,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from comics_data import COMICS from comics_data import COMICS
# Configuration - update these for your site # Configuration - update these for your site
SITE_URL = "http://localhost:5000" # Change to your actual domain SITE_URL = "http://localhost:3000" # Change to your actual domain
SITE_TITLE = "Sunday Comics" SITE_TITLE = "Sunday Comics"
SITE_DESCRIPTION = "A webcomic about life, the universe, and everything" SITE_DESCRIPTION = "A webcomic about life, the universe, and everything"
SITE_LANGUAGE = "en-us" SITE_LANGUAGE = "en-us"

View File

@@ -342,12 +342,88 @@ main {
/* Footer */ /* Footer */
footer { footer {
border-top: 3px solid #000; border-top: 3px solid #000;
text-align: center;
padding: 2rem 0; padding: 2rem 0;
margin-top: 3rem; margin-top: 3rem;
} }
footer p { .footer-content {
display: flex;
justify-content: space-around;
gap: 2rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.footer-section {
flex: 1;
min-width: 200px;
}
.footer-section h3 {
color: #000;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 1rem;
}
.social-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.social-links a {
color: #000;
text-decoration: none;
font-size: 0.85rem;
}
.social-links a:hover {
text-decoration: line-through;
}
.newsletter-form {
display: flex;
gap: 0.5rem;
}
.newsletter-form input[type="email"] {
flex: 1;
padding: 0.5rem;
border: 2px solid #000;
font-family: 'Courier New', Courier, monospace;
font-size: 0.85rem;
}
.newsletter-form button {
padding: 0.5rem 1rem;
background-color: #000;
color: #fff;
border: 2px solid #000;
cursor: pointer;
font-family: 'Courier New', Courier, monospace;
font-size: 0.85rem;
text-transform: uppercase;
}
.newsletter-form button:hover {
background-color: #fff;
color: #000;
}
.newsletter-placeholder {
font-size: 0.85rem;
color: #666;
}
.footer-bottom {
text-align: center;
padding-top: 1.5rem;
border-top: 2px solid #000;
}
.footer-bottom p {
margin: 0; margin: 0;
color: #000; color: #000;
font-size: 0.85rem; font-size: 0.85rem;

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ title }}{% endblock %} - Sunday Comics</title> <title>{% block title %}{{ title }}{% endblock %} - Sunday Comics</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="alternate" type="application/rss+xml" title="Sunday Comics RSS Feed" href="{{ url_for('static', filename='feed.rss') }}">
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body>
@@ -31,7 +32,32 @@
<footer> <footer>
<div class="container"> <div class="container">
<p>&copy; 2025 Sunday Comics. All rights reserved.</p> <div class="footer-content">
<div class="footer-section">
<h3>Follow</h3>
<div class="social-links">
<!-- Uncomment and update with your social links -->
<!-- <a href="https://twitter.com/yourhandle" target="_blank">Twitter</a> -->
<!-- <a href="https://instagram.com/yourhandle" target="_blank">Instagram</a> -->
<!-- <a href="https://mastodon.social/@yourhandle" target="_blank">Mastodon</a> -->
<a href="{{ url_for('static', filename='feed.rss') }}">RSS Feed</a>
</div>
</div>
<div class="footer-section">
<h3>Newsletter</h3>
<!-- Replace with your newsletter service form -->
<!-- <form class="newsletter-form" action="#" method="post">
<input type="email" name="email" placeholder="Enter your email" required>
<button type="submit">Subscribe</button>
</form> -->
<p class="newsletter-placeholder">Newsletter coming soon!</p>
</div>
</div>
<div class="footer-bottom">
<p>&copy; 2025 Sunday Comics. All rights reserved.</p>
</div>
</div> </div>
</footer> </footer>