Docfile commited on
Commit
75ba54e
·
verified ·
1 Parent(s): 6507b5a

Upload 23 files

Browse files
app.py CHANGED
@@ -1,7 +1,17 @@
1
- from flask import Flask, render_template, request, redirect, url_for, abort, flash, session, send_from_directory
 
 
 
 
 
 
 
 
 
 
2
  from werkzeug.utils import secure_filename
3
  from werkzeug.security import check_password_hash, generate_password_hash
4
- from models import db, Board, Post, BannedIP, PostFile
5
  from forms import PostForm
6
  from datetime import datetime
7
  import os
@@ -11,94 +21,121 @@ from markupsafe import Markup
11
  from functools import wraps
12
 
13
  app = Flask(__name__)
14
- app.config['SECRET_KEY'] = 'dev-secret-key' # Change in production
15
  os.makedirs(app.instance_path, exist_ok=True)
16
- app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(app.instance_path, 'ghostboard.db')
17
- app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
18
- app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static/uploads')
19
- os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
20
- app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024 # 20 MB limit
21
- app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'mp4', 'webm'}
22
- app.config['ADMIN_PASSWORD_HASH'] = generate_password_hash('admin') # Default password: admin
 
 
 
 
23
 
24
  db.init_app(app)
25
 
 
26
  def init_db():
27
  db.create_all()
28
 
29
  # Seed default boards
30
  boards = [
31
- {'slug': 'g', 'name': 'Général', 'description': 'Discussion générale'},
32
- {'slug': 'tech', 'name': 'Technologie', 'description': 'Ordinateurs, programmation, gadgets'},
33
- {'slug': 'art', 'name': 'Création', 'description': 'Art, design, musique'},
34
- {'slug': 'news', 'name': 'Actualités', 'description': 'Événements actuels'}
 
 
 
 
35
  ]
36
 
37
  for b_data in boards:
38
- if not Board.query.filter_by(slug=b_data['slug']).first():
39
- board = Board(slug=b_data['slug'], name=b_data['name'], description=b_data['description'])
 
 
 
 
40
  db.session.add(board)
41
 
42
  db.session.commit()
43
 
 
44
  # Auto-initialize database if it doesn't exist
45
  with app.app_context():
46
- if not os.path.exists(os.path.join(app.instance_path, 'ghostboard.db')):
47
  init_db()
48
 
 
49
  def allowed_file(filename):
50
- return '.' in filename and \
51
- filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
 
 
 
52
 
53
  def save_file(file):
54
  if file and allowed_file(file.filename):
55
  original_filename = secure_filename(file.filename)
56
- extension = original_filename.rsplit('.', 1)[1].lower()
57
  new_filename = f"{uuid.uuid4().hex}.{extension}"
58
- file.save(os.path.join(app.config['UPLOAD_FOLDER'], new_filename))
59
  return new_filename, original_filename
60
  return None, None
61
 
 
62
  def delete_post_files(post):
63
  for file in post.files:
64
- path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
65
  if os.path.exists(path):
66
  os.remove(path)
67
  db.session.delete(file)
68
 
 
69
  def delete_thread_content(thread):
70
  # Delete all replies
71
  replies = Post.query.filter_by(thread_id=thread.id).all()
72
  for reply in replies:
73
  delete_post_files(reply)
74
  db.session.delete(reply)
75
-
76
  delete_post_files(thread)
77
  db.session.delete(thread)
78
 
 
79
  def prune_board(board_id):
80
  # Limit to 100 threads
81
- threads_query = Post.query.filter_by(board_id=board_id, thread_id=None).order_by(Post.updated_at.desc())
 
 
82
  if threads_query.count() > 100:
83
  threads_to_delete = threads_query.offset(100).all()
84
  for thread in threads_to_delete:
85
  delete_thread_content(thread)
86
  db.session.commit()
87
 
 
88
  def admin_required(f):
89
  @wraps(f)
90
  def decorated_function(*args, **kwargs):
91
- if not session.get('is_admin'):
92
- return redirect(url_for('admin_login', next=request.url))
93
  return f(*args, **kwargs)
 
94
  return decorated_function
95
 
 
96
  def check_ban():
97
  ip = request.remote_addr
98
  ban = BannedIP.query.filter_by(ip_address=ip).first()
99
  if ban:
100
  abort(403, description=f"Vous êtes banni. Raison : {ban.reason}")
101
 
 
102
  @app.cli.command("init-db")
103
  def init_db_command():
104
  """Create database tables and seed initial data."""
@@ -106,60 +143,171 @@ def init_db_command():
106
  init_db()
107
  print("Base de données initialisée.")
108
 
 
109
  @app.context_processor
110
  def inject_boards():
111
  return dict(global_boards=Board.query.all())
112
 
113
- @app.template_filter('format_post')
114
- def format_post(content):
 
 
 
 
 
115
  if not content:
116
  return ""
117
-
118
  # Escape HTML first (safety)
119
  content = str(Markup.escape(content))
120
-
121
  # Link to quotes >>12345
122
  def replace_quote(match):
123
  post_id = match.group(1)
124
- return f'<a href="#post-{post_id}" class="text-blue-400 hover:underline" onclick="highlightPost({post_id})">&gt;&gt;{post_id}</a>'
125
-
126
- content = re.sub(r'&gt;&gt;(\d+)', replace_quote, content)
127
-
 
 
 
 
 
 
 
 
 
 
 
128
  # Greentext (lines starting with >)
129
- content = re.sub(r'^(&gt;.*)$', r'<span class="text-green-400">\1</span>', content, flags=re.MULTILINE)
130
-
 
 
 
 
 
131
  # Convert newlines to <br>
132
- content = content.replace('\n', '<br>')
133
-
134
  return Markup(content)
135
 
136
- @app.route('/manifest.json')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  def manifest():
138
- return send_from_directory('static', 'manifest.json')
 
139
 
140
- @app.route('/sw.js')
141
  def service_worker():
142
- return send_from_directory('static', 'sw.js')
143
 
144
- @app.route('/')
 
145
  def index():
146
  boards = Board.query.all()
147
- return render_template('index.html', boards=boards)
 
148
 
149
- @app.route('/<slug>/', methods=['GET', 'POST'])
150
  def board(slug):
151
  board = Board.query.filter_by(slug=slug).first_or_404()
152
  form = PostForm()
153
-
154
  if form.validate_on_submit():
155
  check_ban()
156
-
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  # Create new thread
158
  post = Post(
159
  board_id=board.id,
 
160
  content=form.content.data,
161
- nickname=form.nickname.data or 'Anonyme',
162
- ip_address=request.remote_addr
163
  )
