Compare commits
7 Commits
c7cd40f5a9
...
6d36d43b9e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d36d43b9e | |||
| a3f0132c7e | |||
| 08bc888df0 | |||
| 5ae0d2ab39 | |||
| 5905e773b9 | |||
| 881d63dbbb | |||
| a74b9dc9e0 |
42
.dockerignore
Normal file
42
.dockerignore
Normal 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
47
Dockerfile
Normal 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
190
README.md
@@ -15,21 +15,27 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
|
||||
```
|
||||
sunday/
|
||||
├── app.py # Main Flask application
|
||||
├── comics_data.py # Comic data (edit this to add comics)
|
||||
├── 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
|
||||
│ ├── base.html # Base template with navigation
|
||||
│ ├── index.html # Latest comic page
|
||||
│ ├── comic.html # Individual comic viewer
|
||||
│ ├── archive.html # Archive grid
|
||||
│ ├── about.html # About page
|
||||
│ ├── contact.html # Contact page
|
||||
│ └── 404.html # Error page
|
||||
└── static/ # Static files
|
||||
├── css/
|
||||
│ └── style.css # Main stylesheet
|
||||
├── js/
|
||||
└── images/ # Comic images
|
||||
└── thumbs/ # Thumbnail images for archive
|
||||
├── images/ # Comic images
|
||||
│ └── thumbs/ # Thumbnail images for archive
|
||||
└── feed.rss # RSS feed (generated)
|
||||
```
|
||||
|
||||
## Setup
|
||||
@@ -44,32 +50,154 @@ pip install -r requirements.txt
|
||||
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
|
||||
|
||||
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
|
||||
{
|
||||
'number': 1, # Comic number (sequential)
|
||||
'title': 'Comic Title', # Title of the comic
|
||||
'filename': 'comic-001.png', # Image filename
|
||||
'date': '2025-01-01', # Publication date
|
||||
'alt_text': 'Alt text for comic', # Accessibility text
|
||||
'transcript': 'Optional transcript' # Optional transcript
|
||||
'number': 1, # Comic number (required, sequential)
|
||||
'filename': 'comic-001.png', # Image filename (required)
|
||||
'date': '2025-01-01', # Publication date (required)
|
||||
'alt_text': 'Alt text for comic', # Accessibility text (required)
|
||||
'title': 'Comic Title', # Title (optional, shows #X if absent)
|
||||
'author_note': 'Optional note' # Author note (optional)
|
||||
}
|
||||
```
|
||||
|
||||
### 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`)
|
||||
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
|
||||
|
||||
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
|
||||
- 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
|
||||
- `/archive` - Browse all comics in a grid
|
||||
- `/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
|
||||
|
||||
|
||||
28
app.py
28
app.py
@@ -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
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# 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):
|
||||
@@ -58,11 +59,32 @@ def 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)
|
||||
def page_not_found(e):
|
||||
"""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
|
||||
|
||||
|
||||
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
23
docker-compose.yml
Normal 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
|
||||
@@ -13,7 +13,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from comics_data import COMICS
|
||||
|
||||
# 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_DESCRIPTION = "A webcomic about life, the universe, and everything"
|
||||
SITE_LANGUAGE = "en-us"
|
||||
|
||||
@@ -342,12 +342,88 @@ main {
|
||||
/* Footer */
|
||||
footer {
|
||||
border-top: 3px solid #000;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
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;
|
||||
color: #000;
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ title }}{% endblock %} - Sunday Comics</title>
|
||||
<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 %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -31,8 +32,33 @@
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<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>© 2025 Sunday Comics. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user