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

visavi / rotor / 37448563747

06 Oct 2026 10:15AM UTC coverage: 67.064% (+0.9%) from 66.206%
37448563747

push

github

visavi
API: коды и тексты ошибок голосования, лента комментариев GET /api/comments; скоупы withUserVote и capped (CappableTrait), apiUser, индекс comments(relate_type, created_at)

45 of 61 new or added lines in 9 files covered. (73.77%)

5068 of 7557 relevant lines covered (67.06%)

24.93 hits per line

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

68.84
/app/Http/Controllers/ApiController.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace App\Http\Controllers;
6

7
use App\Http\Resources\DialogueResource;
8
use App\Http\Resources\FeedResource;
9
use App\Http\Resources\MessageResource;
10
use App\Http\Resources\NewMessageResource;
11
use App\Http\Resources\SearchResource;
12
use App\Http\Resources\UserProfileResource;
13
use App\Http\Resources\UserResource;
14
use App\Models\Dialogue;
15
use App\Models\Flood;
16
use App\Models\Message;
17
use App\Models\User;
18
use App\Services\ApiSpecService;
19
use App\Services\CommentService;
20
use App\Services\ComplaintService;
21
use App\Services\FeedService;
22
use App\Services\FileService;
23
use App\Services\RatingService;
24
use App\Services\SearchService;
25
use App\Services\UserService;
26
use App\Support\Registry;
27
use App\Traits\HandlesApiPagination;
28
use Closure;
29
use Illuminate\Database\Query\JoinClause;
30
use Illuminate\Http\JsonResponse;
31
use Illuminate\Http\Request;
32
use Illuminate\Http\Resources\Json\JsonResource;
33
use Illuminate\Support\Facades\Cache;
34
use Illuminate\Support\Facades\DB;
35
use Illuminate\Support\Str;
36
use Illuminate\Validation\ValidationException;
37
use Illuminate\View\View;
38