164
  db.session.add(post)
165
  db.session.commit()
@@ -172,42 +320,86 @@ def board(slug):
172
  post_file = PostFile(
173
  post_id=post.id,
174
  filename=filename,
175
- original_filename=original_filename
176
  )
177
  db.session.add(post_file)
178
  db.session.commit()
179
-
180
  # Prune board
181
  prune_board(board.id)
182
-
183
- return redirect(url_for('board', slug=slug))
184
 
185
  # Get threads (posts with no thread_id), sorted by updated_at (bumping)
186
- threads = Post.query.filter_by(board_id=board.id, thread_id=None)\
187
- .order_by(Post.updated_at.desc()).limit(50).all()
188
- return render_template('board.html', board=board, threads=threads, form=form)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
- @app.route('/thread/<int:thread_id>/', methods=['GET', 'POST'])
191
  def thread(thread_id):
192
  thread_post = Post.query.get_or_404(thread_id)
193
  if thread_post.thread_id is not None:
194
  # If it's a reply, redirect to the main thread
195
- return redirect(url_for('thread', thread_id=thread_post.thread_id))
196
-
197
  form = PostForm()
198
  if form.validate_on_submit():
199
  check_ban()
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  # Create reply
202
  reply = Post(
203
  board_id=thread_post.board_id,
204
  thread_id=thread_post.id,
 
205
  content=form.content.data,
206
- nickname=form.nickname.data or 'Anonyme',
207
- ip_address=request.remote_addr
208
  )
209
  db.session.add(reply)
210
-
211
  # Handle multiple files
212
  if form.files.data:
213
  for f in form.files.data:
@@ -216,48 +408,73 @@ def thread(thread_id):
216
  post_file = PostFile(
217
  post_id=reply.id,
218
  filename=filename,
219
- original_filename=original_filename
220
  )
221
  db.session.add(post_file)
222
-
223
  # Bump the thread
224
  thread_post.updated_at = datetime.utcnow()
225
-
226
  db.session.commit()
227
- return redirect(url_for('thread', thread_id=thread_post.id))
228
-
229
- replies = Post.query.filter_by(thread_id=thread_post.id).order_by(Post.created_at.asc()).all()
230
- return render_template('thread.html', thread=thread_post, replies=replies, board=thread_post.board, form=form)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
  # Admin Routes
233
- @app.route('/admin/login', methods=['GET', 'POST'])
234
  def admin_login():
235
- if request.method == 'POST':
236
- password = request.form.get('password')
237
- if check_password_hash(app.config['ADMIN_PASSWORD_HASH'], password):
238
- session['is_admin'] = True
239
- return redirect(url_for('admin_dashboard'))
240
  else:
241
- flash('Mot de passe invalide')
242
- return render_template('admin_login.html')
243
 
244
- @app.route('/admin/logout')
 
245
  def admin_logout():
246
- session.pop('is_admin', None)
247
- return redirect(url_for('index'))
 
248
 
249
- @app.route('/admin')
250
  @admin_required
251
  def admin_dashboard():
252
  # Show recent posts (both threads and replies) for moderation
253
  recent_posts = Post.query.order_by(Post.created_at.desc()).limit(100).all()
254
- return render_template('admin.html', posts=recent_posts)
255
 
256
- @app.route('/admin/delete/<int:post_id>', methods=['POST'])
 
257
  @admin_required
258
  def delete_post(post_id):
259
  post = Post.query.get_or_404(post_id)
260
-
261
  if post.thread_id is None:
262
  # It's a thread starter, delete whole thread
263
  delete_thread_content(post)
@@ -265,12 +482,13 @@ def delete_post(post_id):
265
  # It's a reply
266
  delete_post_file(post)
267
  db.session.delete(post)
268
-
269
  db.session.commit()
270
- flash('Post supprimé')
271
- return redirect(request.referrer or url_for('admin_dashboard'))
 
272
 
273
- @app.route('/admin/ban/<int:post_id>', methods=['POST'])
274
  @admin_required
275
  def ban_ip(post_id):
276
  post = Post.query.get_or_404(post_id)
@@ -278,38 +496,41 @@ def ban_ip(post_id):
278
  ban = BannedIP(ip_address=post.ip_address, reason="Banni par l'administrateur")
279
  db.session.add(ban)
280
  db.session.commit()
281
- flash(f'IP {post.ip_address} bannie')
282
  else:
283
- flash(f'L\'IP {post.ip_address} est déjà bannie')
284
-
285
- return redirect(request.referrer or url_for('admin_dashboard'))
286
 
287
- @app.route('/admin/boards')
 
288
  @admin_required
289
  def admin_boards():
290
  boards = Board.query.all()
291
- return render_template('admin_boards.html', boards=boards)
 
292
 
293
- @app.route('/admin/boards/add', methods=['POST'])
294
  @admin_required
295
  def admin_add_board():
296
- slug = request.form.get('slug')
297
- name = request.form.get('name')
298
- description = request.form.get('description')
299
-
300
  if slug and name:
301
  if not Board.query.filter_by(slug=slug).first():
302
  board = Board(slug=slug, name=name, description=description)
303
  db.session.add(board)
304
  db.session.commit()
305
- flash('Planche ajoutée')
306
  else:
307
- flash('Ce slug existe déjà')
308
  else:
309
- flash('Données manquantes')
310
- return redirect(url_for('admin_boards'))
 
311
 
312
- @app.route('/admin/boards/delete/<int:board_id>', methods=['POST'])
313
  @admin_required
314
  def admin_delete_board(board_id):
315
  board = Board.query.get_or_404(board_id)
@@ -317,11 +538,12 @@ def admin_delete_board(board_id):
317
  posts = Post.query.filter_by(board_id=board.id, thread_id=None).all()
318
  for post in posts:
319
  delete_thread_content(post)
320
-
321
  db.session.delete(board)
322
  db.session.commit()
323
- flash('Planche supprimée')
324
- return redirect(url_for('admin_boards'))
 
325
 
326
- if __name__ == '__main__':
327
  app.run(debug=True)
 
1
+ from flask import (
2
+ Flask,
3
+ render_template,
4
+ request,
5
+ redirect,
6
+ url_for,
7
+ abort,
8
+ flash,
9
+ session,
10
+ send_from_directory,
11
+ )
12
  from werkzeug.utils import secure_filename
13
  from werkzeug.security import check_password_hash, generate_password_hash
14
+ from models import db, Board, Post, BannedIP, PostFile, User
15
  from forms import PostForm
16
  from datetime import datetime
17
  import os
 
