diff --git a/app/Http/Controllers/Api/BindleController.php b/app/Http/Controllers/Api/BindleController.php new file mode 100644 index 0000000..a2e084f --- /dev/null +++ b/app/Http/Controllers/Api/BindleController.php @@ -0,0 +1,36 @@ + + */ + public function __invoke(): Collection + { + return LibraryItem::query() + ->where('type', 'bindle') + ->with([ + 'media', + ]) + ->orderByDesc('id') + ->get() + ->map(fn ($item) => [ + 'id' => $item->id, + 'name' => $item->name, + 'link' => strtok($item->value, '?'), + 'image' => $item->getFirstMediaUrl('main'), + ]); + } +} diff --git a/app/Http/Controllers/Api/BtcMapCommunityController.php b/app/Http/Controllers/Api/BtcMapCommunityController.php new file mode 100644 index 0000000..252bf5f --- /dev/null +++ b/app/Http/Controllers/Api/BtcMapCommunityController.php @@ -0,0 +1,64 @@ +json( + Meetup::query() + ->with([ + 'media', + 'city.country', + ]) + ->where('community', '=', 'einundzwanzig') + ->when( + app()->environment('production'), + fn ($query) => $query->whereHas( + 'city', + fn ($query) => $query + ->whereNotNull('cities.simplified_geojson') + ->whereNotNull('cities.population') + ->whereNotNull('cities.population_date'), + ), + ) + ->get() + ->map(fn ($meetup) => [ + 'id' => $meetup->slug, + 'tags' => [ + 'type' => 'community', + 'name' => $meetup->name, + 'continent' => 'europe', + 'icon:square' => $meetup->logoSquare, + // 'contact:email' => null, + 'contact:twitter' => $meetup->twitter_username ? 'https://twitter.com/'.$meetup->twitter_username : null, + 'contact:website' => $meetup->webpage, + 'contact:telegram' => $meetup->telegram_link, + 'contact:nostr' => $meetup->nostr, + // 'tips:lightning_address' => null, + 'organization' => 'einundzwanzig', + 'language' => $meetup->city->country->language_codes[0] ?? 'de', + 'geo_json' => $meetup->city->simplified_geojson, + 'population' => $meetup->city->population, + 'population:date' => $meetup->city->population_date, + ], + ]) + ->toArray(), + 200, + ['Content-Type' => 'application/json;charset=UTF-8', 'Charset' => 'utf-8'], + JSON_UNESCAPED_SLASHES, + ); + } +} diff --git a/app/Http/Controllers/Api/CityController.php b/app/Http/Controllers/Api/CityController.php index 10cdf1d..e57387c 100644 --- a/app/Http/Controllers/Api/CityController.php +++ b/app/Http/Controllers/Api/CityController.php @@ -5,66 +5,61 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\City; use App\Models\Lecturer; +use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; +use Dedoc\Scramble\Attributes\Group; +use Dedoc\Scramble\Attributes\QueryParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +#[Group(name: 'Stammdaten', weight: 5)] class CityController extends Controller { /** - * Display a listing of the resource. - * @return \Illuminate\Http\Response + * Städte auflisten und durchsuchen + * + * Öffentlicher Endpunkt; liefert id, name und das zugehörige Land, alphabetisch sortiert. Ohne 'selected' wird die Liste auf 10 Einträge begrenzt. */ + #[QueryParameter(name: 'search', description: 'Teilstring-Suche im Namen der Stadt.', required: false, type: 'string')] + #[QueryParameter(name: 'selected', description: 'Lädt gezielt die angegebenen IDs.', required: false, type: 'array')] public function index(Request $request) { return City::query() - ->with(['country:id,name']) - ->select('id', 'name','country_id') - ->orderBy('name') - ->when( + ->with(['country:id,name']) + ->select('id', 'name', 'country_id') + ->orderBy('name') + ->when( $request->search, - fn(Builder $query) => $query + fn (Builder $query) => $query ->where('name', 'ilike', "%{$request->search}%") ) - ->when( - $request->exists('selected'), - fn(Builder $query) => $query->whereIn('id', - $request->input('selected', [])), - fn(Builder $query) => $query->limit(10) - ) - ->get(); + ->when( + $request->exists('selected'), + fn (Builder $query) => $query->whereIn('id', + $request->input('selected', [])), + fn (Builder $query) => $query->limit(10) + ) + ->get(); } - /** - * Store a newly created resource in storage. - * @return \Illuminate\Http\Response - */ + #[ExcludeRouteFromDocs] public function store(Request $request) { // } - /** - * Display the specified resource. - * @return \Illuminate\Http\Response - */ + #[ExcludeRouteFromDocs] public function show(Lecturer $lecturer) { // } - /** - * Update the specified resource in storage. - * @return \Illuminate\Http\Response - */ + #[ExcludeRouteFromDocs] public function update(Request $request, Lecturer $lecturer) { // } - /** - * Remove the specified resource from storage. - * @return \Illuminate\Http\Response - */ + #[ExcludeRouteFromDocs] public function destroy(Lecturer $lecturer) { // diff --git a/app/Http/Controllers/Api/CountryController.php b/app/Http/Controllers/Api/CountryController.php index bbe612c..e4c6be0 100644 --- a/app/Http/Controllers/Api/CountryController.php +++ b/app/Http/Controllers/Api/CountryController.php @@ -4,11 +4,22 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Country; +use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; +use Dedoc\Scramble\Attributes\Group; +use Dedoc\Scramble\Attributes\QueryParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +#[Group(name: 'Stammdaten', weight: 5)] class CountryController extends Controller { + /** + * Länder auflisten und durchsuchen + * + * Öffentlicher Endpunkt; liefert id, name und code (Ländercode), alphabetisch sortiert. Ohne 'selected' wird das Ergebnis auf 10 Einträge begrenzt. Jedes Land enthält zusätzlich eine 'flag' (SVG-URL). + */ + #[QueryParameter(name: 'search', description: 'Suche in Name oder Code (Ländercode).', required: false, type: 'string')] + #[QueryParameter(name: 'selected', description: 'Lädt gezielt die angegebenen Codes oder IDs.', required: false, type: 'array')] public function index(Request $request) { return Country::query() @@ -16,19 +27,17 @@ class CountryController extends Controller ->orderBy('name') ->when( $request->search, - fn(Builder $query) - => $query + fn (Builder $query) => $query ->where('name', 'ilike', "%{$request->search}%") ->orWhere('code', 'ilike', "%{$request->search}%"), ) ->when( $request->exists('selected'), - fn(Builder $query) - => $query + fn (Builder $query) => $query ->whereIn('code', $request->input('selected', [])) ->orWhereIn('id', $request->input('selected', [])), - fn(Builder $query) => $query->limit(10), + fn (Builder $query) => $query->limit(10), ) ->get() ->map(function (Country $country) { @@ -38,37 +47,25 @@ class CountryController extends Controller }); } - /** - * Store a newly created resource in storage. - * @return \Illuminate\Http\Response - */ + #[ExcludeRouteFromDocs] public function store(Request $request) { // } - /** - * Display the specified resource. - * @return \Illuminate\Http\Response - */ + #[ExcludeRouteFromDocs] public function show(Country $country) { // } - /** - * Update the specified resource in storage. - * @return \Illuminate\Http\Response - */ + #[ExcludeRouteFromDocs] public function update(Request $request, Country $country) { // } - /** - * Remove the specified resource from storage. - * @return \Illuminate\Http\Response - */ + #[ExcludeRouteFromDocs] public function destroy(Country $country) { // diff --git a/app/Http/Controllers/Api/CourseController.php b/app/Http/Controllers/Api/CourseController.php index 6e43e23..1e2b43b 100644 --- a/app/Http/Controllers/Api/CourseController.php +++ b/app/Http/Controllers/Api/CourseController.php @@ -4,17 +4,28 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Course; -use App\Models\Lecturer; +use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; +use Dedoc\Scramble\Attributes\Group; +use Dedoc\Scramble\Attributes\QueryParameter; +use Dedoc\Scramble\Attributes\Response as ResponseAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; +#[Group(name: 'Kurse', weight: 1)] class CourseController extends Controller { /** - * Display a listing of the resource. + * Kurse auflisten und durchsuchen + * + * Öffentlicher Endpunkt; liefert id und name, alphabetisch sortiert. Ohne den Parameter + * 'selected' wird das Ergebnis auf 10 Einträge begrenzt. Jeder Kurs enthält zusätzlich + * ein 'image' (Logo-Thumbnail-URL). */ + #[QueryParameter(name: 'search', description: 'Teilstring-Suche im Namen des Kurses.', required: false, type: 'string')] + #[QueryParameter(name: 'user_id', description: 'Filtert die Kurse nach ihrem Ersteller.', required: false, type: 'integer')] + #[QueryParameter(name: 'selected', description: 'Lädt gezielt die angegebenen Kurs-IDs.', required: false, type: 'array')] public function index(Request $request) { return Course::query() @@ -43,12 +54,11 @@ class CourseController extends Controller } /** - * Store a newly created resource in storage. + * Kurs anlegen * - * Allows an authenticated lecturer to create a course programmatically - * (e.g. to sync courses from an external system). Validation mirrors the - * Livewire course create form; `created_by` is set by the model's creating hook. + * Erlaubt einem authentifizierten Referenten, einen Kurs programmatisch anzulegen. */ + #[ResponseAttribute(status: 403, description: 'Nur Referenten (is_lecturer) dürfen Kurse anlegen.')] public function store(Request $request): JsonResponse { abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN); @@ -64,19 +74,18 @@ class CourseController extends Controller return response()->json($course->fresh(), Response::HTTP_CREATED); } - /** - * Display the specified resource. - */ + #[ExcludeRouteFromDocs] public function show(Course $course) { // } /** - * Update the specified resource in storage. + * Kurs aktualisieren * - * Authorized for the course owner (or a super-admin). + * Aktualisiert einen Kurs; nur für den Ersteller oder einen Super-Admin. */ + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller des Kurses oder ein Super-Admin darf ihn ändern.')] public function update(Request $request, Course $course): JsonResponse { abort_unless( @@ -95,9 +104,7 @@ class CourseController extends Controller return response()->json($course->fresh()); } - /** - * Remove the specified resource from storage. - */ + #[ExcludeRouteFromDocs] public function destroy(Course $course) { // diff --git a/app/Http/Controllers/Api/CourseEventController.php b/app/Http/Controllers/Api/CourseEventController.php index 7e8e038..2bae985 100644 --- a/app/Http/Controllers/Api/CourseEventController.php +++ b/app/Http/Controllers/Api/CourseEventController.php @@ -4,22 +4,28 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\CourseEvent; +use Dedoc\Scramble\Attributes\Group; +use Dedoc\Scramble\Attributes\QueryParameter; +use Dedoc\Scramble\Attributes\Response as ResponseAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; +#[Group(name: 'Kurs-Events', weight: 2)] class CourseEventController extends Controller { /** - * Display a listing of the course events created by the authenticated user. + * Eigene Kurs-Events auflisten * - * Useful for an external sync client to detect which events already exist - * (idempotent syncing). Optionally filtered by course_id. + * Liefert alle vom authentifizierten Nutzer erstellten Kurs-Events (inkl. zugehörigem + * Kurs und Veranstaltungsort), absteigend nach Startdatum. Ideal für idempotente + * Synchronisierung durch externe Clients. * * @return Collection */ + #[QueryParameter(name: 'course_id', description: 'Filtert die Kurs-Events auf einen bestimmten Kurs.', required: false, type: 'integer')] public function index(Request $request): Collection { return CourseEvent::query() @@ -34,12 +40,11 @@ class CourseEventController extends Controller } /** - * Store a newly created course event in storage. + * Kurs-Event anlegen * - * Allows an authenticated lecturer to create a dated course event - * programmatically. Validation mirrors the Livewire course event form; - * `created_by` is set by the model's creating hook. + * Erlaubt einem authentifizierten Referenten, ein datiertes Kurs-Event programmatisch anzulegen. */ + #[ResponseAttribute(status: 403, description: 'Nur Referenten (is_lecturer) dürfen Kurs-Events anlegen.')] public function store(Request $request): JsonResponse { abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN); @@ -58,10 +63,11 @@ class CourseEventController extends Controller } /** - * Update the specified course event in storage. + * Kurs-Event aktualisieren * - * Authorized for the course event owner (or a super-admin). + * Aktualisiert ein Kurs-Event; nur für den Ersteller oder einen Super-Admin. */ + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller des Kurs-Events oder ein Super-Admin darf es ändern.')] public function update(Request $request, CourseEvent $courseEvent): JsonResponse { abort_unless( diff --git a/app/Http/Controllers/Api/HighscoreController.php b/app/Http/Controllers/Api/HighscoreController.php index 9541e9c..f71bd68 100644 --- a/app/Http/Controllers/Api/HighscoreController.php +++ b/app/Http/Controllers/Api/HighscoreController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Controller; use App\Http\Requests\StoreHighscoreRequest; use App\Models\Highscore; use Carbon\CarbonImmutable; +use Dedoc\Scramble\Attributes\Group; +use Dedoc\Scramble\Attributes\Response; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; use swentel\nostr\Filter\Filter; @@ -15,8 +17,15 @@ use swentel\nostr\Relay\RelaySet; use swentel\nostr\Request\Request; use swentel\nostr\Subscription\Subscription; +#[Group(name: 'Highscores', weight: 6)] class HighscoreController extends Controller { + /** + * Highscore-Bestenliste abrufen + * + * Öffentliche Bestenliste des Spiels, absteigend nach Satoshis (dann nach Zeitpunkt). + * Die Antwort hat die Form { data: [ { npub, name, satoshis, blocks, datetime } ] }. + */ public function index(): JsonResponse { // npub1pt0kw36ue3w2g4haxq3wgm6a2fhtptmzsjlc2j2vphtcgle72qesgpjyc6 @@ -37,6 +46,15 @@ class HighscoreController extends Controller ]); } + /** + * Highscore einreichen + * + * Reicht einen Highscore ein (idempotent pro npub und Zeitpunkt). + * Zusätzlich auf 10 Anfragen pro Minute begrenzt. + * Fehlt ein Name, versucht der Server, ihn über das Nostr-Profil zu ergänzen. + * Antwortet mit HTTP 202. + */ + #[Response(status: 429, description: 'Zu viele Anfragen (Limit: 10 pro Minute überschritten).')] public function store(StoreHighscoreRequest $request): JsonResponse { $validated = $request->validated(); diff --git a/app/Http/Controllers/Api/LecturerController.php b/app/Http/Controllers/Api/LecturerController.php index 98d4d3b..4346dc9 100644 --- a/app/Http/Controllers/Api/LecturerController.php +++ b/app/Http/Controllers/Api/LecturerController.php @@ -4,48 +4,53 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Lecturer; +use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; +use Dedoc\Scramble\Attributes\Group; +use Dedoc\Scramble\Attributes\QueryParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +#[Group(name: 'Referenten', weight: 4)] class LecturerController extends Controller { /** - * Display a listing of the resource. + * Referenten auflisten und durchsuchen * - * @return \Illuminate\Http\Response + * Öffentlicher Endpunkt; liefert id und name, alphabetisch sortiert. Ohne den Parameter 'selected' wird die Liste auf 10 Einträge begrenzt. Jeder Referent enthält zusätzlich ein 'image' (Avatar-Thumbnail-URL). */ + #[QueryParameter(name: 'search', description: 'Teilstring-Suche im Namen.', required: false, type: 'string')] + #[QueryParameter(name: 'selected', description: 'Lädt gezielt die angegebenen IDs.', required: false, type: 'array')] public function index(Request $request) { return Lecturer::query() - ->select('id', 'name', ) - ->orderBy('name') + ->select('id', 'name') + ->orderBy('name') // ->when($request->has('user_id'), // fn(Builder $query) => $query->where('created_by', $request->user_id)) - ->when( - $request->search, - fn (Builder $query) => $query - ->where('name', 'ilike', "%{$request->search}%") - ) - ->when( - $request->exists('selected'), - fn (Builder $query) => $query->whereIn('id', - $request->input('selected', [])), - fn (Builder $query) => $query->limit(10) - ) - ->get() - ->map(function (Lecturer $lecturer) { - $lecturer->image = $lecturer->getFirstMediaUrl('avatar', - 'thumb'); + ->when( + $request->search, + fn (Builder $query) => $query + ->where('name', 'ilike', "%{$request->search}%") + ) + ->when( + $request->exists('selected'), + fn (Builder $query) => $query->whereIn('id', + $request->input('selected', [])), + fn (Builder $query) => $query->limit(10) + ) + ->get() + ->map(function (Lecturer $lecturer) { + $lecturer->image = $lecturer->getFirstMediaUrl('avatar', + 'thumb'); - return $lecturer; - }); + return $lecturer; + }); } /** * Store a newly created resource in storage. - * - * @return \Illuminate\Http\Response */ + #[ExcludeRouteFromDocs] public function store(Request $request) { // @@ -53,9 +58,8 @@ class LecturerController extends Controller /** * Display the specified resource. - * - * @return \Illuminate\Http\Response */ + #[ExcludeRouteFromDocs] public function show(Lecturer $lecturer) { // @@ -63,9 +67,8 @@ class LecturerController extends Controller /** * Update the specified resource in storage. - * - * @return \Illuminate\Http\Response */ + #[ExcludeRouteFromDocs] public function update(Request $request, Lecturer $lecturer) { // @@ -73,9 +76,8 @@ class LecturerController extends Controller /** * Remove the specified resource from storage. - * - * @return \Illuminate\Http\Response */ + #[ExcludeRouteFromDocs] public function destroy(Lecturer $lecturer) { // diff --git a/app/Http/Controllers/Api/MeetupController.php b/app/Http/Controllers/Api/MeetupController.php index be21a90..f30b700 100644 --- a/app/Http/Controllers/Api/MeetupController.php +++ b/app/Http/Controllers/Api/MeetupController.php @@ -4,16 +4,31 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Meetup; +use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; +use Dedoc\Scramble\Attributes\Group; +use Dedoc\Scramble\Attributes\QueryParameter; +use Dedoc\Scramble\Attributes\Response; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +#[Group(name: 'Meetups', weight: 3)] class MeetupController extends Controller { + #[ExcludeRouteFromDocs] public function ical() { abort(404); } + /** + * Eigene Meetups auflisten + * + * Liefert die Meetups des angemeldeten Nutzers (id, name, inklusive Stadt/Land und Profilbild), + * alphabetisch sortiert. Erfordert eine authentifizierte Sitzung (sonst 401). + */ + #[QueryParameter(name: 'search', description: 'Teilstring-Suche im Meetup- oder Stadtnamen.', required: false, type: 'string')] + #[QueryParameter(name: 'selected', description: 'Lädt gezielt die angegebenen Meetup-IDs.', required: false, type: 'array')] + #[Response(status: 401, description: 'Nicht authentifiziert.')] public function index(Request $request) { $user = $request->user(); @@ -30,16 +45,15 @@ class MeetupController extends Controller ->orderBy('name') ->when( $request->search, - fn(Builder $query) - => $query + fn (Builder $query) => $query ->where('name', 'like', "%{$request->search}%") ->orWhereHas('city', - fn(Builder $query) => $query->where('cities.name', 'ilike', "%{$request->search}%")), + fn (Builder $query) => $query->where('cities.name', 'ilike', "%{$request->search}%")), ) ->when( $request->exists('selected'), - fn(Builder $query) => $query->whereIn('id', $request->input('selected', [])), - fn(Builder $query) => $query->limit(10), + fn (Builder $query) => $query->whereIn('id', $request->input('selected', [])), + fn (Builder $query) => $query->limit(10), ) ->get() ->map(function (Meetup $meetup) { @@ -49,42 +63,26 @@ class MeetupController extends Controller }); } - /** - * Store a newly created resource in storage. - * - * @return \Illuminate\Http\Response - */ + #[ExcludeRouteFromDocs] public function store(Request $request) { // } - /** - * Display the specified resource. - * - * @return \Illuminate\Http\Response - */ - public function show(meetup $meetup) + #[ExcludeRouteFromDocs] + public function show(Meetup $meetup) { // } - /** - * Update the specified resource in storage. - * - * @return \Illuminate\Http\Response - */ - public function update(Request $request, meetup $meetup) + #[ExcludeRouteFromDocs] + public function update(Request $request, Meetup $meetup) { // } - /** - * Remove the specified resource from storage. - * - * @return \Illuminate\Http\Response - */ - public function destroy(meetup $meetup) + #[ExcludeRouteFromDocs] + public function destroy(Meetup $meetup) { // } diff --git a/app/Http/Controllers/Api/MeetupEventController.php b/app/Http/Controllers/Api/MeetupEventController.php new file mode 100644 index 0000000..6384060 --- /dev/null +++ b/app/Http/Controllers/Api/MeetupEventController.php @@ -0,0 +1,69 @@ +> + */ + #[PathParameter(name: 'date', description: 'Optionales Datum (Y-m-d); filtert auf den Monat dieses Datums.', required: false, type: 'string')] + public function __invoke(?string $date = null): Collection + { + if ($date) { + $date = Carbon::parse($date); + } + $events = MeetupEvent::query() + ->with([ + 'meetup.city.country', + 'meetup.media', + ]) + ->when( + $date, + fn ($query) => $query + ->where('start', '>=', $date) + ->where('start', '<=', $date->copy()->endOfMonth()), + ) + ->get(); + + return $events->map(fn ($event) => [ + 'start' => $event->start->format('Y-m-d H:i'), + 'location' => $event->location, + 'description' => $event->description, + 'link' => $event->link, + 'meetup.name' => $event->meetup->name, + 'meetup.portalLink' => url()->route( + 'meetups.landingpage', + [ + 'country' => $event->meetup->city->country, + 'meetup' => $event->meetup, + ], + ), + 'meetup.url' => $event->meetup->telegram_link ?? $event->meetup->webpage, + 'meetup.country' => str($event->meetup->city->country->code)->upper(), + 'meetup.city' => $event->meetup->city->name, + 'meetup.longitude' => (float) $event->meetup->city->longitude, + 'meetup.latitude' => (float) $event->meetup->city->latitude, + 'meetup.twitter_username' => $event->meetup->twitter_username, + 'meetup.website' => $event->meetup->webpage, + 'meetup.simplex' => $event->meetup->simplex, + 'meetup.signal' => $event->meetup->signal, + 'meetup.nostr' => $event->meetup->nostr, + 'meetup.logo' => $event->meetup->getFirstMediaUrl('logo'), + ], + ); + } +} diff --git a/app/Http/Controllers/Api/MeetupMapController.php b/app/Http/Controllers/Api/MeetupMapController.php new file mode 100644 index 0000000..3ef1a01 --- /dev/null +++ b/app/Http/Controllers/Api/MeetupMapController.php @@ -0,0 +1,58 @@ +> + */ + #[QueryParameter(name: 'withIntro', description: 'Presence-Flag: Bei Vorhandensein wird der Intro-Text mitgeliefert.', required: false, type: 'string')] + #[QueryParameter(name: 'withLogos', description: 'Presence-Flag: Bei Vorhandensein wird die Logo-URL mitgeliefert.', required: false, type: 'string')] + public function __invoke(Request $request): Collection + { + return Meetup::query() + ->where('visible_on_map', true) + ->with([ + 'meetupEvents', + 'city.country', + 'media', + ]) + ->get() + ->map(fn ($meetup) => [ + 'name' => $meetup->name, + 'portalLink' => url()->route( + 'meetups.landingpage', + ['country' => $meetup->city->country, 'meetup' => $meetup], + ), + 'url' => $meetup->telegram_link ?? $meetup->webpage, + 'top' => $meetup->github_data['top'] ?? null, + 'left' => $meetup->github_data['left'] ?? null, + 'country' => str($meetup->city->country->code)->upper(), + 'state' => $meetup->github_data['state'] ?? null, + 'city' => $meetup->city->name, + 'longitude' => (float) $meetup->city->longitude, + 'latitude' => (float) $meetup->city->latitude, + 'twitter_username' => $meetup->twitter_username, + 'website' => $meetup->webpage, + 'simplex' => $meetup->simplex, + 'signal' => $meetup->signal, + 'nostr' => $meetup->nostr, + 'next_event' => $meetup->nextEvent, + 'intro' => $request->has('withIntro') ? $meetup->intro : null, + 'logo' => $request->has('withLogos') ? $meetup->getFirstMediaUrl('logo') : null, + ]); + } +} diff --git a/app/Http/Controllers/Api/NostrPlebController.php b/app/Http/Controllers/Api/NostrPlebController.php new file mode 100644 index 0000000..9c7da53 --- /dev/null +++ b/app/Http/Controllers/Api/NostrPlebController.php @@ -0,0 +1,41 @@ + + */ + public function __invoke(): Collection + { + return User::query() + ->select([ + 'email', + 'public_key', + 'lightning_address', + 'lnurl', + 'node_id', + 'paynym', + 'lnbits', + 'nostr', + 'id', + ]) + ->whereNotNull('nostr') + ->where('nostr', 'like', 'npub1%') + ->orderByDesc('id') + ->get() + ->unique('nostr') + ->pluck('nostr'); + } +} diff --git a/app/Http/Controllers/Api/VenueController.php b/app/Http/Controllers/Api/VenueController.php index 8995445..decef41 100644 --- a/app/Http/Controllers/Api/VenueController.php +++ b/app/Http/Controllers/Api/VenueController.php @@ -5,15 +5,24 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Lecturer; use App\Models\Venue; +use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; +use Dedoc\Scramble\Attributes\Group; +use Dedoc\Scramble\Attributes\QueryParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +#[Group(name: 'Stammdaten', weight: 5)] class VenueController extends Controller { /** - * Display a listing of the resource. - * @return \Illuminate\Http\Response + * Veranstaltungsorte auflisten und durchsuchen + * + * Öffentlicher Endpunkt; liefert id, name und die zugehörige Stadt/Land, alphabetisch sortiert. + * Ohne 'selected' wird die Liste auf 10 Einträge begrenzt. Jeder Ort enthält zusätzlich + * 'flag' (SVG-URL der Landesflagge) und 'description' (Stadt + Straße). */ + #[QueryParameter(name: 'search', description: 'Teilstring-Suche im Namen des Veranstaltungsortes.', required: false, type: 'string')] + #[QueryParameter(name: 'selected', description: 'Lädt gezielt die angegebenen Veranstaltungsort-IDs (umgeht die Begrenzung auf 10 Einträge).', required: false, type: 'array')] public function index(Request $request) { return Venue::query() @@ -22,19 +31,19 @@ class VenueController extends Controller ->orderBy('name') ->when( $request->search, - fn(Builder $query) => $query + fn (Builder $query) => $query ->where('name', 'ilike', "%{$request->search}%") ) ->when( $request->exists('selected'), - fn(Builder $query) => $query->whereIn('id', + fn (Builder $query) => $query->whereIn('id', $request->input('selected', [])), - fn(Builder $query) => $query->limit(10) + fn (Builder $query) => $query->limit(10) ) ->get() ->map(function (Venue $venue) { - $venue->flag = asset('vendor/blade-country-flags/4x3-' . $venue->city->country->code . '.svg'); - $venue->description = $venue->city->name . ', ' . $venue->street; + $venue->flag = asset('vendor/blade-country-flags/4x3-'.$venue->city->country->code.'.svg'); + $venue->description = $venue->city->name.', '.$venue->street; return $venue; }); @@ -42,8 +51,8 @@ class VenueController extends Controller /** * Store a newly created resource in storage. - * @return \Illuminate\Http\Response */ + #[ExcludeRouteFromDocs] public function store(Request $request) { // @@ -51,8 +60,8 @@ class VenueController extends Controller /** * Display the specified resource. - * @return \Illuminate\Http\Response */ + #[ExcludeRouteFromDocs] public function show(Lecturer $lecturer) { // @@ -60,8 +69,8 @@ class VenueController extends Controller /** * Update the specified resource in storage. - * @return \Illuminate\Http\Response */ + #[ExcludeRouteFromDocs] public function update(Request $request, Lecturer $lecturer) { // @@ -69,8 +78,8 @@ class VenueController extends Controller /** * Remove the specified resource from storage. - * @return \Illuminate\Http\Response */ + #[ExcludeRouteFromDocs] public function destroy(Lecturer $lecturer) { // diff --git a/app/Http/Controllers/LnurlAuthController.php b/app/Http/Controllers/LnurlAuthController.php index cdea3f0..78f7cfe 100644 --- a/app/Http/Controllers/LnurlAuthController.php +++ b/app/Http/Controllers/LnurlAuthController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers; use App\Models\LoginKey; use App\Models\User; +use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; use eza\lnurl; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -22,6 +23,7 @@ final class LnurlAuthController extends Controller * This endpoint is called by Lightning wallets during LNURL-Auth authentication flow. * It validates the signature provided by wallet against the stored challenge (k1). */ + #[ExcludeRouteFromDocs] public function callback(Request $request): JsonResponse { try { @@ -230,6 +232,7 @@ final class LnurlAuthController extends Controller * * This endpoint is polled by the frontend to detect authentication failures. */ + #[ExcludeRouteFromDocs] public function checkError(Request $request): JsonResponse { $k1 = $request->input('k1'); diff --git a/composer.json b/composer.json index 560b71b..7d233f3 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.3", "akuechler/laravel-geoly": "^1.0", + "dedoc/scramble": "^0.13.26", "druc/laravel-langscanner": "dev-master", "ezadr/lnurl-php": "^1.0", "laravel/framework": "^13.0", diff --git a/composer.lock b/composer.lock index 8363edf..03483c6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5b201cdc2189923877272b6f6a80ba97", + "content-hash": "470341cb1de08f2116075c47f24dd54a", "packages": [ { "name": "akuechler/laravel-geoly", @@ -640,6 +640,86 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "dedoc/scramble", + "version": "v0.13.26", + "source": { + "type": "git", + "url": "https://github.com/dedoc/scramble.git", + "reference": "5ca42b5e23b9d5c120607138f790b51e22d8b4a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/5ca42b5e23b9d5c120607138f790b51e22d8b4a1", + "reference": "5ca42b5e23b9d5c120607138f790b51e22d8b4a1", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "myclabs/deep-copy": "^1.12", + "nikic/php-parser": "^5.0", + "php": "^8.1", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "spatie/laravel-package-tools": "^1.9.2" + }, + "require-dev": { + "larastan/larastan": "^3.3", + "laravel/pint": "^v1.1.0", + "nunomaduro/collision": "^7.0|^8.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.34|^3.7|^4.4", + "pestphp/pest-plugin-laravel": "^2.3|^3.1|^4.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5|^11.5.3|^12.5.12", + "spatie/laravel-permission": "^6.10|^7.2", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Dedoc\\Scramble\\ScrambleServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Dedoc\\Scramble\\": "src", + "Dedoc\\Scramble\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Lytvynenko", + "email": "litvinenko95@gmail.com", + "role": "Developer" + } + ], + "description": "Automatic generation of API documentation for Laravel applications.", + "homepage": "https://github.com/dedoc/scramble", + "keywords": [ + "documentation", + "laravel", + "openapi" + ], + "support": { + "issues": "https://github.com/dedoc/scramble/issues", + "source": "https://github.com/dedoc/scramble/tree/v0.13.26" + }, + "funding": [ + { + "url": "https://github.com/romalytvynenko", + "type": "github" + } + ], + "time": "2026-06-02T14:43:17+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -3727,6 +3807,66 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, { "name": "nesbot/carbon", "version": "3.11.4", @@ -4675,6 +4815,53 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, { "name": "phrity/comparison", "version": "1.4.1", @@ -13058,66 +13245,6 @@ }, "time": "2024-05-16T03:13:13+00:00" }, - { - "name": "myclabs/deep-copy", - "version": "1.13.4", - "source": { - "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" - }, - "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" - }, - "type": "library", - "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Create deep copies (clones) of your objects", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" - ], - "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" - }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2025-08-01T08:46:24+00:00" - }, { "name": "nunomaduro/collision", "version": "v8.9.4", @@ -14056,53 +14183,6 @@ }, "time": "2026-01-06T21:53:42+00:00" }, - { - "name": "phpstan/phpdoc-parser", - "version": "2.3.2", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" - }, - "time": "2026-01-25T14:56:51+00:00" - }, { "name": "phpunit/php-code-coverage", "version": "12.5.7", diff --git a/config/scramble.php b/config/scramble.php new file mode 100644 index 0000000..3caf5ed --- /dev/null +++ b/config/scramble.php @@ -0,0 +1,198 @@ + [ + * 'include' => 'api', + * 'exclude' => ['api/internal'], + * ], + * + * Without *, patterns match path segments (api matches api and api/users, not apiary). + * With *, Str::is is used (e.g. api/v*). + * + * One static include → default server is /{include} and paths are stripped (/users). + * Multiple includes or wildcards → server defaults to / and paths stay full (/api/users). + * Override with `servers`, or use Scramble::registerApi() for separate bases. + */ + 'api_path' => 'api', + + /* + * Your API domain. By default, app domain is used. This is also a part of the default API routes + * matcher, so when implementing your own, make sure you use this config if needed. + */ + 'api_domain' => null, + + /* + * The path where your OpenAPI specification will be exported. + */ + 'export_path' => 'api.json', + + 'info' => [ + /* + * API version. + */ + 'version' => env('API_VERSION', '1.0.0'), + + /* + * Description rendered on the home page of the API documentation (`/docs/api`). + */ + 'description' => <<<'MARKDOWN' + Willkommen bei der **Einundzwanzig API** – der öffentlichen Schnittstelle der + [Einundzwanzig](https://einundzwanzig.space) Bitcoin-Community-Plattform. + + Über diese API erreichst du die Daten der dezentralen deutschsprachigen Bitcoin-Bewegung: + Meetups und ihre Termine, Kurse und Kurs-Events, Referenten, Veranstaltungsorte sowie die + Geo-Daten für die Community-Karte. + + ## Authentifizierung + + Die meisten **Lese-Endpunkte** sind öffentlich und benötigen kein Token. + + **Schreibende Endpunkte** (Kurse & Kurs-Events anlegen/aktualisieren) erfordern ein + persönliches Zugriffstoken. Erzeuge dir eines unter + *Einstellungen → API Tokens* und sende es als Bearer-Token: + + ```http + Authorization: Bearer + ``` + + ## Rate Limiting + + Öffentliche Endpunkte sind auf **60 Anfragen/Minute** begrenzt, das Einreichen von + Highscores zusätzlich auf **10 Anfragen/Minute**. + MARKDOWN, + ], + + 'ui' => [ + 'title' => 'Einundzwanzig API', + ], + + 'renderer' => 'scalar', + + 'renderers' => [ + /* + * Stoplight Elements config options: https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options + */ + 'elements' => [ + 'view' => 'scramble::docs', + 'theme' => 'light', + 'hideTryIt' => false, + 'hideSchemas' => false, + 'logo' => '', + 'tryItCredentialsPolicy' => 'include', + 'layout' => 'responsive', + 'router' => 'hash', + ], + /* + * Scalar API reference config options: https://scalar.com/products/api-references/configuration + */ + 'scalar' => [ + 'view' => 'scramble::scalar', + 'cdn' => 'https://cdn.jsdelivr.net/npm/@scalar/api-reference', + 'theme' => 'laravel', + 'proxyUrl' => 'https://proxy.scalar.com', + 'darkMode' => true, + 'showDeveloperTools' => 'never', + 'agent' => ['disabled' => true], + 'credentials' => 'include', + ], + ], + + /* + * The list of servers of the API. By default, when `null`, server URL will be created from + * `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you + * will need to specify the local server URL manually (if needed). + * + * Example of non-default config (final URLs are generated using Laravel `url` helper): + * + * ```php + * 'servers' => [ + * 'Live' => 'api', + * 'Prod' => 'https://scramble.dedoc.co/api', + * ], + * ``` + */ + 'servers' => [ + 'Production' => 'https://einundzwanzig.space/api', + 'Local' => 'api', + ], + + /** + * Determines how Scramble stores the descriptions of enum cases. + * Available options: + * - 'description' – Case descriptions are stored as the enum schema's description using table formatting. + * - 'extension' – Case descriptions are stored in the `x-enumDescriptions` enum schema extension. + * + * @see https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-enum-descriptions + * - false - Case descriptions are ignored. + */ + 'enum_cases_description_strategy' => 'description', + + /** + * Determines how Scramble stores the names of enum cases. + * Available options: + * - 'names' – Case names are stored in the `x-enumNames` enum schema extension. + * - 'varnames' - Case names are stored in the `x-enum-varnames` enum schema extension. + * - false - Case names are not stored. + */ + 'enum_cases_names_strategy' => false, + + /** + * When Scramble encounters deep objects in query parameters, it flattens the parameters so the generated + * OpenAPI document correctly describes the API. Flattening deep query parameters is relevant until + * OpenAPI 3.2 is released and query string structure can be described properly. + * + * For example, this nested validation rule describes the object with `bar` property: + * `['foo.bar' => ['required', 'int']]`. + * + * When `flatten_deep_query_parameters` is `true`, Scramble will document the parameter like so: + * `{"name":"foo[bar]", "schema":{"type":"int"}, "required":true}`. + * + * When `flatten_deep_query_parameters` is `false`, Scramble will document the parameter like so: + * `{"name":"foo", "schema": {"type":"object", "properties":{"bar":{"type": "int"}}, "required": ["bar"]}, "required":true}`. + */ + 'flatten_deep_query_parameters' => true, + + 'middleware' => [ + 'web', + RestrictedDocsAccess::class, + ], + + 'extensions' => [], + + /* + * Automatically document API security (OpenAPI `security` / `securitySchemes`) based on route + * middleware. + * + * Disabled by default. Uncomment the line below to enable `MiddlewareAuthSecurityStrategy`. + * When at least one documented route uses middleware matching the configured patterns (by default + * `auth` and `auth:*`), bearer auth is applied globally. Routes without matching middleware are + * marked as public (`security: []`). + * + * Set to `null` explicitly to disable. If you already configure security manually via + * `afterOpenApiGenerated` / `extendOpenApi`, keep this disabled to avoid duplicate schemes. + * + * Customize with a class-string or [class, options]: + * + * 'security_strategy' => [ + * \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class, + * [ + * 'middleware' => ['auth', 'auth:*'], + * 'scheme' => \Dedoc\Scramble\Support\Generator\SecurityScheme::http('bearer'), + * ], + * ], + */ + 'security_strategy' => [ + MiddlewareAuthSecurityStrategy::class, + [ + 'middleware' => ['auth', 'auth:*'], + 'scheme' => SecurityScheme::http('bearer'), + ], + ], +]; diff --git a/lang/de.json b/lang/de.json index 8befc46..f141279 100644 --- a/lang/de.json +++ b/lang/de.json @@ -674,5 +674,28 @@ "Öffnen/RSVP": "", "Über den Dozenten": "", "Über den Kurs": "", - "Über uns": "" + "Über uns": "", + "API Tokens - Einstellungen": "", + "Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.": "", + "API Tokens": "", + "Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.": "", + "Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.": "", + "Dein neues API Token": "", + "Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.": "", + "Kopieren": "", + "Kopiert!": "", + "Verstanden": "", + "Token-Name": "", + "z. B. Externer Kurs-Sync": "", + "Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.": "", + "Token erstellen": "", + "Token erstellt.": "", + "Aktive Tokens": "", + "Du hast noch keine API Tokens erstellt.": "", + "Zuletzt verwendet": "", + "Erstellt": "", + "Nie": "", + "Widerrufen": "", + "Token widerrufen": "", + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" } \ No newline at end of file diff --git a/lang/en.json b/lang/en.json index 257ce19..b6c7264 100644 --- a/lang/en.json +++ b/lang/en.json @@ -673,5 +673,28 @@ "Öffnen/RSVP": "Open/RSVP", "Über den Dozenten": "About the lecturer", "Über den Kurs": "About the course", - "Über uns": "About us" + "Über uns": "About us", + "API Tokens - Einstellungen": "", + "Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.": "", + "API Tokens": "", + "Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.": "", + "Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.": "", + "Dein neues API Token": "", + "Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.": "", + "Kopieren": "", + "Kopiert!": "", + "Verstanden": "", + "Token-Name": "", + "z. B. Externer Kurs-Sync": "", + "Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.": "", + "Token erstellen": "", + "Token erstellt.": "", + "Aktive Tokens": "", + "Du hast noch keine API Tokens erstellt.": "", + "Zuletzt verwendet": "", + "Erstellt": "", + "Nie": "", + "Widerrufen": "", + "Token widerrufen": "", + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 94232d5..48193c6 100644 --- a/lang/es.json +++ b/lang/es.json @@ -674,5 +674,28 @@ "Öffnen/RSVP": "Abrir/RSVP", "Über den Dozenten": "Sobre el profesor", "Über den Kurs": "Sobre el curso", - "Über uns": "Sobre nosotros" + "Über uns": "Sobre nosotros", + "API Tokens - Einstellungen": "", + "Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.": "", + "API Tokens": "", + "Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.": "", + "Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.": "", + "Dein neues API Token": "", + "Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.": "", + "Kopieren": "", + "Kopiert!": "", + "Verstanden": "", + "Token-Name": "", + "z. B. Externer Kurs-Sync": "", + "Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.": "", + "Token erstellen": "", + "Token erstellt.": "", + "Aktive Tokens": "", + "Du hast noch keine API Tokens erstellt.": "", + "Zuletzt verwendet": "", + "Erstellt": "", + "Nie": "", + "Widerrufen": "", + "Token widerrufen": "", + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" } \ No newline at end of file diff --git a/lang/hu.json b/lang/hu.json index 64d07b9..aa786a5 100644 --- a/lang/hu.json +++ b/lang/hu.json @@ -670,5 +670,28 @@ "Öffnen/RSVP": "Megnyitás/RSVP", "Über den Dozenten": "Az oktatóról", "Über den Kurs": "A kurzusról", - "Über uns": "Rólunk" + "Über uns": "Rólunk", + "API Tokens - Einstellungen": "", + "Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.": "", + "API Tokens": "", + "Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.": "", + "Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.": "", + "Dein neues API Token": "", + "Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.": "", + "Kopieren": "", + "Kopiert!": "", + "Verstanden": "", + "Token-Name": "", + "z. B. Externer Kurs-Sync": "", + "Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.": "", + "Token erstellen": "", + "Token erstellt.": "", + "Aktive Tokens": "", + "Du hast noch keine API Tokens erstellt.": "", + "Zuletzt verwendet": "", + "Erstellt": "", + "Nie": "", + "Widerrufen": "", + "Token widerrufen": "", + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" } \ No newline at end of file diff --git a/lang/lv.json b/lang/lv.json index 6baf496..de831ac 100644 --- a/lang/lv.json +++ b/lang/lv.json @@ -645,5 +645,28 @@ "Öffnen/RSVP": "Atvērt/apstiprināt", "Über den Dozenten": "Par pasniedzēju", "Über den Kurs": "Par kursu", - "Über uns": "Par mums" + "Über uns": "Par mums", + "API Tokens - Einstellungen": "", + "Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.": "", + "API Tokens": "", + "Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.": "", + "Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.": "", + "Dein neues API Token": "", + "Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.": "", + "Kopieren": "", + "Kopiert!": "", + "Verstanden": "", + "Token-Name": "", + "z. B. Externer Kurs-Sync": "", + "Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.": "", + "Token erstellen": "", + "Token erstellt.": "", + "Aktive Tokens": "", + "Du hast noch keine API Tokens erstellt.": "", + "Zuletzt verwendet": "", + "Erstellt": "", + "Nie": "", + "Widerrufen": "", + "Token widerrufen": "", + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" } \ No newline at end of file diff --git a/lang/nl.json b/lang/nl.json index 484df98..9e39bf9 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -672,5 +672,28 @@ "Öffnen/RSVP": "Openen/RSVP", "Über den Dozenten": "Over de docent", "Über den Kurs": "Over de cursus", - "Über uns": "Over ons" + "Über uns": "Over ons", + "API Tokens - Einstellungen": "", + "Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.": "", + "API Tokens": "", + "Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.": "", + "Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.": "", + "Dein neues API Token": "", + "Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.": "", + "Kopieren": "", + "Kopiert!": "", + "Verstanden": "", + "Token-Name": "", + "z. B. Externer Kurs-Sync": "", + "Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.": "", + "Token erstellen": "", + "Token erstellt.": "", + "Aktive Tokens": "", + "Du hast noch keine API Tokens erstellt.": "", + "Zuletzt verwendet": "", + "Erstellt": "", + "Nie": "", + "Widerrufen": "", + "Token widerrufen": "", + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" } \ No newline at end of file diff --git a/lang/pl.json b/lang/pl.json index 58efb50..73e4108 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -668,5 +668,28 @@ "Öffnen/RSVP": "Otwórz/RSVP", "Über den Dozenten": "O wykładowcy", "Über den Kurs": "O kursie", - "Über uns": "O nas" + "Über uns": "O nas", + "API Tokens - Einstellungen": "", + "Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.": "", + "API Tokens": "", + "Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.": "", + "Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.": "", + "Dein neues API Token": "", + "Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.": "", + "Kopieren": "", + "Kopiert!": "", + "Verstanden": "", + "Token-Name": "", + "z. B. Externer Kurs-Sync": "", + "Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.": "", + "Token erstellen": "", + "Token erstellt.": "", + "Aktive Tokens": "", + "Du hast noch keine API Tokens erstellt.": "", + "Zuletzt verwendet": "", + "Erstellt": "", + "Nie": "", + "Widerrufen": "", + "Token widerrufen": "", + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index b8a3f3e..a6d50c9 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -670,5 +670,28 @@ "Öffnen/RSVP": "Abrir/RSVP", "Über den Dozenten": "Sobre o professor", "Über den Kurs": "Sobre o curso", - "Über uns": "Sobre nós" + "Über uns": "Sobre nós", + "API Tokens - Einstellungen": "", + "Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.": "", + "API Tokens": "", + "Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.": "", + "Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.": "", + "Dein neues API Token": "", + "Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.": "", + "Kopieren": "", + "Kopiert!": "", + "Verstanden": "", + "Token-Name": "", + "z. B. Externer Kurs-Sync": "", + "Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.": "", + "Token erstellen": "", + "Token erstellt.": "", + "Aktive Tokens": "", + "Du hast noch keine API Tokens erstellt.": "", + "Zuletzt verwendet": "", + "Erstellt": "", + "Nie": "", + "Widerrufen": "", + "Token widerrufen": "", + "Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.": "" } \ No newline at end of file diff --git a/resources/views/vendor/scramble/.gitkeep b/resources/views/vendor/scramble/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/views/vendor/scramble/docs.blade.php b/resources/views/vendor/scramble/docs.blade.php new file mode 100644 index 0000000..166f5ab --- /dev/null +++ b/resources/views/vendor/scramble/docs.blade.php @@ -0,0 +1,112 @@ + + + + + + + {{ $config->get('ui.title') ?? config('app.name') . ' - API Docs' }} + + + + + + + + + +renderer()->all(except: ['theme']) as $key => $value) + @continue(! $value) + {{ $key }}="{{ $value === true ? 'true' : ($value === false ? 'false' : $value) }}" + @endforeach +/> + + +@if($config->renderer()->get('theme', 'light') === 'system') + +@endif + + diff --git a/resources/views/vendor/scramble/scalar.blade.php b/resources/views/vendor/scramble/scalar.blade.php new file mode 100644 index 0000000..b19878f --- /dev/null +++ b/resources/views/vendor/scramble/scalar.blade.php @@ -0,0 +1,32 @@ + + + + + + {{ $config->get('ui.title') ?? config('app.name') . ' - API Docs' }} + + +
+ + + + + diff --git a/routes/api.php b/routes/api.php index 0b944c9..01bb577 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,7 @@ middleware('throttle:10,1') ->name('highscores.store'); - Route::get('nostrplebs', function () { - return User::query() - ->select([ - 'email', - 'public_key', - 'lightning_address', - 'lnurl', - 'node_id', - 'paynym', - 'lnbits', - 'nostr', - 'id', - ]) - ->whereNotNull('nostr') - ->where('nostr', 'like', 'npub1%') - ->orderByDesc('id') - ->get() - ->unique('nostr') - ->pluck('nostr'); - }); - Route::get('bindles', function () { - return LibraryItem::query() - ->where('type', 'bindle') - ->with([ - 'media', - ]) - ->orderByDesc('id') - ->get() - ->map(fn ($item) => [ - 'id' => $item->id, - 'name' => $item->name, - 'link' => strtok($item->value, '?'), - 'image' => $item->getFirstMediaUrl('main'), - ]); - }); - Route::get('meetups', function (Request $request) { - return Meetup::query() - ->where('visible_on_map', true) - ->with([ - 'meetupEvents', - 'city.country', - 'media', - ]) - ->get() - ->map(fn ($meetup) => [ - 'name' => $meetup->name, - 'portalLink' => url()->route( - 'meetups.landingpage', - ['country' => $meetup->city->country, 'meetup' => $meetup], - ), - 'url' => $meetup->telegram_link ?? $meetup->webpage, - 'top' => $meetup->github_data['top'] ?? null, - 'left' => $meetup->github_data['left'] ?? null, - 'country' => str($meetup->city->country->code)->upper(), - 'state' => $meetup->github_data['state'] ?? null, - 'city' => $meetup->city->name, - 'longitude' => (float) $meetup->city->longitude, - 'latitude' => (float) $meetup->city->latitude, - 'twitter_username' => $meetup->twitter_username, - 'website' => $meetup->webpage, - 'simplex' => $meetup->simplex, - 'signal' => $meetup->signal, - 'nostr' => $meetup->nostr, - 'next_event' => $meetup->nextEvent, - 'intro' => $request->has('withIntro') ? $meetup->intro : null, - 'logo' => $request->has('withLogos') ? $meetup->getFirstMediaUrl('logo') : null, - ]); - }); - Route::get('meetup-events/{date?}', function ($date = null) { - if ($date) { - $date = Carbon::parse($date); - } - $events = MeetupEvent::query() - ->with([ - 'meetup.city.country', - 'meetup.media', - ]) - ->when( - $date, - fn ($query) => $query - ->where('start', '>=', $date) - ->where('start', '<=', $date->copy()->endOfMonth()), - ) - ->get(); - - return $events->map(fn ($event) => [ - 'start' => $event->start->format('Y-m-d H:i'), - 'location' => $event->location, - 'description' => $event->description, - 'link' => $event->link, - 'meetup.name' => $event->meetup->name, - 'meetup.portalLink' => url()->route( - 'meetups.landingpage', - [ - 'country' => $event->meetup->city->country, - 'meetup' => $event->meetup, - ], - ), - 'meetup.url' => $event->meetup->telegram_link ?? $event->meetup->webpage, - 'meetup.country' => str($event->meetup->city->country->code)->upper(), - 'meetup.city' => $event->meetup->city->name, - 'meetup.longitude' => (float) $event->meetup->city->longitude, - 'meetup.latitude' => (float) $event->meetup->city->latitude, - 'meetup.twitter_username' => $event->meetup->twitter_username, - 'meetup.website' => $event->meetup->webpage, - 'meetup.simplex' => $event->meetup->simplex, - 'meetup.signal' => $event->meetup->signal, - 'meetup.nostr' => $event->meetup->nostr, - 'meetup.logo' => $event->meetup->getFirstMediaUrl('logo'), - ], - ); - }); - Route::get('btc-map-communities', function () { - return response()->json( - Meetup::query() - ->with([ - 'media', - 'city.country', - ]) - ->where('community', '=', 'einundzwanzig') - ->when( - app()->environment('production'), - fn ($query) => $query->whereHas( - 'city', - fn ($query) => $query - ->whereNotNull('cities.simplified_geojson') - ->whereNotNull('cities.population') - ->whereNotNull('cities.population_date'), - ), - ) - ->get() - ->map(fn ($meetup) => [ - 'id' => $meetup->slug, - 'tags' => [ - 'type' => 'community', - 'name' => $meetup->name, - 'continent' => 'europe', - 'icon:square' => $meetup->logoSquare, - // 'contact:email' => null, - 'contact:twitter' => $meetup->twitter_username ? 'https://twitter.com/'.$meetup->twitter_username : null, - 'contact:website' => $meetup->webpage, - 'contact:telegram' => $meetup->telegram_link, - 'contact:nostr' => $meetup->nostr, - // 'tips:lightning_address' => null, - 'organization' => 'einundzwanzig', - 'language' => $meetup->city->country->language_codes[0] ?? 'de', - 'geo_json' => $meetup->city->simplified_geojson, - 'population' => $meetup->city->population, - 'population:date' => $meetup->city->population_date, - ], - ]) - ->toArray(), - 200, - ['Content-Type' => 'application/json;charset=UTF-8', 'Charset' => 'utf-8'], - JSON_UNESCAPED_SLASHES, - ); - }); + Route::get('nostrplebs', NostrPlebController::class); + Route::get('bindles', BindleController::class); + Route::get('meetups', MeetupMapController::class); + Route::get('meetup-events/{date?}', MeetupEventController::class); + Route::get('btc-map-communities', BtcMapCommunityController::class); }); /*