my-posts/main.py
2025-05-31 00:45:59 +03:00

705 lines
23 KiB
Python

import flask
import shutil
import json
import os
import datetime
import random
import string
import uuid
from werkzeug.utils import secure_filename
from markupsafe import escape
import xml.etree.ElementTree as ET
from werkzeug.security import generate_password_hash, check_password_hash
from flask_wtf.csrf import CSRFProtect, generate_csrf
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import magic
app = flask.Flask(__name__)
app.config['DATA_FILE'] = 'data.json'
app.config['BACKUP_DIR'] = 'backups'
app.config['STATIC_FOLDER'] = 'static'
app.config['UPLOAD_FOLDER'] = 'static/uploads'
app.config['CODE_FILE'] = 'code.txt'
app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'gif'}
app.config['SITE_URL'] = 'https://posts.halhadus.rocks'
app.config['MAX_CONTENT_LENGTH'] = 32 * 1024 * 1024
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'set-your-stupid-secret-key')
app.config['WTF_CSRF_ENABLED'] = True
os.makedirs(app.config['STATIC_FOLDER'], exist_ok=True)
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(app.config['BACKUP_DIR'], exist_ok=True)
csrf = CSRFProtect(app)
limiter = Limiter(app=app, key_func=get_remote_address)
def load_data():
try:
with open(app.config['DATA_FILE'], 'r', encoding='utf-8') as f:
data = json.load(f)
sorted_items = sorted(
data.items(),
key=lambda x: x[1].get('date', 0),
reverse=True
)
return sorted_items
except (FileNotFoundError, json.JSONDecodeError):
return []
def format_timestamp(ts):
if not ts:
return ""
try:
dt = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M UTC")
except:
return ""
def format_timestamp_rss(ts):
if not ts:
return ""
try:
datetime.datetime.utcfromtimestamp(ts)
return dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
except:
return ""
def get_upload_code():
code_file = app.config['CODE_FILE']
if os.path.exists(code_file):
with open(code_file, 'r') as f:
return f.read().strip()
else:
code = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
hashed_code = generate_password_hash(code)
with open(code_file, 'w') as f:
f.write(hashed_code)
print(f"Generated upload code (SAVE THIS, WON'T BE SHOWN AGAIN): {code}")
return hashed_code
def verify_upload_code(entered_code):
stored_hash = get_upload_code()
return check_password_hash(stored_hash, entered_code)
def create_backup():
source = app.config['DATA_FILE']
if not os.path.exists(source):
return None
backup_dir = app.config['BACKUP_DIR']
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = os.path.join(backup_dir, f"data_backup_{timestamp}.json")
shutil.copy2(source, backup_file)
return backup_file
def cleanup_old_backups(max_backups=5):
backups = sorted(os.listdir(app.config['BACKUP_DIR']))
if len(backups) > max_backups:
for old_backup in backups[:-max_backups]:
os.remove(os.path.join(app.config['BACKUP_DIR'], old_backup))
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
def validate_file(file_stream):
try:
header = file_stream.read(2048)
file_stream.seek(0)
mime_type = magic.from_buffer(header, mime=True)
return mime_type in ['image/png', 'image/jpeg', 'image/gif']
except Exception as e:
print(f"File validation error: {str(e)}")
return False
def generate_sitemap():
base_url = app.config['SITE_URL']
posts = load_data()
urlset = ET.Element('urlset', xmlns="http://www.sitemaps.org/schemas/sitemap/0.9")
url = ET.SubElement(urlset, 'url')
ET.SubElement(url, 'loc').text = base_url
ET.SubElement(url, 'lastmod').text = datetime.datetime.utcnow().strftime("%Y-%m-%d")
ET.SubElement(url, 'changefreq').text = 'daily'
ET.SubElement(url, 'priority').text = '1.0'
for post_id, post in posts:
url = ET.SubElement(urlset, 'url')
ET.SubElement(url, 'loc').text = f"{base_url}/#{post_id}"
if 'date' in post:
dt = datetime.datetime.utcfromtimestamp(post['date'])
ET.SubElement(url, 'lastmod').text = dt.strftime("%Y-%m-%d")
ET.SubElement(url, 'changefreq').text = 'monthly'
ET.SubElement(url, 'priority').text = '0.8'
return '<?xml version="1.0" encoding="UTF-8"?>' + ET.tostring(urlset, encoding='unicode')
def generate_rss():
base_url = app.config['SITE_URL']
posts = load_data()
rss = ET.Element('rss', version="2.0")
channel = ET.SubElement(rss, 'channel')
ET.SubElement(channel, 'title').text = "Halhadus' Posts"
ET.SubElement(channel, 'link').text = base_url
ET.SubElement(channel, 'description').text = "Personal posts and updates from Halhadus"
ET.SubElement(channel, 'language').text = "en-us"
ET.SubElement(channel, 'lastBuildDate').text = datetime.datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT")
ET.SubElement(channel, 'generator').text = "Halhadus Posts RSS Generator"
for post_id, post in posts:
item = ET.SubElement(channel, 'item')
title = post['text'].strip().split('\n')[0][:50] + ('...' if len(post['text']) > 50 else '')
ET.SubElement(item, 'title').text = title
post_url = f"{base_url}/#{post_id}"
ET.SubElement(item, 'link').text = post_url
ET.SubElement(item, 'guid').text = post_url
description_content = f"{post['text']}"
if 'image' in post and post['image']:
img_url = flask.url_for('static', filename=post['image'], _external=True)
description_content += f'<br><img src="{img_url}" alt="Post image">'
description = ET.SubElement(item, 'description')
description.text = description_content
if 'date' in post and post['date']:
pub_date = format_timestamp_rss(post['date'])
ET.SubElement(item, 'pubDate').text = pub_date
return '<?xml version="1.0" encoding="UTF-8"?>' + ET.tostring(rss, encoding='unicode')
@app.route('/')
def index():
data = load_data()
return flask.render_template_string('''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Halhadus' Posts</title>
<meta name="description" content="Personal posts and updates from Halhadus">
<meta name="author" content="Halhadus">
<meta property="og:title" content="Halhadus' Posts">
<meta property="og:description" content="Personal posts and updates from Halhadus">
<meta property="og:url" content="{{ site_url }}">
<meta property="og:image" content="https://halhadus.rocks/assets/favicon.png">
<meta property="og:type" content="website">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="{{ url_for('rss_feed') }}">
<link rel="icon" type="image/png" href="https://halhadus.rocks/assets/favicon.png">
<style>
:root {
--bg-color: #1f1f1f;
--card-bg: #2a2a2a;
--border-color: #333;
--text-color: #ffffff;
--accent-color: #4361ee;
--font-family: 'JetBrains Mono', monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-family);
line-height: 1.6;
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 40px;
padding: 30px 0 20px;
border-bottom: 1px solid var(--border-color);
}
h1 {
font-size: 2.2rem;
margin-bottom: 5px;
letter-spacing: -0.5px;
}
.site-link {
color: var(--accent-color);
text-decoration: none;
font-size: 0.95rem;
transition: opacity 0.2s;
}
.site-link:hover {
opacity: 0.8;
text-decoration: underline;
}
.rss-link {
position: absolute;
top: 20px;
right: 20px;
color: #ff6600;
text-decoration: none;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 5px;
}
.rss-link:hover {
opacity: 0.8;
}
.git-link {
color: #6cc644;
text-decoration: none;
font-size: 0.9rem;
margin-left: 15px;
}
.git-link:hover {
opacity: 0.8;
}
.posts-grid {
display: grid;
grid-template-columns: 1fr;
gap: 25px;
}
.post-card {
background: var(--card-bg);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.post-image {
width: 100%;
max-height: 400px;
object-fit: contain;
display: block;
margin-top: 15px;
background: #1a1a1a;
padding: 10px;
}
.post-content {
padding: 20px;
}
.post-text {
font-size: 1.05rem;
margin-bottom: 15px;
white-space: pre-wrap;
line-height: 1.7;
}
.post-date {
color: #8a8a8a;
font-size: 0.85rem;
margin-top: 10px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
header {
padding: 20px 0 15px;
}
h1 {
font-size: 1.8rem;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Halhadus' Posts</h1>
<div>
<a href="https://halhadus.rocks" class="site-link">Halhadusite</a>
<a href="https://git.halhadus.rocks/Halhadus/my-posts" class="git-link">
Source Code
</a>
</div>
<a href="{{ url_for('rss_feed') }}" class="rss-link">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11a9 9 0 0 1 9 9"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<circle cx="5" cy="19" r="1"></circle>
</svg>
RSS Feed
</a>
</header>
<main class="posts-grid">
{% if data %}
{% for id, post in data %}
<div class="post-card" id="{{ id }}">
<div class="post-content">
<p class="post-text">{{ post.text | e }}</p>
{% if 'date' in post and post.date %}
<div class="post-date">
{{ format_timestamp(post.date) }}
</div>
{% endif %}
</div>
{% if 'image' in post and post.image %}
<img
src="{{ url_for('static', filename=post.image) }}"
alt="Post image"
class="post-image"
onerror="this.style.display='none'">
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>No posts available</p>
</div>
{% endif %}
</main>
</div>
</body>
</html>
''',
data=data,
site_url=app.config['SITE_URL'],
format_timestamp=format_timestamp)
@app.route('/upload', methods=['GET', 'POST'])
@limiter.limit("10 per minute")
#@csrf.exempt
def upload():
if flask.request.method == 'POST':
entered_code = flask.request.form.get('code')
if not entered_code or not verify_upload_code(entered_code):
return flask.render_template_string('''
<script>
alert("Invalid code!");
window.history.back();
</script>
''')
text = flask.request.form.get('text')
if not text:
return flask.render_template_string('''
<script>
alert("Text content is required!");
window.history.back();
</script>
''')
image_filename = None
if 'image' in flask.request.files:
file = flask.request.files['image']
if file.filename != '' and file:
if not allowed_file(file.filename):
return flask.render_template_string('''
<script>
alert("Invalid file type!");
window.history.back();
</script>
''')
if not validate_file(file.stream):
return flask.render_template_string('''
<script>
alert("Invalid file content!");
window.history.back();
</script>
''')
filename = secure_filename(file.filename)
unique_id = uuid.uuid4().hex
unique_filename = f"{unique_id}_{filename}"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
file.save(file_path)
image_filename = os.path.join('uploads', unique_filename)
new_post = {
'text': text,
'date': int(datetime.datetime.now().timestamp())
}
if image_filename:
new_post['image'] = image_filename
backup_file = create_backup()
cleanup_old_backups()
try:
if os.path.exists(app.config['DATA_FILE']):
with open(app.config['DATA_FILE'], 'r+', encoding='utf-8') as f:
try:
data = json.load(f)
except json.JSONDecodeError:
data = {}
else:
data = {}
post_id = f"post_{int(datetime.datetime.now().timestamp() * 1000)}"
data[post_id] = new_post
temp_file = app.config['DATA_FILE'] + '.tmp'
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(temp_file, app.config['DATA_FILE'])
except Exception as e:
print(f"Error saving post: {str(e)}")
is_corrupted = False
try:
with open(app.config['DATA_FILE'], 'r', encoding='utf-8') as f:
json.load(f)
except:
is_corrupted = True
if is_corrupted and backup_file and os.path.exists(backup_file):
try:
shutil.copy2(backup_file, app.config['DATA_FILE'])
print(f"Restored data from backup: {backup_file}")
return flask.render_template_string('''
<script>
alert("Data file corrupted! Restored from backup. Please try again.");
window.location.href = "/upload";
</script>
''')
except Exception as restore_error:
print(f"Error restoring backup: {str(restore_error)}")
return flask.render_template_string('''
<script>
alert("Error saving post!");
window.history.back();
</script>
''')
return flask.redirect('/')
csrf_token = generate_csrf()
return flask.render_template_string('''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add New Post</title>
<meta name="description" content="Add a new post to Halhadus' Posts">
<meta property="og:title" content="Add New Post">
<meta property="og:url" content="{{ site_url }}/upload">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<link rel="icon" type="image/png" href="https://halhadus.rocks/assets/favicon.png">
<style>
:root {
--bg-color: #1f1f1f;
--card-bg: #2a2a2a;
--border-color: #333;
--text-color: #ffffff;
--accent-color: #4361ee;
--font-family: 'JetBrains Mono', monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-family);
line-height: 1.6;
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 600px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
padding: 20px 0;
}
h1 {
font-size: 2rem;
margin-bottom: 5px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-size: 0.95rem;
}
textarea, input[type="password"], input[type="file"] {
width: 100%;
padding: 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-color);
font-family: var(--font-family);
font-size: 1rem;
}
textarea {
min-height: 150px;
resize: vertical;
}
.submit-btn {
background: var(--accent-color);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-family: var(--font-family);
font-size: 1rem;
font-weight: 700;
width: 100%;
transition: background 0.2s;
}
.submit-btn:hover {
background: #3a51d8;
}
.info-note {
color: #8a8a8a;
font-size: 0.85rem;
margin-top: 5px;
}
.back-link {
display: inline-block;
margin-top: 20px;
color: var(--accent-color);
text-decoration: none;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
h1 {
font-size: 1.8rem;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Add New Post</h1>
</header>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="text">Content:</label>
<textarea name="text" id="text" required placeholder="Write your post content here..."></textarea>
</div>
<div class="form-group">
<label for="image">Image (optional):</label>
<input type="file" name="image" id="image" accept="image/*">
<p class="info-note">Supported formats: PNG, JPG, JPEG, GIF</p>
</div>
<div class="form-group">
<label for="code">Upload Code:</label>
<input type="password" name="code" id="code" required placeholder="Enter your 32-character upload code">
</div>
<button type="submit" class="submit-btn">Publish Post</button>
</form>
<a href="/" class="back-link">← Back to posts</a>
</div>
</body>
</html>
''', site_url=app.config['SITE_URL'], csrf_token=csrf_token)
@app.route('/sitemap.xml')
def sitemap():
sitemap_xml = generate_sitemap()
response = flask.make_response(sitemap_xml)
response.headers['Content-Type'] = 'application/xml'
return response
@app.route('/robots.txt')
def robots():
robots_txt = f"""User-agent: *
Allow: /
Disallow: /upload
Disallow: /static/uploads/
Sitemap: {app.config['SITE_URL']}/sitemap.xml
"""
response = flask.make_response(robots_txt)
response.headers['Content-Type'] = 'text/plain'
return response
@app.route('/rss.xml')
def rss_feed():
rss_xml = generate_rss()
response = flask.make_response(rss_xml)
response.headers['Content-Type'] = 'application/rss+xml'
return response
if __name__ == '__main__':
if not os.path.exists(app.config['DATA_FILE']):
with open(app.config['DATA_FILE'], 'w', encoding='utf-8') as f:
sample_data = {
"firstpost": {
"text": "Hello, world!",
"date": int(datetime.datetime.now().timestamp())
},
}
json.dump(sample_data, f, indent=2)
get_upload_code()
app.run(
port=8080,
)