21
  from functools import wraps
22
 
23
  app = Flask(__name__)
24
+ app.config["SECRET_KEY"] = "dev-secret-key" # Change in production
25
  os.makedirs(app.instance_path, exist_ok=True)
26
+ app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
27
+ app.instance_path, "ghostboard.db"
28
+ )
29
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
30
+ app.config["UPLOAD_FOLDER"] = os.path.join(app.root_path, "static/uploads")
31
+ os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
32
+ app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB limit
33
+ app.config["ALLOWED_EXTENSIONS"] = {"png", "jpg", "jpeg", "gif", "webp", "mp4", "webm"}
34
+ app.config["ADMIN_PASSWORD_HASH"] = generate_password_hash(
35
+ "admin"
36
+ ) # Default password: admin
37
 
38
  db.init_app(app)
39
 
40
+
41
  def init_db():
42
  db.create_all()
43
 
44
  # Seed default boards
45
  boards = [
46
+ {"slug": "g", "name": "Général", "description": "Discussion générale"},
47
+ {
48
+ "slug": "tech",
49
+ "name": "Technologie",
50
+ "description": "Ordinateurs, programmation, gadgets",
51
+ },
52
+ {"slug": "art", "name": "Création", "description": "Art, design, musique"},
53
+ {"slug": "news", "name": "Actualités", "description": "Événements actuels"},
54
  ]
55
 
56
  for b_data in boards:
57
+ if not Board.query.filter_by(slug=b_data["slug"]).first():
58
+ board = Board(
59
+ slug=b_data["slug"],
60
+ name=b_data["name"],
61
+ description=b_data["description"],
62
+ )
63
  db.session.add(board)
64
 
65
  db.session.commit()
66
 
67
+
68
  # Auto-initialize database if it doesn't exist
69
  with app.app_context():
70
+ if not os.path.exists(os.path.join(app.instance_path, "ghostboard.db")):
71
  init_db()
72
 
73
+
74
  def allowed_file(filename):
75
+ return (
76
+ "." in filename
77
+ and filename.rsplit(".", 1)[1].lower() in app.config["ALLOWED_EXTENSIONS"]
78
+ )
79
+
80
 
81
  def save_file(file):
82
  if file and allowed_file(file.filename):
83
  original_filename = secure_filename(file.filename)
84
+ extension = original_filename.rsplit(".", 1)[1].lower()
85
  new_filename = f"{uuid.uuid4().hex}.{extension}"
86
+ file.save(os.path.join(app.config["UPLOAD_FOLDER"], new_filename))
87
  return new_filename, original_filename
88
  return None, None
89
 
90
+
91
  def delete_post_files(post):
92
  for file in post.files:
93
+ path = os.path.join(app.config["UPLOAD_FOLDER"], file.filename)
94
  if os.path.exists(path):
95
  os.remove(path)
96
  db.session.delete(file)
97
 
98
+
99
  def delete_thread_content(thread):
100
  # Delete all replies
101
  replies = Post.query.filter_by(thread_id=thread.id).all()
102
  for reply in replies:
103
  delete_post_files(reply)
104
  db.session.delete(reply)
105
+
106
  delete_post_files(thread)
107
  db.session.delete(thread)
108
 
109
+
110
  def prune_board(board_id):
111
  # Limit to 100 threads
112
+ threads_query = Post.query.filter_by(board_id=board_id, thread_id=None).order_by(
113
+ Post.updated_at.desc()
114
+ )
115
  if threads_query.count() > 100:
116
  threads_to_delete = threads_query.offset(100).all()
117
  for thread in threads_to_delete:
118
  delete_thread_content(thread)
119
  db.session.commit()
120
 
121
+
122
  def admin_required(f):
123
  @wraps(f)
124
  def decorated_function(*args, **kwargs):
125
+ if not session.get("is_admin"):
126
+ return redirect(url_for("admin_login", next=request.url))
127
  return f(*args, **kwargs)
128
+
129
  return decorated_function
130
 
131
+
132
  def check_ban():
133
  ip = request.remote_addr
134
  ban = BannedIP.query.filter_by(ip_address=ip).first()
135
  if ban:
136
  abort(403, description=f"Vous êtes banni. Raison : {ban.reason}")
137
 
138
+
139
  @app.cli.command("init-db")
140
  def init_db_command():
141
  """Create database tables and seed initial data."""
 
143
  init_db()
144
  print("Base de données initialisée.")
145
 
146
+
147
  @app.context_processor
148
  def inject_boards():
149
  return dict(global_boards=Board.query.all())
150
 
151
+
152
+ @app.template_filter("format_post")
153
+ def format_post(content, post_context=None):
154
+ """
155
+ Format post content with quotes and greentext.
156
+ post_context: dict mapping post_id -> nickname for resolving >>12345 references
157
+ """
158
  if not content:
159
  return ""
160
+
161
  # Escape HTML first (safety)
162
  content = str(Markup.escape(content))
163
+
164
  # Link to quotes >>12345
165
  def replace_quote(match):
166
  post_id = match.group(1)
167
+ if post_context and post_id in post_context:
168
+ nickname = post_context[post_id]
169
+ return f'<a href="#post-{post_id}" class="text-blue-400 hover:underline" onclick="highlightPost({post_id})">@<span class="font-semibold">{nickname}</span></a>'
170
+ else:
171
+ # Fallback: try to get from database (slower)
172
+ post = Post.query.get(int(post_id))
173
+ if post and post.user:
174
+ return f'<a href="#post-{post_id}" class="text-blue-400 hover:underline" onclick="highlightPost({post_id})">@<span class="font-semibold">{post.user.nickname}</span></a>'
175
+ elif post:
176
+ return f'<a href="#post-{post_id}" class="text-blue-400 hover:underline" onclick="highlightPost({post_id})">@<span class="font-semibold">{post.nickname}</span></a>'
177
+ else:
178
+ return f'<a href="#post-{post_id}" class="text-blue-400 hover:underline" onclick="highlightPost({post_id})">&gt;&gt;{post_id}</a>'
179
+
180
+ content = re.sub(r"&gt;&gt;(\d+)", replace_quote, content)
181
+
182
  # Greentext (lines starting with >)
183
+ content = re.sub(
184
+ r"^(&gt;.*)$",
185
+ r'<span class="text-green-400">\1</span>',
186
+ content,
187
+ flags=re.MULTILINE,
188
+ )
189
+
190
  # Convert newlines to <br>
191
+ content = content.replace("\n", "<br>")
192
+
193
  return Markup(content)
194
 