39
class ApiController extends Controller
40
{
41
    use HandlesApiPagination;
42

43
    /**
44
     * Главная страница
45
     */
46
    public function index(): View
3 ✔
47
    {
48
        return view('api/index');
3 ✔
49
    }
50

51
    /**
52
     * Спецификация OpenAPI без выключенных разделов
53
     */
54
    public function spec(): JsonResponse
6 ✔
55
    {
56
        return response()->json(ApiSpecService::spec());
6 ✔
57
    }
58

59
    /**
60
     * Авторизация и получение токена
61
     */
62
    public function auth(Request $request): JsonResponse
1 ✔
63
    {
64
        $request->validate([
1 ✔
65
            'login'    => ['required', 'string'],
1 ✔
66
            'password' => ['required', 'string'],
1 ✔
67
        ]);
1 ✔
68

69
        $user = getUserByLoginOrEmail($request->input('login'));
1 ✔
70

71
        if (! $user || ! password_verify($request->input('password'), $user->password)) {
1 ✔
72
            abort(401, __('users.incorrect_login_or_password'));
1 ✔
73
        }
74

75
        if ($user->level === User::BANNED) {
×
76
            abort(403, 'User banned');
×
77
        }
78

79
        if (! $user->apikey) {
×
80
            $user->update(['apikey' => Str::random(32)]);
×
81
        }
82

83
        return response()->json(['token' => $user->apikey]);
×
84
    }
85

86
    /**
87
     * Api ленты
88
     */
89
    public function feed(): JsonResource
7 ✔
90
    {
91
        $feed = new FeedService();
7 ✔
92
        $posts = $feed->getItems();
7 ✔
93
        $posts->setPath(url('/api/feed'));
7 ✔
94

95
        // Голоса не кешируются вместе с лентой, проставляются под текущего пользователя
96
        $feed->applyVotes($posts->getCollection());
7 ✔
97

98
        return FeedResource::collection($posts);
7 ✔
99
    }
100

101
    /**
102
     * Api поиска по сайту
103
     */
104
    public function search(Request $request): JsonResource
7 ✔
105
    {
106
        $query = $request->string('query', $request->input('q'))->value();
7 ✔
107
        $terms = SearchService::terms($query);
7 ✔
108
        $type = SearchService::type($request->input('type'));
7 ✔
109
        $sort = SearchService::sort($request->input('sort'));
7 ✔
110

111
        // Слова короче трех букв fulltext не индексирует, из них запрос не собрать
112
        if (mb_strlen($terms) < SearchService::MIN_LENGTH || mb_strlen($terms) > SearchService::MAX_LENGTH) {
7 ✔
113
            throw ValidationException::withMessages([
4 ✔
114
                'query' => __('main.request_length'),
4 ✔
115
            ]);
4 ✔
116
        }
117

118
        $posts = SearchService::paginate($terms, $type, $sort, $this->apiPerPage($request));
3 ✔
119
        $posts->setPath(url('/api/search'));
3 ✔
120
        $posts->appends($request->only(['query', 'q', 'type', 'sort', 'per_page']));
3 ✔
121

122
        // Список разделов для фильтра клиент берёт из /config (types.search)
123
        return SearchResource::collection($posts)
3 ✔
124
            ->additional([
3 ✔
125
                'query' => $query,
3 ✔
126
                'type'  => $type,
3 ✔
127
                'sort'  => $sort,
3 ✔
128
            ]);
3 ✔
129
    }
130

131
    /**
132
     * Api голосования за запись
133
     */
134
    public function rating(Request $request, RatingService $rating): JsonResponse
3 ✔
135
    {
136
        $result = $rating->vote(
3 ✔
137
            getUser(),
3 ✔
138
            $request->input('type'),
3 ✔
139
            $request->integer('id'),
3 ✔
140
            $request->input('vote'),
3 ✔
141
        );
3 ✔
142

143
        if (! $result['success']) {
3 ✔
144
            return response()->json(['success' => false, 'message' => $result['message'] ?? null], $result['status'] ?? 422);
1 ✔
145
        }
146

147
        // Клиенту API нужны числа, а не разметка сайта
148
        return response()->json([
3 ✔
149
            'success' => true,
3 ✔
150
            'cancel'  => $result['cancel'],
3 ✔
151
            'rating'  => (int) $result['post']->getAttribute('rating'),
3 ✔
152
        ]);
3 ✔
153
    }
154

155
    /**
156
     * Api пользователей
157
     */
158
    public function user(): JsonResource
8 ✔
159
    {
160
        $user = getUser();
8 ✔
161

162
        return new UserProfileResource($user);
8 ✔
163
    }
164

165
    /**
166
     * Api пользователей
167
     */
168
    public function users(string $login): JsonResource
×
169
    {
170
        $user = getUserByLogin($login);
×
171

172
        if (! $user) {
×
173
            abort(404, __('validator.user'));
×
174
        }
175

176
        return new UserResource($user);
×
177
    }
178

179
    /**
180
     * Api диалогов
181
     */
182
    public function dialogues(Request $request): JsonResource
×
183
    {
184
        $user = getUser();
×
185

186
        $lastMessage = Dialogue::query()
×
187
            ->select(
×
188
                'author_id',
×
189
                DB::raw('max(message_id) as message_id'),
×
190
                DB::raw('min(case when reading then 1 else 0 end) as all_reading')
×
191
            )
×
192
            ->where('user_id', $user->id)
×
193
            ->groupBy('author_id');
×
194

195
        $dialogues = Message::query()
×
196
            ->select('d.*', 'm.text', 'd2.all_reading', 'd3.reading as recipient_read')
×
197
            ->from('messages as m')
×
198
            ->join('dialogues as d', 'd.message_id', 'm.id')
×
199
            ->joinSub($lastMessage, 'd2', static function (JoinClause $join) {
×
200
                $join->on('d.message_id', 'd2.message_id');
×
201
            })
×
202
            ->leftJoin('dialogues as d3', function ($join) {
×
203
                $join->on('d.user_id', 'd3.author_id')
×
204
                    ->whereColumn('d.message_id', 'd3.message_id');
×
205
            })
×
206
            ->where('d.user_id', $user->id)
×
207
            ->with('author')
×
NEW
208
            ->orderBy('d.created_at', $this->apiOrder($request, 'desc'))
×
NEW
209
            ->paginate($this->apiPerPage($request));
×
210

211
        return DialogueResource::collection($dialogues);
×
212
    }
213

214
    /**
215
     * Api приватных сообщений
216
     */
217
    public function talk(string $login, Request $request): JsonResource
×
218
    {
219
        $user = getUser();
×
220

221
        if (is_numeric($login)) {
×
222
            $author = (new User())->setAttribute('id', $login);
×
223
        } else {
224
            $author = getUserByLogin($login);
×
225

226
            if (! $author) {
×
227
                abort(404, __('validator.user'));
×
228
            }
229
        }
230

231
        if ($user->id === $author->id) {
×
232
            abort(200, __('messages.empty_dialogue'));
×
233
        }
234

235
        $messages = Message::query()
×
236
            ->select('d.*', 'm.id', 'm.text', 'd2.reading as recipient_read')
×
237
            ->from('messages as m')
×
238
            ->join('dialogues as d', 'd.message_id', 'm.id')
×
239
            ->leftJoin('dialogues as d2', function ($join) {
×
240
                $join->on('d.user_id', 'd2.author_id')
×
241
                    ->whereColumn('d.message_id', 'd2.message_id');
×
242
            })
×
243
            ->where('d.user_id', $user->id)
×
244
            ->where('d.author_id', $author->id)
×
NEW
245
            ->orderBy('d.created_at', $this->apiOrder($request, 'desc'))
×
246
            ->with('user', 'author', 'files')
×
NEW
247
            ->paginate($this->apiPerPage($request));
×
248

249
        Dialogue::query()
×
250
            ->where('user_id', $user->id)
×
251
            ->where('author_id', $author->id)
×
252
            ->where('reading', 0)
×
253
            ->update(['reading' => 1]);
×
254

255
        $dialogue = $messages->first();
×
256
        $dialogue?->setAttribute('all_reading', true);
×
257

258
        return MessageResource::collection($messages)
×
259
            ->additional(['dialogue' => $dialogue ? DialogueResource::make($dialogue) : null]);
×
260
    }
261

262
    /**
263
     * Отправляет приватное сообщение
264
     */
265
    public function createTalk(string $login, Request $request, Flood $flood, FileService $files): JsonResponse
1 ✔
266
    {
267
        $user = getUser();
1 ✔
268
        $recipient = getUserByLogin($login);
1 ✔
269

270
        $validated = $request->validate([
1 ✔
271
            'text' => [
1 ✔
272
                'required',
1 ✔
273
                'string',
1 ✔
274
                'min:' . setting('comment_text_min'),
1 ✔
275
                'max:' . setting('comment_text_max'),
1 ✔
276
                function (string $attribute, mixed $value, Closure $fail) use ($user, $recipient, $flood) {
1 ✔
277
                    if (! $recipient) {
1 ✔
278
                        $fail(__('validator.user'));
×
279
                    } elseif ($recipient->id === $user->id) {
1 ✔
280
                        $fail(__('messages.send_yourself'));
×
281
                    } elseif ($flood->isFlood()) {
1 ✔
282
                        $fail(__('validator.flood', ['sec' => $flood->getPeriod()]));
×
283
                    }
284
                },
1 ✔
285
            ],
1 ✔
286
        ] + FileService::rules(Message::$morphName));
1 ✔
287

288
        $message = $recipient->sendMessage($user, $validated['text']);
1 ✔
289

290
        // Через FileService, а не uploadFile: он конвертирует видео.
291
        // Загруженные заранее сообщение не берёт — они не знают получателя
292
        $files->attach($message, $request->file('files', []));
1 ✔
293

294
        $flood->saveState();
1 ✔
295

296
        $message->load('user', 'files');
1 ✔
297
        $message->setAttribute('type', Message::OUT);
1 ✔
298

299
        return response()->json([
1 ✔
300
            'message' => __('messages.success_sent'),
1 ✔
301
            'data'    => MessageResource::make($message),
1 ✔
302
        ], 201);
1 ✔
303
    }
304

305
    /**
306
     * Удаляет переписку с пользователем
307
     */
308
    public function deleteTalk(string $login): JsonResponse
4 ✔
309
    {
310
        $user = getUser();
4 ✔
311
        $author = getUserByLogin($login);
4 ✔
312

313
        if (! $author) {
4 ✔
314
            abort(404, __('validator.user'));
1 ✔
315
        }
316

317
        // Непрочитанные удалять нельзя: иначе сообщение исчезнет, не дойдя до адресата
318
        if ($user->getCountNewMessages()) {
3 ✔
319
            abort(422, __('messages.unread_messages'));
1 ✔
320
        }
321

322
        $deleted = Dialogue::query()
2 ✔
323
            ->where('user_id', $user->id)
2 ✔
324
            ->where('author_id', $author->id)
2 ✔
325
            ->get()
2 ✔
326
            ->each(static fn (Dialogue $dialogue) => $dialogue->delete())
2 ✔
327
            ->count();
2 ✔
328

329
        if (! $deleted) {
2 ✔
330
            abort(404, __('messages.empty_dialogue'));
1 ✔
331
        }
332

333
        return response()->json(['message' => __('messages.success_deleted')]);
1 ✔
334
    }
335

336
    /**
337
     * Жалоба на запись
338
     */
339
    public function complaint(Request $request, ComplaintService $complaint): JsonResponse
4 ✔
340
    {
341
        $validated = $request->validate([
4 ✔
342
            'type' => ['required', 'string', 'in:' . implode(',', ComplaintService::types())],
4 ✔
343
            'id'   => ['required', 'integer', 'min:1'],
4 ✔
344
            'page' => ['nullable'],
4 ✔
345
        ]);
4 ✔
346

347
        $result = $complaint->create($validated['type'], (int) $validated['id'], $request->input('page'));
3 ✔
348

349
        return response()->json($result, $result['success'] ? 201 : 422);
3 ✔
350
    }
351

352
    /**
353
     * Api новых сообщений
354
     */
355
    public function newMessages(): JsonResponse
1 ✔
356
    {
357
        $user = getUser();
1 ✔
358

359
        $countMessages = Dialogue::query()
1 ✔
360
            ->where('user_id', $user->id)
1 ✔
361
            ->where('reading', 0)
1 ✔
362
            ->count();
1 ✔
363

364
        if (! $countMessages) {
1 ✔
365
            return response()->json(['count' => 0, 'dialogues' => []]);
1 ✔
366
        }
367

368
        $dialogues = Dialogue::query()
×
369
            ->select(
×
370
                'author_id',
×
371
                DB::raw('max(created_at) as last_created_at')
×
372
            )
×
373
            ->selectRaw('count(*) as cnt')
×
374
            ->where('user_id', $user->id)
×
375
            ->where('reading', 0)
×
376
            ->groupBy('author_id')
×
377
            ->with('author')
×
378
            ->get();
×
379

380
        return response()->json([
×
381
            'count'     => $countMessages,
×
382
            'dialogues' => NewMessageResource::collection($dialogues),
×
383
        ]);
×
384
    }
385

386
    /**
387
     * Статистика сайта
388
     *
389
     * Только общие счётчики: свои баллы приходят в /user, непрочитанные — в /messages/new
390
     */
391
    public function stats(): JsonResponse
9 ✔
392
    {
393
        $data = Cache::remember('apiStats', 300, static function () {
9 ✔
394
            $users = User::query()->count();
9 ✔
395
            $today = User::query()->where('created_at', '>', now()->subDay())->count();
9 ✔
396

397
            return [
9 ✔
398
                'users' => [
9 ✔
399
                    'total'  => $users,
9 ✔
400
                    'today'  => $today,
9 ✔
401
                    'admins' => User::query()->whereIn('level', User::ADMIN_GROUPS)->count(),
9 ✔
402
                ],
9 ✔
403
            ];
9 ✔
404
        });
9 ✔
405

406
        // Онлайн живёт своим кэшем на минуту — в общую выборку его класть нельзя
407
        [$usersOnline, $guests, $total] = statsOnline();
9 ✔
408

409
        $data['online'] = [
9 ✔
410
            'users'  => $usersOnline,
9 ✔
411
            'guests' => $guests,
9 ✔
412
            'total'  => $total,
9 ✔
413
        ];
9 ✔
414

415
        // Счётчики разделов приходят от модулей: ядро о них не знает
416
        $data['sections'] = Cache::remember('apiStatsSections', 300, static fn () => array_map(
9 ✔
417
            static fn (array $handlers): array => [
9 ✔
418
                'total' => (int) $handlers['total'](),
9 ✔
419
                'today' => isset($handlers['today']) ? (int) $handlers['today']() : 0,
9 ✔
420
            ],
9 ✔
421
            Registry::$stats,
9 ✔
422
        ));
9 ✔
423

424
        return response()->json($data);
9 ✔
425
    }
426

427
    /**
428
     * Api конфигурации
429
     */
430
    public function config(): JsonResponse
11 ✔
431
    {
432
        // Значения параметра type в разных ручках: набор зависит от того,
433
        // какие модули установлены, поэтому клиент берёт их отсюда, а не хардкодит
434
        $types = [
11 ✔
435
            'search'    => SearchService::types(),
11 ✔
436
            'comment'   => $this->labeled(CommentService::types()),
11 ✔
437
            'rating'    => RatingService::types(),
11 ✔
438
            'media'     => FileService::mediaTypes(),
11 ✔
439
            'file'      => FileService::fileTypes(),
11 ✔
440
            'complaint' => ComplaintService::types(),
11 ✔
441
        ];
11 ✔
442

443
        $data = Cache::remember('apiConfig', 600, static fn () => [
11 ✔
444
            'site' => [
11 ✔
445
                'title'             => setting('title'),
11 ✔
446
                'language'          => setting('language'),
11 ✔
447
                'money_name'        => setting('moneyname'),
11 ✔
448
                'score_name'        => setting('scorename'),
11 ✔
449
                'site_closed'       => (bool) setting('closedsite'),
11 ✔
450
                'registration_open' => (bool) setting('openreg'),
11 ✔
451
            ],
11 ✔
452
            'upload' => [
11 ✔
453
                'max_files'     => setting('maxfiles'),
11 ✔
454
                'max_file_size' => setting('filesize'),
11 ✔
455
                // extensions — для типов из types.file, media_extensions — для types.media
456
                'extensions'       => FileService::extensionList('file_extensions'),
11 ✔
457
                'media_extensions' => FileService::extensionList('media_extensions'),
11 ✔
458
            ],
11 ✔
459
            'message' => [
11 ✔
460
                'text_min' => setting('comment_text_min'),
11 ✔
461
                'text_max' => setting('comment_text_max'),
11 ✔
462
            ],
11 ✔
463
            // Формы регистрации и аккаунта: длины полей зашиты в ядре, цены — в настройках
464
            'account' => [
11 ✔
465
                'login_min'      => 3,
11 ✔
466
                'login_max'      => 20,
11 ✔
467
                'password_min'   => 6,
11 ✔
468
                'password_max'   => 20,
11 ✔
469
                'captcha_type'   => setting('captcha_type'),
11 ✔
470
                'confirm_email'  => UserService::isEmailConfirm(),
11 ✔
471
                'email_required' => UserService::isEmailRequired(),
11 ✔
472
                'email_hidden'   => UserService::isEmailHidden(),
11 ✔
473
                'status_point'   => setting('editstatuspoint'),
11 ✔
474
                'status_money'   => setting('editstatusmoney'),
11 ✔
475
                'color_point'    => setting('editcolorpoint'),
11 ✔
476
                'color_money'    => setting('editcolormoney'),
11 ✔
477
            ],
11 ✔
478
        ]);
11 ✔
479

480
        // Типы и секции модулей не кешируются вместе с настройками ядра: они зависят
481
        // от набора включённых модулей и должны меняться сразу после включения
482
        $data['types'] = $types;
11 ✔
483

484
        // Секции модулей: какие свои настройки отдавать, модуль объявляет в module.php
485
        foreach (Registry::$apiConfig as $section => $settings) {
11 ✔
486
            // Строка — имя настройки, любое другое значение отдаётся как есть
487
            $data[$section] = array_map(
9 ✔
488
                static fn (mixed $value) => is_string($value) ? setting($value) : $value,
9 ✔
489
                $settings,
9 ✔
490
            );
9 ✔
491
        }
492

493
        return response()->json($data);
11 ✔
494
    }
495

496
    /**
497
     * Дополняет список типов их названиями для UI
498
     */
499
    private function labeled(array $types): array
11 ✔
500
    {
501
        $labels = [];
11 ✔
502

503
        foreach ($types as $type) {
11 ✔
504
            $labels[$type] = Registry::label($type) ?? $type;
×
505
        }
506

507
        return $labels;
11 ✔
508
    }
509
}
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