• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

marscoin / martianrepublic / 23804883405

31 Mar 2026 03:14PM UTC coverage: 11.195% (+1.8%) from 9.347%
23804883405

push

github

Martian Congress
refactor: Tier 1 — security fixes, route standardization, code style, docs

S9:  File upload path traversal — assert realpath() within allowed dirs,
     sanitize citizen address in file paths
S11: Citizen registry queries capped with LIMIT 100
S12: External HTTP calls — replaced file_get_contents with Http::timeout()
     in CongressController, Wallet/ApiController, AppHelper
A8:  All routes converted to Laravel 9+ array notation
     [Controller::class, 'method']
D2:  Created DEVELOPMENT.md — complete setup guide for contributors
D4:  Laravel Pint code style enforced across 211 files

Tests updated: public route assertions corrected after route syntax
standardization exposed that old string-notation routes never resolved
properly in tests.

127 tests, 251 assertions, 0 PHPStan errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

302 of 3262 new or added lines in 58 files covered. (9.26%)

139 existing lines in 24 files now uncovered.

620 of 5538 relevant lines covered (11.2%)

1.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/app/Http/Controllers/ForumController.php
1
<?php
2

3
namespace App\Http\Controllers;
4

5
use App\Models\Posts;
6
use App\Models\Profile;
7
use App\Models\Threads;
8
use App\Models\User;
9
use Illuminate\Http\Request;
10
use Illuminate\Support\Facades\Auth;
11
use Illuminate\Support\Facades\DB;
12
use Illuminate\Support\Facades\Log;
13