195
+
196
+ # API Routes for user management
197
+ @app.route("/api/set-nickname", methods=["POST"])
198
+ def set_nickname():
199
+ """Set or update user nickname"""
200
+ data = request.get_json()
201
+ nickname = data.get("nickname", "").strip()
202
+
203
+ if not nickname:
204
+ return {"error": "Pseudo requis"}, 400
205
+
206
+ if len(nickname) > 50:
207
+ return {"error": "Pseudo trop long (max 50 caractères)"}, 400
208
+
209
+ # Check if nickname already exists
210
+ existing_user = User.query.filter_by(nickname=nickname).first()
211
+ if existing_user:
212
+ # If it's the same user, allow it
213
+ if session.get("user_id") == existing_user.id:
214
+ return {"success": True, "user_id": existing_user.id, "nickname": nickname}
215
+ else:
216
+ return {"error": "Ce pseudo est déjà pris"}, 400
217
+
218
+ # Get or create user
219
+ user_id = session.get("user_id")
220
+ if user_id:
221
+ user = User.query.get(user_id)
222
+ if user:
223
+ user.nickname = nickname
224
+ user.last_seen_at = datetime.utcnow()
225
+ else:
226
+ # Session invalid, create new user
227
+ user = User(nickname=nickname)
228
+ db.session.add(user)
229
+ else:
230
+ user = User(nickname=nickname)
231
+ db.session.add(user)
232
+
233
+ db.session.commit()
234
+ session["user_id"] = user.id
235
+ session["nickname"] = user.nickname
236
+
237
+ return {"success": True, "user_id": user.id, "nickname": nickname}
238
+
239
+
240
+ @app.route("/api/me")
241
+ def get_current_user():
242
+ """Get current user info"""
243
+ user_id = session.get("user_id")
244
+ if user_id:
245
+ user = User.query.get(user_id)
246
+ if user:
247
+ user.last_seen_at = datetime.utcnow()
248
+ db.session.commit()
249
+ return {"user_id": user.id, "nickname": user.nickname}
250
+
251
+ return {"user_id": None, "nickname": None}
252
+
253
+
254
+ @app.route("/user/<int:user_id>")
255
+ def user_profile(user_id):
256
+ """Show user profile and posts"""
257
+ user = User.query.get_or_404(user_id)
258
+ posts = (
259
+ Post.query.filter_by(user_id=user_id)
260
+ .order_by(Post.created_at.desc())
261
+ .limit(50)
262
+ .all()
263
+ )
264
+ return render_template("user_profile.html", user=user, posts=posts)
265
+
266
+
267
+ @app.route("/manifest.json")
268
  def manifest():
269
+ return send_from_directory("static", "manifest.json")
270
+
271
 
272
+ @app.route("/sw.js")
273
  def service_worker():
274
+ return send_from_directory("static", "sw.js")
275
 
276
+
277
+ @app.route("/")
278
  def index():
279
  boards = Board.query.all()
280
+ return render_template("index.html", boards=boards)
281
+
282
 
283
+ @app.route("/<slug>/", methods=["GET", "POST"])
284
  def board(slug):
285
  board = Board.query.filter_by(slug=slug).first_or_404()
286
  form = PostForm()
287
+
288
  if form.validate_on_submit():
289
  check_ban()
290
+
291
+ # Get user_id from session or use anonymous
292
+ user_id = session.get("user_id")
293
+ nickname = "Anonyme"
294
+ if user_id:
295
+ user = User.query.get(user_id)
296
+ if user:
297
+ nickname = user.nickname
298
+ else:
299
+ user_id = None # Invalid user_id
300
+ else:
301
+ # Anonymous post
302
+ nickname = form.nickname.data or "Anonyme"
303
+
304
  # Create new thread
305
  post = Post(
306
  board_id=board.id,
307
+ user_id=user_id,
308
  content=form.content.data,
309
+ nickname=nickname,
310
+ ip_address=request.remote_addr,
311
  )
312
  db.session.add(post)
313
  db.session.commit()
 
320
  post_file = PostFile(
321
  post_id=post.id,
322
  filename=filename,
323
+ original_filename=original_filename,
324
  )
325
  db.session.add(post_file)
326
  db.session.commit()
327
+
328
  # Prune board
329
  prune_board(board.id)
330
+
331
+ return redirect(url_for("board", slug=slug))
332
 
333
  # Get threads (posts with no thread_id), sorted by updated_at (bumping)
334
+ threads = (
335
+ Post.query.filter_by(board_id=board.id, thread_id=None)
336
+ .order_by(Post.updated_at.desc())
337
+ .limit(50)
338
+ .all()
339
+ )
340
+
341
+ # Create post context for format_post filter
342
+ # Include all threads and their recent replies
343
+ post_context = {}
344
+ for thread in threads:
345
+ if thread.user:
346
+ post_context[str(thread.id)] = thread.user.nickname
347
+ else:
348
+ post_context[str(thread.id)] = thread.nickname
349
+
350
+ # Also include recent replies for this thread
351
+ recent_replies = (
352
+ Post.query.filter_by(thread_id=thread.id)
353
+ .order_by(Post.created_at.desc())
354
+ .limit(3)
355
+ .all()
356
+ )
357
+ for reply in recent_replies:
358
+ if reply.user:
359
+ post_context[str(reply.id)] = reply.user.nickname
360
+ else:
361
+ post_context[str(reply.id)] = reply.nickname
362
+
363
+ return render_template(
364
+ "board.html", board=board, threads=threads, form=form, post_context=post_context
365
+ )
366
+
367
 
368
+ @app.route("/thread/<int:thread_id>/", methods=["GET", "POST"])
369
  def thread(thread_id):
370
  thread_post = Post.query.get_or_404(thread_id)
371
  if thread_post.thread_id is not None:
372
  # If it's a reply, redirect to the main thread
373
+ return redirect(url_for("thread", thread_id=thread_post.thread_id))
374
+
375
  form = PostForm()
376
  if form.validate_on_submit():
377
  check_ban()
378
 
379
+ # Get user_id from session or use anonymous
380
+ user_id = session.get("user_id")
381
+ nickname = "Anonyme"
382
+ if user_id:
383
+ user = User.query.get(user_id)
384
+ if user:
385
+ nickname = user.nickname
386
+ else:
387
+ user_id = None # Invalid user_id
388
+ else:
389
+ # Anonymous post
390
+ nickname = form.nickname.data or "Anonyme"
391
+
392
  # Create reply
393
  reply = Post(
394
  board_id=thread_post.board_id,
395
  thread_id=thread_post.id,
396
+ user_id=user_id,
397
  content=form.content.data,
398
+ nickname=nickname,
399
+ ip_address=request.remote_addr,
400
  )
401
  db.session.add(reply)
402
+
403
  # Handle multiple files
404
  if form.files.data:
405
  for f in form.files.data:
 
408
  post_file = PostFile(
409
  post_id=reply.id,
410
  filename=filename,
411
+ original_filename=original_filename,
412
  )
413
  db.session.add(post_file)
414
+
415
  # Bump the thread
416
  thread_post.updated_at = datetime.utcnow()
417
+
418
  db.session.commit()
419
+ return redirect(url_for("thread", thread_id=thread_post.id))
420
+
421
+ replies = (
422
+ Post.query.filter_by(thread_id=thread_post.id)
423
+ .order_by(Post.created_at.asc())
424
+ .all()
425
+ )
426
+
427
+ # Create post context for format_post filter (maps post_id -> nickname)
428
+ post_context = {}
429
+ all_posts = [thread_post] + replies
430
+ for post in all_posts:
431
+ if post.user:
432
+ post_context[str(post.id)] = post.user.nickname
433
+ else:
434
+ post_context[str(post.id)] = post.nickname
435
+
436
+ return render_template(
437
+ "thread.html",
438
+ thread=thread_post,
439
+ replies=replies,
440
+ board=thread_post.board,
441
+ form=form,
442
+ post_context=post_context,
443
+ )
444
+
445
 
446
  # Admin Routes
447
+ @app.route("/admin/login", methods=["GET", "POST"])
448
  def admin_login():
449
+ if request.method == "POST":
450
+ password = request.form.get("password")
451
+ if check_password_hash(app.config["ADMIN_PASSWORD_HASH"], password):
452
+ session["is_admin"] = True
453
+ return redirect(url_for("admin_dashboard"))
454
  else:
455
+ flash("Mot de passe invalide")
456
+ return render_template("admin_login.html")
457
 
458
+
459
+ @app.route("/admin/logout")
460
  def admin_logout():
461
+ session.pop("is_admin", None)
462
+ return redirect(url_for("index"))
463
+
464
 
465
+ @app.route("/admin")
466
  @admin_required
467
  def admin_dashboard():
468
  # Show recent posts (both threads and replies) for moderation
469
  recent_posts = Post.query.order_by(Post.created_at.desc()).limit(100).all()
470
+ return render_template("admin.html", posts=recent_posts)
471
 
472
+
473
+ @app.route("/admin/delete/<int:post_id>", methods=["POST"])
474
  @admin_required
475
  def delete_post(post_id):
476
  post = Post.query.get_or_404(post_id)
477
+
478
  if post.thread_id is None:
479
  # It's a thread starter, delete whole thread
480
  delete_thread_content(post)
 
482
  # It's a reply
483
  delete_post_file(post)
484
  db.session.delete(post)
485
+
486
  db.session.commit()
487
+ flash("Post supprimé")
488
+ return redirect(request.referrer or url_for("admin_dashboard"))
489
+
490
 
491
+ @app.route("/admin/ban/<int:post_id>", methods=["POST"])
492
  @admin_required
493
  def ban_ip(post_id):
494
  post = Post.query.get_or_404(post_id)
 
496
  ban = BannedIP(ip_address=post.ip_address, reason="Banni par l'administrateur")
497
  db.session.add(ban)
498
  db.session.commit()
499
+ flash(f"IP {post.ip_address} bannie")
500
  else:
501
+ flash(f"L'IP {post.ip_address} est déjà bannie")
502
+
503
+ return redirect(request.referrer or url_for("admin_dashboard"))
504
 
505
+
506
+ @app.route("/admin/boards")
507
  @admin_required
508
  def admin_boards():
509
  boards = Board.query.all()
510
+ return render_template("admin_boards.html", boards=boards)
511
+
512
 
513
+ @app.route("/admin/boards/add", methods=["POST"])
514
  @admin_required
515
  def admin_add_board():
516
+ slug = request.form.get("slug")
517
+ name = request.form.get("name")
518
+ description = request.form.get("description")
519
+
520
  if slug and name:
521
  if not Board.query.filter_by(slug=slug).first():
522
  board = Board(slug=slug, name=name, description=description)
523
  db.session.add(board)
524
  db.session.commit()
525
+ flash("Planche ajoutée")
526
  else:
527
+ flash("Ce slug existe déjà")
528
  else:
529
+ flash("Données manquantes")
530
+ return redirect(url_for("admin_boards"))
531
+
532
 
533
+ @app.route("/admin/boards/delete/<int:board_id>", methods=["POST"])
534
  @admin_required
535
  def admin_delete_board(board_id):
536
  board = Board.query.get_or_404(board_id)
 
538
  posts = Post.query.filter_by(board_id=board.id, thread_id=None).all()
539
  for post in posts:
540
  delete_thread_content(post)
541
+
542
  db.session.delete(board)
543
  db.session.commit()
544
+ flash("Planche supprimée")
545
+ return redirect(url_for("admin_boards"))
546
+
547
 
548
+ if __name__ == "__main__":
549
  app.run(debug=True)
instance/ghostboard.db CHANGED
Binary files a/instance/ghostboard.db and b/instance/ghostboard.db differ
 
models.py CHANGED
@@ -3,35 +3,52 @@ from datetime import datetime
3
 
4
  db = SQLAlchemy()
5
 
 
6
  class Board(db.Model):
7
  id = db.Column(db.Integer, primary_key=True)
8
  slug = db.Column(db.String(10), unique=True, nullable=False)
9
  name = db.Column(db.String(50), nullable=False)
10
  description = db.Column(db.String(200))
11
- posts = db.relationship('Post', backref='board', lazy=True)
 
 
 
 
 
 
 
 
 
12
 
13
  class PostFile(db.Model):
14
  id = db.Column(db.Integer, primary_key=True)
15
- post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
16
  filename = db.Column(db.String(100), nullable=False)
17
  original_filename = db.Column(db.String(100), nullable=False)
18
 
 
19
  class Post(db.Model):
20
  id = db.Column(db.Integer, primary_key=True)
21
- thread_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) # If null, it's a thread starter
22
- board_id = db.Column(db.Integer, db.ForeignKey('board.id'), nullable=False)
 
 
 
 
 
23
  content = db.Column(db.Text, nullable=True)
24
- nickname = db.Column(db.String(50), default='Anonyme')
25
  created_at = db.Column(db.DateTime, default=datetime.utcnow)
26
- updated_at = db.Column(db.DateTime, default=datetime.utcnow) # For bumping
27
  ip_address = db.Column(db.String(50), nullable=True)
