Club System

This commit is contained in:
2026-06-02 20:54:10 +02:00
parent c68c4d18b5
commit 0b18d289ef
38 changed files with 1464 additions and 118 deletions

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Facades\DB;
use stdClass;
class HashesHelpers
{
public static function findHashes(string $sha1 ): ?StdClass
{
return DB::connection('hashes')->table('hashes')->where('sha1', $sha1)->first();
}
public static function getReferenceName( int $id ): ?string
{
return DB::connection('hashes')->table('dat_reference')->where('id', $id)->first()?->name ?? null;
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Helpers;
use App\Auth\XenForoUser;
use App\Models\Entry;
use App\Services\XenforoApiService;
class XenForoHelpers {
@@ -34,4 +36,28 @@ class XenForoHelpers {
}
return self::XF_AVATAR_COLORS[ crc32( $user->data->username ) % count( self::XF_AVATAR_COLORS ) ];
}
public static function updateEntriesCount( int $userId ): void
{
$count = Entry::where('user_id', $userId)->where('state','published')->count();
$service = app(XenforoApiService::class);
$service->updateEntriesCount( $count, $userId );
}
public static function entryApproved( Entry $entry ): void
{
// 1. Update XF Entry count.
self::updateEntriesCount( $entry->user_id );
// 2. Send a private message
if( \Auth::user()->user_id === $entry->user_id ) {
return;
}
$moderator = \Auth::user()->username;
$title = "Entry approved : {$entry->title}";
$message = "Your entry {$entry->title} has been approved by {$moderator}.";
$service->createConversation([ $entry->user_id ], $title, $message, false, false);
}
}

View File

@@ -4,31 +4,47 @@ namespace App\Http\Controllers;
use App\Helpers\EntryHelpers;
use App\Models\Entry;
use Illuminate\Support\Facades\Gate;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EntryController extends Controller
{
private const SECTION_TYPES = [ 'translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials' ];
private const SECTION_TYPES = ['translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials'];
public function index(): View
{
return view('entries.index');
}
public function show(string $section, Entry $entry): View
{
if( ! in_array($section, self::SECTION_TYPES) )
if (!in_array($section, self::SECTION_TYPES))
abort(404);
if( $entry->type !== $section )
if ($entry->type !== $section)
abort(404);
$comments = EntryHelpers::getLatestComments( $entry );
Gate::authorize('viewAny', $entry);
return view('entries.show', compact('entry', 'section', 'comments' ) );
// Permissions.
$entryPolicy = match ($entry->state) {
'pending' => 'viewPending',
'draft' => 'viewDraft',
'rejected' => 'viewRejected',
'hidden' => 'viewHidden',
'locked' => 'viewLocked',
'published' => null,
'default' => null
};
if ($entryPolicy)
Gate::authorize($entryPolicy, $entry);
$comments = EntryHelpers::getLatestComments($entry);
return view('entries.show', compact('entry', 'section', 'comments'));
}

View File

@@ -51,7 +51,8 @@ class FileServerController extends Controller {
'filepath' => $data['file_path'],
'filesize' => $data['file']['size'],
'favorite_server' => $data['favorite_server'],
'favorite_at' => time()
'favorite_at' => time(),
'state' => 'public'
], now()->addHours(2) );
$data['finished'] = true;

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\XenForoHelpers;
use App\Models\Entry;
use App\Services\XenforoService;
use Illuminate\Http\Request;
class QueueController extends Controller
{
public function index()
{
$entries = Entry::inQueue()
->with(['authors', 'game.platform'])
->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END")
->orderBy('created_at', 'asc')
->get();
return view('queue.index', compact('entries'));
}
public function updateComment(Request $request, Entry $entry)
{
$request->validate(['comment' => 'nullable|string|max:2000']);
$entry->update(['staff_comment' => $request->input('comment')]);
return back()->with('success', 'Comment updated');
}
public function approve(Request $request, Entry $entry)
{
// $entry->update(['state' => 'published']);
XenForoHelpers::entryApproved($entry);
return back()->with('success', 'Entry approved');
}
public function reject(Request $request, Entry $entry)
{
$request->validate(['reason' => 'nullable|string|max:2000']);
$entry->update(['state' => 'rejected', 'staff_comment' => $request->input('reason'), 'rejected_at' => now() ]);
return back()->with('success', 'Entry rejected');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests;
use App\Rules\PublicFileExists;
use App\Rules\XfUserExists;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
@@ -49,12 +50,20 @@ class StoreEntryRequest extends FormRequest
*/
public function rules(): array
{
$isEdit = (bool) $this->route('entry');
$rules = [];
$section = $this->route('section');
$rules['files_uuid'] = 'array|required|min:1';
$rules['files_uuid.*'] = 'string';
if( $isEdit ){
$rules['files_state'] = 'array|required|min:1';
$rules['files_state.*'] = 'string|in:public,private,archived';
}
if( section_must_not_be( 'translations', $section ) ){
$rules['entry_title'] = "required|string|max:255";
} else {
@@ -78,7 +87,6 @@ class StoreEntryRequest extends FormRequest
$rules['new-game-genre'] = 'required_with:new-game-title|integer|nullable|exists:genres,id';
}
$rules['hashes'] = 'array|required|min:1';
$rules['hashes.*.filename'] = 'required|string|max:512';
$rules['hashes.*.hash_crc32'] = 'required|string|max:512';
@@ -101,7 +109,25 @@ class StoreEntryRequest extends FormRequest
$rules['release_site'] = 'nullable|url|max:500';
$rules['youtube_video'] = 'nullable|url|max:500';
$rules['submit-state'] = 'required|string|in:draft,pending,published';
if( $isEdit ){
$ss = 'draft,pending,published';
if( \Auth::user()->can('moderate', $this->route('entry')) && \Auth::user()->can('view-hidden', $this->route('entry')) )
$ss .= ',hidden';
if(\Auth::user()->can('moderate', $this->route('entry')) && \Auth::user()->can('view-locked', $this->route('entry')) )
$ss .= ',locked';
$rules['submit-state'] = 'required|in:' . $ss;
} else {
if( $this->user()->can('skip-queue', '\App\Models\Entry') ){
$rules['submit-state'] = 'required|string|in:draft,pending,published';
} else {
$rules['submit-state'] = 'required|string|in:draft,pending';
}
}
if( $isEdit && $this->user()->can('moderate', $this->route('entry') ) ){
$rules['staff_comment'] = 'nullable|string';
$rules['owner_user_id'] = [ 'required', 'integer', new XfUserExists ];
}
return $rules;
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Helpers\HashesHelpers;
use App\Models\EntryHash;
use App\Models\Game;
use App\Models\Genre;
@@ -50,12 +51,28 @@ class HashesUpload extends Component
*/
public function addHash(string $filename, string $crc32, string $sha1, ?string $verified = null): void
{
$this->hashes[] = [
'filename' => $filename,
'hash_crc32' => $crc32,
'hash_sha1' => $sha1,
'verified' => $verified ?? "No-Intro" // TODO: Change it.
];
if( $verified !== null && $verified !== "No" )
$this->hashes[] = [
'filename' => $filename,
'hash_crc32' => $crc32,
'hash_sha1' => $sha1,
'verified' => $verified
];
else if( ( $hash = HashesHelpers::findHashes( $sha1 ) ) !== null )
$this->hashes[] = [
'filename' => $hash->filename,
'hash_crc32' => $hash->crc32,
'hash_sha1' => $hash->sha1,
'verified' => HashesHelpers::getReferenceName( $hash->dat_reference_id )
];
else
$this->hashes[] = [
'filename' => $filename,
'hash_crc32' => $crc32,
'hash_sha1' => $sha1,
'verified' => "No"
];
}
/**

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class XfUserSelector extends Component
{
private const int MIN_CHARS = 3;
public string $search = '';
public ?int $selected = null;
public ?string $selectedUsername = null;
public function mount( ?int $initialUserId ){
if( $initialUserId ){
$user = DB::connection('xenforo')->table('user')->where('user_id', $initialUserId)->first();
if( $user ){
$this->selected = $user->user_id;
$this->selectedUsername = $user->username;
$this->search = $user->username;
}
}
}
public function updatedSearch(): void
{
if( $this->selected && $this->search !== $this->selectedUsername) {
$this->selected = null;
$this->selectedUsername = null;
}
}
public function selectUser( int $userId, string $username ): void
{
$this->selected = $userId;
$this->selectedUsername = $username;
$this->search = $username;
}
public function getResultsProperty(): array
{
if( strlen($this->search) < self::MIN_CHARS || $this->selected ) {
return [];
}
return DB::connection('xenforo')
->table('user')
->where('username', 'like', '%' . $this->search . '%')
->orderBy('username')
->limit(10)
->get(['user_id','username'])
->toArray();
}
public function render()
{
return view('livewire.xf-user-selector');
}
}

View File

@@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Entry extends Model
{
use SoftDeletes;
/**
* @var string[]
*/
@@ -33,6 +36,8 @@ class Entry extends Model
'user_id',
'complete_title',
'comments_thread_id',
'staff_comment',
'rejected_at',
];
/**
@@ -41,12 +46,21 @@ class Entry extends Model
protected $casts = [
'featured' => 'boolean',
'release_date' => 'date',
'rejected_at' => 'datetime',
];
public function scopePublished( Builder $query ): Builder {
return $query->where( 'state', 'published' );
}
public function scopeInQueue( Builder $query, int $daysRejected = 7 ): Builder {
return $query->withTrashed()->where(function($q) use($daysRejected) {
$q->where('state', 'pending')->whereNull('deleted_at');
})->orWhere(function($q) use($daysRejected) {
$q->where('state', 'rejected')->whereNotNull('rejected_at')->where('rejected_at', '>=', now()->subDays($daysRejected) );
});
}
/**
* Return game link.
* @return BelongsTo
@@ -91,4 +105,18 @@ class Entry extends Model
return $this->hasMany(EntryHash::class);
}
public function parseStaffCredits(): array {
return json_decode( $this->staff_credits ?? "", true );
}
public function getYoutubeVideoId(): ?string {
if( !$this->youtube_link )
return null;
$pattern = '%(?:https?://)?(?:www\.|m\.)?(?:youtu\.be/|youtube(?:-nocookie)?\.com/(?:watch\?.*v=|embed/|v/|shorts/|live/))([\w-]{11})%i';
preg_match($pattern, $this->youtube_link, $matches);
return $matches[1] ?? null;
}
}

View File

@@ -8,8 +8,7 @@ use Illuminate\Auth\Access\Response;
class EntryPolicy
{
public function view(User $user): bool
public function viewAny(User $user): bool
{
if( $user->_can( 'romhackplaza', 'view' ) )
return true;
@@ -17,6 +16,47 @@ class EntryPolicy
return false;
}
public function viewPending(User $user, Entry $entry): bool
{
// Author.
if( $entry->user_id === $user->user_id )
return true;
return $user->_can( 'romhackplaza', 'canModerateEntries' );
}
public function viewDraft(User $user, Entry $entry): bool
{
// Author.
if( $entry->user_id === $user->user_id )
return true;
return $user->_can( 'romhackplaza', 'canSeeOthersDrafts' );
}
public function viewRejected(User $user, Entry $entry): bool
{
// Author.
if( $entry->user_id === $user->user_id )
return true;
return $user->_can( 'romhackplaza', 'canSeeRejectedEntries' );
}
public function viewHidden(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canSeeHiddenEntries' );
}
public function viewLocked(User $user, Entry $entry): bool
{
// Author.
if( $entry->user_id === $user->user_id )
return true;
return $user->_can('romhackplaza', 'canSeeLockedEntries' );
}
public function create(User $user, ?Entry $entry = null ): bool
{
return $user->_can( 'romhackplaza', 'canSubmitEntry' );
@@ -27,13 +67,101 @@ class EntryPolicy
*/
public function update(User $user, Entry $entry): bool
{
if( $user->_can('romhackplaza', 'canEditOthersEntries') )
return true;
if( $entry->state === 'published' ){
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
return true;
// Staff editors
if( $user->_can('romhackplaza', 'canEditOthersEntries') )
return true;
// Author.
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
return true;
return false;
} else if( $entry->state === 'pending' ){
// Staff moderation.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can('romhackplaza', 'canModerateEntries') )
return true;
// Author.
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
return true;
} else if( $entry->state === 'draft' ){
// Staff.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeOthersDrafts' ) )
return true;
// Author.
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
return true;
} else if( $entry->state === 'rejected' ){
// Staff.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeRejectedEntries' ) )
return true;
// Author.
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
return true;
} else if( $entry->state === 'locked' ){
// Staff.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeLockedEntries' ) )
return true;
return false;
} else if( $entry->state === 'hidden' ){
// Staff.
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeHiddenEntries' ) )
return true;
return false;
}
return false;
}
public function skipQueue(User $user, Entry $entry): bool
{
return $user->_can( 'romhackplaza', 'canSubmitEntryInPublished' );
}
public function updateComment(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canModerateEntries' );
}
public function manageButtonsInQueue(User $user, Entry $entry): bool
{
if( $entry->state === 'rejected' ){
return $this->viewRejected( $user, $entry );
}
return $user->_can('romhackplaza', 'canModerateEntries' );
}
public function approve(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canModerateEntries' );
}
public function reject(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canModerateEntries' );
}
public function moderate(User $user, Entry $entry): bool
{
return $user->_can('romhackplaza', 'canModerateEntries' );
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Translation\PotentiallyTranslatedString;
class XfUserExists implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$user = DB::connection('xenforo')->table('user')->where('user_id', $value)->first();
if( !$user ){
$fail("The user ID {$value} does not exist.");
}
}
}

View File

@@ -111,7 +111,7 @@ class FileServersService {
'current_chunk' => $currentChunk,
'total_chunks' => $totalChunks,
// TODO : Must replace User ID
'zeus' => $this->generateZeusToken( 0, $server['base_url'], "Uploadchunk" ),
'zeus' => $this->generateZeusToken( \Auth::user()->user_id, $server['base_url'], "Uploadchunk" ),
]);
if (!$response->successful()) {

View File

@@ -4,6 +4,7 @@ namespace App\Services;
use App\Exceptions\SubmissionException;
use App\Helpers\EntryHelpers;
use App\Helpers\XenForoHelpers;
use App\Http\Requests\StoreEntryRequest;
use App\Jobs\CreateXenForoCommentsThread;
use App\Models\Author;
@@ -70,7 +71,8 @@ class SubmissionsService {
'currentChunk' => 0,
'done' => true,
'error' => null,
'uuid' => $uuid
'uuid' => $uuid,
'state' => $file->state
];
$file = Cache::get("uploaded_file_{$uuid}");
@@ -83,7 +85,8 @@ class SubmissionsService {
'currentChunk' => 0,
'done' => true,
'error' => null,
'uuid' => $uuid
'uuid' => $uuid,
'state' => $file['state']
];
return null;
@@ -105,7 +108,7 @@ class SubmissionsService {
$this->request = $request;
$this->section = $section;
$user_id = 0; // TODO: Replace that.
$user_id = \Auth::user()->user_id;
$entry = DB::transaction(function () use ( $user_id ) {
@@ -184,6 +187,9 @@ class SubmissionsService {
// Step 13: Try to create the comments section.
$this->Step13_CreateCommentsThread( $entry );
// Step 14: Refresh XF count.
XenForoHelpers::updateEntriesCount( $entry->user_id );
return $entry;
}
@@ -417,7 +423,14 @@ class SubmissionsService {
$this->request = $request;
$this->section = $section;
$this->entry = $entry;
$user_id = 0; // TODO: Replace that.
if( \Auth::user()->can('moderate', $entry) ){
$user_id = $this->request->input('owner_user_id');
$oldUserId = $this->entry->user_id;
} else {
$user_id = \Auth::user()->user_id;
$oldUserId = null;
}
$oldMainImage = $entry->main_image;
$galleryPaths = [];
@@ -465,6 +478,11 @@ class SubmissionsService {
'user_id' => $user_id,
'complete_title' => $completeTitle,
];
if( \Auth::user()->can('moderate', $this->entry) ){
$fields['staff_comment'] = $this->request->input('staff_comment');
}
$this->entry->update( $fields );
// STEP 6: Update entry files.
@@ -500,6 +518,11 @@ class SubmissionsService {
// STEP 11 : Update gallery storage.
$this->eStep11c_UpdateGalleryImages( $galleryPaths );
// STEP 12: Refresh XF count.
if( $oldUserId )
XenForoHelpers::updateEntriesCount( $oldUserId );
XenForoHelpers::updateEntriesCount( $entry->user_id );
return $entry;
}
@@ -540,6 +563,7 @@ class SubmissionsService {
private function eStep6_UpdateEntryFiles(int $entryId ): void
{
$requestUuids = $this->request->input('files_uuid', []);
$requestStates = $this->request->input('files_state', []);
$existingUuids = EntryFile::where( 'entry_id', $entryId )->pluck('file_uuid')->toArray();
$needDeletion = array_diff( $existingUuids, $requestUuids );
@@ -552,6 +576,11 @@ class SubmissionsService {
if( !empty( $needAddition ) ){
$this->Step7_SaveEntryFiles( $this->entry->id, $needAddition ); // Same code.
}
$stateMap = array_combine( $requestUuids, $requestStates );
foreach( $stateMap as $uuid => $state ){
EntryFile::where('file_uuid', $uuid)->where('entry_id', $entryId)->where('state', '!=', 'archived')->update(['state' => $state]);
}
}
private function eStep7_UpdateHashes(int $entryId): void

View File

@@ -72,6 +72,13 @@ class XenforoApiService {
});
}
public function createConversation( array $userIdList, string $title, string $message, bool $conversationOpen, bool $openInvite ): bool
{
$response = $this->post("conversations", data: ['recipient_ids' => $userIdList, 'title' => $title, 'message' => $message, 'open_invite' => $openInvite, 'conversation_open' => $conversationOpen] );
return $response['success'] ?? false;
}
public function createCommentsThread( Entry $entry ): bool
{
if( !$entry->comments_thread_id || $entry->comments_thread_id <= 0 ){
@@ -108,4 +115,10 @@ class XenforoApiService {
return $response;
}
public function updateEntriesCount(int $entryCount, int $userId): bool
{
$response = $this->post("romhackplaza_entry/update_entry_count", data: ['count' => $entryCount, 'user_id' => $userId] );
return $response['success'] ?? false;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\View\Components;
use App\Models\Entry;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
@@ -12,20 +13,49 @@ class SubmitEntryStatus extends Component
* Create a new component instance.
*/
public function __construct(
public string $section
public string $section,
public bool $isEdit,
public ?string $currentState,
public null|string|Entry $entry = 'App\Models\Entry',
)
{
//
if( $this->entry === null )
$this->entry = 'App\Models\Entry';
}
public function availableStates(): array
{
// TODO: Change for permissions.
return [
'draft' => "Save into my drafts",
'pending' => "Add to submissions queue",
'published' => "Publish it now"
$labelPublished = $this->isEdit && $this->currentState === 'published' ? "Keep it published" : "Publish it now";
$labelPending = $this->isEdit && $this->currentState === 'pending' ? "Keep it to submissions queue" : "Add to submissions queue";
$labelDraft = $this->isEdit && $this->currentState === 'draft' ? "Keep it into my drafts" : "Save into my drafts";
$labelHidden = $this->isEdit && $this->currentState === 'hidden' ? "Keep it hidden" : "Hide this entry";
$labelLocked = $this->isEdit && $this->currentState === 'locked' ? "Keep it locked" : "Lock this entry";
$states = [
'draft' => $labelDraft,
'pending' => $labelPending,
];
if( $this->isEdit ) {
$isAuthor = $this->entry->user_id === \Auth::user()->user_id;
if( $isAuthor && ( \Auth::user()->can('skip-queue', $this->entry) || $this->currentState === 'published' ) )
$states['published'] = $labelPublished;
else if( \Auth::user()->can('moderate', $this->entry) )
$states['published'] = $labelPublished;
if( \Auth::user()->can('moderate', $this->entry) && \Auth::user()->can('view-hidden', $this->entry) )
$states['hidden'] = $labelHidden;
if(\Auth::user()->can('moderate', $this->entry) && \Auth::user()->can('view-locked', $this->entry) )
$states['locked'] = $labelLocked;
} else {
if( \Auth::user()->can('skip-queue', $this->entry ) ){
$states['published'] = $labelPublished;
}
}
return $states;
}
public function defaultState(): string

View File

@@ -21,6 +21,9 @@ class XfUsernameLink extends Component
if( $this->user === null && $this->userId !== null ){
$this->user = app(XenforoService::class)->getXfUser($this->userId);
if( $this->user === null ){
$this->user = app(XenforoService::class)->getXfUser(config('xenforo.bot_user_id'));
}
}
}