A minimal Flask + PostgreSQL commenting system with hierarchical threading, soft deletion, rate limiting, and XSS protection
Project description
RComments
A minimal, secure, and feature-rich commenting system for Flask + PostgreSQL applications.
Features
- Hierarchical Threading: Support for nested replies with unlimited depth
- Flexible Identity: Registered usernames or anonymous commenting
- Metadata Tracking: Automatic UTC timestamps and IP logging for moderation
- XSS Protection: HTML sanitization to prevent injection attacks
- Rate Limiting: Configurable limits to prevent spam
- Soft Deletion: Comments can be marked as deleted without permanent removal
- Automatic Cleanup: Permanently removes old comments (30 days or 150 comments limit)
- Easy Integration: Simple Flask blueprint registration
Installation
pip install rcomments
Quick Start
1. Configuration
Create a .env file in your project root:
DATABASE_URL=postgresql://username:password@localhost/yourdb
SECRET_KEY=your-flask-secret-key
RATE_LIMIT=10 per minute
MAX_COMMENT_AGE_DAYS=30
MAX_TOTAL_COMMENTS=150
ALLOW_ANONYMOUS=true
2. Flask Application Setup
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_limiter import Limiter
from rcomments import init_config, init_routes, db as rcomments_db
app = Flask(__name__)
app.config.from_prefixed_env() # Loads from .env
# Initialize RComments
init_config()
# Setup database - rcomments uses its own db instance
# Configure your database via DATABASE_URL in .env
rcomments_db.init_app(app)
# Setup rate limiter
limiter = Limiter(
app,
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr)
)
# Initialize routes
init_routes(app, limiter)
if __name__ == "__main__":
app.run(debug=True)
3. Database Migration
Initialize the database:
# Using Flask-Migrate (recommended)
flask db init
flask db migrate -m "Initial RComments tables"
flask db upgrade
# Or using the CLI
rcomments-cli init-db
API Endpoints
All endpoints are prefixed with /api/comments.
GET /api/comments
Fetch all top-level comments with nested replies.
Query Parameters:
sort(optional):ascordesc(default:desc)limit(optional): Maximum number of top-level comments
Response:
{
"success": true,
"data": [
{
"id": 1,
"content": "This is a comment",
"author_name": "John",
"is_anonymous": false,
"status": "ACTIVE",
"created_at": "2024-01-15T10:30:00Z",
"parent_id": null,
"replies": []
}
],
"count": 1
}
POST /api/comments
Create a new comment.
Request Body:
{
"content": "Your comment text",
"author_name": "Your Name",
"author_email": "email@example.com", // optional
"parent_id": 1, // optional, for replies
"is_anonymous": false // optional, default: false
}
Response:
{
"success": true,
"data": {
"id": 2,
"content": "Your comment text",
"author_name": "Your Name",
"is_anonymous": false,
"status": "ACTIVE",
"created_at": "2024-01-15T10:35:00Z",
"parent_id": 1
},
"message": "Comment created successfully"
}
GET /api/comments/<id>
Fetch a single comment with its replies.
DELETE /api/comments/<id>
Soft delete a comment (marks as [Comment removed]).
GET /api/comments/<id>/replies
Fetch direct replies for a comment.
GET /api/comments/stats
Get comment statistics.
POST /api/comments/cleanup
Trigger cleanup of old comments (admin only - secure this in production!).
Query Parameters:
dry_run=true- Preview what would be deleted
Security Features
XSS Protection
All comment content is automatically sanitized using bleach. Only basic formatting tags are allowed:
<b>,<i>,<u>,<em>,<strong>,<br>,<p>
Rate Limiting
Configurable rate limits prevent automated spamming. Default: 10 comments per minute per IP.
IP Logging
IP addresses are hashed (SHA-256) for privacy while maintaining moderation capabilities.
Soft Deletion
Comments can be soft-deleted via the DELETE endpoint, which marks them with status DELETED and replaces content with [Comment removed]. This preserves thread structure while hiding content.
Automatic Cleanup
The cleanup process permanently removes comments from the database based on two limits:
- Age: Comments older than 30 days (configurable) are permanently deleted
- Count: If total active comments exceed 150 (configurable), oldest comments are permanently deleted
Note: Cleanup removes both top-level comments and replies. The dry_run mode allows previewing what would be deleted without making changes.
Integration with React/TypeScript
Example React component:
import React, { useState, useEffect } from 'react';
import axios from 'axios';
interface Comment {
id: number;
content: string;
author_name: string;
is_anonymous: boolean;
created_at: string;
parent_id: number | null;
replies: Comment[];
}
const CommentSection: React.FC = () => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState({
content: '',
author_name: '',
author_email: '',
is_anonymous: false,
parent_id: null as number | null
});
useEffect(() => {
fetchComments();
}, []);
const fetchComments = async () => {
const response = await axios.get('/api/comments');
setComments(response.data.data);
};
const submitComment = async (e: React.FormEvent) => {
e.preventDefault();
await axios.post('/api/comments', newComment);
setNewComment({ content: '', author_name: '', author_email: '', is_anonymous: false, parent_id: null });
fetchComments();
};
const deleteComment = async (id: number) => {
await axios.delete(`/api/comments/${id}`);
fetchComments();
};
return (
<div className="comment-section">
<h2>Comments</h2>
<form onSubmit={submitComment}>
<textarea
value={newComment.content}
onChange={(e) => setNewComment({...newComment, content: e.target.value})}
placeholder="Your comment..."
required
/>
<input
type="text"
value={newComment.author_name}
onChange={(e) => setNewComment({...newComment, author_name: e.target.value})}
placeholder="Your name"
required
/>
<input
type="email"
value={newComment.author_email}
onChange={(e) => setNewComment({...newComment, author_email: e.target.value})}
placeholder="Email (optional)"
/>
<label>
<input
type="checkbox"
checked={newComment.is_anonymous}
onChange={(e) => setNewComment({...newComment, is_anonymous: e.target.checked})}
/>
Post anonymously
</label>
<button type="submit">Post Comment</button>
</form>
<div className="comments-list">
{comments.map(comment => (
<CommentItem
key={comment.id}
comment={comment}
onReply={(parentId) => setNewComment({
...newComment,
parent_id: parentId,
content: '',
})}
onDelete={deleteComment}
/>
))}
</div>
</div>
);
};
const CommentItem: React.FC<{ comment: Comment; onReply: (id: number) => void; onDelete: (id: number) => void }> = ({
comment,
onReply,
onDelete
}) => {
const [showReplyForm, setShowReplyForm] = useState(false);
return (
<div className="comment" style={{ marginLeft: comment.parent_id ? '20px' : '0' }}>
<div className="comment-header">
<strong>{comment.author_name}</strong>
{comment.is_anonymous && <span> (Anonymous)</span>}
<small>{new Date(comment.created_at).toLocaleString()}</small>
</div>
<div className="comment-content" dangerouslySetInnerHTML={{ __html: comment.content }} />
<div className="comment-actions">
<button onClick={() => setShowReplyForm(!showReplyForm)}>Reply</button>
<button onClick={() => onDelete(comment.id)}>Delete</button>
</div>
{showReplyForm && (
<form onSubmit={(e) => { e.preventDefault(); onReply(comment.id); }}>
<textarea placeholder="Your reply..." required />
<button type="submit">Post Reply</button>
</form>
)}
{comment.replies && comment.replies.length > 0 && (
<div className="replies">
{comment.replies.map(reply => (
<CommentItem
key={reply.id}
comment={reply}
onReply={onReply}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
);
};
export default CommentSection;
Command Line Interface
RComments includes a CLI for database management:
# Initialize database and migrations
rcomments-cli init-db
# Create and apply migrations
rcomments-cli migrate-db
# Clean up old comments
rcomments-cli cleanup [--dry-run]
# Show statistics
rcomments-cli stats
Configuration Options
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
postgresql://localhost/rcomments |
PostgreSQL connection string |
SECRET_KEY |
dev-secret-key-change-in-production |
Flask secret key |
RATE_LIMIT |
10 per minute |
Rate limit format |
MAX_COMMENT_AGE_DAYS |
30 |
Maximum age of comments in days |
MAX_TOTAL_COMMENTS |
150 |
Maximum total active comments |
ALLOW_ANONYMOUS |
true |
Allow anonymous comments |
REQUIRE_EMAIL_ANONYMOUS |
false |
Require email for anonymous users |
Development
Setup
cd rcomments
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -e ".[dev]"
Running Tests
pytest tests/ -v
Code Formatting
black rcomments/
flake8 rcomments/
mypy rcomments/
License
MIT License. See LICENSE for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rcomments-0.1.3.tar.gz.
File metadata
- Download URL: rcomments-0.1.3.tar.gz
- Upload date:
- Size: 21.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ca74b6db5a46bc2694cadf85e65b088edb0f2f18e8205aab940d30e32572a9ea
|
|
| MD5 |
b490f001a2c920d8cc42db265aeb16ea
|
|
| BLAKE2b-256 |
aa2bffe784a58c4b219cd75075c4e7afe3344541c70f913dd876f5548fc215c2
|
File details
Details for the file rcomments-0.1.3-py3-none-any.whl.
File metadata
- Download URL: rcomments-0.1.3-py3-none-any.whl
- Upload date:
- Size: 15.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
429e5b4c8982a30995fa85e84c395179d780c583e3c3b6d05dfa7e37de880bc6
|
|
| MD5 |
c6eaba68b54ef5df9a04f9a9f54441cf
|
|
| BLAKE2b-256 |
2f759d6eb5352c269aadfc4ad6b13dfc7b617f1524b5d5883b15c7475d82c3b9
|