28
 
29
- files = db.relationship('PostFile', backref='post', lazy=True)
30
 
31
  # Relationship to access replies easily
32
- replies = db.relationship('Post',
33
- backref=db.backref('parent', remote_side=[id]),
34
- lazy=True)
 
35
 
36
  class BannedIP(db.Model):
37
  id = db.Column(db.Integer, primary_key=True)
 
3
 
4
  db = SQLAlchemy()
5
 
6
+
7
  class Board(db.Model):
8
  id = db.Column(db.Integer, primary_key=True)
9
  slug = db.Column(db.String(10), unique=True, nullable=False)
10
  name = db.Column(db.String(50), nullable=False)
11
  description = db.Column(db.String(200))
12
+ posts = db.relationship("Post", backref="board", lazy=True)
13
+
14
+
15
+ class User(db.Model):
16
+ id = db.Column(db.Integer, primary_key=True)
17
+ nickname = db.Column(db.String(50), unique=True, nullable=False)
18
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
19
+ last_seen_at = db.Column(db.DateTime, default=datetime.utcnow)
20
+ posts = db.relationship("Post", backref="user", lazy=True)
21
+
22
 
23
  class PostFile(db.Model):
24
  id = db.Column(db.Integer, primary_key=True)
25
+ post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False)
26
  filename = db.Column(db.String(100), nullable=False)
27
  original_filename = db.Column(db.String(100), nullable=False)
28
 
29
+
30
  class Post(db.Model):
31
  id = db.Column(db.Integer, primary_key=True)
32
+ thread_id = db.Column(
33
+ db.Integer, db.ForeignKey("post.id"), nullable=True
34
+ ) # If null, it's a thread starter
35
+ board_id = db.Column(db.Integer, db.ForeignKey("board.id"), nullable=False)
36
+ user_id = db.Column(
37
+ db.Integer, db.ForeignKey("user.id"), nullable=True
38
+ ) # Nullable for backward compatibility
39
  content = db.Column(db.Text, nullable=True)
40
+ nickname = db.Column(db.String(50), default="Anonyme") # Keep for historical posts
41
  created_at = db.Column(db.DateTime, default=datetime.utcnow)
42
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow) # For bumping
43
  ip_address = db.Column(db.String(50), nullable=True)
44
 
45
+ files = db.relationship("PostFile", backref="post", lazy=True)
46
 
47
  # Relationship to access replies easily
48
+ replies = db.relationship(
49
+ "Post", backref=db.backref("parent", remote_side=[id]), lazy=True
50
+ )
51
+
52
 
53
  class BannedIP(db.Model):
54
  id = db.Column(db.Integer, primary_key=True)
requirements.txt CHANGED
@@ -1,5 +1,6 @@
1
  Flask
2
  Flask-SQLAlchemy
3
  Flask-WTF
 
4
  email_validator
5
  Werkzeug
 
1
  Flask
2
  Flask-SQLAlchemy
3
  Flask-WTF
4
+ Flask-Migrate
5
  email_validator
6
  Werkzeug
templates/base.html CHANGED
@@ -316,6 +316,109 @@
316
  });
317
  }
318
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  </script>
320
  </body>
321
  </html>
 
316
  });
317
  }
318
  }
319
+
320
+ // User nickname management
321
+ function setNickname(event) {
322
+ event.preventDefault();
323
+ const nickname = document.getElementById('nickname-input').value.trim();
324
+
325
+ if (!nickname) {
326
+ alert('Veuillez entrer un pseudo');
327
+ return;
328
+ }
329
+
330
+ fetch('/api/set-nickname', {
331
+ method: 'POST',
332
+ headers: {
333
+ 'Content-Type': 'application/json',
334
+ },
335
+ body: JSON.stringify({ nickname: nickname })
336
+ })
337
+ .then(response => response.json())
338
+ .then(data => {
339
+ if (data.success) {
340
+ localStorage.setItem('ghostboard_nickname', data.nickname);
341
+ closeNicknameModal();
342
+ location.reload(); // Refresh to update UI
343
+ } else {
344
+ alert(data.error || 'Erreur lors de la sauvegarde');
345
+ }
346
+ })
347
+ .catch(error => {
348
+ console.error('Error:', error);
349
+ alert('Erreur de connexion');
350
+ });
351
+ }
352
+
353
+ function getNickname() {
354
+ return localStorage.getItem('ghostboard_nickname');
355
+ }
356
+
357
+ function checkNicknameAndShowModal() {
358
+ // Check if user has a nickname
359
+ const nickname = getNickname();
360
+ if (!nickname) {
361
+ // Check with server if user exists
362
+ fetch('/api/me')
363
+ .then(response => response.json())
364
+ .then(data => {
365
+ if (data.nickname) {
366
+ localStorage.setItem('ghostboard_nickname', data.nickname);
367
+ } else {
368
+ // No nickname, show modal
369
+ document.getElementById('nickname-modal').classList.remove('hidden');
370
+ document.getElementById('nickname-input').focus();
371
+ }
372
+ })
373
+ .catch(error => {
374
+ console.error('Error checking user:', error);
375
+ // On error, show modal anyway
376
+ document.getElementById('nickname-modal').classList.remove('hidden');
377
+ document.getElementById('nickname-input').focus();
378
+ });
379
+ }
380
+ }
381
+
382
+ function closeNicknameModal() {
383
+ document.getElementById('nickname-modal').classList.add('hidden');
384
+ }
385
+
386
+ // Auto-fill nickname fields
387
+ function autoFillNickname() {
388
+ const nickname = getNickname();
389
+ if (nickname) {
390
+ // Fill nickname inputs
391
+ const inputs = document.querySelectorAll('input[name="nickname"]');
392
+ inputs.forEach(input => {
393
+ if (!input.value) {
394
+ input.value = nickname;
395
+ }
396
+ });
397
+
398
+ // Hide nickname fields since user has a nickname
399
+ const nicknameFields = document.querySelectorAll('#nickname-field, #nickname-input');
400
+ nicknameFields.forEach(field => {
401
+ if (field) field.style.display = 'none';
402
+ });
403
+ } else {
404
+ // Show nickname fields for anonymous users
405
+ const nicknameFields = document.querySelectorAll('#nickname-field, #nickname-input');
406
+ nicknameFields.forEach(field => {
407
+ if (field) field.style.display = 'block';
408
+ });
409
+ }
410
+ }
411
+
412
+ // Initialize on page load
413
+ document.addEventListener('DOMContentLoaded', function() {
414
+ // Check nickname on index page
415
+ if (window.location.pathname === '/') {
416
+ checkNicknameAndShowModal();
417
+ }
418
+
419
+ // Auto-fill nickname fields
420
+ autoFillNickname();
421
+ });
422
  </script>