14
class ForumController extends Controller
15
{
16
    /**
17
     * Forum home — all categories + recent threads.
18
     */
19
    public function index()
×
20
    {
21
        // Categories with thread/post counts
22
        $categories = DB::table('forum_categories')
×
23
            ->select('id', 'parent_id', 'title', 'description', 'color', 'thread_count', 'post_count', 'accepts_threads', 'is_private')
×
24
            ->orderBy('weight')
×
25
            ->orderBy('title')
×
26
            ->get();
×
27

28
        // Recent threads: pinned first, then by last activity
29
        $threads = DB::table('forum_threads')
×
30
            ->join('users', 'forum_threads.author_id', '=', 'users.id')
×
31
            ->leftJoin('forum_categories', 'forum_threads.category_id', '=', 'forum_categories.id')
×
32
            ->leftJoin('proposals', 'proposals.discussion', '=', 'forum_threads.id')
×
33
            ->select(
×
34
                'forum_threads.*',
×
35
                'users.fullname as author_name',
×
36
                'forum_categories.title as category_title',
×
37
                'forum_categories.color as category_color',
×
38
                'proposals.id as proposal_id',
×
39
                'proposals.status as proposal_status',
×
40
                'proposals.tier as proposal_tier'
×
41
            )
×
42
            ->whereNull('forum_threads.deleted_at')
×
43
            ->orderByDesc('forum_threads.pinned')
×
44
            ->orderByDesc('forum_threads.updated_at')
×
45
            ->limit(30)
×
46
            ->get();
×
47

48
        // Auth context for the view
49
        $viewData = $this->getAuthContext();
×
50
        $viewData['categories'] = $categories;
×
51
        $viewData['threads'] = $threads;
×
52

53
        return view('forum.index', $viewData);
×
54
    }
55

56
    /**
57
     * Thread view — thread with all posts (chronological), author info, proposal data.
58
     */
59
    public function show($id)
×
60
    {
61
        $id = (int) $id;
×
62
        $thread = DB::table('forum_threads')
×
63
            ->join('users', 'forum_threads.author_id', '=', 'users.id')
×
64
            ->leftJoin('forum_categories', 'forum_threads.category_id', '=', 'forum_categories.id')
×
65
            ->select(
×
66
                'forum_threads.*',
×
67
                'users.fullname as author_name',
×
68
                'forum_categories.title as category_title',
×
69
                'forum_categories.color as category_color',
×
70
                'forum_categories.id as category_id'
×
71
            )
×
72
            ->where('forum_threads.id', $id)
×
73
            ->whereNull('forum_threads.deleted_at')
×
74
            ->first();
×
75

NEW
76
        if (! $thread) {
×
77
            abort(404);
×
78
        }
79

80
        // All posts for this thread, chronological, with author info and citizen status
81
        $posts = DB::table('forum_posts')
×
82
            ->join('users', 'forum_posts.author_id', '=', 'users.id')
×
83
            ->leftJoin('profile', 'forum_posts.author_id', '=', 'profile.userid')
×
84
            ->leftJoin('citizen', 'forum_posts.author_id', '=', 'citizen.userid')
×
85
            ->select(
×
86
                'forum_posts.*',
×
87
                'users.fullname as author_name',
×
88
                'profile.citizen as is_citizen',
×
89
                'citizen.avatar_link as author_avatar'
×
90
            )
×
91
            ->where('forum_posts.thread_id', $id)
×
92
            ->whereNull('forum_posts.deleted_at')
×
93
            ->orderByRaw('forum_posts.id = ? DESC', [$thread->first_post_id])
×
94
            ->orderBy('forum_posts.created_at')
×
95
            ->get();
×
96

97
        // Check if a proposal is linked to this thread
98
        $proposal = DB::table('proposals')
×
99
            ->where('discussion', $id)
×
100
            ->first();
×
101

102
        $viewData = $this->getAuthContext();
×
103
        $viewData['thread'] = $thread;
×
104
        $viewData['posts'] = $posts;
×
105
        $viewData['proposal'] = $proposal;
×
106

107
        return view('forum.show', $viewData);
×
108
    }
109

110
    /**
111
     * Create a new thread with its first post.
112
     */
113
    public function storeThread(Request $request)
×
114
    {
NEW
115
        if (! Auth::check()) {
×
116
            return redirect('/login');
×
117
        }
118

119
        // Profile / 2FA challenge check
120
        $authCheck = $this->requireProfile();
×
NEW
121
        if ($authCheck) {
×
NEW
122
            return $authCheck;
×
123
        }
124

125
        $request->validate([
×
NEW
126
            'title' => 'required|string|min:3|max:255',
×
NEW
127
            'content' => 'required|string|min:3',
×
128
            'category_id' => 'required|integer|exists:forum_categories,id',
×
129
        ]);
×
130

131
        $uid = Auth::user()->id;
×
132
        $now = now();
×
133

134
        DB::beginTransaction();
×
135
        try {
136
            // Create thread
137
            $threadId = DB::table('forum_threads')->insertGetId([
×
138
                'category_id' => $request->input('category_id'),
×
NEW
139
                'author_id' => $uid,
×
NEW
140
                'title' => $request->input('title'),
×
NEW
141
                'pinned' => 0,
×
NEW
142
                'locked' => 0,
×
143
                'reply_count' => 0,
×
NEW
144
                'created_at' => $now,
×
NEW
145
                'updated_at' => $now,
×
UNCOV
146
            ]);
×
147

148
            // Create first post
149
            $postId = DB::table('forum_posts')->insertGetId([
×
NEW
150
                'thread_id' => $threadId,
×
NEW
151
                'author_id' => $uid,
×
NEW
152
                'content' => $request->input('content'),
×
NEW
153
                'sequence' => 1,
×
154
                'authorName' => Auth::user()->fullname,
×
155
                'created_at' => $now,
×
156
                'updated_at' => $now,
×
157
            ]);
×
158

159
            // Update thread with first/last post references
160
            DB::table('forum_threads')
×
161
                ->where('id', $threadId)
×
162
                ->update([
×
163
                    'first_post_id' => $postId,
×
NEW
164
                    'last_post_id' => $postId,
×
165
                ]);
×
166

167
            // Increment category counters
168
            DB::table('forum_categories')
×
169
                ->where('id', $request->input('category_id'))
×
170
                ->increment('thread_count');
×
171

172
            DB::table('forum_categories')
×
173
                ->where('id', $request->input('category_id'))
×
174
                ->increment('post_count');
×
175

176
            DB::commit();
×
177

178
            Log::info("Forum thread #{$threadId} created by user {$uid}");
×
179

180
            return redirect()->route('forum.thread.show', ['id' => $threadId]);
×
181

182
        } catch (\Exception $e) {
×
183
            DB::rollBack();
×
NEW
184
            Log::error('Failed to create forum thread: '.$e->getMessage());
×
185

UNCOV
186
            return back()->withErrors(['error' => 'Failed to create thread. Please try again.'])->withInput();
×
187
        }
188
    }
189

190
    /**
191
     * Create a post/reply in a thread.
192
     */
193
    public function storePost(Request $request, $threadId)
×
194
    {
NEW
195
        if (! Auth::check()) {
×
196
            return response()->json(['error' => 'Unauthorized'], 401);
×
197
        }
198

199
        // Profile / 2FA challenge check
200
        $authCheck = $this->requireProfile();
×
201
        if ($authCheck) {
×
202
            if ($request->expectsJson()) {
×
203
                return response()->json(['error' => 'Profile verification required'], 403);
×
204
            }
205

UNCOV
206
            return $authCheck;
×
207
        }
208

209
        $request->validate([
×
NEW
210
            'content' => 'required|string|min:3',
×
211
            'parent_id' => 'nullable|integer|exists:forum_posts,id',
×
212
        ]);
×
213

214
        $uid = Auth::user()->id;
×
215
        $now = now();
×
216

217
        // Verify thread exists and is not locked
218
        $thread = DB::table('forum_threads')
×
219
            ->where('id', $threadId)
×
220
            ->whereNull('deleted_at')
×
221
            ->first();
×
222

NEW
223
        if (! $thread) {
×
224
            $msg = 'Thread not found.';
×
225

226
            return $request->expectsJson()
×
227
                ? response()->json(['error' => $msg], 404)
×
228
                : back()->withErrors(['error' => $msg]);
×
229
        }
230

231
        if ($thread->locked) {
×
232
            $msg = 'This thread is locked.';
×
233

234
            return $request->expectsJson()
×
235
                ? response()->json(['error' => $msg], 403)
×
236
                : back()->withErrors(['error' => $msg]);
×
237
        }
238

239
        DB::beginTransaction();
×
240
        try {
241
            // Calculate next sequence number
242
            $maxSeq = DB::table('forum_posts')
×
243
                ->where('thread_id', $threadId)
×
244
                ->max('sequence') ?? 0;
×
245

246
            $postId = DB::table('forum_posts')->insertGetId([
×
NEW
247
                'thread_id' => $threadId,
×
NEW
248
                'author_id' => $uid,
×
NEW
249
                'content' => $request->input('content'),
×
NEW
250
                'post_id' => $request->input('parent_id'),
×
NEW
251
                'sequence' => $maxSeq + 1,
×
252
                'authorName' => Auth::user()->fullname,
×
253
                'created_at' => $now,
×
254
                'updated_at' => $now,
×
255
            ]);
×
256

257
            // Update thread: reply count, last post, bump updated_at
258
            DB::table('forum_threads')
×
259
                ->where('id', $threadId)
×
260
                ->update([
×
NEW
261
                    'reply_count' => DB::raw('COALESCE(reply_count, 0) + 1'),
×
262
                    'last_post_id' => $postId,
×
NEW
263
                    'updated_at' => $now,
×
264
                ]);
×
265

266
            // Increment category post count
267
            DB::table('forum_categories')
×
268
                ->where('id', $thread->category_id)
×
269
                ->increment('post_count');
×
270

271
            DB::commit();
×
272

273
            Log::info("Forum post #{$postId} in thread #{$threadId} by user {$uid}");
×
274

275
            if ($request->expectsJson()) {
×
276
                // Return the new post data for AJAX rendering
277
                $post = DB::table('forum_posts')
×
278
                    ->join('users', 'forum_posts.author_id', '=', 'users.id')
×
279
                    ->leftJoin('profile', 'forum_posts.author_id', '=', 'profile.userid')
×
280
                    ->leftJoin('citizen', 'forum_posts.author_id', '=', 'citizen.userid')
×
281
                    ->select(
×
282
                        'forum_posts.*',
×
283
                        'users.fullname as author_name',
×
284
                        'profile.citizen as is_citizen',
×
285
                        'citizen.avatar_link as author_avatar'
×
286
                    )
×
287
                    ->where('forum_posts.id', $postId)
×
288
                    ->first();
×
289

290
                return response()->json([
×
291
                    'success' => true,
×
NEW
292
                    'post' => $post,
×
293
                ]);
×
294
            }
295

296
            return redirect()->route('forum.thread.show', ['id' => $threadId]);
×
297

298
        } catch (\Exception $e) {
×
299
            DB::rollBack();
×
NEW
300
            Log::error('Failed to create forum post: '.$e->getMessage());
×
301

302
            if ($request->expectsJson()) {
×
303
                return response()->json(['error' => 'Failed to create post.'], 500);
×
304
            }
305

UNCOV
306
            return back()->withErrors(['error' => 'Failed to create post. Please try again.'])->withInput();
×
307
        }
308
    }
309

310
    /**
311
     * Threads filtered by category — pinned first, then by last activity.
312
     */
313
    public function categoryThreads($categoryId)
×
314
    {
315
        $categoryId = (int) $categoryId;
×
316
        $category = DB::table('forum_categories')
×
317
            ->where('id', $categoryId)
×
318
            ->first();
×
319

NEW
320
        if (! $category) {
×
321
            abort(404);
×
322
        }
323

324
        $categories = DB::table('forum_categories')
×
325
            ->select('id', 'parent_id', 'title', 'description', 'color', 'thread_count', 'post_count', 'accepts_threads', 'is_private')
×
326
            ->orderBy('weight')
×
327
            ->orderBy('title')
×
328
            ->get();
×
329

330
        $threads = DB::table('forum_threads')
×
331
            ->join('users', 'forum_threads.author_id', '=', 'users.id')
×
332
            ->leftJoin('proposals', 'proposals.discussion', '=', 'forum_threads.id')
×
333
            ->select(
×
334
                'forum_threads.*',
×
335
                'users.fullname as author_name',
×
336
                'proposals.id as proposal_id',
×
337
                'proposals.status as proposal_status',
×
338
                'proposals.tier as proposal_tier'
×
339
            )
×
340
            ->where('forum_threads.category_id', $categoryId)
×
341
            ->whereNull('forum_threads.deleted_at')
×
342
            ->orderByDesc('forum_threads.pinned')
×
343
            ->orderByDesc('forum_threads.updated_at')
×
344
            ->limit(30)
×
345
            ->get();
×
346

347
        $viewData = $this->getAuthContext();
×
348
        $viewData['category'] = $category;
×
349
        $viewData['categories'] = $categories;
×
350
        $viewData['threads'] = $threads;
×
351

352
        return view('forum.index', $viewData);
×
353
    }
354

355
    // ==================================================================================
356
    // Private helpers
357
    // ==================================================================================
358

359
    /**
360
     * Build common auth context data for views (mirrors CongressController pattern).
361
     */
362
    private function getAuthContext(): array
×
363
    {
364
        if (Auth::check()) {
×
365
            $uid = Auth::user()->id;
×
366
            $profile = Profile::where('userid', $uid)->first();
×
367

368
            return [
×
NEW
369
                'isCitizen' => $profile->citizen ?? false,
×
NEW
370
                'isGP' => $profile->general_public ?? false,
×
371
                'wallet_open' => $profile->civic_wallet_open ?? false,
×
372
            ];
×
373
        }
374

375
        return [
×
NEW
376
            'isCitizen' => false,
×
NEW
377
            'isGP' => false,
×
378
            'wallet_open' => false,
×
379
        ];
×
380
    }
381

382
    /**
383
     * Profile / 2FA challenge guard (same pattern as CongressController).
384
     * Returns a redirect if the user needs to complete profile setup or 2FA,
385
     * or null if everything is fine.
386
     */
387
    private function requireProfile()
×
388
    {
389
        $uid = Auth::user()->id;
×
390
        $profile = Profile::where('userid', $uid)->first();
×
391

NEW
392
        if (! $profile) {
×
393
            return redirect('/twofa');
×
394
        }
395

396
        if ($profile->openchallenge == 1 || is_null($profile->openchallenge)) {
×
397
            return redirect('/twofachallenge');
×
398
        }
399

400
        return null;
×
401
    }
402
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc