Initial commit

This commit is contained in:
2026-05-20 18:25:15 +02:00
commit 95f0b4ff01
288 changed files with 90909 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use App\Models\Entry;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EntryController extends Controller
{
private const SECTION_TYPES = [ 'translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials' ];
public function index(): View
{
$entries = Entry::published()
->with(['game.platform', 'platform'])
->latest('published_at')
->paginate(30);
return view('entries.index', compact('entries'));
}
public function show(string $section, Entry $entry): View
{
if( ! in_array($section, self::SECTION_TYPES) )
abort(404);
if( $entry->type !== $section )
abort(404);
return view('entries.show', compact('entry', 'section'));
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Models\EntryFile;
use App\Services\FileServersService;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class FileServerController extends Controller {
public function __construct(private FileServersService $fs) {}
/**
* @throws ConnectionException
*/
public function uploadChunk(Request $request, string $section): JsonResponse {
$request->validate([
'file' => 'required|file',
'file_uuid' => 'required|string|max:128',
'current_chunk' => 'required|integer|min:0',
'total_chunks' => 'required|integer|min:1',
'filename' => 'required|string|max:255',
]);
$type = $section;
$fileUuid = $request->input('file_uuid');
$currentChunk = (int) $request->input('current_chunk');
$totalChunks = (int) $request->input('total_chunks');
$filename = $request->input('filename');
$data = $this->fs->uploadChunk(
$request->file('file'),
$fileUuid,
$currentChunk,
$totalChunks,
$filename,
$type
);
if( !isset( $data['file'] ) || $data['file'] === false ){
$data['finished'] = false;
return response()->json($data);
}
\Cache::put("uploaded_file_{$fileUuid}", [
'uuid' => $fileUuid,
'type' => $type,
'filename' => $filename,
'filepath' => $data['file_path'],
'filesize' => $data['file']['size'],
'favorite_server' => $data['favorite_server'],
'favorite_at' => time()
], now()->addHours(2) );
$data['finished'] = true;
return response()->json($data);
}
public function download(Request $request, int $entry_id, EntryFile $file ) {
if( $file->entry_id != $entry_id ) {
abort(404);
}
// TODO: DL Count.
return redirect( $this->fs->getDownloadFileUrl( $file) );
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\View\View;
class HomeController extends Controller
{
public function index(): View {
return view('home');
}
}

View File

@@ -0,0 +1,265 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\SubmissionException;
use App\Helpers\FormHelpers;
use App\Http\Requests\StoreEntryRequest;
use App\Models\Author;
use App\Models\Entry;
use App\Models\EntryFile;
use App\Models\EntryGallery;
use App\Models\EntryHash;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Language;
use App\Models\Modification;
use App\Models\Platform;
use App\Models\Status;
use App\Services\SubmissionsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
class SubmissionController extends Controller
{
public function __construct(private SubmissionsService $services){}
public function create(Request $request, string $section)
{
$data = [
'entry' => new Entry(),
'section' => $section,
'words' => FormHelpers::getEntryFormWords($section),
'isEdit' => false,
'oldModifications' => old( 'modifications', [] ),
'oldLanguages' => old( 'languages', [] ),
'oldFilesArray' => $this->services->prepareOldFiles( null )
];
if( $data['words'] === [] )
abort(500);
if( section_must_be( 'romhacks', $section ) ){
$data['modifications'] = Modification::orderBy('name')->get();
}
if( section_must_be( [ 'romhacks', 'translations' ], $section ) ){
$data['statuses'] = Status::orderBy('id')->get();
}
return view('submissions.create', $data);
}
public function edit(Request $request, string $section, Entry $entry){
if( $entry->type !== $section )
abort(404);
$data = [
'entry' => $entry,
'section' => $section,
'words' => FormHelpers::getEntryFormWords($section),
'isEdit' => true,
'oldModifications' => old('modifications', $entry->modifications->pluck('id')->toArray() ?? [] ),
'oldLanguages' => old('languages', $entry->languages->pluck('id')->toArray() ?? [] ),
'oldFilesArray' => $this->services->prepareOldFiles( $entry )
];
if( $data['words'] === [] )
abort(500);
if( section_must_be( 'romhacks', $section ) ){
$data['modifications'] = Modification::orderBy('name')->get();
}
if( section_must_be( [ 'romhacks', 'translations' ], $section ) ){
$data['statuses'] = Status::orderBy('id')->get();
}
return view('submissions.edit', $data);
}
public function store(StoreEntryRequest $request, string $section){
try {
$entry = $this->services->storeEntry($request, $section);
return match ($entry->state) {
'published' => redirect()->route('entries.show', ['section' => $section, 'entry' => $entry->slug])->with('success', "Your entry has been published."),
'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."),
default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.")
};
} catch ( SubmissionException $e ) {
return back()->withInput()->withErrors(['error' => $e->getMessage()]);
} catch ( \Exception $e ) {
return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]);
}
}
public function update(StoreEntryRequest $request, string $section, Entry $entry)
{
if( $entry->type !== $section ) {
abort(404);
}
$gameId = null;
if( !$request->input('game_id') ){
if( $request->input('new-game-title') && $request->input('new-game-platform') && $request->input('new-game-genre') ){
$platform = Platform::find($request->input('new-game-platform'));
$genre = Genre::find($request->input('new-game-genre'));
$game = Game::create([
'name' => $request->input('new-game-title'),
'slug' => Str::slug($request->input('new-game-title')),
'platform_id' => $platform->id,
'genre_id' => $genre->id,
]);
$gameId = $game->id;
}
} else {
$gameId = $request->input('game_id');
}
$mainImage = $entry->main_image;
if ( $request->hasFile('main-image') ) {
if ( $mainImage ) {
Storage::disk('public')->delete($mainImage);
}
$mainImage = $request->file('main-image')->store('entries/main_images', 'public');
} elseif ( $request->input('remove_main_image') === '1' ) {
if ( $mainImage ) {
Storage::disk('public')->delete($mainImage);
}
$mainImage = null;
}
$staffCredits = collect($request->input('credits', []))
->filter(fn($item) => isset($item['name']) || isset($item['description']))
->map(function ($item) {
$name = trim($item['name'] ?? '');
$description = trim($item['description'] ?? '');
if ($name === '' && $description === '') {
return null;
}
return trim($name . ($name !== '' && $description !== '' ? ' — ' : '') . $description);
})
->filter()
->implode("\n");
$fields = [
'type' => $section,
'title' => $request->input('entry_title'),
'slug' => $request->input('slug') ?? Str::slug($request->input('entry_title', '')),
'description' => $request->input('description'),
'main_image' => $mainImage,
'state' => $request->input('submit-state', 'draft'),
'game_id' => $gameId,
'status_id' => $request->input('status'),
'version' => $request->input('version'),
'release_date' => $request->input('release-date'),
'staff_credits' => $staffCredits ?: null,
'relevant_link' => $request->input('release_site'),
'youtube_link' => $request->input('youtube_video'),
];
$entry->update($fields);
$entry->hashes()->delete();
foreach ( $request->input('hashes', []) as $hash ) {
if( !isset($hash['filename'], $hash['crc32'], $hash['sha1'], $hash['verified']) ) {
continue;
}
EntryHash::create([
'entry_id' => $entry->id,
'filename' => $hash['filename'],
'hash_crc32' => $hash['crc32'],
'hash_sha1' => $hash['sha1'],
'verified' => $hash['verified'],
]);
}
$authorIds = [];
foreach ( $request->input('authors', []) as $authorId ) {
$author = Author::find($authorId);
if( $author ) {
$authorIds[] = $author->id;
}
}
foreach( $request->input('new-authors', []) as $authorName ) {
$authorName = trim($authorName);
if ($authorName === '') continue;
$author = Author::firstOrCreate(
['slug' => Str::slug($authorName)],
['name' => $authorName],
);
$authorIds[] = $author->id;
}
$entry->authors()->sync(array_values(array_unique($authorIds)));
if( section_must_be( 'romhacks', $section ) ){
$entry->modifications()->sync($request->input('modifications', []));
} else {
$entry->modifications()->sync([]);
}
$entry->languages()->sync($request->input('languages', []));
$existingFileUuids = $request->input('existing_file_ids', []);
if (!is_array($existingFileUuids)) {
$existingFileUuids = [];
}
$entry->files()->whereNotIn('file_uuid', $existingFileUuids)->delete();
foreach ( $request->input('file_ids', []) as $file_uuid ) {
$fileData = Cache::pull("uploaded_file_{$file_uuid}");
if( ! $fileData ) {
continue;
}
EntryFile::create([
'entry_id' => $entry->id,
'file_uuid' => $fileData['uuid'],
'filename' => $fileData['filename'],
'filepath' => $fileData['filepath'],
'favorite_server' => $fileData['favorite_server'],
'favorite_at' => \DateTimeImmutable::createFromTimestamp( $fileData['favorite_at'] ),
'filesize' => $fileData['filesize'],
'state' => 'public'
]);
}
$existingGalleryIds = $request->input('existing_gallery_ids', []);
if (!is_array($existingGalleryIds)) {
$existingGalleryIds = [];
}
$entry->gallery()->whereNotIn('id', $existingGalleryIds)->get()->each(function ($gallery) {
if ($gallery->image) {
Storage::disk('public')->delete($gallery->image);
}
$gallery->delete();
});
foreach ( $request->file('gallery', [] ) as $galleryFile ){
if( !$galleryFile->isValid() ){
continue;
}
$path = $galleryFile->store('entries/gallery/' . $entry->id, 'public');
EntryGallery::create([
'entry_id' => $entry->id,
'image' => $path
]);
}
return match( $entry->state ){
'published' => redirect()->route('entries.show', [ 'section' => $section, 'entry' => $entry->slug ])->with('success', "Your entry has been published."),
'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."),
default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.")
};
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\TemporaryFileUploadRequest;
use App\Services\TemporaryFileService;
use Illuminate\Http\Request;
class TemporaryFileController extends Controller
{
public function __construct(private TemporaryFileService $services) {}
public function upload(TemporaryFileUploadRequest $request){
$file = $request->file('file');
if( !$file || !$file->isValid()){
response()->json( ['path' => null], 400);
}
$path = $this->services->uploadFile( $file );
if( !$path ){
response()->json( ['path' => null], 500);
}
return response()->json(['path' => $path]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckXenForoPermissions
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, string ...$permissions ): Response
{
if( !\Auth::check() )
return redirect()->to(config('app.forum_url') . '/login' );
if( empty($permissions) ) // No permissions needed.
return $next($request);
foreach ($permissions as $permissionStr) {
[$group, $permission] = explode('.', $permissionStr);
if( !\Auth::user()->can($group, $permission) )
return $this->deny($request, $permission);
}
return $next($request);
}
private function deny(Request $request, string $permission): Response
{
if($request->expectsJson())
return \response()->json(['error' => 'forbidden'], 403);
return response()->view('pages.forbidden', [
'permission' => $permission,
], 403 );
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Http\Requests;
use App\Rules\PublicFileExists;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class StoreEntryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// TODO: Change it by role.
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function prepareForValidation(): void
{
$newGameTitle = trim((string) $this->input('new-game-title', ''));
$newGamePlatform = $this->input('new-game-platform');
$newGameGenre = $this->input('new-game-genre');
$this->merge([
'game_id' => $this->input('game_id') !== '' ? $this->input('game_id') : null,
'new-game-title' => $newGameTitle !== '' ? $newGameTitle : null,
'new-game-platform' => $newGamePlatform !== '' ? $newGamePlatform : null,
'new-game-genre' => $newGameGenre !== '' ? $newGameGenre : null,
'gallery' => $this->input('gallery') !== '' ? $this->input('gallery') : null,
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [];
$section = $this->route('section');
$rules['files_uuid'] = 'array|required|min:1';
$rules['files_uuid.*'] = 'string';
if( section_must_not_be( 'translations', $section ) ){
$rules['entry_title'] = "required|string|max:255";
} else {
$rules['entry_title'] = "nullable|string|max:255";
}
if( section_must_be( 'romhacks', $section ) ){
$rules['modifications'] = 'array|required|min:1';
$rules['modifications.*'] = 'integer|exists:modifications,id';
}
$rules['version'] = 'required|string|max:50';
$rules['release-date'] = 'required|date';
$rules['status'] = 'required|integer|exists:statuses,id';
$rules['description'] = 'required|string';
if( section_must_be( ['romhacks', 'translations' ], $section ) ){
$rules['game_id'] = 'required_without:new-game-title|nullable|integer|exists:games,id';
$rules['new-game-title'] = 'required_without:game_id|nullable|string|max:255';
$rules['new-game-platform'] = 'required_with:new-game-title|nullable|integer|exists:platforms,id';
$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';
$rules['hashes.*.hash_sha1'] = 'required|string|max:512';
$rules['hashes.*.verified'] = 'required|string|max:512';
$rules['languages'] = 'array|required|min:1';
$rules['languages.*'] = 'integer|exists:languages,id';
$rules['main-image'] = [ 'required', 'string', new PublicFileExists ];
$rules['gallery'] = 'array|required|min:1';
$rules['gallery.*'] = [ 'string', new PublicFileExists ];
$rules['authors'] = 'array|required_without:new-authors|min:1';
$rules['authors.*'] = 'integer|exists:authors,id';
$rules['new-authors'] = 'array|required_without:authors|min:1';
$rules['new-authors.*'] = 'string|max:255';
$rules['staff_credits'] = 'nullable|json';
$rules['release_site'] = 'nullable|url|max:500';
$rules['youtube_video'] = 'nullable|url|max:500';
$rules['submit-state'] = 'required|string|in:draft,pending,published';
return $rules;
}
public function messages(): array
{
return [
'entry_title.required' => 'Please provide an entry title.',
'slug.unique' => 'An entry with this title already exists. Please choose another title.',
'file_ids.required' => 'Please upload at least one file.',
'file_ids.min' => 'Please upload at least one file.',
'hashes.required' => 'Please add at least one hash.',
'hashes.min' => 'Please add at least one hash.',
'gallery.required' => 'Please add at least one screenshot.',
'gallery.min' => 'Please add at least one screenshot.',
'new-game-platform.required_without' => 'Please choose a platform for the new game.',
'new-game-genre.required_without' => 'Please choose a genre for the new game.',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class TemporaryFileUploadRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'file' => 'required|file|max:100000'
];
}
}