423
  </body>
424
  </html>
templates/board.html CHANGED
@@ -45,7 +45,7 @@
45
  </div>
46
 
47
  <div class="text-gray-800 text-base leading-relaxed break-words line-clamp-4 mb-3">
48
- {{ thread.content|default('', true)|truncate(300)|format_post }}
49
  </div>
50
 
51
  <div class="flex items-center justify-between">
@@ -112,10 +112,10 @@
112
  <div class="hidden">
113
  {{ form.honeypot() }}
114
  </div>
115
- <div>
116
- <label class="block text-gray-700 text-sm font-semibold mb-2" for="nickname">Pseudo</label>
117
- {{ form.nickname(class="w-full bg-gray-50 text-gray-900 border border-gray-200 rounded-lg py-3 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent", placeholder="Pseudo (Optionnel)") }}
118
- </div>
119
  <div>
120
  <label class="block text-gray-700 text-sm font-semibold mb-2" for="content">Message</label>
121
  {{ form.content(class="w-full bg-gray-50 text-gray-900 border border-gray-200 rounded-lg py-3 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent h-40 resize-none text-base", placeholder="Écrivez quelque chose...") }}
 
45
  </div>
46
 
47
  <div class="text-gray-800 text-base leading-relaxed break-words line-clamp-4 mb-3">
48
+ {{ thread.content|default('', true)|truncate(300)|format_post(post_context) }}
49
  </div>
50
 
51
  <div class="flex items-center justify-between">
 
112
  <div class="hidden">
113
  {{ form.honeypot() }}
114
  </div>
115
+ <div id="nickname-field">
116
+ <label class="block text-gray-700 text-sm font-semibold mb-2" for="nickname">Pseudo</label>
117
+ {{ form.nickname(class="w-full bg-gray-50 text-gray-900 border border-gray-200 rounded-lg py-3 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent", placeholder="Pseudo (Optionnel)") }}
118
+ </div>
119
  <div>
120
  <label class="block text-gray-700 text-sm font-semibold mb-2" for="content">Message</label>
121
  {{ form.content(class="w-full bg-gray-50 text-gray-900 border border-gray-200 rounded-lg py-3 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent h-40 resize-none text-base", placeholder="Écrivez quelque chose...") }}
templates/index.html CHANGED
@@ -15,4 +15,38 @@
15
  </a>
16
  {% endfor %}
17
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  {% endblock %}
 
15
  </a>
16
  {% endfor %}
17
  </div>
18
+
19
+ <!-- Nickname Modal -->
20
+ <div id="nickname-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-40 backdrop-blur-sm p-4">
21
+ <div class="absolute inset-0" onclick="closeNicknameModal()"></div>
22
+ <div class="bg-white w-full max-w-sm rounded-2xl shadow-2xl transform transition-all relative z-10 overflow-hidden">
23
+ <div class="p-6 text-center">
24
+ <h3 class="text-xl font-bold text-gray-900 mb-4">Comment voulez-vous être appelé ?</h3>
25
+ <p class="text-sm text-gray-600 mb-6">Choisissez un pseudo pour vos posts. Il sera enregistré et réutilisé automatiquement.</p>
26
+
27
+ <form onsubmit="setNickname(event)" class="space-y-4">
28
+ <div>
29
+ <input
30
+ type="text"
31
+ id="nickname-input"
32
+ placeholder="Votre pseudo..."
33
+ maxlength="50"
34
+ class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-center text-lg"
35
+ required
36
+ >
37
+ </div>
38
+ <button
39
+ type="submit"
40
+ class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition-colors"
41
+ >
42
+ Commencer
43
+ </button>
44
+ </form>
45
+
46
+ <p class="text-xs text-gray-500 mt-4">
47
+ Vous pourrez changer votre pseudo plus tard.
48
+ </p>
49
+ </div>
50
+ </div>
51
+ </div>
52
  {% endblock %}
templates/thread.html CHANGED
@@ -55,7 +55,7 @@
55
  <div class="space-y-6">
56
  <!-- Text -->
57
  <div class="prose prose-slate max-w-none text-slate-800 text-lg leading-relaxed break-words">
58
- {{ thread.content|format_post }}
59
  </div>
60
 
61
  <!-- Media Grid -->
@@ -138,7 +138,7 @@
138
  {% endif %}
139
 
140
  <div class="prose prose-sm max-w-none text-[15px] leading-snug text-slate-800">
141
- {{ reply.content|format_post }}
142
  </div>
143
  </div>
144
  </div>
 
55
  <div class="space-y-6">
56
  <!-- Text -->
57
  <div class="prose prose-slate max-w-none text-slate-800 text-lg leading-relaxed break-words">
58
+ {{ thread.content|format_post(post_context) }}
59
  </div>
60
 
61
  <!-- Media Grid -->
 
138
  {% endif %}
139
 
140
  <div class="prose prose-sm max-w-none text-[15px] leading-snug text-slate-800">
141
+ {{ reply.content|format_post(post_context) }}
142
  </div>
143
  </div>
144
  </div>
templates/user_profile.html ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Profil de {{ user.nickname }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="max-w-4xl mx-auto px-3 sm:px-6">
7
+ <div class="mb-6 pt-6">
8
+ <a href="{{ url_for('index') }}" class="inline-flex items-center text-slate-500 hover:text-blue-600 transition-colors font-medium text-sm">
9
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" viewBox="0 0 20 20" fill="currentColor">
10
+ <path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
11
+ </svg>
12
+ Retour à l'accueil
13
+ </a>
14
+ </div>
15
+
16
+ <!-- User Header -->
17
+ <div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 mb-6">
18
+ <div class="flex items-center gap-4">
19
+ <div class="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-2xl shadow-md">
20
+ {{ user.nickname[0]|upper }}
21
+ </div>
22
+ <div class="flex-1">
23
+ <h1 class="text-2xl font-bold text-slate-900">{{ user.nickname }}</h1>
24
+ <p class="text-slate-600">Membre depuis {{ user.created_at.strftime('%d %B %Y') }}</p>
25
+ <p class="text-sm text-slate-500">{{ posts|length }} posts publiés</p>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <!-- Posts List -->
31
+ <div class="space-y-4">
32
+ <h2 class="text-xl font-semibold text-slate-900 mb-4">Posts récents</h2>
33
+
34
+ {% for post in posts %}
35
+ <div class="bg-white p-6 rounded-xl border border-slate-100 shadow-sm">
36
+ <div class="flex items-start gap-4">
37
+ <!-- Avatar -->
38
+ <div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-sm shadow-sm flex-shrink-0">
39
+ {{ user.nickname[0]|upper }}
40
+ </div>
41
+
42
+ <!-- Content -->
43
+ <div class="flex-1 min-w-0">
44
+ <div class="flex items-center gap-2 mb-2">
45
+ <span class="font-semibold text-slate-900">{{ user.nickname }}</span>
46
+ <span class="text-slate-500">•</span>
47
+ <span class="text-sm text-slate-500">{{ post.created_at.strftime('%d/%m/%Y %H:%M') }}</span>
48
+ <span class="text-slate-500">•</span>
49
+ <a href="{{ url_for('board', slug=post.board.slug) }}" class="text-blue-600 hover:underline">/{{ post.board.slug }}/</a>
50
+ </div>
51
+
52
+ <div class="text-slate-800 mb-3 break-words">
53
+ {{ post.content|format_post }}
54
+ </div>
55
+
56
+ <!-- Media -->
57
+ {% if post.files %}
58
+ <div class="flex flex-wrap gap-2 mb-3">
59
+ {% for file in post.files %}
60
+ <div class="rounded-lg overflow-hidden border border-slate-200">
61
+ {% if file.filename.endswith(('.mp4', '.webm')) %}
62
+ <video controls class="max-w-full max-h-48">
63
+ <source src="{{ url_for('static', filename='uploads/' + file.filename) }}" type="video/{{ file.filename.split('.')[-1] }}">
64
+ </video>
65
+ {% else %}
66
+ <img src="{{ url_for('static', filename='uploads/' + file.filename) }}" alt="Image" class="max-w-full max-h-48 object-cover">
67
+ {% endif %}
68
+ </div>
69
+ {% endfor %}
70
+ </div>
71
+ {% endif %}
72
+
73
+ <!-- Thread link if it's a reply -->
74
+ {% if post.thread_id %}
75
+ <a href="{{ url_for('thread', thread_id=post.thread_id) }}" class="text-sm text-blue-600 hover:underline">
76
+ Voir le fil complet
77
+ </a>
78
+ {% endif %}
79
+ </div>
80
+ </div>
81
+ </div>
82
+ {% endfor %}
83
+
84
+ {% if posts|length == 0 %}
85
+ <div class="text-center text-slate-400 py-12 bg-white rounded-xl border border-slate-100 border-dashed">
86
+ <p>Aucun post trouvé.</p>
87
+ </div>
88
+ {% endfor %}
89
+ </div>
90
+ </div>
91
+ {% endblock %}
test_features.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test script for the new nickname and @pseudo features
4
+ """
5
+
6
+ import requests
7
+ import time
8
+
9
+ BASE_URL = "http://127.0.0.1:5000"
10
+
11
+
12
+ def test_nickname_api():
13
+ """Test the nickname API"""
14
+ print("Testing nickname API...")
15
+
16
+ # Test set nickname
17
+ response = requests.post(
18
+ f"{BASE_URL}/api/set-nickname", json={"nickname": "TestUser123"}
19
+ )
20
+ print(f"Set nickname response: {response.status_code}")
21
+ data = response.json()
22
+ print(f"Response: {data}")
23
+
24
+ # Test get current user
25
+ response = requests.get(f"{BASE_URL}/api/me")
26
+ print(f"Get user response: {response.status_code}")
27
+ data = response.json()
28
+ print(f"Current user: {data}")
29
+
30
+
31
+ def test_board_page():
32
+ """Test accessing a board page"""
33
+ print("\nTesting board page...")
34
+ response = requests.get(f"{BASE_URL}/g/")
35
+ print(f"Board page response: {response.status_code}")
36
+ if "nickname-modal" in response.text:
37
+ print("✅ Nickname modal found on board page")
38
+ else:
39
+ print("❌ Nickname modal not found")
40
+
41
+
42
+ if __name__ == "__main__":
43
+ # Wait for server to start
44
+ time.sleep(2)
45
+
46
+ try:
47
+ test_nickname_api()
48
+ test_board_page()
49
+ print("\n✅ All tests completed!")
50
+ except Exception as e:
51
+ print(f"❌ Test failed: {e}")
tests.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from app import app, db, Board, Post, BannedIP, prune_board
3
+
4
+ class GhostBoardTestCase(unittest.TestCase):
5
+ def setUp(self):
6
+ app.config['TESTING'] = True
7
+ app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
8
+ app.config['WTF_CSRF_ENABLED'] = False
9
+ self.app = app.test_client()
10
+ with app.app_context():
11
+ db.create_all()
12
+ b = Board(slug='tst', name='Test Board', description='Testing')
13
+ db.session.add(b)
14
+ db.session.commit()
15
+
16
+ def tearDown(self):
17
+ with app.app_context():
18
+ db.session.remove()
19
+ db.drop_all()
20
+
21
+ def test_board_exists(self):
22
+ response = self.app.get('/tst/')
23
+ self.assertEqual(response.status_code, 200)
24
+ self.assertIn(b'Test Board', response.data)
25
+
26
+ def test_create_thread(self):
27
+ with app.app_context():
28
+ # Mock form data
29
+ response = self.app.post('/tst/', data=dict(
30
+ content='Test Thread Content'
31
+ ), follow_redirects=True)
32
+ self.assertEqual(response.status_code, 200)
33
+ self.assertIn(b'Test Thread Content', response.data)
34
+
35
+ post = Post.query.first()
36
+ self.assertIsNotNone(post)
37
+ self.assertIsNone(post.thread_id)
38
+
39
+ def test_pruning(self):
40
+ with app.app_context():
41
+ board = Board.query.filter_by(slug='tst').first()
42
+ # Create 101 threads
43
+ for i in range(101):
44
+ p = Post(board_id=board.id, content=f'Thread {i}')
45
+ db.session.add(p)
46
+ db.session.commit()
47
+
48
+ self.assertEqual(Post.query.count(), 101)
49
+ prune_board(board.id)
50
+ self.assertEqual(Post.query.count(), 100)
51
+
52
+ def test_ban_check(self):
53
+ with app.app_context():
54
+ banned = BannedIP(ip_address='127.0.0.1', reason='Test Ban')
55
+ db.session.add(banned)
56
+ db.session.commit()
57
+
58
+ response = self.app.post('/tst/', data=dict(
59
+ content='Should fail'
60
+ ))
61
+ self.assertEqual(response.status_code, 403)
62
+
63
+ if __name__ == '__main__':
64
+ unittest.main()